feat(core): TCP timing and closing after Java, measured with Java FILIUS
TCP follows Java's TCPSocket where it differs from Swift: each attempt and TIME_WAIT wait three round trips that follow the speed (8250 ms at 90 %), counted from when the segment is handed down, before ARP; the next segment ends TIME_WAIT; the other end closes by itself on FIN; data no one acknowledges closes with FIN; the accepted socket has the listener's initial sequence number, and the listener takes the next. Not taken from Java: three SYNs, not one (Java's retry loop ends after the first timeout), named in tcpJava.test.ts as JAVA_BUGS. tcpJava.test.ts compares TcpRun's three stories frame by frame and state by state; the Swift comparison only checks that the TCP scenarios differ. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+4
-3
@@ -13,7 +13,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `src/model/link.ts`, `src/model/graph.ts` | `Model/TopologyLink.swift`, `Model/TopologyGraph.swift` | übertragen, bisher nur die Daten (Abfragen folgen mit dem Editor) |
|
||||
| `src/model/documentation.ts` | `Model/TopologyDocumentation.swift` | übertragen; FNV-Kennung mit BigInt, gegen die IDs aus dem Swift-Import geprüft |
|
||||
| `src/runtime/config.ts` | `State/TopologyEditorState.swift` (Laufzeit-Konfigurationen, WLAN eines Rechners, DHCP, Portweiterleitung, Remote Link ohne LAN-Transport, Programme und ihre Konfiguration, Firewall, DNS-Einträge aus `State/TopologyDNSResolver.swift`, E-Mail-Konten und -Nachrichten aus `State/TopologyEmail.swift`); dazu Echo-Server- und Client-Konfiguration, die Swift nicht speichert | übertragen; Java-Beschriftungen der Oberfläche weggelassen |
|
||||
| `src/runtime/engine.ts` | `State/TopologyNetworkRuntimeEngine.swift` Z. ~313–3585, ~4472–5187 (UDP), ~4753–4850 | übertragen: Warteschlange, Uhr, Ethernet, Switch, ARP, IPv4, ICMP, Routing, Firewall, UDP-Sockets (binden, freie Ports nach Javas Zufall, senden samt Broadcast, zustellen, empfangen mit Wartezeit, schließen), TCP (Z. ~5187–5913: Server- und Client-Sockets, Handschlag mit Ergebnis, Segmente zu 1460 Byte bis zum PSH, Zeitgrenze 3 × 2,5 s mit höchstens drei Versuchen, Abbau über alle Java-Zustände samt `TIME_WAIT`), DHCP (Z. ~5913–6694: Server mit festen Zuordnungen, Adressbereich, Angeboten, Leases und Sperrliste; Client seit F2.3b nach Javas `DHCPClient` neu gebaut und an Java-Filius gemessen: je Versuch eine Nachricht, eine Antwort, die nicht passt, wird gelesen und verworfen; die ARP-Prüfung wartet eine RTT, eine Antwort für die Adresse beendet sie sofort, dann DECLINE; nach einem Zeitablauf wiederholt der Zustand seine Nachricht, nach dem fünften nimmt der Client eine Link-Local-Adresse `169.254.1.x/16` (ARP-Probe, ARP-Reply an alle) und gibt keine alte Adresse zurück; Swift sendete doppelt, wartete nicht auf ARP und kehrte nach zehn Versuchen zur alten Adresse zurück. Abweichend von Java: Nach fünf belegten Link-Local-Adressen gibt der Client auf (Java sucht endlos), und ein ARP-Paket für eine andere Adresse beendet die Prüfung nicht. Die RTT der DHCP-Zeiten (Warten, Angebote, Sperrliste) folgt wie in Java der Geschwindigkeit (50 Kabel, 2750 ms bei 90 %); Swift rechnet fest mit 2500 ms. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Gateway-NAT, DNS, RIP, Remote Link, WLAN |
|
||||
| `src/runtime/engine.ts` | `State/TopologyNetworkRuntimeEngine.swift` Z. ~313–3585, ~4472–5187 (UDP), ~4753–4850 | übertragen: Warteschlange, Uhr, Ethernet, Switch, ARP, IPv4, ICMP, Routing, Firewall, UDP-Sockets (binden, freie Ports nach Javas Zufall, senden samt Broadcast, zustellen, empfangen mit Wartezeit, schließen), TCP (Z. ~5187–5913: Server- und Client-Sockets, Handschlag mit Ergebnis, Segmente zu 1460 Byte bis zum PSH, höchstens drei Versuche, Abbau über alle Java-Zustände samt `TIME_WAIT`; seit F2.3c nach Javas `TCPSocket` an Java-Filius gemessen: je Versuch und für `TIME_WAIT` 3 RTT nach der Geschwindigkeit (8250 ms bei 90 %, Swift fest 7500 bzw. 2500 ms), der Zeitablauf zählt ab der Übergabe des Segments, vor ARP; ein Segment beendet `TIME_WAIT`; die Gegenseite schließt beim FIN von selbst; nicht bestätigte Daten schließen mit FIN statt sofort; der angenommene Socket hat die Startnummer des Listeners, der Listener eine neue. Abweichend von Java: drei SYN statt einem, weil Javas Wiederholungsschleife nach dem ersten Zeitablauf abbricht), DHCP (Z. ~5913–6694: Server mit festen Zuordnungen, Adressbereich, Angeboten, Leases und Sperrliste; Client seit F2.3b nach Javas `DHCPClient` neu gebaut und an Java-Filius gemessen: je Versuch eine Nachricht, eine Antwort, die nicht passt, wird gelesen und verworfen; die ARP-Prüfung wartet eine RTT, eine Antwort für die Adresse beendet sie sofort, dann DECLINE; nach einem Zeitablauf wiederholt der Zustand seine Nachricht, nach dem fünften nimmt der Client eine Link-Local-Adresse `169.254.1.x/16` (ARP-Probe, ARP-Reply an alle) und gibt keine alte Adresse zurück; Swift sendete doppelt, wartete nicht auf ARP und kehrte nach zehn Versuchen zur alten Adresse zurück. Abweichend von Java: Nach fünf belegten Link-Local-Adressen gibt der Client auf (Java sucht endlos), und ein ARP-Paket für eine andere Adresse beendet die Prüfung nicht. Die RTT der DHCP-Zeiten (Warten, Angebote, Sperrliste) folgt wie in Java der Geschwindigkeit (50 Kabel, 2750 ms bei 90 %); Swift rechnet fest mit 2500 ms. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Gateway-NAT, DNS, RIP, Remote Link, WLAN |
|
||||
| `src/runtime/ipv4.ts` | Engine `parseIPv4`/`sameSubnet` | übertragen |
|
||||
| `src/runtime/packets.ts` | Engine, Paketmodelle | übertragen; `Data` → `Uint8Array` |
|
||||
| `src/runtime/random.ts` | Engine, `TopologyJavaCompatibleRandom` | übertragen; BigInt für die 48-Bit-Arithmetik; dazu `nextDouble` (Javas `Math.random`, an OpenJDK 21 gemessen) |
|
||||
@@ -27,8 +27,9 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `test/virtualFileSystem.test.ts`, `test/vfs.*.json` | `TopologyProjectPersistenceTests.swift`, `TopologyEditorReducerTests.swift` (drei Dateisystem-Tests, als Fälle) | übertragen und erweitert: 11 Fälle, Ergebnis aus dem Swift-Kern |
|
||||
| `test/strings.test.ts`, `test/strings.*.json` | — | neu: Fälle und ganze Zeichenmengen, Ergebnis aus dem Swift-Kern |
|
||||
| `test/engine.test.ts` | `TopologyNetworkRuntimeEngineTests.swift` (21 von 72 Tests) | übertragen; `nextInt`- und `nextDouble`-Referenzwerte an OpenJDK 21 gemessen; die DHCP-Client-Tests seit F2.3b nach Javas Verhalten (neun, davon zwei benannte Java-Fehler, die der Kern nicht übernimmt) |
|
||||
| `test/oracle.test.ts`, `test/scenarios/` | — | neu: 14 Szenarien (davon 2 UDP, 3 TCP, 4 DHCP), Ergebnis mit dem Swift-Kern abgeglichen (`tools/swift-oracle`); Schritte für UDP, TCP und DHCP; die vier DHCP-Szenarien folgen seit F2.3b Java und müssen von Swift abweichen |
|
||||
| `test/dhcpJava.test.ts`, `test/scenarios/*.java.json`, `test/scenario.ts` | — | neu: die DHCP-Szenarien gegen Java-Filius (`tools/java-oracle`, `DhcpRun`): je Client dieselben Frames in derselben Reihenfolge, Abstände auf 200 ms, dieselben Adressen am Ende |
|
||||
| `test/oracle.test.ts`, `test/scenarios/` | — | neu: 14 Szenarien (davon 2 UDP, 3 TCP, 4 DHCP), Ergebnis mit dem Swift-Kern abgeglichen (`tools/swift-oracle`); Schritte für UDP, TCP und DHCP; die vier DHCP-Szenarien folgen seit F2.3b Java, die drei TCP-Szenarien seit F2.3c; sie müssen von Swift abweichen |
|
||||
| `test/dhcpJava.test.ts`, `test/scenarios/*.java.json`, `test/scenario.ts`, `test/javaRun.ts` | — | neu: die DHCP-Szenarien gegen Java-Filius (`tools/java-oracle`, `DhcpRun`): je Client dieselben Frames in derselben Reihenfolge, Abstände auf 200 ms, dieselben Adressen am Ende |
|
||||
| `test/tcpJava.test.ts`, `test/scenarios/tcp-java-*.java.json` | — | neu: drei TCP-Abläufe gegen Java-Filius (`TcpRun`): Frames mit Flags und Nummern, Socket-Zustände; ein benannter Java-Fehler (`JAVA_BUGS`: nur ein SYN) |
|
||||
| `test/routeTable.test.ts` | `TopologyJavaRouteTableTests.swift` (8 von 12 Tests) | übertragen; Geometrie- und Reducer-Tests folgen mit Editor und Dokument |
|
||||
|
||||
## packages/fls
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
// without remote link, wireless and gateway NAT; with the firewall)
|
||||
// "UDP sockets and Router RIP" (lines ~4472–5187, the
|
||||
// UDP sockets; RIP not yet)
|
||||
// "Java-compatible TCP sockets" (lines ~5187–5913)
|
||||
// "Java-compatible TCP sockets" (lines ~5187–5913; the
|
||||
// timing and closing after Java's TCPSocket, measured with Java
|
||||
// FILIUS: 3 round trips per attempt and for TIME_WAIT, the other end
|
||||
// closes by itself on FIN, failed data closes with FIN)
|
||||
// "DHCP server and automatic client" (lines ~5913–6694; the
|
||||
// client rebuilt after Java's DHCPClient, measured with Java FILIUS:
|
||||
// one message per attempt, an ARP check that waits a round trip,
|
||||
@@ -366,8 +369,6 @@ function sameIgnoringCase(lhs: string | undefined, rhs: string | undefined): boo
|
||||
|
||||
export const TCP_MAXIMUM_SEGMENT_SIZE = 1_460;
|
||||
export const TCP_MAXIMUM_SEND_ATTEMPTS = 3;
|
||||
export const TCP_ROUND_TRIP_TIME_MILLISECONDS = 2_500;
|
||||
export const TCP_DEFAULT_TIMEOUT_MILLISECONDS = TCP_MAXIMUM_SEND_ATTEMPTS * TCP_ROUND_TRIP_TIME_MILLISECONDS;
|
||||
export const TCP_EPHEMERAL_PORT_LOWER_BOUND = 49_152;
|
||||
export const TCP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE = 65_535;
|
||||
|
||||
@@ -1330,7 +1331,8 @@ export class NetworkRuntimeEngine {
|
||||
tcpState: 'SYN_RCVD',
|
||||
parentListenerSocketId: listener.id,
|
||||
});
|
||||
const session = this.makeTcpSession(acceptedId, 'SYN_RCVD');
|
||||
// Java accepts on the socket that listened: it has the listener's initial sequence number.
|
||||
const session = this.makeTcpSession(acceptedId, 'SYN_RCVD', state.tcpSessionsById.get(listener.id)?.nextSendSequenceNumber);
|
||||
session.remoteSequenceNumber = nextTcpSequenceNumber(segment);
|
||||
state.tcpSessionsById.set(acceptedId, session);
|
||||
getOrCreate(state.tcpAcceptedSocketIdsByListenerId, listener.id, () => []).push(acceptedId);
|
||||
@@ -1370,14 +1372,28 @@ export class NetworkRuntimeEngine {
|
||||
this.sendTcpAcknowledgement(socketId, segment);
|
||||
return;
|
||||
}
|
||||
case 'SYN_RCVD':
|
||||
case 'SYN_RCVD': {
|
||||
if (!has(TCP_FLAG.acknowledgement) || !this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber)) return;
|
||||
this.updateTcpState(socketId, 'ESTABLISHED');
|
||||
// Java: the socket that listened is the connection now; the server
|
||||
// listens on with a new socket, and a new initial sequence number.
|
||||
const listenerId = socket.parentListenerSocketId;
|
||||
if (listenerId !== undefined && this.stateValue.tcpSessionsById.has(listenerId)) {
|
||||
this.stateValue.tcpSessionsById.set(listenerId, this.makeTcpSession(listenerId, 'LISTEN'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'TIME_WAIT':
|
||||
this.clearTcpPendingTransmission(socketId);
|
||||
this.updateTcpState(socketId, 'CLOSED');
|
||||
return;
|
||||
case 'ESTABLISHED':
|
||||
if (has(TCP_FLAG.finish)) {
|
||||
this.sendTcpAcknowledgement(socketId, segment);
|
||||
this.updateTcpState(socketId, 'CLOSE_WAIT');
|
||||
// Java's socket thread leaves listen once the state is no longer
|
||||
// ESTABLISHED and closes by itself, without waiting for the application.
|
||||
this.closeTcpSocket(socketId);
|
||||
} else if (segment.payload.length > 0 || has(TCP_FLAG.push)) {
|
||||
this.receiveTcpPayload(socketId, segment);
|
||||
} else if (has(TCP_FLAG.acknowledgement)) {
|
||||
@@ -1453,6 +1469,9 @@ export class NetworkRuntimeEngine {
|
||||
if (socket === undefined || socket.remoteIPAddress === undefined || socket.remotePort === undefined || session === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
// Java's socket starts waiting when it hands the segment down, before
|
||||
// ARP has found the next hop; the timeout counts from here.
|
||||
const handedDownAt = state.currentTimeMilliseconds;
|
||||
let segment: TcpSegment;
|
||||
if (retransmission && session.lastSentSegment !== undefined) {
|
||||
segment = session.lastSentSegment;
|
||||
@@ -1500,14 +1519,19 @@ export class NetworkRuntimeEngine {
|
||||
detail: retransmission ? `${detail} retransmission attempt ${session.sendAttempts}` : detail,
|
||||
});
|
||||
const delivery = this.sendIPv4Packet(socket.nodeId, packet, networkInterface.portId);
|
||||
if (state.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined) this.scheduleTcpTimeoutIfNeeded(socketId);
|
||||
if (state.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined) this.scheduleTcpTimeoutIfNeeded(socketId, handedDownAt);
|
||||
return delivery;
|
||||
}
|
||||
|
||||
private scheduleTcpTimeoutIfNeeded(socketId: Id): void {
|
||||
private scheduleTcpTimeoutIfNeeded(socketId: Id, fromMilliseconds: number): void {
|
||||
const pending = this.stateValue.pendingEvents.some((event) => event.kind.kind === 'tcpTimeout' && event.kind.sessionId === socketId);
|
||||
if (pending) return;
|
||||
this.schedule(this.stateValue.currentTimeMilliseconds + TCP_DEFAULT_TIMEOUT_MILLISECONDS, { kind: 'tcpTimeout', sessionId: socketId });
|
||||
this.schedule(fromMilliseconds + this.tcpTimeoutMilliseconds(), { kind: 'tcpTimeout', sessionId: socketId });
|
||||
}
|
||||
|
||||
/** Java's defaultTimeout: MAX_SENDEVERSUCHE round trips, for each attempt and for TIME_WAIT. */
|
||||
private tcpTimeoutMilliseconds(): number {
|
||||
return TCP_MAXIMUM_SEND_ATTEMPTS * this.runtimeSpeed.roundTripTimeMilliseconds;
|
||||
}
|
||||
|
||||
private handleTcpTimeout(socketId: Id): void {
|
||||
@@ -1523,7 +1547,14 @@ export class NetworkRuntimeEngine {
|
||||
if (session === undefined || last === undefined) return;
|
||||
if (session.sendAttempts >= TCP_MAXIMUM_SEND_ATTEMPTS) {
|
||||
this.clearTcpPendingTransmission(socketId);
|
||||
this.updateTcpState(socketId, 'CLOSED');
|
||||
// Java: data that no one acknowledges (sent only while ESTABLISHED)
|
||||
// ends senden with schliessen, an orderly close with FIN. A handshake
|
||||
// or a FIN that fails closes at once.
|
||||
if (socket.tcpState === 'ESTABLISHED') {
|
||||
this.closeTcpSocket(socketId);
|
||||
} else {
|
||||
this.updateTcpState(socketId, 'CLOSED');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const synchronize = (last.flags & TCP_FLAG.synchronize) !== 0;
|
||||
@@ -1560,10 +1591,11 @@ export class NetworkRuntimeEngine {
|
||||
);
|
||||
}
|
||||
|
||||
/** Java's close loop: TIME_WAIT ends with the next segment, else after defaultTimeout. */
|
||||
private enterTcpTimeWait(socketId: Id): void {
|
||||
this.clearTcpPendingTransmission(socketId);
|
||||
this.updateTcpState(socketId, 'TIME_WAIT');
|
||||
this.schedule(this.stateValue.currentTimeMilliseconds + TCP_ROUND_TRIP_TIME_MILLISECONDS, { kind: 'tcpTimeout', sessionId: socketId });
|
||||
this.schedule(this.stateValue.currentTimeMilliseconds + this.tcpTimeoutMilliseconds(), { kind: 'tcpTimeout', sessionId: socketId });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1579,8 +1611,12 @@ export class NetworkRuntimeEngine {
|
||||
const socket = this.stateValue.socketsById.get(socketId);
|
||||
if (socket === undefined || socket.tcpState === undefined) return advancedToTimeout;
|
||||
const waitingOnState = options.waitingStates?.includes(socket.tcpState) === true;
|
||||
// Data waits for its acknowledgement only while connected: once the
|
||||
// socket closes (Java: senden gives up and calls schliessen), it returns.
|
||||
const waitingOnAck =
|
||||
options.waitingForAcknowledgement === true && this.stateValue.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined;
|
||||
options.waitingForAcknowledgement === true &&
|
||||
socket.tcpState === 'ESTABLISHED' &&
|
||||
this.stateValue.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined;
|
||||
if (!waitingOnState && !waitingOnAck) return advancedToTimeout;
|
||||
const deadlines = this.stateValue.pendingEvents
|
||||
.filter((event) => event.kind.kind === 'tcpTimeout' && event.kind.sessionId === socketId)
|
||||
@@ -1637,8 +1673,11 @@ export class NetworkRuntimeEngine {
|
||||
return Number(BigInt.asUintN(32, (value % 4_294_967_296n) * 1_000_000n));
|
||||
}
|
||||
|
||||
private makeTcpSession(socketId: Id, socketState: TcpSocketState): TcpSession {
|
||||
const initialSequence = this.allocateTcpInitialSequenceNumber();
|
||||
private makeTcpSession(
|
||||
socketId: Id,
|
||||
socketState: TcpSocketState,
|
||||
initialSequence: number = this.allocateTcpInitialSequenceNumber(),
|
||||
): TcpSession {
|
||||
return {
|
||||
id: socketId,
|
||||
socketId,
|
||||
|
||||
@@ -1,133 +1,44 @@
|
||||
// DHCP as Java FILIUS runs it. tools/java-oracle's DhcpRun built each
|
||||
// scenario's network in Java FILIUS, ran it and wrote what every network card
|
||||
// saw (<name>.java.json, FILIUS' own packet log). The engine runs the same
|
||||
// network. For each DHCP client, the frames that concern it must come in the
|
||||
// same order, with the same content, at about the same distance from each
|
||||
// other, and every host must end with the same addresses.
|
||||
// saw (<name>.java.json). The engine runs the same network. For each DHCP
|
||||
// client, the frames that concern it must come in the same order, with the
|
||||
// same content, at about the same distance from each other (javaRun.ts), and
|
||||
// every host must end with the same addresses.
|
||||
//
|
||||
// A frame counts when it first shows on any card (sent, or received), in
|
||||
// Java and in the engine alike. Not only on the client's own card: there,
|
||||
// FILIUS' log misses the DISCOVER of the client that sends second when two
|
||||
// start together (measured in four of four runs of dhcp-lan; the server
|
||||
// receives it) — a fault of the log, not of the network. Java logs no frame
|
||||
// identity; the same text within DUPLICATE_WINDOW_MILLISECONDS is one frame
|
||||
// (measured: a frame reaches the last card within some 130 ms; the closest
|
||||
// two distinct frames of the same text are the DISCOVERs around a DECLINE,
|
||||
// some 520 ms apart).
|
||||
//
|
||||
// Java runs on threads in real time, at the default speed of 90 % (55 ms a
|
||||
// cable, a round trip time of 2750 ms). Its gaps match the engine's within a
|
||||
// few milliseconds, except where a card sends two frames at once: Java's
|
||||
// cable carries one frame at a time (SimplexVerbindung sleeps the delay with
|
||||
// the port's buffer locked, and the sender waits), the engine's cable all of
|
||||
// them together — up to three cables' delay, measured. A gap may differ by
|
||||
// GAP_TOLERANCE_MILLISECONDS; a wrong round trip time (250 ms) may not. Java draws the link-local
|
||||
// address with Math.random; it is compared as 169.254.1.x. Where several
|
||||
// clients start together, which one is first varies in Java (the first
|
||||
// thread pays for loading the classes); a client is therefore compared
|
||||
// without the other clients' frames and the answers to them.
|
||||
// Java draws the link-local address with Math.random; it is compared as
|
||||
// 169.254.1.x. Where several clients start together, which one is first
|
||||
// varies in Java (the first thread pays for loading the classes); a client is
|
||||
// therefore compared without the other clients' frames and the answers to
|
||||
// them.
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { NetworkRuntimeEngine, stableMacAddress, type EthernetFrame } from '../src/index.js';
|
||||
import { snapshotOf, type Scenario } from './scenario.js';
|
||||
import { stableMacAddress } from '../src/index.js';
|
||||
import { GAP_TOLERANCE_MILLISECONDS, javaFrames, watchedEngine, type JavaFrame, type SeenFrame } from './javaRun.js';
|
||||
import { type Scenario } from './scenario.js';
|
||||
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
|
||||
const GAP_TOLERANCE_MILLISECONDS = 200;
|
||||
const DUPLICATE_WINDOW_MILLISECONDS = 300;
|
||||
|
||||
interface JavaFrame {
|
||||
time: number;
|
||||
source: string;
|
||||
destination: string;
|
||||
arp?: number;
|
||||
senderIp?: string;
|
||||
targetIp?: string;
|
||||
sourceIp?: string;
|
||||
destinationIp?: string;
|
||||
protocol?: number;
|
||||
sourcePort?: number;
|
||||
destinationPort?: number;
|
||||
payload?: string;
|
||||
icmp?: number;
|
||||
}
|
||||
|
||||
interface JavaRun {
|
||||
frames: Record<string, JavaFrame[]>;
|
||||
devices: Record<string, [string, string, string, string]>;
|
||||
}
|
||||
|
||||
interface SeenFrame {
|
||||
readonly time: number;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
const LINK_LOCAL = /169\.254\.1\.\d+/g;
|
||||
|
||||
function javaText(frame: JavaFrame): string {
|
||||
const macs = `${frame.source} > ${frame.destination}`.toUpperCase();
|
||||
if (frame.arp !== undefined) return `${macs} ARP ${frame.arp === 1 ? 'request' : 'reply'} ${frame.senderIp} > ${frame.targetIp}`;
|
||||
if (frame.icmp !== undefined) return `${macs} ICMP ${frame.icmp}`;
|
||||
return `${macs} UDP ${frame.sourceIp}:${frame.sourcePort} > ${frame.destinationIp}:${frame.destinationPort} ${frame.payload}`;
|
||||
}
|
||||
|
||||
function engineText(frame: EthernetFrame): string {
|
||||
const macs = `${frame.sourceMacAddress} > ${frame.destinationMacAddress}`.toUpperCase();
|
||||
if (frame.payload.kind === 'arp') {
|
||||
const arp = frame.payload.packet;
|
||||
return `${macs} ARP ${arp.operation} ${arp.senderIPAddress} > ${arp.targetIPAddress}`;
|
||||
}
|
||||
const packet = frame.payload.packet;
|
||||
if (packet.payload.kind !== 'udp') return `${macs} ${packet.payload.kind.toUpperCase()}`;
|
||||
const datagram = packet.payload.datagram;
|
||||
return `${macs} UDP ${packet.senderIPAddress}:${datagram.sourcePort} > ${packet.receiverIPAddress}:${datagram.destinationPort} ${new TextDecoder().decode(datagram.payload)}`;
|
||||
}
|
||||
|
||||
/** The engine's run of the scenario's network, without its steps, for `milliseconds`. */
|
||||
function runEngine(scenario: Scenario, milliseconds: number) {
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed);
|
||||
const framesByIdentity = new Map<number, EthernetFrame>();
|
||||
const send = engine.sendEthernetFrame.bind(engine);
|
||||
engine.sendEthernetFrame = (fromNodeId, outgoingPortId, frame) => {
|
||||
framesByIdentity.set(frame.identity, frame);
|
||||
send(fromNodeId, outgoingPortId, frame);
|
||||
};
|
||||
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
|
||||
const { engine, frames } = watchedEngine(scenario);
|
||||
engine.handle({ type: 'advance', toMilliseconds: milliseconds });
|
||||
|
||||
// Every frame when it first shows on a host's card (as FILIUS logs it: sent, or received).
|
||||
const hosts = new Set(scenario.nodes.filter((node) => node.kind !== 'networkSwitch').map((node) => node.id));
|
||||
const firstSeen = new Map<number, SeenFrame>();
|
||||
for (const event of engine.state.packetTraces) {
|
||||
if (event.layer !== 'physical' || !hosts.has(event.nodeId) || event.frameIdentity === undefined) continue;
|
||||
if (!((event.direction === 'outbound' && event.operation === 'sent') || (event.direction === 'inbound' && event.operation === 'received'))) continue;
|
||||
if (firstSeen.has(event.frameIdentity)) continue;
|
||||
firstSeen.set(event.frameIdentity, { time: event.timeMilliseconds, text: engineText(framesByIdentity.get(event.frameIdentity)!) });
|
||||
}
|
||||
const devices = Object.fromEntries(
|
||||
[...engine.state.topologySnapshot.deviceConfigurations].map(([node, device]) => [
|
||||
node,
|
||||
[device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer],
|
||||
]),
|
||||
);
|
||||
return { frames: [...firstSeen.values()], devices };
|
||||
}
|
||||
|
||||
/** Java's frames of all cards, each once: the same text within the window is the same frame. */
|
||||
function javaFrames(java: JavaRun): SeenFrame[] {
|
||||
const all = Object.values(java.frames)
|
||||
.flat()
|
||||
.map((frame) => ({ time: frame.time, text: javaText(frame) }))
|
||||
.sort((lhs, rhs) => lhs.time - rhs.time);
|
||||
const frames: SeenFrame[] = [];
|
||||
for (const frame of all) {
|
||||
if (frames.some((each) => each.text === frame.text && frame.time - each.time <= DUPLICATE_WINDOW_MILLISECONDS)) continue;
|
||||
frames.push(frame);
|
||||
}
|
||||
return frames;
|
||||
return { frames: frames(), devices };
|
||||
}
|
||||
|
||||
/** Consecutive frames without the other clients' own: the text, and the time since the one before. */
|
||||
@@ -147,7 +58,7 @@ function clientMac(scenario: Scenario, nodeId: string): string {
|
||||
}
|
||||
|
||||
const runs = readdirSync(scenarioDirectory)
|
||||
.filter((name) => name.endsWith('.java.json'))
|
||||
.filter((name) => name.startsWith('dhcp-') && name.endsWith('.java.json'))
|
||||
.map((name) => name.replace('.java.json', ''))
|
||||
.sort();
|
||||
|
||||
@@ -166,7 +77,7 @@ describe('DHCP as Java FILIUS runs it', () => {
|
||||
const clients = scenario.dhcpClients ?? [];
|
||||
for (const client of clients) {
|
||||
const others = clients.filter((each) => each !== client).map((each) => clientMac(scenario, each));
|
||||
const expected = steps(javaFrames(java), others);
|
||||
const expected = steps(javaFrames(java.frames), others);
|
||||
const actual = steps(engine.frames, others);
|
||||
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
|
||||
expect(actual[index]?.text, `${client} frame #${index + 1}`).toBe(expected[index]?.text);
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
stableMacAddress,
|
||||
switchConfiguration,
|
||||
TCP_FLAG,
|
||||
TCP_ROUND_TRIP_TIME_MILLISECONDS,
|
||||
tcpFlagDescription,
|
||||
topologySnapshot,
|
||||
traceRetentionPolicy,
|
||||
@@ -899,7 +898,7 @@ describe('TCP sockets', () => {
|
||||
expect(tcpFlagDescription(0)).toBe('');
|
||||
});
|
||||
|
||||
it("closes with Java's FIN states, and TIME_WAIT releases both ends", () => {
|
||||
it("closes with Java's FIN states: the other end closes by itself, TIME_WAIT lasts three round trips", () => {
|
||||
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)!;
|
||||
@@ -907,16 +906,82 @@ describe('TCP sockets', () => {
|
||||
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);
|
||||
// Java's socket thread leaves listen on the FIN and closes without the application.
|
||||
const serverStates = engine.state.packetTraces
|
||||
.filter((trace) => trace.nodeId === serverId && trace.detail?.startsWith('TCP ') === true && trace.detail.includes(' -> '))
|
||||
.map((trace) => trace.detail);
|
||||
expect(serverStates.slice(-3)).toEqual(['TCP ESTABLISHED -> CLOSE_WAIT', 'TCP CLOSE_WAIT -> LAST_ACK', 'TCP LAST_ACK -> CLOSED']);
|
||||
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
|
||||
engine.closeTcpSocket(serverSocketId);
|
||||
expect(engine.state.socketsById.get(serverSocketId)?.tcpState).toBe('CLOSED');
|
||||
|
||||
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + TCP_ROUND_TRIP_TIME_MILLISECONDS });
|
||||
// Java's defaultTimeout: three round trips of 2750 ms at 90 %.
|
||||
const timeWait = engine.state.packetTraces.find((trace) => trace.nodeId === clientId && trace.detail?.endsWith('-> TIME_WAIT') === true)!
|
||||
.timeMilliseconds;
|
||||
engine.handle({ type: 'advance', toMilliseconds: timeWait + 3 * 2_750 - 1 });
|
||||
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
|
||||
engine.handle({ type: 'advance', toMilliseconds: timeWait + 3 * 2_750 });
|
||||
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('CLOSED');
|
||||
expect(engine.state.socketsById.get(listenerId)?.tcpState).toBe('LISTEN');
|
||||
});
|
||||
|
||||
it('ends TIME_WAIT with the next segment that comes', () => {
|
||||
const { engine, clientId, serverId } = pair(0x1771, 0x1772, '10.21.0');
|
||||
engine.openTcpServerSocket(serverId, 9090);
|
||||
const clientSocketId = engine.openTcpClientSocket(clientId, '10.21.0.2', 9090)!;
|
||||
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
||||
engine.closeTcpSocket(clientSocketId);
|
||||
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
|
||||
|
||||
const localPort = engine.state.socketsById.get(clientSocketId)!.localPort;
|
||||
engine.sendIPv4Packet(serverId, {
|
||||
identity: 77,
|
||||
senderIPAddress: '10.21.0.2',
|
||||
receiverIPAddress: '10.21.0.1',
|
||||
timeToLive: 64,
|
||||
protocol: 'tcp',
|
||||
payload: {
|
||||
kind: 'tcp',
|
||||
segment: { sourcePort: 9090, destinationPort: localPort, sequenceNumber: 1, acknowledgementNumber: 0, flags: TCP_FLAG.acknowledgement, payload: new Uint8Array() },
|
||||
},
|
||||
});
|
||||
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 100 });
|
||||
|
||||
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('CLOSED');
|
||||
});
|
||||
|
||||
it('accepts on the socket that listened, and listens on with the next sequence number', () => {
|
||||
const [firstId, firstPortId, secondId, secondPortId, serverId, serverPortId, switchId] = [
|
||||
0x1781, 0x1782, 0x1783, 0x1784, 0x1785, 0x1786, 0x1787,
|
||||
].map(uuid) as [string, string, string, string, string, string, string];
|
||||
const switchPorts = [0x1791, 0x1792, 0x1793].map(uuid) as [string, string, string];
|
||||
const engine = startedEngine({
|
||||
nodes: [
|
||||
pc(firstId, firstPortId),
|
||||
pc(secondId, secondPortId),
|
||||
pc(serverId, serverPortId),
|
||||
{ id: switchId, kind: 'networkSwitch', ports: switchPorts.map((id, index) => portSnapshot(id, `sw${index + 1}`)) },
|
||||
],
|
||||
links: [
|
||||
link(firstId, firstPortId, switchId, switchPorts[0]),
|
||||
link(secondId, secondPortId, switchId, switchPorts[1]),
|
||||
link(serverId, serverPortId, switchId, switchPorts[2]),
|
||||
],
|
||||
deviceConfigurations: new Map([
|
||||
[firstId, device('10.22.0.1')],
|
||||
[secondId, device('10.22.0.2')],
|
||||
[serverId, device('10.22.0.3')],
|
||||
]),
|
||||
});
|
||||
// Java counts initial sequence numbers over all TCP sockets: the listener 1, the first client 2,
|
||||
// the listener's successor 3 (made when the first is accepted), the second client 4.
|
||||
const listenerId = engine.openTcpServerSocket(serverId, 80)!;
|
||||
expect(engine.connectTcp(engine.openTcpClientSocket(firstId, '10.22.0.3', 80)!)).toBe(true);
|
||||
expect(engine.connectTcp(engine.openTcpClientSocket(secondId, '10.22.0.3', 80)!)).toBe(true);
|
||||
|
||||
const accepted = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)!;
|
||||
expect(accepted.map((id) => engine.state.tcpSessionsById.get(id)?.nextSendSequenceNumber)).toEqual([1_000_001, 3_000_001]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DHCP', () => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// What tools/java-oracle's DhcpRun and TcpRun recorded in Java FILIUS
|
||||
// (<name>.java.json: every frame each card saw, FILIUS' own packet log), and
|
||||
// the same view of an engine run: each frame when it first shows on any
|
||||
// host's card, as a line of text. Shared by dhcpJava.test.ts and
|
||||
// tcpJava.test.ts.
|
||||
//
|
||||
// Not only the frames of one card: FILIUS' log misses the DISCOVER of the
|
||||
// DHCP client that sends second when two start together (measured in four of
|
||||
// four runs of dhcp-lan; the server receives it) — a fault of the log, not of
|
||||
// the network. Java logs no frame identity; the same text within
|
||||
// DUPLICATE_WINDOW_MILLISECONDS is one frame (measured: a frame reaches the
|
||||
// last card within some 130 ms; the closest two distinct frames of the same
|
||||
// text are the DISCOVERs around a DECLINE, some 520 ms apart).
|
||||
//
|
||||
// Java runs on threads in real time, at the default speed of 90 % (55 ms a
|
||||
// cable, a round trip time of 2750 ms). Its gaps match the engine's within a
|
||||
// few milliseconds, except where a card sends two frames at once: Java's
|
||||
// cable carries one frame at a time (SimplexVerbindung sleeps the delay with
|
||||
// the port's buffer locked, and the sender waits), the engine's cable all of
|
||||
// them together — up to three cables' delay, measured. A gap may differ by
|
||||
// GAP_TOLERANCE_MILLISECONDS; a wrong round trip time (250 ms) may not.
|
||||
|
||||
import { NetworkRuntimeEngine, TCP_FLAG, type EthernetFrame } from '../src/index.js';
|
||||
import { snapshotOf, type Scenario } from './scenario.js';
|
||||
|
||||
export const GAP_TOLERANCE_MILLISECONDS = 200;
|
||||
const DUPLICATE_WINDOW_MILLISECONDS = 300;
|
||||
|
||||
export interface JavaFrame {
|
||||
time: number;
|
||||
source: string;
|
||||
destination: string;
|
||||
arp?: number;
|
||||
senderIp?: string;
|
||||
targetIp?: string;
|
||||
sourceIp?: string;
|
||||
destinationIp?: string;
|
||||
protocol?: number;
|
||||
sourcePort?: number;
|
||||
destinationPort?: number;
|
||||
payload?: string | null;
|
||||
icmp?: number;
|
||||
tcpFlags?: string;
|
||||
sequence?: number;
|
||||
acknowledgement?: number;
|
||||
}
|
||||
|
||||
export interface SeenFrame {
|
||||
readonly time: number;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
function javaText(frame: JavaFrame): string {
|
||||
const macs = `${frame.source} > ${frame.destination}`.toUpperCase();
|
||||
if (frame.arp !== undefined) return `${macs} ARP ${frame.arp === 1 ? 'request' : 'reply'} ${frame.senderIp} > ${frame.targetIp}`;
|
||||
if (frame.icmp !== undefined) return `${macs} ICMP ${frame.icmp}`;
|
||||
const ends = `${frame.sourceIp}:${frame.sourcePort} > ${frame.destinationIp}:${frame.destinationPort}`;
|
||||
if (frame.tcpFlags !== undefined) {
|
||||
return `${macs} TCP ${ends} ${frame.tcpFlags || '-'} seq=${frame.sequence} ack=${frame.acknowledgement} length=${(frame.payload ?? '').length}`;
|
||||
}
|
||||
return `${macs} UDP ${ends} ${frame.payload}`;
|
||||
}
|
||||
|
||||
const FLAG_LETTERS: [number, string][] = [
|
||||
[TCP_FLAG.synchronize, 'S'],
|
||||
[TCP_FLAG.acknowledgement, 'A'],
|
||||
[TCP_FLAG.finish, 'F'],
|
||||
[TCP_FLAG.push, 'P'],
|
||||
[TCP_FLAG.reset, 'R'],
|
||||
];
|
||||
|
||||
function engineText(frame: EthernetFrame): string {
|
||||
const macs = `${frame.sourceMacAddress} > ${frame.destinationMacAddress}`.toUpperCase();
|
||||
if (frame.payload.kind === 'arp') {
|
||||
const arp = frame.payload.packet;
|
||||
return `${macs} ARP ${arp.operation} ${arp.senderIPAddress} > ${arp.targetIPAddress}`;
|
||||
}
|
||||
const packet = frame.payload.packet;
|
||||
if (packet.payload.kind === 'icmp') return `${macs} ICMP`;
|
||||
if (packet.payload.kind === 'tcp') {
|
||||
const segment = packet.payload.segment;
|
||||
const flags = FLAG_LETTERS.filter(([bit]) => (segment.flags & bit) !== 0).map(([, letter]) => letter).join('');
|
||||
return `${macs} TCP ${packet.senderIPAddress}:${segment.sourcePort} > ${packet.receiverIPAddress}:${segment.destinationPort} ${flags || '-'} seq=${segment.sequenceNumber} ack=${segment.acknowledgementNumber} length=${segment.payload.length}`;
|
||||
}
|
||||
const datagram = packet.payload.datagram;
|
||||
return `${macs} UDP ${packet.senderIPAddress}:${datagram.sourcePort} > ${packet.receiverIPAddress}:${datagram.destinationPort} ${new TextDecoder().decode(datagram.payload)}`;
|
||||
}
|
||||
|
||||
/** A started engine on the scenario's network whose frames can be read afterwards as FILIUS logs them. */
|
||||
export function watchedEngine(scenario: Scenario) {
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed);
|
||||
const framesByIdentity = new Map<number, EthernetFrame>();
|
||||
const send = engine.sendEthernetFrame.bind(engine);
|
||||
engine.sendEthernetFrame = (fromNodeId, outgoingPortId, frame) => {
|
||||
framesByIdentity.set(frame.identity, frame);
|
||||
send(fromNodeId, outgoingPortId, frame);
|
||||
};
|
||||
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
|
||||
|
||||
/** Every frame when it first shows on a host's card (sent, or received). */
|
||||
const frames = (): SeenFrame[] => {
|
||||
const hosts = new Set(scenario.nodes.filter((node) => node.kind !== 'networkSwitch').map((node) => node.id));
|
||||
const firstSeen = new Map<number, SeenFrame>();
|
||||
for (const event of engine.state.packetTraces) {
|
||||
if (event.layer !== 'physical' || !hosts.has(event.nodeId) || event.frameIdentity === undefined) continue;
|
||||
if (!((event.direction === 'outbound' && event.operation === 'sent') || (event.direction === 'inbound' && event.operation === 'received'))) {
|
||||
continue;
|
||||
}
|
||||
if (firstSeen.has(event.frameIdentity)) continue;
|
||||
firstSeen.set(event.frameIdentity, { time: event.timeMilliseconds, text: engineText(framesByIdentity.get(event.frameIdentity)!) });
|
||||
}
|
||||
return [...firstSeen.values()];
|
||||
};
|
||||
return { engine, frames };
|
||||
}
|
||||
|
||||
/** Java's frames of all cards, each once: the same text within the window is the same frame. */
|
||||
export function javaFrames(framesByNode: Record<string, JavaFrame[]>): SeenFrame[] {
|
||||
const all = Object.values(framesByNode)
|
||||
.flat()
|
||||
.map((frame) => ({ time: frame.time, text: javaText(frame) }))
|
||||
.sort((lhs, rhs) => lhs.time - rhs.time);
|
||||
const frames: SeenFrame[] = [];
|
||||
for (const frame of all) {
|
||||
if (frames.some((each) => each.text === frame.text && frame.time - each.time <= DUPLICATE_WINDOW_MILLISECONDS)) continue;
|
||||
frames.push(frame);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** Consecutive frames: the text, and the time since the one before. */
|
||||
export function gaps(frames: readonly SeenFrame[]) {
|
||||
return frames.map((frame, index) => ({ text: frame.text, gap: index === 0 ? 0 : frame.time - frames[index - 1]!.time }));
|
||||
}
|
||||
@@ -285,7 +285,8 @@ const SWIFT_ERRORS: Record<string, (expected: Record<string, unknown>) => Record
|
||||
|
||||
/**
|
||||
* Scenarios where the engine follows Java instead of Swift; the Java
|
||||
* comparison checks them (dhcpJava.test.ts, measured with Java FILIUS). Here
|
||||
* comparisons check that behaviour (dhcpJava.test.ts, tcpJava.test.ts,
|
||||
* measured with Java FILIUS). Here
|
||||
* they must differ from Swift, or the departure is not real.
|
||||
*/
|
||||
const JAVA_INSTEAD_OF_SWIFT: Record<string, string> = {
|
||||
@@ -293,6 +294,9 @@ const JAVA_INSTEAD_OF_SWIFT: Record<string, string> = {
|
||||
'dhcp-two-servers': 'DHCP client: the second OFFER is read and dropped, no REQUEST twice (Swift: answered with NAK)',
|
||||
'dhcp-conflict': 'DHCP client: the ARP check finds the address taken and declines (Swift: it does not wait, two cards share the address)',
|
||||
'dhcp-no-server': 'DHCP client: a link-local address after five timeouts (Swift: the old address after ten)',
|
||||
'tcp-lan': 'TCP: the other end closes by itself on FIN, TIME_WAIT lasts 3 round trips, the accepted socket has the listener\'s sequence number',
|
||||
'tcp-routed-loss': 'TCP: 3 round trips per attempt follow the speed (8250 ms at 90 %), failed data closes with FIN',
|
||||
'tcp-closing': 'TCP: TIME_WAIT lasts 3 round trips and ends with the next segment; the other end closes by itself',
|
||||
};
|
||||
|
||||
describe('same result as the Swift engine', () => {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// TCP as Java FILIUS runs it. tools/java-oracle's TcpRun played stories of
|
||||
// socket calls on tcp-lan's network in Java FILIUS and wrote every frame each
|
||||
// card saw, and every state each socket went through (tcp-java-<story>.java.json).
|
||||
// The engine plays the same stories. The frames must come in the same order,
|
||||
// with the same content, at about the same distance from each other
|
||||
// (javaRun.ts); each state Java saw must come at about the same time.
|
||||
//
|
||||
// Java polled the states every 2 ms from another thread; a state the engine
|
||||
// enters and leaves within STATE_UNSEEN_MILLISECONDS (CLOSE_WAIT, when the
|
||||
// passive side closes by itself) Java may not have seen. The free port a
|
||||
// client draws differs (Java's random, the engine's seed); it is compared as
|
||||
// "client". Times count from the client's SYN.
|
||||
//
|
||||
// The engine's calls block on its virtual clock one after the other, where
|
||||
// Java's server answers in a thread of its own: the server's answer starts
|
||||
// when the client's send returns, one cable's delay later than in Java.
|
||||
|
||||
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 { GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, watchedEngine, type JavaFrame, type SeenFrame } from './javaRun.js';
|
||||
import type { Scenario } from './scenario.js';
|
||||
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
|
||||
const STATE_UNSEEN_MILLISECONDS = 20;
|
||||
const SERVER = '00000000-0000-0000-0000-000000000101';
|
||||
const CLIENT = '00000000-0000-0000-0000-000000000102';
|
||||
const OTHER_CLIENT = '00000000-0000-0000-0000-000000000103';
|
||||
|
||||
interface JavaRun {
|
||||
frames: Record<string, JavaFrame[]>;
|
||||
events: [number, string][];
|
||||
states: Record<string, [number, string][]>;
|
||||
}
|
||||
|
||||
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". */
|
||||
interface Story {
|
||||
readonly client: Id;
|
||||
readonly play: (engine: NetworkRuntimeEngine, advanceTo: (milliseconds: number) => void) => void;
|
||||
}
|
||||
|
||||
const STORIES: Record<string, Story> = {
|
||||
exchange: {
|
||||
client: CLIENT,
|
||||
play: (engine, advanceTo) => {
|
||||
const listener = engine.openTcpServerSocket(SERVER, 80)!;
|
||||
advanceTo(500);
|
||||
const client = engine.openTcpClientSocket(CLIENT, '192.168.0.10', 80)!;
|
||||
engine.connectTcpWithResult(client);
|
||||
const accepted = engine.state.tcpAcceptedSocketIdsByListenerId.get(listener)![0]!;
|
||||
engine.sendTcp(client, text('GET /'));
|
||||
engine.receiveTcp(accepted);
|
||||
engine.sendTcp(accepted, text('x'.repeat(3000)));
|
||||
engine.receiveTcp(client);
|
||||
engine.closeTcpSocket(client);
|
||||
// The server closes a second after it saw CLOSE_WAIT.
|
||||
while (engine.state.socketsById.get(accepted)?.tcpState === 'ESTABLISHED') {
|
||||
advanceTo(engine.state.currentTimeMilliseconds + 1);
|
||||
}
|
||||
advanceTo(engine.state.currentTimeMilliseconds + 1_000);
|
||||
engine.closeTcpSocket(accepted);
|
||||
},
|
||||
},
|
||||
closedPort: {
|
||||
client: OTHER_CLIENT,
|
||||
play: (engine, advanceTo) => {
|
||||
advanceTo(500);
|
||||
const client = engine.openTcpClientSocket(OTHER_CLIENT, '192.168.0.10', 81)!;
|
||||
engine.connectTcpWithResult(client);
|
||||
},
|
||||
},
|
||||
lostData: {
|
||||
client: CLIENT,
|
||||
play: (engine, advanceTo) => {
|
||||
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.setGlobalPacketLossEnabled(true);
|
||||
engine.sendTcp(client, text('verloren'));
|
||||
engine.setGlobalPacketLossEnabled(false);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** The states of the node's connection socket, from the engine's traces: when each was entered. */
|
||||
function engineStates(engine: NetworkRuntimeEngine, nodeId: Id, fromEstablished: boolean): [number, string][] {
|
||||
const transitions = engine.state.packetTraces
|
||||
.filter((trace) => trace.nodeId === nodeId && trace.detail?.startsWith('TCP ') === true && trace.detail.includes(' -> '))
|
||||
.map((trace) => [trace.timeMilliseconds, trace.detail!.slice(4).split(' -> ')] as const)
|
||||
// The listener's own (CLOSED -> LISTEN and back) are not the connection's.
|
||||
.filter(([, [from, to]]) => !(from === 'CLOSED' && to === 'LISTEN') && !(from === 'LISTEN' && to === 'CLOSED'));
|
||||
const states: [number, string][] = transitions.map(([time, [, to]]) => [time, to!]);
|
||||
if (!fromEstablished) return states;
|
||||
const established = states.findIndex(([, state]) => state === 'ESTABLISHED');
|
||||
return established < 0 ? [] : states.slice(established);
|
||||
}
|
||||
|
||||
/** Each state Java saw comes in the engine at about the same time; the engine's others are brief. */
|
||||
function expectSameStates(label: string, actual: [number, string][], expected: [number, string][]) {
|
||||
let next = 0;
|
||||
const matched = new Set<number>();
|
||||
for (const [time, state] of expected) {
|
||||
const index = actual.findIndex(([, each], candidate) => candidate >= next && each === state);
|
||||
expect(index, `${label}: ${state} (Java at ${time} ms)`).toBeGreaterThanOrEqual(0);
|
||||
expect(Math.abs(actual[index]![0] - time), `${label}: time of ${state}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
|
||||
matched.add(index);
|
||||
next = index + 1;
|
||||
}
|
||||
for (const [index, [time, state]] of actual.entries()) {
|
||||
if (matched.has(index)) continue;
|
||||
const until = actual[index + 1]?.[0] ?? Number.POSITIVE_INFINITY;
|
||||
expect(until - time, `${label}: ${state} at ${time} ms, which Java did not see`).toBeLessThanOrEqual(STATE_UNSEEN_MILLISECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Times from the first SYN, the client's free port as "client". */
|
||||
function normalized(frames: readonly SeenFrame[]): SeenFrame[] {
|
||||
const syn = frames.find((frame) => / TCP .* S(A)?(F)?(P)? seq=/.test(frame.text) && !frame.text.includes(' SA'))!;
|
||||
return frames.map((frame) => ({
|
||||
time: frame.time - syn.time,
|
||||
text: frame.text.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Java errs, per story: its run corrected, as Java means it.
|
||||
*
|
||||
* closedPort: connectClientMode sends the SYN in a loop over
|
||||
* MAX_SENDEVERSUCHE attempts (with sendeSegment's repeat flag for the
|
||||
* 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.
|
||||
*/
|
||||
const JAVA_TIMEOUT_MILLISECONDS = 3 * 2_750;
|
||||
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
|
||||
closedPort: (java) => {
|
||||
const client = OTHER_CLIENT;
|
||||
const syn = java.frames[client]!.find((frame) => frame.tcpFlags === 'S')!;
|
||||
const waitFrom = java.states.client!.find(([, state]) => state === 'SYN_SENT')![0];
|
||||
const retries = [1, 2].map((attempt) => ({ ...syn, time: waitFrom + attempt * JAVA_TIMEOUT_MILLISECONDS }));
|
||||
return {
|
||||
...java,
|
||||
frames: { ...java.frames, [client]: [...java.frames[client]!, ...retries] },
|
||||
states: {
|
||||
...java.states,
|
||||
client: java.states.client!.map(([time, state]) =>
|
||||
state === 'CLOSED' && time > waitFrom ? [waitFrom + 3 * JAVA_TIMEOUT_MILLISECONDS, state] : [time, state],
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
describe('TCP as Java FILIUS runs it', () => {
|
||||
it('corrects Java only where it errs', () => {
|
||||
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
|
||||
const java = JSON.parse(readFileSync(join(scenarioDirectory, `tcp-java-${name}.java.json`), 'utf8')) as JavaRun;
|
||||
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
|
||||
}
|
||||
});
|
||||
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'tcp-lan.scenario.json'), 'utf8')) as Scenario;
|
||||
|
||||
for (const [name, story] of Object.entries(STORIES)) {
|
||||
it(name, () => {
|
||||
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `tcp-java-${name}.java.json`), 'utf8')) as JavaRun;
|
||||
const java = JAVA_BUGS[name]?.(measured) ?? measured;
|
||||
const { engine, frames } = watchedEngine(scenario);
|
||||
const advanceTo = (milliseconds: number) => {
|
||||
engine.handle({ type: 'advance', toMilliseconds: milliseconds });
|
||||
};
|
||||
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());
|
||||
const expectedSteps = gaps(expected);
|
||||
const actualSteps = gaps(actual);
|
||||
for (let index = 0; index < Math.max(expectedSteps.length, actualSteps.length); index += 1) {
|
||||
expect(actualSteps[index]?.text, `frame #${index + 1}`).toBe(expectedSteps[index]?.text);
|
||||
expect(Math.abs(actualSteps[index]!.gap - expectedSteps[index]!.gap), `gap before frame #${index + 1}`).toBeLessThanOrEqual(
|
||||
GAP_TOLERANCE_MILLISECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
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]);
|
||||
// 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -32,7 +32,8 @@ npm test # vergleicht mit den geschriebenen *.swift.json
|
||||
F2.3b folgt der DHCP-Client des Kerns Java statt Swift; die vier
|
||||
DHCP-Szenarien vergleicht `dhcpJava.test.ts` mit Java-Läufen
|
||||
(`tools/java-oracle`, `DhcpRun`), am Swift-Ergebnis prüft der Test nur
|
||||
noch, dass sie abweichen.
|
||||
noch, dass sie abweichen. Ebenso seit F2.3c die drei TCP-Szenarien
|
||||
(`tcpJava.test.ts`, `TcpRun`).
|
||||
Das Ergebnis enthält die Sockets mit der Länge ihrer Warteschlange, dem
|
||||
TCP-Zustand, dem annehmenden Socket und den Sitzungswerten.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user