From 0a34f3a4c400794793604744fab484298b501bba Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Thu, 1 Oct 2026 14:14:07 +0000 Subject: [PATCH] fix(core): a learned RIP route expires at its time and is announced at once Java's RIPTable.check marks an expired route only at the router's next beacon, up to some 35 s late. The engine schedules each router's first expiry, marks the route with 16 hops at that time and sends its table at once, as after any change (better than Java where Java only samples, decided 01.10.2026). ripJava's expire story is compared up to the expiry and then checks that R2 marks the route at its time and R1 a cable later. Co-Authored-By: Claude Opus 5.5 --- CHANGES.md | 2 +- packages/core/src/runtime/engine.ts | 57 +++++++++++++++++++++-------- packages/core/test/engine.test.ts | 26 ++++++++++--- packages/core/test/ripJava.test.ts | 51 ++++++++++++++++++++++---- tools/java-oracle/README.md | 4 ++ 5 files changed, 112 insertions(+), 28 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d64c74a..0621278 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. seit F2.3e wie Java: SYN+ACK und FIN gehen nie ein zweites Mal hinaus; der Server gibt einen Handschlag nach 3 RTT auf und schließt mit FIN; nach dem FIN wartet ein Socket höchstens fünfmal 3 RTT, jedes Segment beendet eine Wartezeit, dann CLOSED (Java lässt den Zustand stehen). Abweichend von Java: drei SYN statt einem, weil Javas Wiederholungsschleife nach dem ersten Zeitablauf abbricht, und ein wiederholtes SYN in `SYN_RCVD` bekommt das SYN+ACK noch einmal, statt den Socket zu schließen), 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). Seit F2.4b-3 geht jedes ankommende Paket wie in Java durch den IP-Thread (UDP, TCP) oder den ICMP-Thread (ICMP) seines Knotens: zustellen, Echo-Reply, weiterleiten, eins nach dem anderen; solange ARP wartet, warten die folgenden. Abweichend von Java weckt eine ARP-Antwort alle, die auf die Adresse warten (Javas `notify` weckt einen beliebigen). 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; seit F2.4c auf den Ereignissen statt blockierend und mit rekursivem Server nach `DNSServer.answerWithRemoteData` (Nameserver der Zone, dann der eigene DNS-Server, sonst Verweis), abweichend von Java fragt er sich nie selbst (Java endlos). Gateway-NAT seit F2.6 (Z. ~3585–3900) nach Javas `filius.software.nat`, an Java-Filius gemessen: die Übersetzung vor dem IP-Thread (wie Javas NAT-Threads zwischen Karte und Ethernet), ausgehend WAN-Adresse und der Port des Eintrags (ICMP behält die Kennung), eingehend nach Gegenstelle, Port und Protokoll, sonst nach der Portweiterleitung (legt einen dynamischen Eintrag an), ein Ping an die WAN-Adresse beantwortet der Heimrouter, Pakete von außen an das LAN verschwinden, beide Seiten durch die Firewall des Heimrouters, ausgehend TTL 1 bekommt „Zeit überschritten“; dynamische Einträge verfallen nach 300 s mal Javas Verzögerungsfaktor (Swift fest 300 s), aufgefrischt nur eingehend, seit 01.10.2026 genau in diesem Alter (Java findet sie erst bei seiner sekündlichen Prüfung, bis zu 1 s später; so auch der Switch: Javas `checkSAT` prüft jede Sekunde, der Kern verfällt genau zur Lernzeit); der Heimrouter leitet weiter wie ein Router. Abweichend von Java: ein LAN-Endpunkt bekommt für jede neue Gegenstelle einen Eintrag auf demselben WAN-Port (Java behält nur den für die erste, die Antworten weiterer bleiben am Heimrouter hängen). RIP seit F2.5a (Z. ~4700–5090) nach Javas `filius.software.rip`, seit F2.5b an Java-Filius gemessen (die Pause in Javas `float`-Rechnung): eine Route mit 0 Hops je eigenem Netz, die nie verfällt; der erste Beacon nach einer Sekunde, dann alle 25,2–35,2 s aus einem Zufall je Router (Java: ein ungesetzter `Random` je Router; der Kern setzt ihn aus Szenario und Router-ID, `ripSeed`; Swift: ein Zufall für alle); eine Nachricht je Schnittstelle ohne die Routen über sie (Split Horizon); eine neue Route oder mehr Hops vom selben Router senden sofort; eine gelernte Route wird erst beim nächsten eigenen Beacon auf 16 gesetzt, wenn sie verfallen ist (Swift: bei jedem Ereignis); Nachrichten von jedem Quellport (Swift: nur 521); ein RIP-Router leitet nur nach seiner Tabelle weiter, wenige Hops vor langer Maske. `receiveUdp` kehrt wie Javas `empfangen` zurück, sobald ein Datagramm da ist. Den Mitschnitt je Karte (`capture.ts`) schreibt sie seit F2.8a dort, wo Java ihn schreibt: beim Abgang auf das Kabel und bei der Ankunft an der Karte. WLAN seit F2.7 an Java-Filius gemessen: zur Laufzeit ist ein Funkkabel ein Kabel wie jedes andere, und eine SSID verbindet nichts (nach ihr legt nur Javas Editor das Funkkabel, `JHostKonfiguration.updateWifiConnection`); Swifts Verbindung über die SSID zur Laufzeit, auch ohne Kabel (`wirelessAssociations`), nicht übernommen. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Remote Link | +| `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. seit F2.3e wie Java: SYN+ACK und FIN gehen nie ein zweites Mal hinaus; der Server gibt einen Handschlag nach 3 RTT auf und schließt mit FIN; nach dem FIN wartet ein Socket höchstens fünfmal 3 RTT, jedes Segment beendet eine Wartezeit, dann CLOSED (Java lässt den Zustand stehen). Abweichend von Java: drei SYN statt einem, weil Javas Wiederholungsschleife nach dem ersten Zeitablauf abbricht, und ein wiederholtes SYN in `SYN_RCVD` bekommt das SYN+ACK noch einmal, statt den Socket zu schließen), 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). Seit F2.4b-3 geht jedes ankommende Paket wie in Java durch den IP-Thread (UDP, TCP) oder den ICMP-Thread (ICMP) seines Knotens: zustellen, Echo-Reply, weiterleiten, eins nach dem anderen; solange ARP wartet, warten die folgenden. Abweichend von Java weckt eine ARP-Antwort alle, die auf die Adresse warten (Javas `notify` weckt einen beliebigen). 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; seit F2.4c auf den Ereignissen statt blockierend und mit rekursivem Server nach `DNSServer.answerWithRemoteData` (Nameserver der Zone, dann der eigene DNS-Server, sonst Verweis), abweichend von Java fragt er sich nie selbst (Java endlos). Gateway-NAT seit F2.6 (Z. ~3585–3900) nach Javas `filius.software.nat`, an Java-Filius gemessen: die Übersetzung vor dem IP-Thread (wie Javas NAT-Threads zwischen Karte und Ethernet), ausgehend WAN-Adresse und der Port des Eintrags (ICMP behält die Kennung), eingehend nach Gegenstelle, Port und Protokoll, sonst nach der Portweiterleitung (legt einen dynamischen Eintrag an), ein Ping an die WAN-Adresse beantwortet der Heimrouter, Pakete von außen an das LAN verschwinden, beide Seiten durch die Firewall des Heimrouters, ausgehend TTL 1 bekommt „Zeit überschritten“; dynamische Einträge verfallen nach 300 s mal Javas Verzögerungsfaktor (Swift fest 300 s), aufgefrischt nur eingehend, seit 01.10.2026 genau in diesem Alter (Java findet sie erst bei seiner sekündlichen Prüfung, bis zu 1 s später; so auch der Switch: Javas `checkSAT` prüft jede Sekunde, der Kern verfällt genau zur Lernzeit); der Heimrouter leitet weiter wie ein Router. Abweichend von Java: ein LAN-Endpunkt bekommt für jede neue Gegenstelle einen Eintrag auf demselben WAN-Port (Java behält nur den für die erste, die Antworten weiterer bleiben am Heimrouter hängen). RIP seit F2.5a (Z. ~4700–5090) nach Javas `filius.software.rip`, seit F2.5b an Java-Filius gemessen (die Pause in Javas `float`-Rechnung): eine Route mit 0 Hops je eigenem Netz, die nie verfällt; der erste Beacon nach einer Sekunde, dann alle 25,2–35,2 s aus einem Zufall je Router (Java: ein ungesetzter `Random` je Router; der Kern setzt ihn aus Szenario und Router-ID, `ripSeed`; Swift: ein Zufall für alle); eine Nachricht je Schnittstelle ohne die Routen über sie (Split Horizon); eine neue Route oder mehr Hops vom selben Router senden sofort; eine gelernte Route wird genau nach ihren 75 s auf 16 gesetzt und sofort gemeldet (Java erst beim nächsten eigenen Beacon, bis zu gut 30 s später; Swift bei jedem Ereignis; seit 01.10.2026, vorher wie Java); Nachrichten von jedem Quellport (Swift: nur 521); ein RIP-Router leitet nur nach seiner Tabelle weiter, wenige Hops vor langer Maske. `receiveUdp` kehrt wie Javas `empfangen` zurück, sobald ein Datagramm da ist. Den Mitschnitt je Karte (`capture.ts`) schreibt sie seit F2.8a dort, wo Java ihn schreibt: beim Abgang auf das Kabel und bei der Ankunft an der Karte. WLAN seit F2.7 an Java-Filius gemessen: zur Laufzeit ist ein Funkkabel ein Kabel wie jedes andere, und eine SSID verbindet nichts (nach ihr legt nur Javas Editor das Funkkabel, `JHostKonfiguration.updateWifiConnection`); Swifts Verbindung über die SSID zur Laufzeit, auch ohne Kabel (`wirelessAssociations`), nicht übernommen. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Remote Link | | `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` | diff --git a/packages/core/src/runtime/engine.ts b/packages/core/src/runtime/engine.ts index db80a36..f02e425 100644 --- a/packages/core/src/runtime/engine.ts +++ b/packages/core/src/runtime/engine.ts @@ -9,8 +9,9 @@ // lays the cable by it; Swift's wirelessAssociations, which join by // SSID at run time, are not taken. Measured with Java FILIUS) // "UDP sockets and Router RIP" (lines ~4472–5187; RIP -// after Java's filius.software.rip: expiry checked at the beacon, a -// random of its own per router, any source port) +// after Java's filius.software.rip: a random of its own per router, +// any source port; a route expires at its time, not at the next +// beacon as in Java) // "Java-compatible TCP sockets" (lines ~5187–5913; the // timing and closing after Java's TCPSocket, measured with Java // FILIUS: 3 round trips per attempt and for TIME_WAIT, the other end @@ -142,6 +143,8 @@ export class NotPortedError extends Error { export type ScheduledEventKind = | { readonly kind: 'parityMarker'; readonly label: string } | { readonly kind: 'ripBeacon'; readonly nodeId: Id } + /** A learned route of the router reaches its time. */ + | { readonly kind: 'ripRouteExpiry'; 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. */ @@ -1136,6 +1139,9 @@ export class NetworkRuntimeEngine { case 'ripBeacon': this.sendRipBeacon(kind.nodeId); return; + case 'ripRouteExpiry': + this.expireRipRoutes(kind.nodeId); + return; case 'dhcpClientStart': this.runDhcpClient(kind.nodeId); return; @@ -2481,23 +2487,43 @@ export class NetworkRuntimeEngine { } /** - * RIPBeacon.announce, one round: learned routes past their time get hop - * count 16 (RIPTable.check — only here, not when they expire), the table - * goes out on every interface, the next round comes 25.2 to 35.2 s later. + * RIPBeacon.announce, one round: the table goes out on every interface, + * the next round comes 25.2 to 35.2 s later. */ private sendRipBeacon(nodeId: Id): void { const router = this.ripRoutersByNodeId.get(nodeId); - const table = this.stateValue.ripTablesByNodeId.get(nodeId); - if (router === undefined || table === undefined) return; - const now = this.stateValue.currentTimeMilliseconds; - this.stateValue.ripTablesByNodeId.set( - nodeId, - table.map((route) => - route.expiresAtMilliseconds !== undefined && route.expiresAtMilliseconds < now ? { ...route, metric: RIP_INFINITY } : route, - ), - ); + if (router === undefined || !this.stateValue.ripTablesByNodeId.has(nodeId)) return; this.advertiseRipRoutes(nodeId, router.beaconSocketId); - this.scheduleRipBeacon(nodeId, now + router.random.nextRipJitterMilliseconds()); + this.scheduleRipBeacon(nodeId, this.stateValue.currentTimeMilliseconds + router.random.nextRipJitterMilliseconds()); + } + + /** + * A learned route that has heard nothing for its time gets hop count 16, + * and the router tells its neighbours at once, as after any change. Java + * (RIPTable.check) looks only at its next beacon, up to some 35 s later; + * the engine at the time itself (better than Java where it only samples: + * the user, 01.10.2026). + */ + private expireRipRoutes(nodeId: Id): void { + const table = this.stateValue.ripTablesByNodeId.get(nodeId); + if (table === undefined) return; + const now = this.stateValue.currentTimeMilliseconds; + const expired = (route: RouteRow) => route.expiresAtMilliseconds !== undefined && route.expiresAtMilliseconds <= now && (route.metric ?? RIP_INFINITY) < RIP_INFINITY; + if (table.some(expired)) { + this.stateValue.ripTablesByNodeId.set(nodeId, table.map((route) => (expired(route) ? { ...route, metric: RIP_INFINITY } : route))); + this.scheduleRipBeacon(nodeId, now); + } + this.scheduleRipRouteExpiry(nodeId); + } + + /** One pending expiry per router: when its first reachable learned route runs out. */ + private scheduleRipRouteExpiry(nodeId: Id): void { + const state = this.stateValue; + state.pendingEvents = state.pendingEvents.filter((event) => !(event.kind.kind === 'ripRouteExpiry' && event.kind.nodeId === nodeId)); + const deadlines = (state.ripTablesByNodeId.get(nodeId) ?? []) + .filter((route) => route.expiresAtMilliseconds !== undefined && (route.metric ?? RIP_INFINITY) < RIP_INFINITY) + .map((route) => route.expiresAtMilliseconds!); + if (deadlines.length > 0) this.schedule(Math.min(...deadlines), { kind: 'ripRouteExpiry', nodeId }); } /** One pending beacon per router, the earlier one wins (Java: setNextBeacon, then notify). */ @@ -2600,6 +2626,7 @@ export class NetworkRuntimeEngine { } } state.ripTablesByNodeId.set(nodeId, table); + this.scheduleRipRouteExpiry(nodeId); if (announce) this.scheduleRipBeacon(nodeId, state.currentTimeMilliseconds); } diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index d2fc04c..2f62fc1 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -2047,18 +2047,34 @@ describe('RIP', () => { expect(beacons(withSocket, routerAId)).toEqual(beacons(plain, routerAId)); }); - it('marks a learned route unreachable at the first beacon after it expired, and keeps it (Java: RIPTable.check)', () => { + it('marks a learned route unreachable when it expires, keeps it, and tells its neighbours at once (Java: at its next beacon)', () => { const engine = ripTopology(); engine.handle({ type: 'advance', toMilliseconds: 2_000 }); engine.setGlobalPacketLossEnabled(true); const expiry = route(engine, routerAId, '172.16.0.0')!.expiresAtMilliseconds!; - engine.handle({ type: 'advance', toMilliseconds: expiry + 1 }); + engine.handle({ type: 'advance', toMilliseconds: expiry - 1 }); expect(route(engine, routerAId, '172.16.0.0')?.metric).toBe(1); const [beacon] = beacons(engine, routerAId) as [number]; - engine.handle({ type: 'advance', toMilliseconds: beacon - 1 }); - expect(route(engine, routerAId, '172.16.0.0')?.metric).toBe(1); - engine.handle({ type: 'advance', toMilliseconds: beacon }); + expect(beacon).toBeGreaterThan(expiry); + engine.handle({ type: 'advance', toMilliseconds: expiry }); expect(route(engine, routerAId, '172.16.0.0')).toMatchObject({ metric: 16, expiresAtMilliseconds: expiry }); + expect(sentTables(engine, routerAId).at(-1)).toBe(expiry); + // The next beacon comes a pause after the message, not at the old time. + expect(beacons(engine, routerAId)[0]).toBeGreaterThan(beacon); + }); + + it('expires each learned route at its own time, also after another expired first', () => { + const engine = ripTopology(); + engine.handle({ type: 'advance', toMilliseconds: 2_000 }); + const first = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n198.51.100.0 255.255.255.0 1'); + engine.handle({ type: 'advance', toMilliseconds: first + 10_000 }); + const second = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n203.0.113.0 255.255.255.0 1'); + engine.setGlobalPacketLossEnabled(true); + engine.handle({ type: 'advance', toMilliseconds: second + 75_000 - 1 }); + expect(route(engine, routerAId, '198.51.100.0')?.metric).toBe(16); + expect(route(engine, routerAId, '203.0.113.0')?.metric).toBe(2); + engine.handle({ type: 'advance', toMilliseconds: second + 75_000 }); + expect(route(engine, routerAId, '203.0.113.0')?.metric).toBe(16); }); it('routes with its table alone: a manual route does not count', () => { diff --git a/packages/core/test/ripJava.test.ts b/packages/core/test/ripJava.test.ts index a46800b..b021ec0 100644 --- a/packages/core/test/ripJava.test.ts +++ b/packages/core/test/ripJava.test.ts @@ -24,19 +24,23 @@ // it, where the engine's routers, started together, send their own networks // first and then at once what they learned. Up to STARTUP_MILLISECONDS // (Java's time) the tables must reach the same state, and frames are not -// compared; after it, every frame and every change. +// compared; after it, every frame and every change — up to where the engine +// is better than Java (BETTER_THAN_JAVA_FROM). import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { RIP_FIRST_BEACON_DELAY_MILLISECONDS, type Id, type IPv4DeliveryResult, type NetworkRuntimeEngine } from '../src/index.js'; +import { RIP_FIRST_BEACON_DELAY_MILLISECONDS, RIP_INFINITY, RIP_TIMEOUT_MILLISECONDS, stableMacAddress, type Id, type IPv4DeliveryResult, type NetworkRuntimeEngine } from '../src/index.js'; import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, javaFrames, watchedEngine, type JavaFrame, type SeenFrame } from './javaRun.js'; import type { Scenario } from './scenario.js'; const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios'); const PC_A = '00000000-0000-0000-0000-000000000401'; +const R1 = '00000000-0000-0000-0000-000000000402'; +const R2 = '00000000-0000-0000-0000-000000000403'; const R3 = '00000000-0000-0000-0000-000000000404'; +const R3_TOWARDS_R2 = '00000000-0000-0000-0000-000000000441'; const STATE_UNSEEN_MILLISECONDS = 5; const STARTUP_MILLISECONDS = 5_000; @@ -65,6 +69,26 @@ function pingResult(result: IPv4DeliveryResult, engine: NetworkRuntimeEngine): s return answered ? 'ICMP 0/0' : 'null'; } +/** + * Where the engine is better than Java, per story: compared up to that time + * (Java's), and the engine's own course checked after it. + * + * expire: a learned route expires 75 s after its last message. Java's router + * looks at its next beacon (RIPTable.check), up to some 35 s later; the + * engine marks it at the time itself and tells its neighbours at once (the + * user, 01.10.2026: better than Java where Java only samples). From then on + * R2's beacons, and what follows them, come at other times. The time: R3's + * last message on R2's card, plus the route's 75 s. + */ +const BETTER_THAN_JAVA_FROM: Record number> = { + expire: (java) => { + const r3 = stableMacAddress(R3_TOWARDS_R2).toLowerCase(); + const silent = java.events.find(([, event]) => event === 'R3 silent')![0]; + const heard = java.frames[`${R2}#1`]!.filter((frame) => frame.source.toLowerCase() === r3 && frame.sourcePort === 521 && frame.time < silent); + return Math.max(...heard.map((frame) => frame.time)) + RIP_TIMEOUT_MILLISECONDS; + }, +}; + type Story = (engine: NetworkRuntimeEngine, at: (javaMilliseconds: number) => void, call: (label: string, run: () => string) => void) => void; const STORIES: Record = { @@ -131,8 +155,11 @@ describe('RIP as Java FILIUS runs it', () => { engine.handle({ type: 'advance', toMilliseconds: end - offset + 1_000 }); const startup = STARTUP_MILLISECONDS - offset; - const expected = bySender(javaFrames(java.frames).filter((frame) => frame.time >= STARTUP_MILLISECONDS), offset); - const actual = bySender(frames().filter((frame) => frame.time >= startup), 0); + const expiry = BETTER_THAN_JAVA_FROM[name]?.(java); + // Compared up to a tolerance before it: Java's log and the engine's run meet it within that. + const better = expiry === undefined ? Number.POSITIVE_INFINITY : expiry - GAP_TOLERANCE_MILLISECONDS; + const expected = bySender(javaFrames(java.frames).filter((frame) => frame.time >= STARTUP_MILLISECONDS && frame.time < better), offset); + const actual = bySender(frames().filter((frame) => frame.time >= startup && frame.time < better - offset), 0); expect([...actual.keys()].sort()).toEqual([...expected.keys()].sort()); for (const [sender, javaList] of expected) { const engineList = actual.get(sender)!; @@ -143,7 +170,7 @@ describe('RIP as Java FILIUS runs it', () => { } // Each card's capture, after the startup. - expectSameCaptures(engine, scenario, java.frames, { from: STARTUP_MILLISECONDS, starts: { java: offset, engine: 0 } }); + expectSameCaptures(engine, scenario, java.frames, { from: STARTUP_MILLISECONDS, until: better, starts: { java: offset, engine: 0 } }); // Each router's table: the same state when the startup is over, then the same changes at about the same times. for (const router of routers) { @@ -151,8 +178,8 @@ describe('RIP as Java FILIUS runs it', () => { const before = states.filter(([time]) => time < until); return [...before.slice(-1), ...states.filter(([time]) => time >= until)]; }; - const javaStates = settled(java.tables.filter(([, node]) => node === router), STARTUP_MILLISECONDS); - const all = tables.filter(([, node]) => node === router); + const javaStates = settled(java.tables.filter(([time, node]) => node === router && time < better), STARTUP_MILLISECONDS); + const all = tables.filter(([time, node]) => node === router && time < better - offset); const engineStates = settled( all.filter(([time], index) => index === all.length - 1 || all[index + 1]![0] - time >= STATE_UNSEEN_MILLISECONDS), startup, @@ -164,6 +191,16 @@ describe('RIP as Java FILIUS runs it', () => { } } + if (expiry !== undefined) { + // R2 marks R3's network at that time, and R1 learns it from R2's message a cable later. + const unreachableSince = (router: Id) => + tables.find(([, node, text]) => node === router && text.includes(`10.0.3.0/255.255.255.0 `) && text.includes(` ${RIP_INFINITY}`))?.[0]; + expect(Math.abs(unreachableSince(R2)! - (expiry - offset)), 'R2 marks the route at its time').toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS); + expect(unreachableSince(R1)! - unreachableSince(R2)!, 'R1 learns it a cable later').toBe(engine.simulationSpeed.linkTransmissionDelayMilliseconds); + const javaR2 = java.tables.find(([, node, text]) => node === R2 && text.endsWith(` ${RIP_INFINITY}`))![0]; + expect(javaR2 - expiry, 'Java marks it later, at its beacon').toBeGreaterThan(GAP_TOLERANCE_MILLISECONDS); + } + const javaCalls = java.events.filter(([, event]) => event.startsWith('sendProbe')); expect(events.map(([, event]) => event)).toEqual(javaCalls.map(([, event]) => event.replace(/ from .*$/, ''))); expect(Math.abs(events[1]![0] - events[0]![0] - (javaCalls[1]![0] - javaCalls[0]![0])), 'duration of the ping').toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS); diff --git a/tools/java-oracle/README.md b/tools/java-oracle/README.md index 4c086c1..14fa3ee 100644 --- a/tools/java-oracle/README.md +++ b/tools/java-oracle/README.md @@ -371,6 +371,10 @@ Gemessen (30.09.2026, dazu die Pausenformel am JDK): nach dem Verfallen (`RIPTable.check`): R3s Netz verfiel bei R2 nach rund 76,4 s, R2 setzte es bei seinem Beacon um 86,7 s auf 16; R1 hörte die 16 vom selben Router und meldete sofort. Der Ping bekommt dann ICMP 3/0 von R1. + Der Kern setzt die Route seit 01.10.2026 genau beim Verfallen auf 16 und + meldet sofort (besser als Java, wo Java nur bei seinem Takt nachsieht); + `ripJava.test.ts` vergleicht `expire` bis dahin und prüft danach, dass R2 + zur Zeit und R1 ein Kabel später auf 16 steht (`BETTER_THAN_JAVA_FROM`). * Die Router starten nacheinander, rund 20 ms auseinander; wer einen Nachbarn vor seinem ersten Beacon hört, sendet die gelernte Route gleich mit. Senden mehrere zugleich, fehlt im Lauscher mancher eigene Frame.