feat(core): TCP in the simulation core

engine.ts ports Swift's "Java-compatible TCP sockets" in one piece, since
half a state machine could not be checked: server and client sockets (a
free port from Java's random numbers on their own seed), the three-way
handshake with its result (connected, invalid socket, unreachable, timed
out), segments of 1460 bytes, messages up to the push, a timeout of
3 × 2.5 s with at most three attempts, closing through every Java state
including TIME_WAIT (2.5 s), sequence numbers modulo 2^32.

Three scenarios match the Swift engine trace for trace: a LAN with data
over three segments, closing from both ends, a closed port and an
unreachable host; two networks through a router with a firewall rule,
data lost until the connection closes, and an accepted socket that does
not hold the port of its closed listener; CLOSING, reached by a lost FIN.
The engine delivers a FIN within the same close, so both ends close at
once only with packet loss; an empty message does not arrive in Swift (it
does not advance the sequence number), nor here.

Swift's four TCP engine tests are ported, with tests for stopping, a
foreign SYN+ACK at a listener and the flag names. 17 mutation probes, 16
caught; the other (delivered, not waited, not connected gives unreachable)
cannot be reached through Swift's engine, which never sends a reset and
always acknowledges correctly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 10:03:16 +00:00
co-authored by Claude Opus 5.5
parent e80be3bbdf
commit 12849b1535
14 changed files with 20973 additions and 22 deletions
+47
View File
@@ -75,6 +75,9 @@ interface Scenario {
socket?: string;
payload?: string;
timeout?: number;
// TCP: the payload repeated this many times, for messages longer than a segment.
repeatCount?: number;
localPort?: number;
}[];
}
@@ -281,6 +284,37 @@ function run(scenario: Scenario): Record<string, unknown> {
);
break;
}
case 'openTcpServer':
results.push(engine.openTcpServerSocket(step.node!, step.port!, step.localIp) ?? null);
break;
case 'openTcpClient':
results.push(
engine.openTcpClientSocket(step.node!, step.target!, step.port!, {
...(step.localIp === undefined ? {} : { localIPAddress: step.localIp }),
...(step.localPort === undefined ? {} : { localPort: step.localPort }),
}) ?? null,
);
break;
case 'connectTcp':
results.push(engine.connectTcpWithResult(step.socket!));
break;
case 'sendTcp':
results.push(engine.sendTcp(step.socket!, new TextEncoder().encode((step.payload ?? '').repeat(step.repeatCount ?? 1))));
break;
case 'receiveTcp': {
const message = engine.receiveTcp(step.socket!);
results.push(message === undefined ? null : new TextDecoder().decode(message));
break;
}
case 'acceptedTcp':
results.push(engine.state.tcpAcceptedSocketIdsByListenerId.get(step.socket!) ?? []);
break;
case 'peerTcp':
results.push(engine.peerTcpSocketId(step.socket!) ?? null);
break;
case 'cleanTcp':
results.push(engine.cleanClosedTcpSocket(step.socket!));
break;
case 'closeSocket':
engine.closeSocket(step.socket!);
results.push(!engine.state.socketsById.has(step.socket!));
@@ -316,6 +350,19 @@ function run(scenario: Scenario): Record<string, unknown> {
remoteIp: socket.remoteIPAddress ?? null,
remotePort: socket.remotePort ?? null,
queued: state.udpReceiveQueuesBySocketId.get(socket.id)?.length ?? 0,
tcpState: socket.tcpState ?? null,
parent: socket.parentListenerSocketId === undefined ? null : upper(socket.parentListenerSocketId),
session: ((session) =>
session === undefined
? null
: {
attempts: session.sendAttempts,
next: session.nextSendSequenceNumber,
remote: session.remoteSequenceNumber,
pending: session.lastSentSegment !== undefined,
assembly: session.receiveAssembly.length,
messages: session.receivedMessages.length,
})(state.tcpSessionsById.get(socket.id)),
})),
firewallDecisions: state.firewallDecisions.map((decision) => ({
packet: decision.packetIdentity,