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
+130 -1
View File
@@ -1,7 +1,7 @@
// Ported from filius-ipad ios/FiliusPadTests/TopologyNetworkRuntimeEngineTests.swift:
// the event queue, the virtual clock, Java-compatible randomness, restart,
// Ethernet/switch/ARP/IPv4/ICMP, routing errors, the firewall, packet loss
// and trace retention, UDP sockets. The remaining Swift tests (TCP, DHCP,
// and trace retention, UDP and TCP sockets. The remaining Swift tests (DHCP,
// DNS, RIP, NAT, remote link, packet viewer) follow with their protocol
// slices.
@@ -21,6 +21,8 @@ import {
stableMacAddress,
switchConfiguration,
TCP_FLAG,
TCP_ROUND_TRIP_TIME_MILLISECONDS,
tcpFlagDescription,
topologySnapshot,
traceRetentionPolicy,
UNSPECIFIED_MAC_ADDRESS,
@@ -209,6 +211,25 @@ describe('event queue and virtual clock', () => {
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed: 14, initialTimeMilliseconds: 0 });
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBe(uuid(1));
});
it('forgets TCP sessions and accepted sockets on stop', () => {
const [clientId, clientPortId, serverId, serverPortId] = [0x21, 0x22, 0x23, 0x24].map(uuid) as [string, string, string, string];
const engine = startedEngine({
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
links: [link(clientId, clientPortId, serverId, serverPortId)],
deviceConfigurations: new Map([
[clientId, device('10.3.0.1')],
[serverId, device('10.3.0.2')],
]),
});
engine.openTcpServerSocket(serverId, 80);
expect(engine.connectTcp(engine.openTcpClientSocket(clientId, '10.3.0.2', 80)!)).toBe(true);
expect(engine.state.tcpSessionsById.size).toBe(3);
engine.handle({ type: 'stop' });
expect(engine.state.tcpSessionsById.size).toBe(0);
expect(engine.state.tcpAcceptedSocketIdsByListenerId.size).toBe(0);
});
});
describe('UDP sockets', () => {
@@ -738,3 +759,111 @@ describe('packet loss and trace retention', () => {
expect(engine.state.nextTraceIdentity).toBe(7);
});
});
describe('TCP sockets', () => {
/** Two PCs on one cable, started. */
const pair = (first: number, second: number, prefix: string) => {
const [clientId, clientPortId, serverId, serverPortId] = [first, first + 0x10, second, second + 0x10].map(uuid) as [
string,
string,
string,
string,
];
const engine = startedEngine({
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
links: [link(clientId, clientPortId, serverId, serverPortId)],
deviceConfigurations: new Map([
[clientId, device(`${prefix}.1`)],
[serverId, device(`${prefix}.2`)],
]),
});
return { engine, clientId, serverId };
};
it('makes an established client and an accepted server socket with the three-way handshake', () => {
const { engine, clientId, serverId } = pair(0x1701, 0x1702, '10.17.0');
const listenerId = engine.openTcpServerSocket(serverId, 8080)!;
const clientSocketId = engine.openTcpClientSocket(clientId, '10.17.0.2', 8080)!;
expect(engine.connectTcp(clientSocketId)).toBe(true);
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('ESTABLISHED');
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('ESTABLISHED');
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_SENT -> ESTABLISHED')).toBe(true);
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_RCVD -> ESTABLISHED')).toBe(true);
});
it("sends segments of Java's MSS and reassembles one message up to the push", () => {
const { engine, clientId, serverId } = pair(0x1721, 0x1722, '10.18.0');
const listenerId = engine.openTcpServerSocket(serverId, 8081)!;
const clientSocketId = engine.openTcpClientSocket(clientId, '10.18.0.2', 8081)!;
expect(engine.connectTcp(clientSocketId)).toBe(true);
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
const payload = Uint8Array.from({ length: 3_001 }, (_, index) => index % 251);
expect(engine.sendTcp(clientSocketId, payload)).toBe(true);
expect(engine.receiveTcp(acceptedSocketId)).toEqual(payload);
const payloadLengths = engine.state.packetTraces
.filter((trace) => trace.detail?.startsWith('TCP data') === true)
.map((trace) => Number(trace.afterHeaders.find((header) => header.name === 'payloadLength')?.value));
expect(payloadLengths).toEqual([1_460, 1_460, 81]);
});
it('retransmits a connection attempt at most three times and releases the port', () => {
const clientId = uuid(0x1741);
const engine = startedEngine({ nodes: [pc(clientId, uuid(0x1751))], links: [], deviceConfigurations: new Map([[clientId, device('10.19.0.1')]]) });
const socketId = engine.openTcpClientSocket(clientId, '10.19.0.99', 80)!;
const localPort = engine.state.socketsById.get(socketId)!.localPort;
expect(engine.connectTcp(socketId)).toBe(false);
expect(engine.state.tcpSessionsById.get(socketId)?.sendAttempts).toBe(3);
expect(engine.state.socketsById.get(socketId)?.tcpState).toBe('CLOSED');
expect(engine.isTcpPortReserved(clientId, localPort)).toBe(false);
expect(engine.state.packetTraces.filter((trace) => trace.detail?.includes('TCP SYN retransmission') === true)).toHaveLength(2);
});
it('drops a SYN+ACK at a listening port with no connection of its own', () => {
const { engine, clientId, serverId } = pair(0x1781, 0x1782, '10.21.0');
const listenerId = engine.openTcpServerSocket(serverId, 7000)!;
const segment = { sourcePort: 80, destinationPort: 7000, sequenceNumber: 5, acknowledgementNumber: 9, flags: TCP_FLAG.synchronize | TCP_FLAG.acknowledgement, payload: new Uint8Array() };
engine.sendIPv4Packet(clientId, {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '10.21.0.1',
receiverIPAddress: '10.21.0.2',
timeToLive: 64,
protocol: 'tcp',
payload: { kind: 'tcp', segment },
});
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 1_000 });
expect(engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)).toEqual([]);
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP destination port closed')).toBe(true);
});
it('names the flags of a segment in Swift\'s order', () => {
expect(tcpFlagDescription(TCP_FLAG.acknowledgement | TCP_FLAG.push | TCP_FLAG.reset | TCP_FLAG.synchronize | TCP_FLAG.finish)).toBe(
'FIN+SYN+RST+PSH+ACK',
);
expect(tcpFlagDescription(TCP_FLAG.synchronize | TCP_FLAG.acknowledgement)).toBe('SYN+ACK');
expect(tcpFlagDescription(0)).toBe('');
});
it("closes with Java's FIN states, and TIME_WAIT releases both ends", () => {
const { engine, clientId, serverId } = pair(0x1761, 0x1762, '10.20.0');
const listenerId = engine.openTcpServerSocket(serverId, 9090)!;
const clientSocketId = engine.openTcpClientSocket(clientId, '10.20.0.2', 9090)!;
expect(engine.connectTcp(clientSocketId)).toBe(true);
const serverSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
engine.closeTcpSocket(clientSocketId);
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('FIN_WAIT_2');
expect(engine.state.socketsById.get(serverSocketId)?.tcpState).toBe('CLOSE_WAIT');
engine.closeTcpSocket(serverSocketId);
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
expect(engine.state.socketsById.get(serverSocketId)?.tcpState).toBe('CLOSED');
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + TCP_ROUND_TRIP_TIME_MILLISECONDS });
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('CLOSED');
expect(engine.state.socketsById.get(listenerId)?.tcpState).toBe('LISTEN');
});
});