captureRows.ts builds Java's rows per frame (Lauscher.datenVorbereiten): Ethernet; ARP, ICMP or IP; TCP or UDP; the data with Java's protocol recognition; with addresses and ports or ports only. The comment stays structured; captureCommentText says it with given words and numbers. ScenarioNetwork writes Java's finished tables per card (Lauscher.getDaten, German locale); all runtime runs are recorded again, NatRun has ttlOut. expectSameCaptures compares both views for every frame. Fixed where the engine differed: an ARP request's target MAC is the broadcast address, an ICMP error's identifier and sequence number are 0 (RFC 792), and host unreachable from a router carries the packet as forwarded. Not taken: Java's lower-case broadcast address in a reply to 0.0.0.0, and the typo 'Exeeded'. RIP keeps its recordings: Java's staggered router start now gives a different startup on this machine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
12 KiB
TypeScript
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, type JavaTables } 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 extends JavaTables {
|
|
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 }, tables: java });
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
});
|