Make configuration and compatibility contracts executable

This commit is contained in:
2026-11-12 08:27:53 +01:00
parent 306266ae40
commit 85a75383a0
28 changed files with 2284 additions and 8 deletions
@@ -0,0 +1,45 @@
package de.neitzel.fxdesigner.config;
import java.util.*;
/** Versioned shared configuration. Validated and resolved by core before use. */
public record DesignerConfiguration(String schemaVersion, Map<String, Context> contexts) {
public DesignerConfiguration {
contexts = Map.copyOf(contexts);
}
public record Preview(
String sourceSet,
String module,
String provider,
String defaultExample,
List<String> examples) {
public Preview {
examples = List.copyOf(examples);
}
}
public record ModulePath(String entryModule, List<String> addReads, List<String> addOpens) {
public ModulePath {
addReads = List.copyOf(addReads);
addOpens = List.copyOf(addOpens);
}
}
public record Context(
String applicationModule,
List<String> mavenProfiles,
String launchMode,
String buildRuntime,
String targetRuntime,
int buildJava,
int targetJava,
Set<String> requiredCapabilities,
Preview preview,
ModulePath modulePath) {
public Context {
mavenProfiles = List.copyOf(mavenProfiles);
requiredCapabilities = Set.copyOf(requiredCapabilities);
}
}
}
@@ -0,0 +1,182 @@
package de.neitzel.fxdesigner.protocol;
import static de.neitzel.fxdesigner.protocol.WireProtocol.*;
import java.io.*;
import java.nio.*;
import java.nio.charset.*;
import java.util.*;
/**
* Network byte order length-prefix, fixed magic, strict bounded fields. No object deserialization.
*/
public final class FrameCodec {
private static final int MAGIC = 0x4e465831;
private FrameCodec() {}
public static void write(OutputStream stream, Message message) throws IOException {
var bytes = new ByteArrayOutputStream();
var out = new DataOutputStream(bytes);
out.writeInt(MAGIC);
if (message instanceof Hello h) {
out.writeByte(0);
text(out, h.session());
out.writeLong(h.workerPid());
text(out, h.token());
version(out, h.wire());
version(out, h.metadata());
version(out, h.preview());
strings(out, h.capabilities());
strings(out, h.required());
} else if (message instanceof Welcome w) {
out.writeByte(2);
text(out, w.session());
version(out, w.wire());
var names = new HashSet<String>();
w.operations().forEach(o -> names.add(o.name()));
strings(out, names);
} else if (message instanceof Packet p) {
out.writeByte(1);
out.writeByte(p.kind().ordinal());
out.writeByte(p.operation().ordinal());
var i = p.identity();
text(out, i.session());
text(out, i.request());
text(out, i.context());
text(out, i.snapshot());
out.writeInt(i.revisions().size());
for (var e : new TreeMap<>(i.revisions()).entrySet()) {
text(out, e.getKey());
out.writeLong(e.getValue());
}
out.writeInt(i.width());
out.writeInt(i.height());
byte[] data = p.payload();
out.writeInt(data.length);
out.write(data);
}
if (bytes.size() > MAX_FRAME) throw new IOException("Frame exceeds limit");
var target = new DataOutputStream(stream);
target.writeInt(bytes.size());
bytes.writeTo(target);
target.flush();
}
/** EOF/truncation is a transport failure. Length is checked before allocating the frame. */
public static Message read(InputStream stream) throws IOException {
var source = new DataInputStream(stream);
int size = source.readInt();
if (size < 5 || size > MAX_FRAME) throw new IOException("Invalid frame length");
byte[] data = source.readNBytes(size);
if (data.length != size) throw new EOFException("Truncated frame");
var in = new DataInputStream(new ByteArrayInputStream(data));
try {
if (in.readInt() != MAGIC) throw new IOException("Invalid protocol magic");
Message message;
int type = in.readUnsignedByte();
if (type == 0)
message =
new Hello(
text(in),
in.readLong(),
text(in),
version(in),
version(in),
version(in),
strings(in),
strings(in));
else if (type == 1) {
int k = in.readUnsignedByte(), o = in.readUnsignedByte();
if (k >= Kind.values().length || o >= Operation.values().length)
throw new IOException("Unknown message kind/operation");
String session = text(in), request = text(in), context = text(in), snapshot = text(in);
int count = count(in, MAX_DOCUMENTS);
var revisions = new LinkedHashMap<String, Long>();
for (int n = 0; n < count; n++) {
String key = text(in);
if (revisions.put(key, in.readLong()) != null)
throw new IOException("Duplicate document revision");
}
var identity =
new Identity(
session, request, context, snapshot, revisions, in.readInt(), in.readInt());
int length =
count(
in,
o == Operation.RENDER.ordinal() && k == Kind.RESULT.ordinal()
? MAX_IMAGE
: MAX_METADATA);
if (o == Operation.RENDER.ordinal()
&& k == Kind.RESULT.ordinal()
&& length != (long) identity.width() * identity.height() * 4)
throw new IOException("Image dimensions do not match payload");
if (length > in.available()) throw new EOFException("Truncated payload");
message =
new Packet(Kind.values()[k], Operation.values()[o], identity, in.readNBytes(length));
} else if (type == 2) {
String session = text(in);
var wire = version(in);
var operations = EnumSet.noneOf(Operation.class);
for (String operation : strings(in)) operations.add(Operation.valueOf(operation));
message = new Welcome(session, wire, operations);
} else throw new IOException("Unknown frame type");
if (in.available() != 0) throw new IOException("Trailing frame content");
return message;
} catch (IllegalArgumentException e) {
throw new IOException("Invalid protocol fields", e);
}
}
private static int count(DataInputStream in, int maximum) throws IOException {
int n = in.readInt();
if (n < 0 || n > maximum) throw new IOException("Field exceeds limit");
return n;
}
private static void text(DataOutputStream out, String value) throws IOException {
if (value.length() > MAX_TEXT) throw new IOException("Text exceeds limit");
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_TEXT) throw new IOException("Text exceeds limit");
out.writeInt(bytes.length);
out.write(bytes);
}
private static String text(DataInputStream in) throws IOException {
int n = count(in, MAX_TEXT);
byte[] b = in.readNBytes(n);
if (b.length != n) throw new EOFException("Truncated text");
try {
return StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(b))
.toString();
} catch (CharacterCodingException e) {
throw new IOException("Invalid UTF-8", e);
}
}
private static void version(DataOutputStream out, Version v) throws IOException {
out.writeInt(v.major());
out.writeInt(v.minor());
}
private static Version version(DataInputStream in) throws IOException {
return new Version(in.readInt(), in.readInt());
}
private static void strings(DataOutputStream out, Set<String> values) throws IOException {
if (values.size() > 128) throw new IOException("Too many capabilities");
out.writeInt(values.size());
for (String v : new TreeSet<>(values)) text(out, v);
}
private static Set<String> strings(DataInputStream in) throws IOException {
int count = count(in, 128);
var s = new HashSet<String>();
for (int n = 0; n < count; n++)
if (!s.add(text(in))) throw new IOException("Duplicate capability");
return s;
}
}
@@ -0,0 +1,167 @@
package de.neitzel.fxdesigner.protocol;
import static de.neitzel.fxdesigner.protocol.WireProtocol.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.*;
import java.util.*;
/**
* Host-side authentication/negotiation and bounded request correlation, independent of sockets/UI.
*/
public final class ProtocolSession implements AutoCloseable {
private enum State {
NEW,
READY,
CLOSED
}
private State state = State.NEW;
private final String session, token;
private final long workerPid;
private final Set<String> supported, required;
private Set<Operation> operations = Set.of();
private record Pending(Packet request, Instant deadline) {}
private final Map<String, Pending> pending = new HashMap<>();
private long sequence;
public ProtocolSession(
String session, long workerPid, String token, Set<String> supported, Set<String> required) {
if (session == null
|| session.isBlank()
|| workerPid < 1
|| token == null
|| !token.matches("[0-9a-f]{64}"))
throw new IllegalArgumentException("Invalid launch identity");
this.session = session;
this.workerPid = workerPid;
this.token = token;
this.supported = Set.copyOf(supported);
this.required = Set.copyOf(required);
}
public static String newToken() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
public synchronized Set<Operation> authenticate(InetAddress peer, Hello hello) {
if (state != State.NEW) throw new IllegalStateException("Handshake already consumed");
state = State.CLOSED;
if (!peer.isLoopbackAddress()
|| !session.equals(hello.session())
|| workerPid != hello.workerPid()
|| !MessageDigest.isEqual(
token.getBytes(StandardCharsets.US_ASCII),
hello.token().getBytes(StandardCharsets.UTF_8)))
throw new IllegalArgumentException("Worker authentication failed");
if (hello.wire().major() != 1)
throw new IllegalArgumentException("Unsupported wire major version");
if (!supported.containsAll(hello.required()) || !hello.capabilities().containsAll(required))
throw new IllegalArgumentException("Required capability unavailable");
var usable = EnumSet.noneOf(Operation.class);
if (hello.metadata().major() == 1
&& supported.contains("discover-v1")
&& hello.capabilities().contains("discover-v1")) usable.add(Operation.DISCOVER);
if (hello.preview().major() == 1
&& supported.contains("render-rgba-v1")
&& hello.capabilities().contains("render-rgba-v1")) usable.add(Operation.RENDER);
var requiredByEither = new HashSet<>(required);
requiredByEither.addAll(hello.required());
if ((requiredByEither.contains("discover-v1") && !usable.contains(Operation.DISCOVER))
|| (requiredByEither.contains("render-rgba-v1") && !usable.contains(Operation.RENDER)))
throw new IllegalArgumentException("Required capability version incompatible");
operations = Set.copyOf(usable);
state = State.READY;
return operations;
}
public synchronized Welcome welcome() {
ready();
return new Welcome(session, new Version(1, 0), operations);
}
public synchronized Packet request(
Operation operation,
String context,
String snapshot,
Map<String, Long> revisions,
int width,
int height,
byte[] payload,
Instant now,
Duration timeout) {
ready();
if (!operations.contains(operation))
throw new IllegalStateException("Operation not negotiated");
if (timeout.isNegative() || timeout.isZero() || timeout.compareTo(Duration.ofMinutes(5)) > 0)
throw new IllegalArgumentException("Invalid request timeout");
expire(now);
if (pending.size() >= MAX_PENDING) throw new IllegalStateException("Too many pending requests");
var packet =
new Packet(
Kind.REQUEST,
operation,
new Identity(
session, Long.toString(++sequence), context, snapshot, revisions, width, height),
payload);
pending.put(packet.identity().request(), new Pending(packet, now.plus(timeout)));
return packet;
}
/** False means stale/unrelated/mismatched; such results must not mutate the UI. */
public synchronized boolean accept(Packet result, Instant now) {
ready();
expire(now);
if (result.kind() != Kind.RESULT && result.kind() != Kind.ERROR) return false;
var p = pending.get(result.identity().request());
if (p == null
|| p.request.operation() != result.operation()
|| !p.request.identity().equals(result.identity())) return false;
pending.remove(result.identity().request());
return true;
}
public synchronized Optional<Packet> cancel(String request) {
ready();
var p = pending.remove(request);
return p == null
? Optional.empty()
: Optional.of(
new Packet(Kind.CANCEL, p.request.operation(), p.request.identity(), new byte[0]));
}
/**
* On any render input/size/snapshot change the caller cancels the superseded request before
* issuing another.
*/
public synchronized List<Packet> expire(Instant now) {
var cancelled = new ArrayList<Packet>();
var iterator = pending.values().iterator();
while (iterator.hasNext()) {
var p = iterator.next();
if (!now.isBefore(p.deadline)) {
cancelled.add(
new Packet(Kind.CANCEL, p.request.operation(), p.request.identity(), new byte[0]));
iterator.remove();
}
}
return List.copyOf(cancelled);
}
private void ready() {
if (state != State.READY)
throw new IllegalStateException("Session not authenticated or closed");
}
public synchronized void close() {
state = State.CLOSED;
pending.clear();
}
}
@@ -0,0 +1,153 @@
package de.neitzel.fxdesigner.protocol;
import java.util.*;
/** Draft wire 1.0 types. Product, metadata and preview versions are independent. */
public final class WireProtocol {
private WireProtocol() {}
public static final int MAX_TEXT = 4096,
MAX_METADATA = 1024 * 1024,
MAX_IMAGE = 16 * 1024 * 1024,
MAX_FRAME = MAX_IMAGE + MAX_METADATA,
MAX_DOCUMENTS = 128,
MAX_PENDING = 128;
public record Version(int major, int minor) {
public Version {
if (major < 0 || minor < 0) throw new IllegalArgumentException("Negative version");
}
}
public sealed interface Message permits Hello, Welcome, Packet {}
public record Hello(
String session,
long workerPid,
String token,
Version wire,
Version metadata,
Version preview,
Set<String> capabilities,
Set<String> required)
implements Message {
public Hello {
Objects.requireNonNull(session);
Objects.requireNonNull(token);
Objects.requireNonNull(wire);
Objects.requireNonNull(metadata);
Objects.requireNonNull(preview);
capabilities = Set.copyOf(capabilities);
required = Set.copyOf(required);
}
@Override
public String toString() {
return "Hello[session=" + session + ", workerPid=" + workerPid + ", credentials=<redacted>]";
}
}
public record Welcome(String session, Version wire, Set<Operation> operations)
implements Message {
public Welcome {
Objects.requireNonNull(session);
Objects.requireNonNull(wire);
operations = Set.copyOf(operations);
}
}
public enum Kind {
REQUEST,
RESULT,
ERROR,
CANCEL
}
public enum ErrorCode {
INVALID_REQUEST,
LOAD_FAILED,
TIMEOUT,
CANCELLED,
INTERNAL_ERROR
}
public enum Operation {
DISCOVER,
RENDER
}
public record Identity(
String session,
String request,
String context,
String snapshot,
Map<String, Long> revisions,
int width,
int height) {
public Identity {
for (String s : List.of(session, request, context, snapshot))
if (s.isBlank() || s.length() > MAX_TEXT)
throw new IllegalArgumentException("Invalid correlation identity");
revisions = Map.copyOf(revisions);
if (revisions.size() > MAX_DOCUMENTS || revisions.values().stream().anyMatch(r -> r < 0))
throw new IllegalArgumentException("Invalid revisions");
if (width < 0
|| height < 0
|| width > 8192
|| height > 8192
|| (long) width * height * 4 > MAX_IMAGE)
throw new IllegalArgumentException("Invalid image dimensions");
}
}
/**
* RENDER RESULT payloads are raw RGBA8; other payloads are opaque UTF-8 metadata owned by later
* adapters.
*/
public record Packet(Kind kind, Operation operation, Identity identity, byte[] payload)
implements Message {
public Packet {
Objects.requireNonNull(kind);
Objects.requireNonNull(operation);
Objects.requireNonNull(identity);
Objects.requireNonNull(payload);
boolean render = operation == Operation.RENDER;
if (render
&& (identity.width() == 0 || identity.height() == 0 || identity.revisions().isEmpty()))
throw new IllegalArgumentException("Rendering needs dimensions and document revisions");
if (!render
&& (identity.width() != 0 || identity.height() != 0 || !identity.revisions().isEmpty()))
throw new IllegalArgumentException("Discovery does not carry render identity");
boolean image = render && kind == Kind.RESULT;
if (image
? payload.length != (long) identity.width() * identity.height() * 4
: payload.length > MAX_METADATA)
throw new IllegalArgumentException("Invalid payload size");
if (kind == Kind.CANCEL && payload.length != 0)
throw new IllegalArgumentException("Cancellation payload must be empty");
if (!image) {
try {
String text =
java.nio.charset.StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.decode(java.nio.ByteBuffer.wrap(payload))
.toString();
if (kind == Kind.ERROR) {
int split = text.indexOf('\n');
if (split < 1) throw new IllegalArgumentException("Error needs code and detail");
ErrorCode.valueOf(text.substring(0, split));
}
} catch (java.nio.charset.CharacterCodingException e) {
throw new IllegalArgumentException("Invalid UTF-8 payload", e);
}
}
payload = payload.clone();
}
@Override
public byte[] payload() {
return payload.clone();
}
}
}