feat(core): SYN+ACK and FIN go once, as in Java, measured with Java FILIUS

TcpRun loses exactly one frame (loseFirst: the card's output buffer drops
it; the sender's packet log still shows it, as with setDrop) in three new
stories: lostSynAck, lostFin, lostLastFin.

Measured: Java never repeats a SYN+ACK or a FIN. The server gives a
handshake up after one timeout (3 RTT) and closes with a FIN from SYN_RCVD
(its boundary check sometimes lets it wait twice; it means once). After its
FIN a socket waits at most five times, each wait ended by a segment or the
timeout; then it is gone. The core does the same (TCP_CLOSING_WAITS, with
TIME_WAIT in the loop) and shows CLOSED where Java leaves the state
standing (a named Java bug).

One departure: a repeated SYN in SYN_RCVD gets the SYN+ACK once more and
the wait starts anew; Java closes there, but its client never repeats the
SYN. lostSynAck is compared up to the second SYN (JAVA_UNTIL); the rest is
an engine test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 18:08:18 +00:00
co-authored by Claude Opus 5.5
parent a88fa1512c
commit 7e0348636c
11 changed files with 1056 additions and 40 deletions
+53 -1
View File
@@ -44,7 +44,7 @@ import {
type RuntimeOutput,
type ScheduledEventKind,
} from '../src/index.js';
import { device, letFramesArrive, link, pc, startedEngine, uuid } from './fixtures.js';
import { device, letFramesArrive, link, loseFirst, pc, startedEngine, uuid } from './fixtures.js';
const marker = (label: string): ScheduledEventKind => ({ kind: 'parityMarker', label });
@@ -995,6 +995,58 @@ describe('TCP sockets', () => {
expect(engine.state.packetTraces.filter((trace) => trace.detail?.includes('TCP SYN retransmission') === true)).toHaveLength(2);
});
it('answers a repeated SYN with its SYN+ACK once more: a lost SYN+ACK costs one attempt (Java would close)', () => {
const { engine, clientId, serverId } = pair(0x17f1, 0x17f2, '10.26.0');
loseFirst(engine, serverId, (segment) => segment.flags === (TCP_FLAG.synchronize | TCP_FLAG.acknowledgement));
const listenerId = engine.openTcpServerSocket(serverId, 80)!;
const clientSocketId = engine.openTcpClientSocket(clientId, '10.26.0.2', 80)!;
expect(engine.connectTcpWithResult(clientSocketId)).toBe('connected');
// The second SYN leaves a timeout after the first was handed down, its SYN+ACK comes a cable later each way.
expect(engine.state.currentTimeMilliseconds).toBe(3 * 2_750 + 2 * 55);
letFramesArrive(engine);
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('ESTABLISHED');
expect(engine.state.packetTraces.filter((trace) => trace.detail?.startsWith('TCP SYN+ACK') === true).map((trace) => trace.detail)).toEqual([
'TCP SYN+ACK',
'TCP SYN+ACK retransmission attempt 2',
]);
});
it("gives a handshake up after one timeout and closes with a FIN, sent once, then five waits", () => {
const { engine, clientId, serverId } = pair(0x17f5, 0x17f6, '10.27.0');
const listenerId = engine.openTcpServerSocket(serverId, 80)!;
// A SYN from the client's address, but no socket there: the SYN+ACK finds no one.
engine.sendIPv4Packet(clientId, {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '10.27.0.1',
receiverIPAddress: '10.27.0.2',
timeToLive: 64,
protocol: 'tcp',
payload: {
kind: 'tcp',
segment: { sourcePort: 5000, destinationPort: 80, sequenceNumber: 1, acknowledgementNumber: 0, flags: TCP_FLAG.synchronize, payload: new Uint8Array() },
},
});
engine.handle({ type: 'advance', toMilliseconds: 60_000 });
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
const states = engine.state.packetTraces
.filter((trace) => trace.nodeId === serverId && trace.detail?.includes(' -> ') === true && !trace.detail.includes('LISTEN -> CLOSED') && !trace.detail.includes('CLOSED -> LISTEN'))
.map((trace) => [trace.timeMilliseconds, trace.detail]);
// The SYN arrives after ARP there and back: 165 ms.
expect(states).toEqual([
[165, 'TCP LISTEN -> SYN_RCVD'],
[165 + 3 * 2_750, 'TCP SYN_RCVD -> FIN_WAIT_1'],
[165 + 3 * 2_750 + 5 * 3 * 2_750, 'TCP FIN_WAIT_1 -> CLOSED'],
]);
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('CLOSED');
expect(engine.state.packetTraces.filter((trace) => trace.nodeId === serverId && trace.layer === 'transport' && trace.detail?.startsWith('TCP ') === true && trace.operation !== 'accepted' && trace.operation !== 'dropped').map((trace) => trace.detail)).toEqual([
'TCP SYN+ACK',
'TCP FIN',
]);
});
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)!;