Files
mdg-filius/packages/core/test/ripJava.test.ts
T
julian.kiedaischandClaude Opus 5.5 0a34f3a4c4 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>
2026-10-01 14:14:07 +00:00

210 lines
12 KiB
TypeScript

// RIP as Java FILIUS runs it. tools/java-oracle's RipRun ran rip-chain's
// network in Java FILIUS, each router's pauses drawn from the random the core
// seeds (ripSeed), and wrote every frame each card saw, every change of each
// router's RIP table and the story's calls (rip-java-<story>.java.json). The
// engine plays the same story: the frames must come in the same order, with
// the same content, at about the same distance from each other (javaRun.ts);
// each table must go through the same states at about the same times; the
// calls must return the same.
//
// Java starts the routers some 200 ms after the network (ScenarioNetwork
// starts switches and servers first), one after the other; the engine starts
// all at once. The story's calls come at Java's times less that offset, taken
// from the first RIP message (a router's first beacon comes a second after
// its start). Frames that leave different cards within a few milliseconds
// come in Java in the order its threads happen to run, and its packet log
// misses a sender's own frame when several send at once (then the receiver's
// entry, a cable later, counts): the frames are compared per sending card, in
// order, each at about the same time. A table state Java's poll (every 2 ms)
// could not see — the engine's lasts less than STATE_UNSEEN_MILLISECONDS — is
// left out.
//
// The first seconds depend on the order Java's routers start in: one that
// hears a neighbour before its own first beacon sends the learned route with
// 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 — 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, 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;
/** The frames by the card that sent them (the source MAC address), each list in order. */
function bySender(frames: readonly SeenFrame[], shift: number): Map<string, SeenFrame[]> {
const result = new Map<string, SeenFrame[]>();
for (const frame of frames) {
const sender = frame.text.slice(0, 17);
const list = result.get(sender) ?? [];
list.push({ time: frame.time - shift, text: frame.text });
result.set(sender, list);
}
return result;
}
interface JavaRun {
frames: Record<string, JavaFrame[]>;
events: [number, string][];
tables: [number, Id, string][];
}
/** A ping's result in Java's words: the answer's type and code, or null. */
function pingResult(result: IPv4DeliveryResult, engine: NetworkRuntimeEngine): string {
if (result.type === 'icmpError') return result.kind === 'destinationNetworkUnreachable' ? 'ICMP 3/0' : `ICMP ${result.kind}`;
const answered = (engine.state.icmpObservationsByNodeId.get(PC_A) ?? []).some((observation) => observation.message.kind === 'echoReply');
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> = {
converge: (engine, at, call) => {
at(45_000);
call('sendProbe 10.0.3.2', () => pingResult(engine.sendIcmpEcho(PC_A, '10.0.3.2'), engine));
},
expire: (engine, at, call) => {
at(10_000);
// RipRun sets R3's next beacon to never: it sends no more (unless a change sends at once).
engine.state.pendingEvents = engine.state.pendingEvents.filter((event) => !(event.kind.kind === 'ripBeacon' && event.kind.nodeId === R3));
at(125_000);
call('sendProbe 10.0.3.2', () => pingResult(engine.sendIcmpEcho(PC_A, '10.0.3.2'), engine));
},
};
/** The table as RipRun writes it: `network/mask gateway interface hops; …`. */
function tableText(engine: NetworkRuntimeEngine, nodeId: Id): string {
return (engine.state.ripTablesByNodeId.get(nodeId) ?? [])
.map((route) => `${route.destinationNetwork}/${route.subnetMask} ${route.nextHop} ${route.interfaceIPAddress} ${route.metric}`)
.join('; ');
}
describe('RIP as Java FILIUS runs it', () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'rip-chain.scenario.json'), 'utf8')) as Scenario;
for (const [name, story] of Object.entries(STORIES)) {
it(name, () => {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `rip-java-${name}.java.json`), 'utf8')) as JavaRun;
const offset = Math.min(...javaFrames(java.frames).filter((frame) => frame.text.includes(':521 >')).map((frame) => frame.time)) - RIP_FIRST_BEACON_DELAY_MILLISECONDS;
const { engine, frames } = watchedEngine(scenario);
// Every change of a table, after each event.
const tables: [number, Id, string][] = [];
const routers = [...engine.state.ripTablesByNodeId.keys()];
const last = new Map<Id, string>();
const poll = () => {
for (const router of routers) {
const text = tableText(engine, router);
if (last.get(router) === text) continue;
last.set(router, text);
tables.push([engine.state.currentTimeMilliseconds, router, text]);
}
};
poll();
const target = engine as unknown as { processScheduledRuntimeEvent: (kind: unknown) => void };
const process = target.processScheduledRuntimeEvent.bind(engine);
target.processScheduledRuntimeEvent = (kind) => {
process(kind);
poll();
};
const events: [number, string][] = [];
story(
engine,
(javaMilliseconds) => engine.handle({ type: 'advance', toMilliseconds: javaMilliseconds - offset }),
(label, run) => {
events.push([engine.state.currentTimeMilliseconds, `${label} called`]);
const result = run();
events.push([engine.state.currentTimeMilliseconds, `${label} returned ${result}`]);
},
);
const end = Math.max(...Object.values(java.frames).flat().map((frame) => frame.time), ...java.tables.map(([time]) => time));
engine.handle({ type: 'advance', toMilliseconds: end - offset + 1_000 });
const startup = STARTUP_MILLISECONDS - offset;
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)!;
for (let index = 0; index < Math.max(javaList.length, engineList.length); index += 1) {
expect(engineList[index]?.text, `${sender} frame #${index + 1}`).toBe(javaList[index]?.text);
expect(Math.abs(engineList[index]!.time - javaList[index]!.time), `${sender} time of frame #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
}
}
// Each card's capture, after the startup.
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) {
const settled = <T extends [number, Id, string]>(states: T[], until: number): T[] => {
const before = states.filter(([time]) => time < until);
return [...before.slice(-1), ...states.filter(([time]) => time >= until)];
};
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,
);
expect(engineStates.map(([, , text]) => text), router).toEqual(javaStates.map(([, , text]) => text));
for (const [index, [time]] of engineStates.entries()) {
if (index === 0) continue;
expect(Math.abs(time - (javaStates[index]![0] - offset)), `${router} state #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
}
}
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);
});
}
});