// 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-.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. 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 { 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 R3 = '00000000-0000-0000-0000-000000000404'; 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 { const result = new Map(); 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; 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'; } type Story = (engine: NetworkRuntimeEngine, at: (javaMilliseconds: number) => void, call: (label: string, run: () => string) => void) => void; const STORIES: Record = { 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(); 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 expected = bySender(javaFrames(java.frames).filter((frame) => frame.time >= STARTUP_MILLISECONDS), offset); const actual = bySender(frames().filter((frame) => frame.time >= startup), 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 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 = (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(([, node]) => node === router), STARTUP_MILLISECONDS); const all = tables.filter(([, node]) => node === router); 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); } } 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); }); } });