From dbbc1fd70d9dd542048f070e31abff5a8687c052 Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Wed, 30 Sep 2026 17:11:23 +0000 Subject: [PATCH] feat(core): ARP after Java, measured with Java FILIUS An ARP lookup asks, waits a round trip, asks once more and gives up after another round trip; then host unreachable, observed at the sender itself or sent back by the router (for ICMP only for an echo request). A waiting sender wakes after what else happens at that time. A ping then waits a round trip for its answer, counted from when it left. Swift asks twice at once while events run and lets the packet wait without end when the address exists anywhere in the network; the option swiftArp keeps that for the Swift comparison, and three scenarios are compared with Swift until the trace where ARP fails or a ping goes unanswered. arpJava.test.ts compares ArpRun: both pings match; the two UDP stories wait for Java's transport thread (next step). DNS without a server now matches Java too. Co-Authored-By: Claude Opus 5.5 --- CHANGES.md | 5 +- packages/core/src/runtime/engine.ts | 366 ++++++++++++++++++---------- packages/core/test/arpJava.test.ts | 81 ++++++ packages/core/test/dnsJava.test.ts | 13 +- packages/core/test/engine.test.ts | 57 +++++ packages/core/test/javaRun.ts | 11 +- packages/core/test/oracle.test.ts | 28 ++- tools/swift-oracle/README.md | 5 +- 8 files changed, 414 insertions(+), 152 deletions(-) create mode 100644 packages/core/test/arpJava.test.ts diff --git a/CHANGES.md b/CHANGES.md index 49d54bf..00d552b 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, 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. Seit F2.3d befördert ein Kabel einen Frame nach dem anderen wie Javas `SimplexVerbindung` (ein Frame wartet im Ausgangspuffer, bis der vorige angekommen ist, und erscheint im Protokoll, wenn er losgeht); Swift schickt alle zugleich, die Option `swiftCables` behält das für den Swift-Vergleich. 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. DNS seit F2.4a nach Javas `DNSServer`/`Resolver`, an Java-Filius gemessen: Server auf Port 53 mit Einträgen aus dem Modell (die Antwort wiederholt die Frage nicht, RCODE immer 0, ein Verweis mit NS und A im Antwortteil), iterativer Resolver mit 5 RTT je Anfrage, der nur die Server behält, an die er verwiesen wurde; noch ohne Rekursion. `receiveUdp` kehrt wie Javas `empfangen` zurück, sobald ein Datagramm da ist. 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. Seit F2.3d befördert ein Kabel einen Frame nach dem anderen wie Javas `SimplexVerbindung` (ein Frame wartet im Ausgangspuffer, bis der vorige angekommen ist, und erscheint im Protokoll, wenn er losgeht); Swift schickt alle zugleich, die Option `swiftCables` behält das für den Swift-Vergleich. 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. ARP seit F2.4b nach Javas `holeARPTabellenEintrag`, an Java-Filius gemessen: eine Auflösung fragt, wartet eine RTT, fragt ein zweites Mal und gibt nach einer weiteren RTT auf; dann „Host unreachable" beim Absender selbst oder vom Router zurück (bei ICMP nur für Echo-Anfragen); ein wartender Absender wacht nach den übrigen Ereignissen desselben Zeitpunkts auf; der Ping wartet danach eine RTT auf die Antwort. Swift schickt in der Ereignisverarbeitung beide Anfragen zugleich und lässt das Paket ohne Ende warten, wenn die Adresse irgendwo im Netz vorkommt (Wissen, das kein Knoten hat), und blockiert direkte Aufrufe ohne Wartezeit; die Option `swiftArp` behält das für den Swift-Vergleich. DNS seit F2.4a nach Javas `DNSServer`/`Resolver`, an Java-Filius gemessen: Server auf Port 53 mit Einträgen aus dem Modell (die Antwort wiederholt die Frage nicht, RCODE immer 0, ein Verweis mit NS und A im Antwortteil), iterativer Resolver mit 5 RTT je Anfrage, der nur die Server behält, an die er verwiesen wurde; noch ohne Rekursion. `receiveUdp` kehrt wie Javas `empfangen` zurück, sobald ein Datagramm da ist. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Gateway-NAT, DNS, RIP, Remote Link, WLAN | | `src/runtime/dns.ts` | Java `filius.software.dns.DNSNachricht`, `Query`, `ResourceRecord` | neu nach Java statt nach Swift (Swift ersetzt Javas Textnachrichten durch `FILIUS-DNS-TYPED/1`): Kopfzeile `ID=… QR=… RCODE=… QDCOUNT=… …`, Frage- und Eintragszeilen, Suche nach Namen ohne Rücksicht auf Groß- und Kleinschreibung, NS der längsten Zone, sonst der Wurzel | | `src/runtime/ipv4.ts` | Engine `parseIPv4`/`sameSubnet` | übertragen | | `src/runtime/packets.ts` | Engine, Paketmodelle | übertragen; `Data` → `Uint8Array` | @@ -28,8 +28,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, die drei TCP-Szenarien seit F2.3c; sie müssen von Swift abweichen | +| `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; drei weitere (`firewall-and-loss`, `routed-two-networks`, `switch-lan`) folgen seit F2.4b ab der Spur, an der ARP scheitert oder ein Ping unbeantwortet bleibt, Java und stimmen bis dahin mit Swift | | `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/arpJava.test.ts`, `test/scenarios/arp-routed.scenario.json`, `test/scenarios/arp-java-*.java.json` | — | neu: vier ARP-Abläufe gegen Java-Filius (`ArpRun`); die zwei Pings stimmen, die zwei UDP-Abläufe warten auf Javas Transport-Thread | | `test/dnsJava.test.ts`, `test/scenarios/dns-lan.scenario.json`, `test/scenarios/dns-java-*.java.json` | — | neu: fünf DNS-Abfragen gegen Java-Filius (`DnsRun`): Frames, Ergebnis und Dauer; eine noch nicht wie Java (fehlender Server: Javas ARP) | | `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 | diff --git a/packages/core/src/runtime/engine.ts b/packages/core/src/runtime/engine.ts index 0e4476f..ca4cc7d 100644 --- a/packages/core/src/runtime/engine.ts +++ b/packages/core/src/runtime/engine.ts @@ -17,6 +17,9 @@ // processScheduledRuntimeEvent, dropForGlobalPacketLoss (lines ~4753–4850) // A cable carries one frame after the other, as Java's SimplexVerbindung // (Swift: all at once; the option swiftCables keeps that for the comparison). +// ARP after Java's holeARPTabellenEintrag: a lookup asks twice, a round trip +// each, then host unreachable (Swift: all at once, or without end when the +// address exists anywhere; the option swiftArp keeps that for the comparison). // DNS after Java (filius.software.dns: DNSServer, DNSServerMitarbeiter, // Resolver, DNSQueryAgent), not Swift's typed format; recursion not yet. // Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht, @@ -52,7 +55,6 @@ import { firewallConfiguration, interfaceKey, } from './config.js'; -import { deepEqual } from '../util/equal.js'; import { maskAddress, parseIPv4, sameSubnet } from './ipv4.js'; import { forwardingClone, @@ -120,6 +122,10 @@ export type ScheduledEventKind = | { readonly kind: 'ripBeacon'; readonly nodeId: Id } | { readonly kind: 'dhcpClientStart'; readonly nodeId: Id } | { readonly kind: 'dhcpTimeout'; readonly nodeId: Id } + /** An ARP lookup waited a round trip for its answer. */ + | { readonly kind: 'arpTimeout'; readonly lookupId: number } + /** The lookups waiting for an address wake up, after what else happens at that time. */ + | { readonly kind: 'arpAnswered'; readonly nodeId: Id; readonly ipAddress: string; readonly macAddress: string } | { readonly kind: 'tcpTimeout'; readonly sessionId: Id } | { /** A frame that waited in the port's output buffer leaves: the cable was busy. */ @@ -522,10 +528,26 @@ type PacketRoute = | { readonly type: 'egress'; readonly interface: NetworkInterface; readonly nextHop: string } | { readonly type: 'unavailable' }; -interface PendingIPv4Transmission { - readonly packet: IPv4Packet; - readonly interface: NetworkInterface; - readonly operation: TraceOperation; +/** Java's IP asks ARP this many times, a round trip each (holeARPTabellenEintrag(ziel, 2, …)). */ +export const ARP_ATTEMPTS = 2; + +interface ArpLookup { + readonly id: number; + readonly nodeId: Id; + readonly ipAddress: string; + readonly networkInterface: NetworkInterface; + attemptsLeft: number; + /** Swift, for the comparison: a lookup made while events run waits in a list emptied by the answer itself. */ + readonly swiftQueued: boolean; + readonly done: (macAddress: string | undefined) => void; +} + +/** What a sender learns when its packet has left, or when it could not. */ +interface IPv4SendCallbacks { + /** The packet left (or failed) — at once, or when ARP has answered or given up. */ + readonly onFinished?: (result: IPv4DeliveryResult) => void; + /** ARP found no one for the next hop (Java: the sender sends host unreachable). */ + readonly onUnresolved?: () => void; } function toSeed(seed: bigint | number): bigint { @@ -534,12 +556,14 @@ function toSeed(seed: bigint | number): bigint { /** A departure waits for the delivery of the frame before it, so draining the deliveries reaches it. */ function isNetworkDeliveryEvent(kind: ScheduledEventKind): boolean { - return kind.kind === 'ethernetLinkDeparture' || kind.kind === 'ethernetLinkDelivery' || kind.kind === 'remoteLinkDelivery'; + return ( + kind.kind === 'ethernetLinkDeparture' || + kind.kind === 'ethernetLinkDelivery' || + kind.kind === 'remoteLinkDelivery' || + kind.kind === 'arpAnswered' + ); } -function isRemoteLinkWaitEvent(kind: ScheduledEventKind): boolean { - return isNetworkDeliveryEvent(kind) || kind.kind === 'lanRemoteLinkTransmission'; -} function getOrCreate(map: Map, key: K, create: () => V): V { let value = map.get(key); @@ -562,10 +586,13 @@ export class NetworkRuntimeEngine { private runtimeSpeed: RuntimeSpeed; private readonly packetTraceRetentionPolicy: TraceRetentionPolicy; private isProcessingScheduledEvents = false; - private pendingIPv4TransmissionsByArpKey = new Map(); + /** ARP lookups waiting for their answer (Java: a thread in holeARPTabellenEintrag). */ + private arpLookups: ArpLookup[] = []; + private nextArpLookupId = 1; /** Per cable end (node and port): when the last frame sent there arrives. */ private cableBusyUntilByPort = new Map(); private readonly cablesCarryOneFrameAtATime: boolean; + private readonly swiftArp: boolean; /** * `swiftCables`: a cable carries all frames at once, as in the Swift @@ -577,9 +604,10 @@ export class NetworkRuntimeEngine { constructor( seed: bigint | number = DEFAULT_SEED, packetTraceRetentionPolicy: TraceRetentionPolicy = PRODUCTION_TRACE_RETENTION, - options: { readonly swiftCables?: boolean } = {}, + options: { readonly swiftCables?: boolean; readonly swiftArp?: boolean } = {}, ) { this.cablesCarryOneFrameAtATime = options.swiftCables !== true; + this.swiftArp = options.swiftArp === true; const normalizedSeed = toSeed(seed); this.stateValue = initialState(normalizedSeed); this.random = new JavaCompatibleRandom(normalizedSeed); @@ -626,7 +654,6 @@ export class NetworkRuntimeEngine { this.linkLocalRandom = new JavaCompatibleRandom(seed ^ 0x4c4c_4144n); this.dnsIdRandom = new JavaCompatibleRandom(seed ^ 0x444e_5349n); this.isProcessingScheduledEvents = false; - this.pendingIPv4TransmissionsByArpKey.clear(); this.cableBusyUntilByPort.clear(); this.initializeRipState(); this.initializeDhcpState(); @@ -641,7 +668,7 @@ export class NetworkRuntimeEngine { state.phase = 'stopped'; state.globalPacketLossEnabled = false; this.isProcessingScheduledEvents = false; - this.pendingIPv4TransmissionsByArpKey.clear(); + this.arpLookups = []; state.pendingEvents = []; state.arpCachesByNodeId.clear(); state.switchForwardingTablesByNodeId.clear(); @@ -834,6 +861,12 @@ export class NetworkRuntimeEngine { case 'dhcpTimeout': this.handleDhcpClientTimeout(kind.nodeId); return; + case 'arpTimeout': + this.handleArpTimeout(kind.lookupId); + return; + case 'arpAnswered': + this.completeArpLookups(kind.nodeId, kind.ipAddress, kind.macAddress); + return; case 'tcpTimeout': this.handleTcpTimeout(kind.sessionId); return; @@ -2651,15 +2684,15 @@ export class NetworkRuntimeEngine { /** * The MAC address for `targetIPAddress`, asking by ARP broadcast when it is - * not cached. Blocks like Java: the frames travel (virtual time passes) - * before the call returns. + * not cached. Blocks like Java: each request waits a round trip for its + * answer (virtual time passes) before the next, or before the call gives up. */ resolveMacAddress( nodeId: Id, targetIPAddress: string, options: { preferredInterfaceId?: Id | undefined; maxRetries?: number; selectInterfaceByAddress?: boolean } = {}, ): string | undefined { - const maxRetries = options.maxRetries ?? 2; + const maxRetries = options.maxRetries ?? ARP_ATTEMPTS; const selectInterfaceByAddress = options.selectInterfaceByAddress ?? true; const interfaces = this.networkInterfaces(nodeId); if (interfaces.length === 0) return undefined; @@ -2676,13 +2709,76 @@ export class NetworkRuntimeEngine { } if (selectedInterface === undefined) return undefined; - for (let attempt = 0; attempt < Math.max(0, maxRetries); attempt += 1) { - this.sendArpRequest(nodeId, selectedInterface, targetIPAddress); - this.advanceRemoteLinkEventsUntilArpCacheResolves(nodeId, targetIPAddress); - const resolved = this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress); - if (resolved !== undefined) return resolved.macAddress; + let finished = false; + let resolved: string | undefined; + this.lookUpMacAddress(nodeId, targetIPAddress, selectedInterface, maxRetries, (macAddress) => { + finished = true; + resolved = macAddress; + }); + if (!finished && !this.isProcessingScheduledEvents) { + this.advanceUntil(() => finished, this.stateValue.currentTimeMilliseconds + (maxRetries + 1) * this.runtimeSpeed.roundTripTimeMilliseconds); } - return undefined; + return resolved; + } + + /** + * Java's holeARPTabellenEintrag as a lookup of its own: a known address at + * once; otherwise an ARP request and a round trip's wait for the answer, + * `attempts` times, then undefined. Java wakes on any change of the ARP + * table and gives up when it finds nothing; the lookup waits for its + * address (as the DHCP client's check does). + */ + private lookUpMacAddress( + nodeId: Id, + ipAddress: string, + networkInterface: NetworkInterface, + attempts: number, + done: (macAddress: string | undefined) => void, + ): void { + const known = this.knownMacAddress(nodeId, ipAddress); + if (known !== undefined || attempts <= 0) { + done(known); + return; + } + const swiftQueued = this.swiftArp && this.isProcessingScheduledEvents; + const lookup: ArpLookup = { id: this.nextArpLookupId++, nodeId, ipAddress, networkInterface, attemptsLeft: attempts - 1, swiftQueued, done }; + this.arpLookups.push(lookup); + if (swiftQueued) { + // Swift, only for the comparison: every request at once, then the packet waits without end. + for (let attempt = 0; attempt < attempts; attempt += 1) this.sendArpRequest(nodeId, networkInterface, ipAddress); + lookup.attemptsLeft = 0; + return; + } + this.schedule(this.stateValue.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds, { kind: 'arpTimeout', lookupId: lookup.id }); + this.sendArpRequest(nodeId, networkInterface, ipAddress); + } + + /** The node learned `ipAddress`: every lookup waiting for it has its answer. */ + private completeArpLookups(nodeId: Id, ipAddress: string, macAddress: string, onlySwiftQueued = false): void { + const answered = this.arpLookups.filter( + (lookup) => lookup.nodeId === nodeId && lookup.ipAddress === ipAddress && (!onlySwiftQueued || lookup.swiftQueued), + ); + if (answered.length === 0) return; + const ids = new Set(answered.map((lookup) => lookup.id)); + this.arpLookups = this.arpLookups.filter((lookup) => !ids.has(lookup.id)); + this.stateValue.pendingEvents = this.stateValue.pendingEvents.filter( + (event) => !(event.kind.kind === 'arpTimeout' && ids.has(event.kind.lookupId)), + ); + for (const lookup of answered) lookup.done(macAddress); + } + + /** No answer within the round trip: ask again, or give up. */ + private handleArpTimeout(lookupId: number): void { + const lookup = this.arpLookups.find((each) => each.id === lookupId); + if (lookup === undefined) return; + if (lookup.attemptsLeft > 0) { + lookup.attemptsLeft -= 1; + this.schedule(this.stateValue.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds, { kind: 'arpTimeout', lookupId }); + this.sendArpRequest(lookup.nodeId, lookup.networkInterface, lookup.ipAddress); + return; + } + this.arpLookups = this.arpLookups.filter((each) => each.id !== lookupId); + lookup.done(undefined); } /** The MAC address for `ipAddress` without asking: localhost, one of the node's own, or cached. */ @@ -2724,39 +2820,6 @@ export class NetworkRuntimeEngine { this.sendEthernetFrame(nodeId, networkInterface.portId, frame); } - private advanceRemoteLinkEventsUntilArpCacheResolves(nodeId: Id, targetIPAddress: string): void { - if (this.isProcessingScheduledEvents) return; - this.advancePendingRemoteLinkEvents( - () => this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress) !== undefined, - ); - } - - private advanceRemoteLinkEventsUntilIcmpObservation( - nodeId: Id, - observationCount: number, - identifier: number, - sequenceNumber: number, - ): void { - if (this.isProcessingScheduledEvents) return; - this.advancePendingRemoteLinkEvents(() => { - const newObservations = (this.stateValue.icmpObservationsByNodeId.get(nodeId) ?? []).slice(observationCount); - return newObservations.some( - (observation) => - observation.message.identifier === identifier && observation.message.sequenceNumber === sequenceNumber, - ); - }); - } - - private advancePendingRemoteLinkEvents(condition: () => boolean): void { - const maximumEvents = 1_024; - let processedEventCount = 0; - while (!condition() && processedEventCount < maximumEvents) { - const nextDeadline = this.earliestDeadline(isRemoteLinkWaitEvent); - if (nextDeadline === undefined) return; - this.advance(nextDeadline); - processedEventCount += 1; - } - } private earliestDeadline(filter: (kind: ScheduledEventKind) => boolean): number | undefined { let earliest: number | undefined; @@ -3086,7 +3149,16 @@ export class NetworkRuntimeEngine { macAddress: packet.senderMacAddress, updatedAtMilliseconds: this.stateValue.currentTimeMilliseconds, }); - this.flushIPv4TransmissionsAwaitingArp(nodeId, packet.senderIPAddress, packet.senderMacAddress); + // Java's waiting thread wakes when the ARP thread is done (Swift, for the comparison: at once). + this.completeArpLookups(nodeId, packet.senderIPAddress, packet.senderMacAddress, true); + if (this.arpLookups.some((lookup) => lookup.nodeId === nodeId && lookup.ipAddress === packet.senderIPAddress)) { + this.schedule(this.stateValue.currentTimeMilliseconds, { + kind: 'arpAnswered', + nodeId, + ipAddress: packet.senderIPAddress, + macAddress: packet.senderMacAddress, + }); + } this.dhcpClientLearnedAddress(nodeId, packet.senderIPAddress); } this.recordTrace({ @@ -3191,8 +3263,27 @@ export class NetworkRuntimeEngine { detail: 'ICMP echo request', }); const observationCount = this.stateValue.icmpObservationsByNodeId.get(fromNodeId)?.length ?? 0; - const result = this.sendIPv4Packet(fromNodeId, packet, sourceInterface.portId); - this.advanceRemoteLinkEventsUntilIcmpObservation(fromNodeId, observationCount, identifier, sequenceNumber); + const answered = () => + (this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? []) + .slice(observationCount) + .some((observation) => observation.message.identifier === identifier && observation.message.sequenceNumber === sequenceNumber); + let sent: IPv4DeliveryResult | undefined; + let sentAt = this.stateValue.currentTimeMilliseconds; + const handedOn = this.sendIPv4PacketWithOptions(fromNodeId, packet, sourceInterface.portId, true, 'sent', { + onFinished: (outcome) => { + sent = outcome; + sentAt = this.stateValue.currentTimeMilliseconds; + }, + }); + if (!this.isProcessingScheduledEvents) { + // Java's sendProbe: the caller waits for ARP (up to two round trips), + // then a round trip for the answer (ICMPThread.waitForIcmpResponse), + // counted from when the packet left — the engine may have run past it. + const rtt = this.runtimeSpeed.roundTripTimeMilliseconds; + this.advanceUntil(() => sent !== undefined, this.stateValue.currentTimeMilliseconds + (ARP_ATTEMPTS + 1) * rtt); + if (sentAt + rtt > this.stateValue.currentTimeMilliseconds) this.advanceUntil(answered, sentAt + rtt); + } + const result = sent ?? handedOn; const newObservations = (this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? []).slice(observationCount); const observation = newObservations.findLast( (candidate) => candidate.message.identifier === identifier && candidate.message.sequenceNumber === sequenceNumber, @@ -3254,8 +3345,78 @@ export class NetworkRuntimeEngine { preferredInterfaceId: Id | undefined, allowIcmpErrorGeneration: boolean, operation: TraceOperation, + callbacks: IPv4SendCallbacks = {}, ): IPv4DeliveryResult { const route = this.routeForPacket(fromNodeId, packet.receiverIPAddress, preferredInterfaceId); + if (route.type === 'egress') return this.sendIPv4ToNextHop(fromNodeId, packet, route, allowIcmpErrorGeneration, operation, callbacks); + const result = this.sendIPv4Directly(fromNodeId, packet, route, preferredInterfaceId, allowIcmpErrorGeneration, operation); + callbacks.onFinished?.(result); + return result; + } + + /** + * To the next hop, once ARP knows it; until then the packet waits for its + * lookup and the result says it was handed on. When ARP finds no one: host + * unreachable — observed at the sender itself, or sent back by the router + * (onUnresolved). + */ + private sendIPv4ToNextHop( + fromNodeId: Id, + packet: IPv4Packet, + route: { readonly interface: NetworkInterface; readonly nextHop: string }, + allowIcmpErrorGeneration: boolean, + operation: TraceOperation, + callbacks: IPv4SendCallbacks, + ): IPv4DeliveryResult { + let finished: IPv4DeliveryResult | undefined; + this.lookUpMacAddress(fromNodeId, route.nextHop, route.interface, ARP_ATTEMPTS, (macAddress) => { + if (macAddress !== undefined) { + this.transmitIPv4Packet(fromNodeId, packet, route.interface, macAddress, operation); + finished = { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId }; + } else { + this.recordTrace({ + packetIdentity: packet.identity, + nodeId: fromNodeId, + interfaceId: route.interface.portId, + direction: 'outbound', + layer: 'network', + operation: 'dropped', + beforeHeaders: ipv4Headers(packet), + detail: `no ARP answer for ${route.nextHop}`, + }); + if (allowIcmpErrorGeneration) { + this.observeLocalIcmpError(fromNodeId, route.interface.portId, 'destinationHostUnreachable', packet); + finished = { type: 'icmpError', packetIdentity: packet.identity, nodeId: fromNodeId, kind: 'destinationHostUnreachable' }; + } else { + callbacks.onUnresolved?.(); + finished = { type: 'dropped', packetIdentity: packet.identity, nodeId: fromNodeId }; + } + } + callbacks.onFinished?.(finished); + }); + if (finished === undefined && this.swiftArp && this.isProcessingScheduledEvents) { + this.recordTrace({ + packetIdentity: packet.identity, + nodeId: fromNodeId, + interfaceId: route.interface.portId, + direction: 'outbound', + layer: 'network', + operation: 'created', + beforeHeaders: ipv4Headers(packet), + detail: `waiting for ARP resolution of ${route.nextHop}`, + }); + } + return finished ?? { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId }; + } + + private sendIPv4Directly( + fromNodeId: Id, + packet: IPv4Packet, + route: PacketRoute, + preferredInterfaceId: Id | undefined, + allowIcmpErrorGeneration: boolean, + operation: TraceOperation, + ): IPv4DeliveryResult { switch (route.type) { case 'local': { const networkInterface = route.interface ?? this.networkInterfaces(fromNodeId)[0]; @@ -3281,30 +3442,8 @@ export class NetworkRuntimeEngine { return { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId }; } - case 'egress': { - const destinationMacAddress = this.resolveMacAddress(fromNodeId, route.nextHop, { - preferredInterfaceId: route.interface.portId, - maxRetries: 2, - }); - if (destinationMacAddress === undefined) { - if (this.isProcessingScheduledEvents && this.hasConfiguredInterface(route.nextHop)) { - this.enqueueIPv4TransmissionAwaitingArp(fromNodeId, route.nextHop, packet, route.interface, operation); - return { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId }; - } - if (allowIcmpErrorGeneration) { - this.observeLocalIcmpError(fromNodeId, route.interface.portId, 'destinationHostUnreachable', packet); - return { - type: 'icmpError', - packetIdentity: packet.identity, - nodeId: fromNodeId, - kind: 'destinationHostUnreachable', - }; - } - return { type: 'dropped', packetIdentity: packet.identity, nodeId: fromNodeId }; - } - this.transmitIPv4Packet(fromNodeId, packet, route.interface, destinationMacAddress, operation); - return { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId }; - } + case 'egress': + return this.sendIPv4ToNextHop(fromNodeId, packet, route, allowIcmpErrorGeneration, operation, {}); case 'unavailable': if (allowIcmpErrorGeneration) { @@ -3320,46 +3459,6 @@ export class NetworkRuntimeEngine { } } - private hasConfiguredInterface(ipAddress: string): boolean { - return this.stateValue.topologySnapshot.nodes.some((node) => - this.networkInterfaces(node.id).some((candidate) => candidate.ipAddress === ipAddress), - ); - } - - private enqueueIPv4TransmissionAwaitingArp( - fromNodeId: Id, - nextHop: string, - packet: IPv4Packet, - networkInterface: NetworkInterface, - operation: TraceOperation, - ): void { - const key = arpKey(fromNodeId, nextHop); - const transmission: PendingIPv4Transmission = { packet, interface: networkInterface, operation }; - const pending = this.pendingIPv4TransmissionsByArpKey.get(key); - if (pending?.some((candidate) => deepEqual(candidate, transmission)) === true) return; - getOrCreate(this.pendingIPv4TransmissionsByArpKey, key, () => []).push(transmission); - this.recordTrace({ - packetIdentity: packet.identity, - nodeId: fromNodeId, - interfaceId: networkInterface.portId, - direction: 'outbound', - layer: 'network', - operation: 'created', - beforeHeaders: ipv4Headers(packet), - detail: `waiting for ARP resolution of ${nextHop}`, - }); - } - - private flushIPv4TransmissionsAwaitingArp(nodeId: Id, ipAddress: string, macAddress: string): void { - const key = arpKey(nodeId, ipAddress); - const transmissions = this.pendingIPv4TransmissionsByArpKey.get(key); - if (transmissions === undefined) return; - this.pendingIPv4TransmissionsByArpKey.delete(key); - for (const transmission of transmissions) { - this.transmitIPv4Packet(nodeId, transmission.packet, transmission.interface, macAddress, transmission.operation); - } - } - private transmitIPv4Packet( fromNodeId: Id, packet: IPv4Packet, @@ -3451,15 +3550,16 @@ export class NetworkRuntimeEngine { afterHeaders: ipv4Headers(forwarded), detail: 'TTL decremented', }); - const result = this.sendIPv4PacketWithOptions(nodeId, forwarded, undefined, false, 'forwarded'); - if (result.type === 'dropped') { - const route = this.routeForPacket(nodeId, packetForProcessing.receiverIPAddress); - this.emitIcmpError( - nodeId, - networkInterface, - route.type === 'unavailable' ? 'destinationNetworkUnreachable' : 'destinationHostUnreachable', - packet, - ); + // Java: a router whose ARP finds no one answers host unreachable; for + // ICMP only an echo request (ICMP.sendUnicastToNextHop). + const echoOrNotIcmp = packet.payload.kind !== 'icmp' || packet.payload.message.kind === 'echoRequest'; + const result = this.sendIPv4PacketWithOptions(nodeId, forwarded, undefined, false, 'forwarded', { + onUnresolved: () => { + if (echoOrNotIcmp) this.emitIcmpError(nodeId, networkInterface, 'destinationHostUnreachable', packet); + }, + }); + if (result.type === 'dropped' && this.routeForPacket(nodeId, packetForProcessing.receiverIPAddress).type === 'unavailable') { + this.emitIcmpError(nodeId, networkInterface, 'destinationNetworkUnreachable', packet); } } @@ -3900,10 +4000,6 @@ function portKey(nodeId: Id, portId: Id): string { return `${nodeId}|${portId}`; } -function arpKey(nodeId: Id, ipAddress: string): string { - return `${nodeId}|${ipAddress}`; -} - function bestInterface(targetIPAddress: string, interfaces: readonly NetworkInterface[]): NetworkInterface | undefined { let selected: NetworkInterface | undefined; let selectedMask: number | undefined; diff --git a/packages/core/test/arpJava.test.ts b/packages/core/test/arpJava.test.ts new file mode 100644 index 0000000..242fbef --- /dev/null +++ b/packages/core/test/arpJava.test.ts @@ -0,0 +1,81 @@ +// ARP as Java FILIUS runs it. tools/java-oracle's ArpRun sent to addresses no +// card has on arp-routed's network in Java FILIUS and wrote every frame each +// card saw and when each call started and ended (arp-java-.java.json). +// The engine makes the same calls: the frames must come in the same order, +// with the same content, at about the same distance from each other +// (javaRun.ts), the calls must return the same, after about as long. + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import type { IPv4DeliveryResult, NetworkRuntimeEngine } from '../src/index.js'; +import { GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, watchedEngine, type JavaFrame } from './javaRun.js'; +import type { Scenario } from './scenario.js'; + +const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios'); +const HOST = '00000000-0000-0000-0000-000000000301'; + +interface JavaRun { + frames: Record; + events: [number, string][]; +} + +/** A ping's result in Java's words: the ICMP answer's type and code, or null when none came. */ +function pingResult(result: IPv4DeliveryResult, answered: boolean): string { + if (result.type === 'icmpError' && result.kind === 'destinationHostUnreachable') return 'ICMP 3/1'; + return answered ? 'ICMP 0/0' : 'null'; +} + +const STORIES: Record string> = { + pingLan: (engine) => pingResult(engine.sendIcmpEcho(HOST, '10.0.0.9'), false), + pingRouted: (engine) => { + const result = engine.sendIcmpEcho(HOST, '20.0.0.9'); + return pingResult(result, result.type === 'delivered' && result.nodeId !== HOST); + }, + udpTwice: () => '', + udpBehind: () => '', +}; + +/** + * Stories the engine does not play like Java yet, with the reason; each must + * still differ from Java. + * + * udpTwice, udpBehind: Java's transport thread sends one segment after the + * other and holds its queue while ARP waits, so the next send blocks — the + * engine's threads come in the next step. + */ +const NOT_YET_LIKE_JAVA: Record = { + udpTwice: "Java's transport thread: the second datagram waits for the first one's ARP", + udpBehind: "Java's transport thread: a datagram to a reachable host waits behind one whose ARP fails", +}; + +describe('ARP as Java FILIUS runs it', () => { + const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'arp-routed.scenario.json'), 'utf8')) as Scenario; + + for (const [name, story] of Object.entries(STORIES)) { + it(name, () => { + const java = JSON.parse(readFileSync(join(scenarioDirectory, `arp-java-${name}.java.json`), 'utf8')) as JavaRun; + const { engine, frames } = watchedEngine(scenario); + engine.handle({ type: 'advance', toMilliseconds: 500 }); + const called = engine.state.currentTimeMilliseconds; + const result = story(engine); + const returned = engine.state.currentTimeMilliseconds; + engine.handle({ type: 'advance', toMilliseconds: 16_000 }); + + const expected = gaps(javaFrames(java.frames)); + const actual = gaps(frames()); + if (name in NOT_YET_LIKE_JAVA) { + expect(JSON.stringify(actual), NOT_YET_LIKE_JAVA[name]).not.toBe(JSON.stringify(expected)); + return; + } + const [[javaCalled], [javaReturned, javaOutcome]] = java.events as [[number, string], [number, string]]; + expect(javaOutcome.replace(/^.* returned /, '').replace(/ from .*$/, '')).toBe(result); + expect(Math.abs(returned - called - (javaReturned - javaCalled)), 'duration of the call').toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS); + for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) { + expect(actual[index]?.text, `frame #${index + 1}`).toBe(expected[index]?.text); + expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `gap before frame #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS); + } + }); + } +}); diff --git a/packages/core/test/dnsJava.test.ts b/packages/core/test/dnsJava.test.ts index c335b71..2f6e6fe 100644 --- a/packages/core/test/dnsJava.test.ts +++ b/packages/core/test/dnsJava.test.ts @@ -53,17 +53,8 @@ function normalized(frames: readonly SeenFrame[]): SeenFrame[] { })); } -/** - * Stories the engine does not play like Java yet, with the reason; each must - * still differ from Java (else it belongs to the others) and return the same. - * - * noServer: Java's transport thread sends while the resolver already waits, - * and asks ARP twice, a round trip apart. The engine's ARP (Swift's) blocks - * the call and asks again at once — rebuilt after Java in its own step. - */ -const NOT_YET_LIKE_JAVA: Record = { - noServer: "ARP: Java's second request comes a round trip after the first, while the resolver waits", -}; +/** Stories the engine does not play like Java yet, with the reason; each must still differ from Java. */ +const NOT_YET_LIKE_JAVA: Record = {}; describe('DNS as Java FILIUS runs it', () => { const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'dns-lan.scenario.json'), 'utf8')) as Scenario; diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index 40721c7..7f9517f 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -1531,3 +1531,60 @@ describe('DNS', () => { expect(() => lan({ '10.30.0.2': [] }, true)).toThrow(NotPortedError); }); }); + +describe('ARP', () => { + const requestsFor = (engine: NetworkRuntimeEngine, ipAddress: string) => + engine.state.packetTraces.filter((trace) => trace.detail === `ARP request for ${ipAddress}`).map((trace) => trace.timeMilliseconds); + + it('asks twice, a round trip apart, then gives up', () => { + const { engine, sourceNodeId } = routedTopology(); + const called = engine.state.currentTimeMilliseconds; + + expect(engine.resolveMacAddress(sourceNodeId, '10.0.0.9')).toBeUndefined(); + expect(requestsFor(engine, '10.0.0.9')).toEqual([called, called + 2_750]); + expect(engine.state.currentTimeMilliseconds).toBe(called + 2 * 2_750); + }); + + it('lets a router answer host unreachable for an echo request, not for an echo reply', () => { + const { engine, sourceNodeId } = routedTopology(); + const toNowhere = (kind: 'echoRequest' | 'echoReply', identity: number): IPv4Packet => ({ + identity, + senderIPAddress: '10.0.0.2', + receiverIPAddress: '20.0.0.9', + timeToLive: 64, + protocol: 'icmp', + payload: { kind: 'icmp', message: icmpMessage(kind, { identifier: identity, sequenceNumber: 1, data: new Uint8Array() }) }, + }); + const observed = () => (engine.state.icmpObservationsByNodeId.get(sourceNodeId) ?? []).map((each) => each.message.kind); + + engine.sendIPv4Packet(sourceNodeId, toNowhere('echoReply', 901)); + engine.handle({ type: 'advance', toMilliseconds: 10_000 }); + expect(observed()).toEqual([]); + + engine.sendIPv4Packet(sourceNodeId, toNowhere('echoRequest', 902)); + engine.handle({ type: 'advance', toMilliseconds: 20_000 }); + expect(observed()).toEqual(['destinationHostUnreachable']); + }); + + it('forgets the lookups that wait on stop', () => { + const [firstId, firstPortId, secondId, secondPortId] = [0x1a01, 0x1a02, 0x1a03, 0x1a04].map(uuid) as [string, string, string, string]; + const input = (secondIp: string) => ({ + nodes: [pc(firstId, firstPortId), pc(secondId, secondPortId)], + links: [link(firstId, firstPortId, secondId, secondPortId)], + deviceConfigurations: new Map([ + [firstId, device('10.0.0.2')], + [secondId, device(secondIp)], + ]), + }); + const engine = startedEngine(input('10.0.0.8')); + const socket = engine.bindUdpSocket(firstId, { remoteIPAddress: '10.0.0.9', remotePort: 9 })!; + engine.sendUdp(socket, new TextEncoder().encode('lost')); + + engine.handle({ type: 'stop' }); + engine.handle({ type: 'start', snapshot: topologySnapshot(input('10.0.0.9')), seed: 14, initialTimeMilliseconds: 0 }); + expect(engine.resolveMacAddress(firstId, '10.0.0.9')).toBeDefined(); + engine.handle({ type: 'advance', toMilliseconds: 1_000 }); + + expect(engine.state.packetTraces.filter((trace) => trace.layer === 'network' && trace.operation === 'sent')).toEqual([]); + }); +}); diff --git a/packages/core/test/javaRun.ts b/packages/core/test/javaRun.ts index 2ebbbe3..94957a4 100644 --- a/packages/core/test/javaRun.ts +++ b/packages/core/test/javaRun.ts @@ -62,6 +62,15 @@ function javaText(frame: JavaFrame): string { return `${macs} UDP ${ends} ${frame.payload}`; } +/** The ICMP type numbers Java logs. */ +const ICMP_TYPES: Record = { + echoReply: 0, + destinationNetworkUnreachable: 3, + destinationHostUnreachable: 3, + echoRequest: 8, + timeExceeded: 11, +}; + const FLAG_LETTERS: [number, string][] = [ [TCP_FLAG.synchronize, 'S'], [TCP_FLAG.acknowledgement, 'A'], @@ -77,7 +86,7 @@ function engineText(frame: EthernetFrame): string { 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 === 'icmp') return `${macs} ICMP ${ICMP_TYPES[packet.payload.message.kind]}`; if (packet.payload.kind === 'tcp') { const segment = packet.payload.segment; const flags = FLAG_LETTERS.filter(([bit]) => (segment.flags & bit) !== 0).map(([, letter]) => letter).join(''); diff --git a/packages/core/test/oracle.test.ts b/packages/core/test/oracle.test.ts index a95ff83..ba7a3f1 100644 --- a/packages/core/test/oracle.test.ts +++ b/packages/core/test/oracle.test.ts @@ -73,7 +73,7 @@ function observationJson(item: IcmpObservation): Record { function run(scenario: Scenario): Record { // Swift's cables carry all frames at once; everything else is compared as it is. - const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true }); + const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true, swiftArp: true }); if (scenario.speedPercent !== undefined) engine.setSimulationSpeed(scenario.speedPercent); engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 }); @@ -301,6 +301,16 @@ const JAVA_INSTEAD_OF_SWIFT: Record = { 'tcp-closing': 'TCP: TIME_WAIT lasts 3 round trips and ends with the next segment; the other end closes by itself', }; +/** + * Scenarios that follow Java from a trace on: the traces before it must match + * Swift, the rest must differ (the Java comparisons check that behaviour). + */ +const SWIFT_UNTIL_TRACE: Record = { + 'firewall-and-loss': { trace: 36, reason: "a ping without an answer waits a round trip (Java's sendProbe), Swift returns at once" }, + 'routed-two-networks': { trace: 156, reason: 'the router answers host unreachable when ARP has given up after two round trips, Swift at once' }, + 'switch-lan': { trace: 155, reason: 'ARP for an address no card has asks again after a round trip, then host unreachable' }, +}; + describe('same result as the Swift engine', () => { it('corrects Swift only where it errs', () => { for (const [name, correct] of Object.entries(SWIFT_ERRORS)) { @@ -321,7 +331,21 @@ describe('same result as the Swift engine', () => { }); } - for (const name of scenarioNames.filter((each) => !(each in JAVA_INSTEAD_OF_SWIFT))) { + for (const [name, { trace, reason }] of Object.entries(SWIFT_UNTIL_TRACE)) { + it(`${name} matches Swift until trace #${trace}, then follows Java: ${reason}`, () => { + const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario; + const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record; + const actual = run(scenario); + const expectedTraces = swift.traces as unknown[]; + const actualTraces = actual.traces as unknown[]; + for (let index = 0; index < trace - 1; index += 1) { + expect(actualTraces[index], `trace event #${index + 1}`).toEqual(expectedTraces[index]); + } + expect(actualTraces[trace - 1]).not.toEqual(expectedTraces[trace - 1]); + }); + } + + for (const name of scenarioNames.filter((each) => !(each in JAVA_INSTEAD_OF_SWIFT) && !(each in SWIFT_UNTIL_TRACE))) { it(name, () => { const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario; const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record; diff --git a/tools/swift-oracle/README.md b/tools/swift-oracle/README.md index d005eb4..92e0c8b 100644 --- a/tools/swift-oracle/README.md +++ b/tools/swift-oracle/README.md @@ -36,7 +36,10 @@ npm test # vergleicht mit den geschriebenen *.swift.json (`tcpJava.test.ts`, `TcpRun`). Seit F2.3d befördert ein Kabel des Kerns einen Frame nach dem anderen wie Java; der Swift-Vergleich startet die Engine mit `swiftCables` (alle Frames zugleich, wie Swift) und prüft so - alles andere weiter Spur für Spur. + alles andere weiter Spur für Spur. Ebenso stellt `swiftArp` Swifts ARP in + der Ereignisverarbeitung nach (beide Anfragen zugleich, Warteliste ohne + Ende); drei Szenarien vergleicht der Test nur bis zu der Spur, an der ARP + scheitert oder ein Ping unbeantwortet bleibt (`SWIFT_UNTIL_TRACE`). Das Ergebnis enthält die Sockets mit der Länge ihrer Warteschlange, dem TCP-Zustand, dem annehmenden Socket und den Sitzungswerten.