feat(core): sends return at once and go through Java's transport thread

A send hands its frame to the card and returns, as in Java; the frames
travel while the clock advances. Swift's send ran the network until every
frame had arrived; the option swiftSends keeps that for the Swift
comparison. Waiting calls (TCP connect, acknowledgement) now run event by
event until the answer instead of to the timeout.

UDP and TCP send through a thread per node and protocol (Java's
TransportProtokoll): one segment after the other; while IP waits for ARP,
the next send blocks and a segment from an event queues behind it. Senders
that wait take their turns in the order they came (a fair lock; Java's
leaves the order to chance).

All four ArpRun stories now match Java FILIUS, including when each call
returns. Tests let frames arrive explicitly with letFramesArrive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 17:49:05 +00:00
co-authored by Claude Opus 5.5
parent dbbc1fd70d
commit a88fa1512c
7 changed files with 343 additions and 71 deletions
+3 -3
View File
@@ -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/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/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/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. 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/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. Seit F2.4b-2 kehrt ein Senden wie in Java sofort zurück, die Frames reisen, während die Uhr läuft (Swift: der Aufruf kehrt erst zurück, wenn alle Frames angekommen sind; die Option `swiftSends` behält das für den Swift-Vergleich), und UDP und TCP senden über Javas Transport-Thread je Knoten und Protokoll: ein Segment nach dem anderen; solange IP auf ARP wartet, blockiert das nächste `senden`. Wartende Absender kommen in der Reihenfolge an die Reihe, in der sie kamen (Javas Sperre überlässt das dem Zufall). 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/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/ipv4.ts` | Engine `parseIPv4`/`sameSubnet` | übertragen |
| `src/runtime/packets.ts` | Engine, Paketmodelle | übertragen; `Data` → `Uint8Array` | | `src/runtime/packets.ts` | Engine, Paketmodelle | übertragen; `Data` → `Uint8Array` |
@@ -30,8 +30,8 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `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/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; 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/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/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/arpJava.test.ts`, `test/scenarios/arp-routed.scenario.json`, `test/scenarios/arp-java-*.java.json` | — | neu: vier ARP-Abläufe gegen Java-Filius (`ArpRun`): Frames, Abstände und wann jeder Aufruf zurückkehrt; die zwei Pings stimmen seit F2.4b-1, die zwei UDP-Abläufe seit F2.4b-2 (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/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; seit F2.4b-1 alle wie Java |
| `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/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 | | `test/routeTable.test.ts` | `TopologyJavaRouteTableTests.swift` (8 von 12 Tests) | übertragen; Geometrie- und Reducer-Tests folgen mit Editor und Dokument |
+121 -27
View File
@@ -20,6 +20,11 @@
// ARP after Java's holeARPTabellenEintrag: a lookup asks twice, a round trip // 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 // each, then host unreachable (Swift: all at once, or without end when the
// address exists anywhere; the option swiftArp keeps that for the comparison). // address exists anywhere; the option swiftArp keeps that for the comparison).
// A send hands its frame to the card and returns, as in Java; the frames
// travel while the clock advances (Swift: the send returns when every frame
// in flight has arrived; the option swiftSends keeps that for the comparison).
// Java's transport send thread: per node and protocol, one segment after the
// other; while IP waits for ARP, a socket's next send blocks.
// DNS after Java (filius.software.dns: DNSServer, DNSServerMitarbeiter, // DNS after Java (filius.software.dns: DNSServer, DNSServerMitarbeiter,
// Resolver, DNSQueryAgent), not Swift's typed format; recursion not yet. // Resolver, DNSQueryAgent), not Swift's typed format; recursion not yet.
// Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht, // Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht,
@@ -542,6 +547,19 @@ interface ArpLookup {
readonly done: (macAddress: string | undefined) => void; readonly done: (macAddress: string | undefined) => void;
} }
/**
* Java's TransportProtokoll send thread, one per node and protocol: it takes
* the segments one after the other and hands each to IP, which waits there
* for ARP.
*/
interface TransportSender {
busy: boolean;
/** How many segments it has taken, and how many are out. */
started: number;
finished: number;
readonly queue: ((finished: () => void) => void)[];
}
/** What a sender learns when its packet has left, or when it could not. */ /** What a sender learns when its packet has left, or when it could not. */
interface IPv4SendCallbacks { interface IPv4SendCallbacks {
/** The packet left (or failed) — at once, or when ARP has answered or given up. */ /** The packet left (or failed) — at once, or when ARP has answered or given up. */
@@ -589,10 +607,13 @@ export class NetworkRuntimeEngine {
/** ARP lookups waiting for their answer (Java: a thread in holeARPTabellenEintrag). */ /** ARP lookups waiting for their answer (Java: a thread in holeARPTabellenEintrag). */
private arpLookups: ArpLookup[] = []; private arpLookups: ArpLookup[] = [];
private nextArpLookupId = 1; private nextArpLookupId = 1;
/** Per node and transport protocol (`nodeId/udp`, `nodeId/tcp`). */
private transportSenders = new Map<string, TransportSender>();
/** Per cable end (node and port): when the last frame sent there arrives. */ /** Per cable end (node and port): when the last frame sent there arrives. */
private cableBusyUntilByPort = new Map<string, number>(); private cableBusyUntilByPort = new Map<string, number>();
private readonly cablesCarryOneFrameAtATime: boolean; private readonly cablesCarryOneFrameAtATime: boolean;
private readonly swiftArp: boolean; private readonly swiftArp: boolean;
private readonly swiftSends: boolean;
/** /**
* `swiftCables`: a cable carries all frames at once, as in the Swift * `swiftCables`: a cable carries all frames at once, as in the Swift
@@ -600,14 +621,18 @@ export class NetworkRuntimeEngine {
* frame after the other (SimplexVerbindung sleeps the delay with the port's * frame after the other (SimplexVerbindung sleeps the delay with the port's
* buffer locked); a frame waits in the output buffer until the one before * buffer locked); a frame waits in the output buffer until the one before
* has arrived, and shows in the packet log when it leaves. * has arrived, and shows in the packet log when it leaves.
*
* `swiftArp`, `swiftSends`: Swift's ARP and Swift's send that returns
* when the network is quiet, likewise only for the comparison.
*/ */
constructor( constructor(
seed: bigint | number = DEFAULT_SEED, seed: bigint | number = DEFAULT_SEED,
packetTraceRetentionPolicy: TraceRetentionPolicy = PRODUCTION_TRACE_RETENTION, packetTraceRetentionPolicy: TraceRetentionPolicy = PRODUCTION_TRACE_RETENTION,
options: { readonly swiftCables?: boolean; readonly swiftArp?: boolean } = {}, options: { readonly swiftCables?: boolean; readonly swiftArp?: boolean; readonly swiftSends?: boolean } = {},
) { ) {
this.cablesCarryOneFrameAtATime = options.swiftCables !== true; this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
this.swiftArp = options.swiftArp === true; this.swiftArp = options.swiftArp === true;
this.swiftSends = options.swiftSends === true;
const normalizedSeed = toSeed(seed); const normalizedSeed = toSeed(seed);
this.stateValue = initialState(normalizedSeed); this.stateValue = initialState(normalizedSeed);
this.random = new JavaCompatibleRandom(normalizedSeed); this.random = new JavaCompatibleRandom(normalizedSeed);
@@ -669,6 +694,7 @@ export class NetworkRuntimeEngine {
state.globalPacketLossEnabled = false; state.globalPacketLossEnabled = false;
this.isProcessingScheduledEvents = false; this.isProcessingScheduledEvents = false;
this.arpLookups = []; this.arpLookups = [];
this.transportSenders.clear();
state.pendingEvents = []; state.pendingEvents = [];
state.arpCachesByNodeId.clear(); state.arpCachesByNodeId.clear();
state.switchForwardingTablesByNodeId.clear(); state.switchForwardingTablesByNodeId.clear();
@@ -1054,20 +1080,25 @@ export class NetworkRuntimeEngine {
afterHeaders: udpHeaders(datagram), afterHeaders: udpHeaders(datagram),
}); });
const handedOn: IPv4DeliveryResult = { type: 'delivered', packetIdentity, nodeId: socket.nodeId };
if (destinationIPAddress === LIMITED_BROADCAST_IP_ADDRESS) { if (destinationIPAddress === LIMITED_BROADCAST_IP_ADDRESS) {
for (const networkInterface of boundInterface === undefined ? interfaces : [boundInterface]) { const broadcastInterfaces = boundInterface === undefined ? interfaces : [boundInterface];
const packet: IPv4Packet = { for (const networkInterface of broadcastInterfaces) traceSent(networkInterface, packetIdentity);
identity: packetIdentity, return this.handToTransportSender(socket.nodeId, 'udp', handedOn, (finished) => {
senderIPAddress: networkInterface.ipAddress, for (const networkInterface of broadcastInterfaces) {
receiverIPAddress: destinationIPAddress, const packet: IPv4Packet = {
timeToLive: 1, identity: packetIdentity,
protocol: 'udp', senderIPAddress: networkInterface.ipAddress,
payload: { kind: 'udp', datagram }, receiverIPAddress: destinationIPAddress,
}; timeToLive: 1,
traceSent(networkInterface, packet.identity); protocol: 'udp',
this.sendLimitedBroadcastPacket(socket.nodeId, networkInterface, packet); payload: { kind: 'udp', datagram },
} };
return { type: 'delivered', packetIdentity, nodeId: socket.nodeId }; this.sendLimitedBroadcastPacket(socket.nodeId, networkInterface, packet);
}
finished();
return handedOn;
});
} }
let networkInterface: NetworkInterface; let networkInterface: NetworkInterface;
@@ -1086,7 +1117,57 @@ export class NetworkRuntimeEngine {
payload: { kind: 'udp', datagram }, payload: { kind: 'udp', datagram },
}; };
traceSent(networkInterface, packet.identity); traceSent(networkInterface, packet.identity);
return this.sendIPv4Packet(socket.nodeId, packet, networkInterface.portId); const portId = networkInterface.portId;
return this.handToTransportSender(socket.nodeId, 'udp', handedOn, (finished) =>
this.sendIPv4PacketWithOptions(socket.nodeId, packet, portId, true, 'sent', { onFinished: finished }),
);
}
/**
* Hands a segment to the node's send thread for the protocol (Java's
* TransportProtokoll.senden). The thread holds its list while IP waits for
* ARP: a socket's send then blocks until the segment in hand is out, as the
* caller does here; from within an event, the segment queues behind it.
* The senders that wait take their turns in the order they came, before
* the thread takes the next — as with a fair lock; Java's is not fair and
* leaves the order to chance. The result is the send's own when the thread
* takes it at once, else that it was handed on.
*/
private handToTransportSender(
nodeId: Id,
protocol: 'udp' | 'tcp',
handedOn: IPv4DeliveryResult,
send: (finished: () => void) => IPv4DeliveryResult,
): IPv4DeliveryResult {
const sender = getOrCreate(this.transportSenders, `${nodeId}/${protocol}`, () => ({ busy: false, started: 0, finished: 0, queue: [] }));
if (sender.busy && !this.isProcessingScheduledEvents) {
// The segment in hand is out once ARP has answered or given up.
const inHand = sender.started;
const deadline = this.stateValue.currentTimeMilliseconds + (ARP_ATTEMPTS + 1) * this.runtimeSpeed.roundTripTimeMilliseconds;
this.advanceUntil(() => sender.finished >= inHand, deadline);
if (this.stateValue.phase !== 'running' || this.transportSenders.get(`${nodeId}/${protocol}`) !== sender) return handedOn;
}
let result = handedOn;
sender.queue.push((finished) => {
result = send(finished);
});
if (!sender.busy) this.startNextTransportSend(sender);
return result;
}
private startNextTransportSend(sender: TransportSender): void {
const next = sender.queue.shift();
if (next === undefined) return;
sender.busy = true;
sender.started += 1;
let out = false;
next(() => {
if (out) return;
out = true;
sender.finished += 1;
sender.busy = false;
this.startNextTransportSend(sender);
});
} }
private sendLimitedBroadcastPacket(fromNodeId: Id, networkInterface: NetworkInterface, packet: IPv4Packet): void { private sendLimitedBroadcastPacket(fromNodeId: Id, networkInterface: NetworkInterface, packet: IPv4Packet): void {
@@ -1689,9 +1770,6 @@ export class NetworkRuntimeEngine {
if (socket === undefined || socket.remoteIPAddress === undefined || socket.remotePort === undefined || session === undefined) { if (socket === undefined || socket.remoteIPAddress === undefined || socket.remotePort === undefined || session === undefined) {
return 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; let segment: TcpSegment;
if (retransmission && session.lastSentSegment !== undefined) { if (retransmission && session.lastSentSegment !== undefined) {
segment = session.lastSentSegment; segment = session.lastSentSegment;
@@ -1738,9 +1816,16 @@ export class NetworkRuntimeEngine {
afterHeaders: tcpHeaders(segment), afterHeaders: tcpHeaders(segment),
detail: retransmission ? `${detail} retransmission attempt ${session.sendAttempts}` : detail, detail: retransmission ? `${detail} retransmission attempt ${session.sendAttempts}` : detail,
}); });
const delivery = this.sendIPv4Packet(socket.nodeId, packet, networkInterface.portId); const portId = networkInterface.portId;
if (state.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined) this.scheduleTcpTimeoutIfNeeded(socketId, handedDownAt); const handedOn: IPv4DeliveryResult = { type: 'delivered', packetIdentity: packet.identity, nodeId: socket.nodeId };
return delivery; return this.handToTransportSender(socket.nodeId, 'tcp', handedOn, (finished) => {
// Java's socket starts waiting when it has handed the segment down,
// before ARP has found the next hop; the timeout counts from here.
const handedDownAt = state.currentTimeMilliseconds;
const delivery = this.sendIPv4PacketWithOptions(socket.nodeId, packet, portId, true, 'sent', { onFinished: finished });
if (state.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined) this.scheduleTcpTimeoutIfNeeded(socketId, handedDownAt);
return delivery;
});
} }
private scheduleTcpTimeoutIfNeeded(socketId: Id, fromMilliseconds: number): void { private scheduleTcpTimeoutIfNeeded(socketId: Id, fromMilliseconds: number): void {
@@ -1826,10 +1911,9 @@ export class NetworkRuntimeEngine {
socketId: Id, socketId: Id,
options: { waitingStates?: readonly TcpSocketState[]; waitingForAcknowledgement?: boolean }, options: { waitingStates?: readonly TcpSocketState[]; waitingForAcknowledgement?: boolean },
): boolean { ): boolean {
let advancedToTimeout = false; const waiting = () => {
while (this.stateValue.phase === 'running') {
const socket = this.stateValue.socketsById.get(socketId); const socket = this.stateValue.socketsById.get(socketId);
if (socket === undefined || socket.tcpState === undefined) return advancedToTimeout; if (socket === undefined || socket.tcpState === undefined) return false;
const waitingOnState = options.waitingStates?.includes(socket.tcpState) === true; const waitingOnState = options.waitingStates?.includes(socket.tcpState) === true;
// Data waits for its acknowledgement only while connected: once the // Data waits for its acknowledgement only while connected: once the
// socket closes (Java: senden gives up and calls schliessen), it returns. // socket closes (Java: senden gives up and calls schliessen), it returns.
@@ -1837,13 +1921,17 @@ export class NetworkRuntimeEngine {
options.waitingForAcknowledgement === true && options.waitingForAcknowledgement === true &&
socket.tcpState === 'ESTABLISHED' && socket.tcpState === 'ESTABLISHED' &&
this.stateValue.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined; this.stateValue.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined;
if (!waitingOnState && !waitingOnAck) return advancedToTimeout; return waitingOnState || waitingOnAck;
};
let advancedToTimeout = false;
while (this.stateValue.phase === 'running' && waiting()) {
const deadlines = this.stateValue.pendingEvents const deadlines = this.stateValue.pendingEvents
.filter((event) => event.kind.kind === 'tcpTimeout' && event.kind.sessionId === socketId) .filter((event) => event.kind.kind === 'tcpTimeout' && event.kind.sessionId === socketId)
.map((event) => event.deadlineMilliseconds); .map((event) => event.deadlineMilliseconds);
if (deadlines.length === 0) return advancedToTimeout; if (deadlines.length === 0) return advancedToTimeout;
advancedToTimeout = true; advancedToTimeout = true;
this.advance(Math.min(...deadlines)); // Until the answer comes, or the attempt's time is up.
this.advanceUntil(() => !waiting(), Math.min(...deadlines));
} }
return advancedToTimeout; return advancedToTimeout;
} }
@@ -2895,8 +2983,14 @@ export class NetworkRuntimeEngine {
this.drainPendingNetworkDeliveryEventsIfNeeded(); this.drainPendingNetworkDeliveryEventsIfNeeded();
} }
/**
* Swift's send, for the comparison: a frame sent from outside the events
* runs the network until every frame in flight has arrived. Java's send
* hands the frame to the card and returns; the frames travel while the
* clock advances.
*/
private drainPendingNetworkDeliveryEventsIfNeeded(): void { private drainPendingNetworkDeliveryEventsIfNeeded(): void {
if (this.isProcessingScheduledEvents) return; if (this.isProcessingScheduledEvents || !this.swiftSends) return;
const maximumEvents = 16_384; const maximumEvents = 16_384;
let processedEventCount = 0; let processedEventCount = 0;
while (processedEventCount < maximumEvents) { while (processedEventCount < maximumEvents) {
+46 -31
View File
@@ -3,7 +3,7 @@
// card saw and when each call started and ended (arp-java-<story>.java.json). // card saw and when each call started and ended (arp-java-<story>.java.json).
// The engine makes the same calls: the frames must come in the same order, // 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 // with the same content, at about the same distance from each other
// (javaRun.ts), the calls must return the same, after about as long. // (javaRun.ts); the calls must end at about the same times and return the same.
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
@@ -27,28 +27,41 @@ function pingResult(result: IPv4DeliveryResult, answered: boolean): string {
return answered ? 'ICMP 0/0' : 'null'; return answered ? 'ICMP 0/0' : 'null';
} }
const STORIES: Record<string, (engine: NetworkRuntimeEngine) => string> = { /** Records a call as ArpRun does: when it started, when it ended and what it returned. */
pingLan: (engine) => pingResult(engine.sendIcmpEcho(HOST, '10.0.0.9'), false), type Call = (label: string, run: () => string) => void;
pingRouted: (engine) => {
const result = engine.sendIcmpEcho(HOST, '20.0.0.9'); function udpSend(engine: NetworkRuntimeEngine, target: string, payload: string): string {
return pingResult(result, result.type === 'delivered' && result.nodeId !== HOST); const socket = engine.bindUdpSocket(HOST, { remoteIPAddress: target, remotePort: 9 })!;
engine.sendUdp(socket, new TextEncoder().encode(payload));
return '';
}
const STORIES: Record<string, (engine: NetworkRuntimeEngine, call: Call) => void> = {
pingLan: (engine, call) => call('sendProbe 10.0.0.9', () => pingResult(engine.sendIcmpEcho(HOST, '10.0.0.9'), false)),
pingRouted: (engine, call) =>
call('sendProbe 20.0.0.9', () => {
const result = engine.sendIcmpEcho(HOST, '20.0.0.9');
return pingResult(result, result.type === 'delivered' && result.nodeId !== HOST);
}),
udpTwice: (engine, call) => {
call('senden eins', () => udpSend(engine, '10.0.0.9', 'eins'));
call('senden zwei', () => udpSend(engine, '10.0.0.9', 'zwei'));
},
udpBehind: (engine, call) => {
call('senden verloren', () => udpSend(engine, '10.0.0.9', 'verloren'));
call('senden dahinter', () => udpSend(engine, '20.0.0.2', 'dahinter'));
}, },
udpTwice: () => '',
udpBehind: () => '',
}; };
/** /** The client's free port as "client": Java draws it at random, the engine from its seed. */
* Stories the engine does not play like Java yet, with the reason; each must function withClientPort<T extends { text: string }>(frames: readonly T[]): T[] {
* still differ from Java. return frames.map((frame) => ({ ...frame, text: frame.text.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole)) }));
* }
* 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 /** Java's events without its answer's sender ("from …"), which the engine's results do not name. */
* engine's threads come in the next step. function javaEvent(event: string): string {
*/ return event.replace(/ from .*$/, '');
const NOT_YET_LIKE_JAVA: Record<string, string> = { }
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', () => { describe('ARP as Java FILIUS runs it', () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'arp-routed.scenario.json'), 'utf8')) as Scenario; const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'arp-routed.scenario.json'), 'utf8')) as Scenario;
@@ -58,20 +71,22 @@ describe('ARP as Java FILIUS runs it', () => {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `arp-java-${name}.java.json`), 'utf8')) as JavaRun; const java = JSON.parse(readFileSync(join(scenarioDirectory, `arp-java-${name}.java.json`), 'utf8')) as JavaRun;
const { engine, frames } = watchedEngine(scenario); const { engine, frames } = watchedEngine(scenario);
engine.handle({ type: 'advance', toMilliseconds: 500 }); engine.handle({ type: 'advance', toMilliseconds: 500 });
const called = engine.state.currentTimeMilliseconds; const events: [number, string][] = [];
const result = story(engine); story(engine, (label, run) => {
const returned = engine.state.currentTimeMilliseconds; events.push([engine.state.currentTimeMilliseconds, `${label} called`]);
const result = run();
events.push([engine.state.currentTimeMilliseconds, `${label} returned ${result}`]);
});
engine.handle({ type: 'advance', toMilliseconds: 16_000 }); engine.handle({ type: 'advance', toMilliseconds: 16_000 });
const expected = gaps(javaFrames(java.frames)); const expected = gaps(withClientPort(javaFrames(java.frames)));
const actual = gaps(frames()); const actual = gaps(withClientPort(frames()));
if (name in NOT_YET_LIKE_JAVA) { expect(events.map(([, event]) => event)).toEqual(java.events.map(([, event]) => javaEvent(event)));
expect(JSON.stringify(actual), NOT_YET_LIKE_JAVA[name]).not.toBe(JSON.stringify(expected)); for (const [index, [time]] of events.entries()) {
return; expect(Math.abs(time - events[0]![0] - (java.events[index]![0] - java.events[0]![0])), `time of ${events[index]![1]}`).toBeLessThanOrEqual(
GAP_TOLERANCE_MILLISECONDS,
);
} }
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) { 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(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); expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `gap before frame #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
+152 -9
View File
@@ -44,7 +44,7 @@ import {
type RuntimeOutput, type RuntimeOutput,
type ScheduledEventKind, type ScheduledEventKind,
} from '../src/index.js'; } from '../src/index.js';
import { device, link, pc, startedEngine, uuid } from './fixtures.js'; import { device, letFramesArrive, link, pc, startedEngine, uuid } from './fixtures.js';
const marker = (label: string): ScheduledEventKind => ({ kind: 'parityMarker', label }); const marker = (label: string): ScheduledEventKind => ({ kind: 'parityMarker', label });
@@ -274,6 +274,29 @@ describe('event queue and virtual clock', () => {
expect(engine.state.packetTraces.find((trace) => trace.layer === 'physical' && trace.operation === 'sent')?.timeMilliseconds).toBe(0); expect(engine.state.packetTraces.find((trace) => trace.layer === 'physical' && trace.operation === 'sent')?.timeMilliseconds).toBe(0);
}); });
it('forgets the send threads on stop', () => {
const [firstId, firstPortId, secondId, secondPortId] = [0x29, 0x2a, 0x2b, 0x2c].map(uuid) as [string, string, string, string];
const input = {
nodes: [pc(firstId, firstPortId), pc(secondId, secondPortId)],
links: [link(firstId, firstPortId, secondId, secondPortId)],
deviceConfigurations: new Map([
[firstId, device('10.6.0.1')],
[secondId, device('10.6.0.2')],
]),
};
const engine = startedEngine(input);
// The UDP thread waits for ARP on .99 when the simulation stops.
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.6.0.99', remotePort: 9 })!, new TextEncoder().encode('lost'));
engine.handle({ type: 'stop' });
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed: 14, initialTimeMilliseconds: 0 });
const receiver = engine.bindUdpSocket(secondId, { localPort: 9 })!;
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.6.0.2', remotePort: 9 })!, new TextEncoder().encode('found'));
expect(engine.state.currentTimeMilliseconds).toBe(0);
expect(new TextDecoder().decode(engine.receiveUdp(receiver, 1_000)?.datagram.payload)).toBe('found');
});
it('forgets TCP sessions and accepted sockets on stop', () => { it('forgets TCP sessions and accepted sockets on stop', () => {
const [clientId, clientPortId, serverId, serverPortId] = [0x21, 0x22, 0x23, 0x24].map(uuid) as [string, string, string, string]; const [clientId, clientPortId, serverId, serverPortId] = [0x21, 0x22, 0x23, 0x24].map(uuid) as [string, string, string, string];
const engine = startedEngine({ const engine = startedEngine({
@@ -319,7 +342,7 @@ describe('UDP sockets', () => {
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeUndefined(); expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeUndefined();
engine.sendUdp(sender!, new TextEncoder().encode('hello')); engine.sendUdp(sender!, new TextEncoder().encode('hello'));
const received = engine.receiveUdp(receiver!); const received = engine.receiveUdp(receiver!, 1_000);
expect(text(received?.datagram.payload)).toBe('hello'); expect(text(received?.datagram.payload)).toBe('hello');
expect(received?.senderIPAddress).toBe('192.168.5.10'); expect(received?.senderIPAddress).toBe('192.168.5.10');
@@ -327,6 +350,36 @@ describe('UDP sockets', () => {
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeDefined(); expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeDefined();
}); });
it('returns from a send at once, the datagram arrives as the clock advances (swiftSends: when it has arrived)', () => {
const arrival = (swiftSends: boolean) => {
const [sourceNodeId, sourcePortId, targetNodeId, targetPortId] = [0x15c1, 0x15c2, 0x15c3, 0x15c4].map(uuid) as [string, string, string, string];
const engine = new NetworkRuntimeEngine(14, undefined, { swiftSends });
engine.handle({
type: 'start',
snapshot: topologySnapshot({
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
deviceConfigurations: new Map([
[sourceNodeId, device('192.168.6.10')],
[targetNodeId, device('192.168.6.20')],
]),
}),
seed: 14,
initialTimeMilliseconds: 0,
});
const receiver = engine.bindUdpSocket(targetNodeId, { localPort: 9000 })!;
const sender = engine.bindUdpSocket(sourceNodeId, { remoteIPAddress: '192.168.6.20', remotePort: 9000 })!;
engine.sendUdp(sender, new TextEncoder().encode('hello'));
const returnedAt = engine.state.currentTimeMilliseconds;
const waiting = engine.state.udpReceiveQueuesBySocketId.get(receiver)?.length ?? 0;
// ARP there and back, then the datagram: three cables of 55 ms.
return { returnedAt, waiting, receivedAt: engine.receiveUdp(receiver, 1_000)?.receivedAtMilliseconds };
};
expect(arrival(false)).toEqual({ returnedAt: 0, waiting: 0, receivedAt: 165 });
expect(arrival(true)).toEqual({ returnedAt: 165, waiting: 1, receivedAt: 165 });
});
it('draws ephemeral ports without disturbing the seeded RIP jitter', () => { it('draws ephemeral ports without disturbing the seeded RIP jitter', () => {
const nodeId = uuid(0x15f1); const nodeId = uuid(0x15f1);
const input = { nodes: [pc(nodeId, uuid(0x15f2))], links: [], deviceConfigurations: new Map([[nodeId, device('192.168.10.10')]]) }; const input = { nodes: [pc(nodeId, uuid(0x15f2))], links: [], deviceConfigurations: new Map([[nodeId, device('192.168.10.10')]]) };
@@ -366,13 +419,13 @@ describe('UDP sockets', () => {
engine.sendUdp(sender, new TextEncoder().encode('broadcast'), { ipAddress: '255.255.255.255', port: 9000 }); engine.sendUdp(sender, new TextEncoder().encode('broadcast'), { ipAddress: '255.255.255.255', port: 9000 });
expect(engine.receiveUdp(receiverA)?.senderIPAddress).toBe('10.0.0.1'); expect(engine.receiveUdp(receiverA, 1_000)?.senderIPAddress).toBe('10.0.0.1');
expect(engine.receiveUdp(receiverB)?.senderIPAddress).toBe('20.0.0.1'); expect(engine.receiveUdp(receiverB, 1_000)?.senderIPAddress).toBe('20.0.0.1');
expect(engine.state.socketsById.get(receiverA)?.remoteIPAddress).toBe('10.0.0.1'); expect(engine.state.socketsById.get(receiverA)?.remoteIPAddress).toBe('10.0.0.1');
expect(engine.state.socketsById.get(receiverA)?.remotePort).toBe(8000); expect(engine.state.socketsById.get(receiverA)?.remotePort).toBe(8000);
engine.sendUdp(receiverA, new TextEncoder().encode('reply')); engine.sendUdp(receiverA, new TextEncoder().encode('reply'));
expect(text(engine.receiveUdp(sender)?.datagram.payload)).toBe('reply'); expect(text(engine.receiveUdp(sender, 1_000)?.datagram.payload)).toBe('reply');
}); });
it('advances the virtual clock for a receive timeout and returns nothing', () => { it('advances the virtual clock for a receive timeout and returns nothing', () => {
@@ -621,12 +674,15 @@ describe('switch', () => {
engine.state.packetTraces.filter((trace) => trace.nodeId === switchId && trace.detail === detail); engine.state.packetTraces.filter((trace) => trace.nodeId === switchId && trace.detail === detail);
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, '02:00:00:00:00:FE')); engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, '02:00:00:00:00:FE'));
letFramesArrive(engine);
expect(switchTraces('switch unknown unicast flooding')).toHaveLength(2); expect(switchTraces('switch unknown unicast flooding')).toHaveLength(2);
engine.sendEthernetFrame(hostBId, hostBPortId, frame(macB, macA)); engine.sendEthernetFrame(hostBId, hostBPortId, frame(macB, macA));
letFramesArrive(engine);
expect(switchTraces('switch learned unicast forwarding')).toHaveLength(1); expect(switchTraces('switch learned unicast forwarding')).toHaveLength(1);
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, macA)); engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, macA));
letFramesArrive(engine);
expect(switchTraces('switch filtered destination on incoming port').length).toBeGreaterThan(0); expect(switchTraces('switch filtered destination on incoming port').length).toBeGreaterThan(0);
expect(engine.switchSatEntries(switchId)).toHaveLength(2); expect(engine.switchSatEntries(switchId)).toHaveLength(2);
@@ -777,6 +833,7 @@ describe('packet loss and trace retention', () => {
engine.setGlobalPacketLossEnabled(true); engine.setGlobalPacketLossEnabled(true);
engine.sendEthernetFrame(firstNodeId, firstPortId, frame); engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
letFramesArrive(engine);
expect(engine.state.globalPacketLossEnabled).toBe(true); expect(engine.state.globalPacketLossEnabled).toBe(true);
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.size ?? 0).toBe(0); expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.size ?? 0).toBe(0);
@@ -792,6 +849,7 @@ describe('packet loss and trace retention', () => {
engine.setGlobalPacketLossEnabled(false); engine.setGlobalPacketLossEnabled(false);
engine.sendEthernetFrame(firstNodeId, firstPortId, frame); engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
letFramesArrive(engine);
expect(engine.state.globalPacketLossEnabled).toBe(false); expect(engine.state.globalPacketLossEnabled).toBe(false);
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.get('10.0.0.1')?.macAddress).toBe( expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.get('10.0.0.1')?.macAddress).toBe(
@@ -864,6 +922,9 @@ describe('TCP sockets', () => {
expect(engine.connectTcp(clientSocketId)).toBe(true); expect(engine.connectTcp(clientSocketId)).toBe(true);
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!; const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('ESTABLISHED'); expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('ESTABLISHED');
// connect returns with the SYN+ACK; the client's ACK is still on its way.
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('SYN_RCVD');
letFramesArrive(engine);
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('ESTABLISHED'); expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('ESTABLISHED');
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_SENT -> ESTABLISHED')).toBe(true); expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_SENT -> ESTABLISHED')).toBe(true);
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_RCVD -> ESTABLISHED')).toBe(true); expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_RCVD -> ESTABLISHED')).toBe(true);
@@ -885,6 +946,42 @@ describe('TCP sockets', () => {
expect(payloadLengths).toEqual([1_460, 1_460, 81]); expect(payloadLengths).toEqual([1_460, 1_460, 81]);
}); });
it("sends TCP in a thread of its own: UDP waiting for ARP does not hold a connection up", () => {
const { engine, clientId, serverId } = pair(0x1731, 0x1732, '10.24.0');
engine.openTcpServerSocket(serverId, 80);
engine.sendUdp(engine.bindUdpSocket(clientId, { remoteIPAddress: '10.24.0.99', remotePort: 9 })!, new TextEncoder().encode('nobody'));
const clientSocketId = engine.openTcpClientSocket(clientId, '10.24.0.2', 80)!;
expect(engine.connectTcp(clientSocketId)).toBe(true);
// ARP for the server (behind the one for .99 on the cable), SYN and SYN+ACK: four cables of 55 ms.
expect(engine.state.currentTimeMilliseconds).toBe(55 + 4 * 55);
});
it("holds the TCP thread while ARP waits: a connection's SYN waits behind a SYN+ACK to no one", () => {
const { engine, clientId, serverId } = pair(0x17e1, 0x17e2, '10.25.0');
engine.openTcpServerSocket(serverId, 80);
engine.openTcpServerSocket(clientId, 81);
// A SYN to the client's port 81 from .99, which no card has: the client's answer waits for ARP.
engine.sendIPv4Packet(serverId, {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '10.25.0.99',
receiverIPAddress: '10.25.0.1',
timeToLive: 64,
protocol: 'tcp',
payload: {
kind: 'tcp',
segment: { sourcePort: 5000, destinationPort: 81, sequenceNumber: 1, acknowledgementNumber: 0, flags: TCP_FLAG.synchronize, payload: new Uint8Array() },
},
});
engine.handle({ type: 'advance', toMilliseconds: 200 });
const clientSocketId = engine.openTcpClientSocket(clientId, '10.25.0.2', 80)!;
expect(engine.connectTcp(clientSocketId)).toBe(true);
// The SYN arrived after ARP there and back (165 ms); ARP for .99 gives up two
// round trips later, then the client's SYN and the server's SYN+ACK.
expect(engine.state.currentTimeMilliseconds).toBe(165 + 2 * 2_750 + 2 * 55);
});
it('retransmits a connection attempt at most three times and releases the port', () => { it('retransmits a connection attempt at most three times and releases the port', () => {
const clientId = uuid(0x1741); const clientId = uuid(0x1741);
const engine = startedEngine({ nodes: [pc(clientId, uuid(0x1751))], links: [], deviceConfigurations: new Map([[clientId, device('10.19.0.1')]]) }); const engine = startedEngine({ nodes: [pc(clientId, uuid(0x1751))], links: [], deviceConfigurations: new Map([[clientId, device('10.19.0.1')]]) });
@@ -932,6 +1029,7 @@ describe('TCP sockets', () => {
const serverSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!; const serverSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
engine.closeTcpSocket(clientSocketId); engine.closeTcpSocket(clientSocketId);
letFramesArrive(engine);
// Java's socket thread leaves listen on the FIN and closes without the application. // Java's socket thread leaves listen on the FIN and closes without the application.
const serverStates = engine.state.packetTraces const serverStates = engine.state.packetTraces
.filter((trace) => trace.nodeId === serverId && trace.detail?.startsWith('TCP ') === true && trace.detail.includes(' -> ')) .filter((trace) => trace.nodeId === serverId && trace.detail?.startsWith('TCP ') === true && trace.detail.includes(' -> '))
@@ -971,8 +1069,10 @@ describe('TCP sockets', () => {
engine.openTcpServerSocket(serverId, 80); engine.openTcpServerSocket(serverId, 80);
const client = engine.openTcpClientSocket(clientId, '10.23.0.2', 80)!; const client = engine.openTcpClientSocket(clientId, '10.23.0.2', 80)!;
expect(engine.connectTcp(client)).toBe(true); expect(engine.connectTcp(client)).toBe(true);
letFramesArrive(engine);
const closedAt = engine.state.currentTimeMilliseconds; const closedAt = engine.state.currentTimeMilliseconds;
engine.closeTcpSocket(client); engine.closeTcpSocket(client);
letFramesArrive(engine);
// The server answers the FIN with its ACK and, closing by itself, its FIN at once. // The server answers the FIN with its ACK and, closing by itself, its FIN at once.
const physical = (nodeId: string, operation: string) => const physical = (nodeId: string, operation: string) =>
engine.state.packetTraces engine.state.packetTraces
@@ -1013,6 +1113,7 @@ describe('TCP sockets', () => {
const clientSocketId = engine.openTcpClientSocket(clientId, '10.21.0.2', 9090)!; const clientSocketId = engine.openTcpClientSocket(clientId, '10.21.0.2', 9090)!;
expect(engine.connectTcp(clientSocketId)).toBe(true); expect(engine.connectTcp(clientSocketId)).toBe(true);
engine.closeTcpSocket(clientSocketId); engine.closeTcpSocket(clientSocketId);
letFramesArrive(engine);
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT'); expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
const localPort = engine.state.socketsById.get(clientSocketId)!.localPort; const localPort = engine.state.socketsById.get(clientSocketId)!.localPort;
@@ -1059,6 +1160,7 @@ describe('TCP sockets', () => {
// the listener's successor 3 (made when the first is accepted), the second client 4. // the listener's successor 3 (made when the first is accepted), the second client 4.
const listenerId = engine.openTcpServerSocket(serverId, 80)!; const listenerId = engine.openTcpServerSocket(serverId, 80)!;
expect(engine.connectTcp(engine.openTcpClientSocket(firstId, '10.22.0.3', 80)!)).toBe(true); expect(engine.connectTcp(engine.openTcpClientSocket(firstId, '10.22.0.3', 80)!)).toBe(true);
letFramesArrive(engine);
expect(engine.connectTcp(engine.openTcpClientSocket(secondId, '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)!; const accepted = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)!;
@@ -1423,7 +1525,7 @@ describe('DNS', () => {
]), ]),
), ),
}); });
return { engine, client: hosts[0]!.id }; return { engine, client: hosts[0]!.id, server: hosts[1]!.id };
}; };
const queries = (engine: NetworkRuntimeEngine) => const queries = (engine: NetworkRuntimeEngine) =>
engine.state.packetTraces.filter((trace) => trace.detail?.startsWith('DNS query') === true).map((trace) => trace.detail); engine.state.packetTraces.filter((trace) => trace.detail?.startsWith('DNS query') === true).map((trace) => trace.detail);
@@ -1516,14 +1618,55 @@ describe('DNS', () => {
expect(engine.state.currentTimeMilliseconds - called).toBe(4 * 110); expect(engine.state.currentTimeMilliseconds - called).toBe(4 * 110);
}); });
it("answers behind a datagram of its own whose ARP fails: Java's UDP thread sends one after the other", () => {
const { engine, client, server } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9'] });
const socket = engine.bindUdpSocket(server, { remoteIPAddress: '10.30.0.99', remotePort: 9 })!;
const called = engine.state.currentTimeMilliseconds;
engine.sendUdp(socket, new TextEncoder().encode('nobody'));
expect(engine.resolveDnsAddress(client, 'web.filius.de')).toEqual({ type: 'resolved', address: '10.30.0.9' });
// The answer leaves when ARP has given up on .99 (two round trips), and takes two cables through the switch.
expect(engine.state.currentTimeMilliseconds - called).toBe(2 * 2_750 + 110);
});
it('lets the senders that wait take their turns in the order they came, before the thread takes the next', () => {
const { engine, client, server } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9'] });
const receiver = engine.bindUdpSocket(client, { localPort: 9 })!;
// The server's UDP thread waits for ARP on .99 from 0 to 5500 ms.
engine.sendUdp(engine.bindUdpSocket(server, { remoteIPAddress: '10.30.0.99', remotePort: 9 })!, new TextEncoder().encode('nobody'));
// A query from .98, which no card has, reaches the server at 330 ms; its answer waits its turn.
const query = encodeDnsMessage({
id: 1,
queryResponse: 0,
responseCode: 0,
questions: [{ name: 'web.filius.de.', type: 'A', questionClass: 'IN' }],
answers: [],
authority: [],
additional: [],
});
engine.sendIPv4Packet(client, {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '10.30.0.98',
receiverIPAddress: '10.30.0.2',
timeToLive: 64,
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort: 5000, destinationPort: 53, payload: new TextEncoder().encode(query) } },
});
engine.handle({ type: 'advance', toMilliseconds: 400 });
// As with a fair lock (Java's is not): the send returns when the datagram
// in hand is out; the answer to .98 goes before it and waits for ARP in turn.
engine.sendUdp(engine.bindUdpSocket(server, { remoteIPAddress: '10.30.0.1', remotePort: 9 })!, new TextEncoder().encode('third'));
expect(engine.state.currentTimeMilliseconds).toBe(2 * 2_750);
expect(engine.receiveUdp(receiver, 10_000)?.receivedAtMilliseconds).toBe(4 * 2_750 + 110);
});
it('gives up after five round trips when no server answers, and closes its socket', () => { it('gives up after five round trips when no server answers, and closes its socket', () => {
const { engine, client } = lan({ '10.30.0.2': [] }); const { engine, client } = lan({ '10.30.0.2': [] });
const called = engine.state.currentTimeMilliseconds; const called = engine.state.currentTimeMilliseconds;
// 10.30.0.3 is there, but no DNS server runs on it. // 10.30.0.3 is there, but no DNS server runs on it.
expect(engine.resolveDns(client, 'web.filius.de', 'A', '10.30.0.3')).toEqual({ type: 'timedOut' }); expect(engine.resolveDns(client, 'web.filius.de', 'A', '10.30.0.3')).toEqual({ type: 'timedOut' });
// Java's five round trips; before them the engine's send still blocks for // Java's five round trips, from handing the query down: the send returns at once.
// ARP and the query's way to .3 (Java waits from handing it down — the ARP step). expect(engine.state.currentTimeMilliseconds - called).toBe(5 * 2_750);
expect(engine.state.currentTimeMilliseconds - called).toBe(3 * 110 + 5 * 2_750);
expect([...engine.state.socketsById.values()].filter((socket) => socket.nodeId === client)).toEqual([]); expect([...engine.state.socketsById.values()].filter((socket) => socket.nodeId === client)).toEqual([]);
}); });
+14
View File
@@ -48,3 +48,17 @@ export function startedEngine(
export function uuid(suffix: number): Id { export function uuid(suffix: number): Id {
return `00000000-0000-0000-0000-${suffix.toString(16).toUpperCase().padStart(12, '0')}`; return `00000000-0000-0000-0000-${suffix.toString(16).toUpperCase().padStart(12, '0')}`;
} }
const FRAME_EVENTS = new Set(['ethernetLinkDeparture', 'ethernetLinkDelivery', 'remoteLinkDelivery', 'arpAnswered']);
/**
* Advances until every frame in flight has arrived — what Swift's send did
* by itself; the engine's send returns at once, as Java's.
*/
export function letFramesArrive(engine: NetworkRuntimeEngine): void {
const inFlight = () =>
engine.state.pendingEvents.filter((event) => FRAME_EVENTS.has(event.kind.kind)).map((event) => event.deadlineMilliseconds);
for (let deadlines = inFlight(); deadlines.length > 0; deadlines = inFlight()) {
engine.handle({ type: 'advance', toMilliseconds: Math.min(...deadlines) });
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ function observationJson(item: IcmpObservation): Record<string, unknown> {
function run(scenario: Scenario): Record<string, unknown> { function run(scenario: Scenario): Record<string, unknown> {
// Swift's cables carry all frames at once; everything else is compared as it is. // Swift's cables carry all frames at once; everything else is compared as it is.
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true, swiftArp: true }); const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true, swiftArp: true, swiftSends: true });
if (scenario.speedPercent !== undefined) engine.setSimulationSpeed(scenario.speedPercent); if (scenario.speedPercent !== undefined) engine.setSimulationSpeed(scenario.speedPercent);
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 }); engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
+6
View File
@@ -273,3 +273,9 @@ Gemessen (30.09.2026), was der Kern (nach Swift) anders macht:
erst danach. erst danach.
* Ein Ping wartet eine RTT auf die Antwort; das „Host unreachable" des * Ein Ping wartet eine RTT auf die Antwort; das „Host unreachable" des
Routers (nach dessen 5,5 s) kommt zu spät. Routers (nach dessen 5,5 s) kommt zu spät.
Der Kern spielt alle vier Abläufe wie Java (`packages/core/test/arpJava.test.ts`:
Frames, Abstände und die Zeiten, zu denen die Aufrufe zurückkehren). Die UDP-Abläufe
seit F2.4b-2: ein Senden kehrt sofort zurück wie in Java (Swifts Senden wartete, bis
alle Frames angekommen waren), und je Knoten und Protokoll sendet ein Transport-Thread
ein Segment nach dem anderen.