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
+105 -8
View File
@@ -11,12 +11,15 @@
// client draws differs (Java's random, the engine's seed); it is compared as
// "client". Times count from the client's SYN.
//
// The lost* stories lose one frame on its sender's card, as TcpRun's
// loseFirst does in Java: the card shows it, no one receives it.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { Id, NetworkRuntimeEngine } from '../src/index.js';
import { TCP_FLAG, type Id, type NetworkRuntimeEngine, type TcpSegment } from '../src/index.js';
import { loseFirst } from './fixtures.js';
import { GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, watchedEngine, type JavaFrame, type SeenFrame } from './javaRun.js';
import type { Scenario } from './scenario.js';
@@ -39,12 +42,30 @@ interface JavaRun {
const text = (value: string) => new TextEncoder().encode(value);
/** The story as the engine plays it; the nodes whose socket stands for Java's "client" and "server". */
/**
* The story as the engine plays it; the node whose socket stands for Java's
* "client". Java watched the server's socket from ESTABLISHED on, or, with
* `serverListening`, from the start (LISTEN).
*/
interface Story {
readonly client: Id;
readonly serverListening?: boolean;
readonly play: (engine: NetworkRuntimeEngine, advanceTo: (milliseconds: number) => void) => void;
}
const isSynAck = (segment: TcpSegment) => (segment.flags & TCP_FLAG.synchronize) !== 0 && (segment.flags & TCP_FLAG.acknowledgement) !== 0;
const isFin = (segment: TcpSegment) => (segment.flags & TCP_FLAG.finish) !== 0;
/** Connected, then the client closes half a second later; the server closes by itself. */
function connectAndClose(engine: NetworkRuntimeEngine, advanceTo: (milliseconds: number) => void): void {
engine.openTcpServerSocket(SERVER, 80);
advanceTo(500);
const client = engine.openTcpClientSocket(CLIENT, '192.168.0.10', 80)!;
engine.connectTcpWithResult(client);
advanceTo(engine.state.currentTimeMilliseconds + 500);
engine.closeTcpSocket(client);
}
const STORIES: Record<string, Story> = {
exchange: {
client: CLIENT,
@@ -88,6 +109,30 @@ const STORIES: Record<string, Story> = {
engine.setGlobalPacketLossEnabled(false);
},
},
lostSynAck: {
client: CLIENT,
serverListening: true,
play: (engine, advanceTo) => {
loseFirst(engine, SERVER, isSynAck);
engine.openTcpServerSocket(SERVER, 80);
advanceTo(500);
engine.connectTcpWithResult(engine.openTcpClientSocket(CLIENT, '192.168.0.10', 80)!);
},
},
lostFin: {
client: CLIENT,
play: (engine, advanceTo) => {
loseFirst(engine, CLIENT, isFin);
connectAndClose(engine, advanceTo);
},
},
lostLastFin: {
client: CLIENT,
play: (engine, advanceTo) => {
loseFirst(engine, SERVER, isFin);
connectAndClose(engine, advanceTo);
},
},
};
/** The states of the node's connection socket, from the engine's traces: when each was entered. */
@@ -138,8 +183,21 @@ function normalized(frames: readonly SeenFrame[]): SeenFrame[] {
* second and third), but its timeout branch sets closeSocket, and the loop
* ends after the first: one SYN, and verbinden fails after one wait. The
* engine sends the SYN three times, a wait of three round trips each.
*
* lostFin, lostLastFin: close waits at most five times (TCP_CLOSING_WAITS),
* each ended by a segment or after the timeout, and then the socket is gone —
* but its state stays FIN_WAIT_1, FIN_WAIT_2 or LAST_ACK. The engine shows
* CLOSED after the last wait.
*/
const JAVA_TIMEOUT_MILLISECONDS = 3 * 2_750;
const JAVA_CLOSING_WAITS = 5;
/** The socket's states, with CLOSED after Java's close loop: from `state`, `waits` more waits of the timeout. */
function closedAfterWaits(states: [number, string][], state: string, waits: number): [number, string][] {
const from = states.find(([, each]) => each === state)![0];
return [...states, [from + waits * JAVA_TIMEOUT_MILLISECONDS, 'CLOSED']];
}
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
closedPort: (java) => {
const client = OTHER_CLIENT;
@@ -157,6 +215,30 @@ const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
},
};
},
// The client's FIN is lost: its five waits pass without a segment.
lostFin: (java) => ({ ...java, states: { ...java.states, client: closedAfterWaits(java.states.client!, 'FIN_WAIT_1', JAVA_CLOSING_WAITS) } }),
// The server's FIN is lost: the client's first wait ended with the ACK, the server's five pass.
lostLastFin: (java) => ({
...java,
states: {
...java.states,
client: closedAfterWaits(java.states.client!, 'FIN_WAIT_2', JAVA_CLOSING_WAITS - 1),
server: closedAfterWaits(java.states.server!, 'LAST_ACK', JAVA_CLOSING_WAITS),
},
}),
};
/**
* Where a story is compared only up to a time (Java's, from its start):
* after it, Java follows its own error and the engine does not.
*
* lostSynAck: Java's client sends one SYN (closedPort above) and gives up a
* timeout later; the engine's client sends it again then, the server answers
* it with its SYN+ACK once more, and they connect (engine.test.ts). Compared
* up to just before the second SYN.
*/
const JAVA_UNTIL: Record<string, (java: JavaRun) => number> = {
lostSynAck: (java) => java.states.client!.find(([, state]) => state === 'SYN_SENT')![0] + JAVA_TIMEOUT_MILLISECONDS - GAP_TOLERANCE_MILLISECONDS,
};
describe('TCP as Java FILIUS runs it', () => {
@@ -167,6 +249,15 @@ describe('TCP as Java FILIUS runs it', () => {
}
});
it('cuts only where Java goes on with its error', () => {
for (const [name, until] of Object.entries(JAVA_UNTIL)) {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `tcp-java-${name}.java.json`), 'utf8')) as JavaRun;
const cut = until(java);
const later = [...Object.values(java.frames).flat().map((frame) => frame.time), ...Object.values(java.states).flat().map(([time]) => time)];
expect(later.some((time) => time >= cut), name).toBe(true);
}
});
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'tcp-lan.scenario.json'), 'utf8')) as Scenario;
for (const [name, story] of Object.entries(STORIES)) {
@@ -180,8 +271,14 @@ describe('TCP as Java FILIUS runs it', () => {
story.play(engine, advanceTo);
advanceTo(Math.max(...java.events.map(([time]) => time), ...Object.values(java.states).flatMap((each) => each.map(([time]) => time))) + 5_000);
const expected = normalized(javaFrames(java.frames));
const actual = normalized(frames());
// Times from the SYN; with JAVA_UNTIL, only what comes before the cut.
const javaSyn = java.frames[story.client]!.find((frame) => frame.tcpFlags === 'S')!.time;
const engineSyn = frames().find((frame) => / TCP .* S seq=/.test(frame.text))!.time;
const until = (JAVA_UNTIL[name]?.(measured) ?? Number.POSITIVE_INFINITY) - javaSyn;
const before = <T extends { time: number } | [number, string]>(items: T[]): T[] =>
items.filter((item) => (Array.isArray(item) ? item[0] : item.time) < until);
const expected = before(normalized(javaFrames(java.frames)));
const actual = before(normalized(frames()));
const expectedSteps = gaps(expected);
const actualSteps = gaps(actual);
for (let index = 0; index < Math.max(expectedSteps.length, actualSteps.length); index += 1) {
@@ -191,14 +288,14 @@ describe('TCP as Java FILIUS runs it', () => {
);
}
const javaSyn = java.frames[story.client]!.find((frame) => frame.tcpFlags === 'S')!.time;
const engineSyn = frames().find((frame) => / TCP .* S seq=/.test(frame.text))!.time;
const shift = (states: [number, string][], syn: number): [number, string][] => states.map(([time, state]) => [time - syn, state]);
const shift = (states: [number, string][], syn: number): [number, string][] => before(states.map(([time, state]) => [time - syn, state]));
// From SYN_SENT: Java's first CLOSED is when the socket was made.
const javaClient = java.states.client!.filter(([, state], index) => !(index === 0 && state === 'CLOSED'));
expectSameStates('client', shift(engineStates(engine, story.client, false), engineSyn), shift(javaClient, javaSyn));
if (java.states.server !== undefined) {
expectSameStates('server', shift(engineStates(engine, SERVER, true), engineSyn), shift(java.states.server, javaSyn));
// A listening server from its first change: Java's first LISTEN is when it began to listen.
const javaServer = story.serverListening === true ? java.states.server.slice(1) : java.states.server;
expectSameStates('server', shift(engineStates(engine, SERVER, story.serverListening !== true), engineSyn), shift(javaServer, javaSyn));
}
});
}