diff --git a/CHANGES.md b/CHANGES.md index 93b6d19..1eb2d57 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/packages/core/src/runtime/engine.ts b/packages/core/src/runtime/engine.ts index 2bc00c8..419e651 100644 --- a/packages/core/src/runtime/engine.ts +++ b/packages/core/src/runtime/engine.ts @@ -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, diff --git a/packages/core/test/dhcpJava.test.ts b/packages/core/test/dhcpJava.test.ts index d1939e0..2ea705b 100644 --- a/packages/core/test/dhcpJava.test.ts +++ b/packages/core/test/dhcpJava.test.ts @@ -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 (.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 (.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; devices: Record; } -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(); - 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(); - 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); diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index fb3d2a9..78da565 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -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', () => { diff --git a/packages/core/test/javaRun.ts b/packages/core/test/javaRun.ts new file mode 100644 index 0000000..61ffb45 --- /dev/null +++ b/packages/core/test/javaRun.ts @@ -0,0 +1,134 @@ +// What tools/java-oracle's DhcpRun and TcpRun recorded in Java FILIUS +// (.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(); + 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(); + 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): 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 })); +} diff --git a/packages/core/test/oracle.test.ts b/packages/core/test/oracle.test.ts index 1463c2d..1e147c1 100644 --- a/packages/core/test/oracle.test.ts +++ b/packages/core/test/oracle.test.ts @@ -285,7 +285,8 @@ const SWIFT_ERRORS: Record) => 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 = { @@ -293,6 +294,9 @@ const JAVA_INSTEAD_OF_SWIFT: Record = { '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', () => { diff --git a/packages/core/test/tcpJava.test.ts b/packages/core/test/tcpJava.test.ts new file mode 100644 index 0000000..470db6d --- /dev/null +++ b/packages/core/test/tcpJava.test.ts @@ -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-.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; + events: [number, string][]; + states: Record; +} + +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 = { + 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(); + 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 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)); + } + }); + } +}); diff --git a/tools/swift-oracle/README.md b/tools/swift-oracle/README.md index 9c47b15..41394f5 100644 --- a/tools/swift-oracle/README.md +++ b/tools/swift-oracle/README.md @@ -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.