feat(core): modems run — the pair in the project, and a modem of another project hands its frames on (K0–K2)
Measured first: tools/java-oracle's ModemRun joins two networks by a pair of Java modems over TCP on localhost. The modem has no card and adds no delay of its own — a frame takes the cables on both sides, nothing more. The core pairs the two enabled modems on a port (Swift's inProject, with its conditions and cycle suppression) on Java's timing: no 20 ms. The modem example of FILIUS now pings through instead of failing the core. A modem of another project (relay) gives its frames to whoever runs the engine and puts on its cable what comes back, with this engine's frame and packet numbers; a frame dies after MAXIMUM_RELAY_HOPS trips. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// Modems as Java FILIUS runs them. tools/java-oracle's ModemRun joined two
|
||||
// networks by a pair of modems (a server and a client over TCP on localhost)
|
||||
// and pinged across (modem-java-<story>.java.json): the modem has no card,
|
||||
// so no capture of its own, and adds no delay — a frame takes the cables on
|
||||
// both sides, nothing more. The engine pairs the two modems on a port and must
|
||||
// show every card the same frames at about the same times.
|
||||
//
|
||||
// Beside Java: the pair's cycle suppression, three modems on a port, a
|
||||
// partner without its cable (Swift's conditions; Java's modems have no pair
|
||||
// in the project).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { withSnapshot, type NetworkRuntimeEngine } from '../src/index.js';
|
||||
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, type JavaFrame, type JavaTables } from './javaRun.js';
|
||||
import { snapshotOf, type Scenario } from './scenario.js';
|
||||
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
const id = (number: number) => `00000000-0000-0000-0000-000000000${number}`;
|
||||
const HOST = id(401);
|
||||
const SERVER_MODEM = id(405);
|
||||
const CLIENT_MODEM = id(406);
|
||||
|
||||
interface JavaRun extends JavaTables {
|
||||
frames: Record<string, JavaFrame[]>;
|
||||
events: [number, string][];
|
||||
}
|
||||
|
||||
/** A ping from HOST in Java's words: the ICMP answer's type and code, or null when none came in time. */
|
||||
function ping(engine: NetworkRuntimeEngine, target: string, sequenceNumber: number): string {
|
||||
const result = engine.sendIcmpEcho(HOST, target, { sequenceNumber });
|
||||
if (result.type === 'icmpError' && result.kind === 'destinationHostUnreachable') return 'ICMP 3/1';
|
||||
const answered = (engine.state.icmpObservationsByNodeId.get(HOST) ?? []).some((observation) => observation.message.kind === 'echoReply' && observation.message.sequenceNumber === sequenceNumber);
|
||||
return answered ? 'ICMP 0/0' : 'null';
|
||||
}
|
||||
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'modem-lan.scenario.json'), 'utf8')) as Scenario;
|
||||
|
||||
/** Without the client modem's configuration: the server modem has no partner, as Java's that is not connected. */
|
||||
const unpaired = () => {
|
||||
const snapshot = snapshotOf(scenario);
|
||||
return withSnapshot(snapshot, { remoteLinkConfigurationsByNodeId: new Map([...snapshot.remoteLinkConfigurationsByNodeId].filter(([node]) => node !== CLIENT_MODEM)) });
|
||||
};
|
||||
|
||||
const STORIES: Record<string, { readonly pings: number; readonly snapshot?: () => ReturnType<typeof snapshotOf> }> = {
|
||||
ping: { pings: 2 },
|
||||
unconnected: { pings: 1, snapshot: unpaired },
|
||||
};
|
||||
|
||||
/** Java's events without its answer's sender ("from …"), which the engine's results do not name. */
|
||||
const javaEvent = (event: string) => event.replace(/ from .*$/, '');
|
||||
|
||||
describe('modems as Java FILIUS runs them', () => {
|
||||
for (const [name, story] of Object.entries(STORIES)) {
|
||||
it(name, () => {
|
||||
const java = JSON.parse(readFileSync(join(scenarioDirectory, `modem-java-${name}.java.json`), 'utf8')) as JavaRun;
|
||||
const { engine } = watchedEngine(scenario, story.snapshot?.());
|
||||
engine.handle({ type: 'advance', toMilliseconds: 500 });
|
||||
const events: [number, string][] = [];
|
||||
for (let sequence = 1; sequence <= story.pings; sequence += 1) {
|
||||
events.push([engine.state.currentTimeMilliseconds, 'sendProbe 10.0.0.2 called']);
|
||||
const result = ping(engine, '10.0.0.2', sequence);
|
||||
events.push([engine.state.currentTimeMilliseconds, `sendProbe 10.0.0.2 returned ${result}`]);
|
||||
}
|
||||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||||
|
||||
expect(events.map(([, event]) => event)).toEqual(java.events.map(([, event]) => javaEvent(event)));
|
||||
for (const [index, [time]] of events.entries()) {
|
||||
expect(Math.abs(time - events[0]![0] - (java.events[index]![0] - java.events[0]![0])), `time of ${events[index]![1]}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
|
||||
}
|
||||
expectSameCaptures(engine, scenario, java.frames, { tables: java });
|
||||
// A modem has no card: nothing in a capture of its port.
|
||||
expect(engine.capture.frames(id(451))).toEqual([]);
|
||||
expect(engine.capture.frames(id(461))).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('a pair of modems in the project', () => {
|
||||
it('stands active while running, stopped otherwise', () => {
|
||||
const { engine } = watchedEngine(scenario);
|
||||
expect(engine.remoteLinkCondition(SERVER_MODEM)).toEqual({ kind: 'active', partnerNodeId: CLIENT_MODEM });
|
||||
expect(engine.remoteLinkCondition(CLIENT_MODEM)).toEqual({ kind: 'active', partnerNodeId: SERVER_MODEM });
|
||||
expect(engine.remoteLinkCondition(HOST)).toBeUndefined();
|
||||
engine.handle({ type: 'stop' });
|
||||
expect(engine.remoteLinkCondition(SERVER_MODEM)).toEqual({ kind: 'stopped' });
|
||||
});
|
||||
|
||||
it('adds no delay of its own: across the pair a frame takes three cables, as two take two', () => {
|
||||
const { engine } = watchedEngine(scenario);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 500 });
|
||||
ping(engine, '10.0.0.2', 1);
|
||||
const sent = engine.capture.frames(id(411))[0]!.timeMilliseconds;
|
||||
// The ARP request: 10.0.0.3 behind the switch (two cables), 10.0.0.2 behind the pair (three).
|
||||
const overSwitch = engine.capture.frames(id(417))[0]!.timeMilliseconds - sent;
|
||||
const overPair = engine.capture.frames(id(413))[0]!.timeMilliseconds - sent;
|
||||
expect(overSwitch).toBeGreaterThan(0);
|
||||
expect(overPair).toBe((overSwitch / 2) * 3);
|
||||
});
|
||||
|
||||
it('pairs only enabled modems', () => {
|
||||
const snapshot = snapshotOf(scenario);
|
||||
const off = withSnapshot(snapshot, {
|
||||
remoteLinkConfigurationsByNodeId: new Map(
|
||||
[...snapshot.remoteLinkConfigurationsByNodeId].map(([node, configuration]) => [node, node === CLIENT_MODEM ? { ...configuration, isEnabled: false } : configuration]),
|
||||
),
|
||||
});
|
||||
const { engine } = watchedEngine(scenario, off);
|
||||
expect(engine.remoteLinkCondition(SERVER_MODEM)).toEqual({ kind: 'unpaired' });
|
||||
expect(engine.remoteLinkCondition(CLIENT_MODEM)).toEqual({ kind: 'disabled' });
|
||||
});
|
||||
|
||||
it('has no partner when it is alone on its port, and drops what comes', () => {
|
||||
const { engine } = watchedEngine(scenario, unpaired());
|
||||
expect(engine.remoteLinkCondition(SERVER_MODEM)).toEqual({ kind: 'unpaired' });
|
||||
engine.handle({ type: 'advance', toMilliseconds: 500 });
|
||||
ping(engine, '10.0.0.2', 1);
|
||||
expect(engine.state.packetTraces.some((event) => event.nodeId === SERVER_MODEM && event.operation === 'dropped' && event.detail === 'remote link unpaired')).toBe(true);
|
||||
expect(engine.capture.frames(id(413))).toEqual([]);
|
||||
});
|
||||
|
||||
it('is ambiguous with a third modem on the port, and passes nothing', () => {
|
||||
const third = id(408);
|
||||
const snapshot = snapshotOf(scenario);
|
||||
const crowded = withSnapshot(snapshot, {
|
||||
nodes: [...snapshot.nodes, { id: third, kind: 'remoteLink', ports: [{ id: id(481), label: 'remote0', macAddress: '02:00:00:00:04:81' }] }],
|
||||
remoteLinkConfigurationsByNodeId: new Map([...snapshot.remoteLinkConfigurationsByNodeId, [third, { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true }]]),
|
||||
});
|
||||
const { engine } = watchedEngine(scenario, crowded);
|
||||
expect(engine.remoteLinkCondition(SERVER_MODEM)).toEqual({ kind: 'ambiguous', enabledNodeCount: 3 });
|
||||
engine.handle({ type: 'advance', toMilliseconds: 500 });
|
||||
expect(ping(engine, '10.0.0.2', 1)).toBe('ICMP 3/1');
|
||||
});
|
||||
|
||||
it('is detached when its partner has no cable', () => {
|
||||
const snapshot = snapshotOf(scenario);
|
||||
const { engine } = watchedEngine(scenario, withSnapshot(snapshot, { links: snapshot.links.filter((link) => link.sourceNodeId !== CLIENT_MODEM) }));
|
||||
expect(engine.remoteLinkCondition(SERVER_MODEM)).toEqual({ kind: 'detached', partnerNodeId: CLIENT_MODEM });
|
||||
engine.handle({ type: 'advance', toMilliseconds: 500 });
|
||||
expect(ping(engine, '10.0.0.2', 1)).toBe('ICMP 3/1');
|
||||
});
|
||||
|
||||
it('passes a frame once: both modems on one switch make a circle that ends at the modems', () => {
|
||||
// The client modem on the switch's third port instead of at 10.0.0.2: a broadcast goes into the server modem, out of the client modem, back into the switch.
|
||||
const snapshot = snapshotOf(scenario);
|
||||
const circle = withSnapshot(snapshot, {
|
||||
nodes: snapshot.nodes.map((node) => (node.id === id(404) ? { ...node, ports: [...node.ports, { id: id(444), label: 'sw4', macAddress: '02:00:00:00:04:44' }] } : node)),
|
||||
links: [
|
||||
...snapshot.links.filter((link) => link.targetNodeId !== id(403)),
|
||||
{ id: '00000000-0000-0000-0000-00000000F405', sourceNodeId: CLIENT_MODEM, sourcePortId: id(461), targetNodeId: id(404), targetPortId: id(444) },
|
||||
],
|
||||
});
|
||||
const { engine } = watchedEngine(scenario, circle);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 500 });
|
||||
expect(ping(engine, '10.0.0.3', 1)).toBe('ICMP 0/0');
|
||||
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
|
||||
// The switch floods the request to both modems: the client modem already passed it on as the server modem's partner.
|
||||
expect(engine.state.packetTraces.some((event) => event.nodeId === CLIENT_MODEM && event.detail === 'remote link cycle suppression')).toBe(true);
|
||||
// 10.0.0.3 saw the request once, not again and again.
|
||||
expect(engine.capture.frames(id(417)).filter((each) => each.frame.payload.kind === 'arp' && each.frame.payload.packet.operation === 'request')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user