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
+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);