Make configuration and compatibility contracts executable
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user