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 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 14:14:07 +00:00
co-authored by Claude Opus 5.5
parent 739deafbf4
commit 0a34f3a4c4
5 changed files with 112 additions and 28 deletions
+42 -15
View File
@@ -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);
}
+21 -5
View File
@@ -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', () => {
+44 -7
View File
@@ -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<string, (java: JavaRun) => 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<string, Story> = {
@@ -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);