feat(core): ARP after Java, measured with Java FILIUS

An ARP lookup asks, waits a round trip, asks once more and gives up after
another round trip; then host unreachable, observed at the sender itself or
sent back by the router (for ICMP only for an echo request). A waiting
sender wakes after what else happens at that time. A ping then waits a
round trip for its answer, counted from when it left. Swift asks twice at
once while events run and lets the packet wait without end when the
address exists anywhere in the network; the option swiftArp keeps that for
the Swift comparison, and three scenarios are compared with Swift until the
trace where ARP fails or a ping goes unanswered.

arpJava.test.ts compares ArpRun: both pings match; the two UDP stories wait
for Java's transport thread (next step). DNS without a server now matches
Java too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 17:11:23 +00:00
co-authored by Claude Opus 5.5
parent 67fb608412
commit dbbc1fd70d
8 changed files with 414 additions and 152 deletions
+81
View File
@@ -0,0 +1,81 @@
// ARP as Java FILIUS runs it. tools/java-oracle's ArpRun sent to addresses no
// card has on arp-routed's network in Java FILIUS and wrote every frame each
// card saw and when each call started and ended (arp-java-<story>.java.json).
// The engine makes the same calls: the frames must come in the same order,
// with the same content, at about the same distance from each other
// (javaRun.ts), the calls must return the same, after about as long.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { IPv4DeliveryResult, NetworkRuntimeEngine } from '../src/index.js';
import { GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, watchedEngine, type JavaFrame } from './javaRun.js';
import type { Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
const HOST = '00000000-0000-0000-0000-000000000301';
interface JavaRun {
frames: Record<string, JavaFrame[]>;
events: [number, string][];
}
/** A ping's result in Java's words: the ICMP answer's type and code, or null when none came. */
function pingResult(result: IPv4DeliveryResult, answered: boolean): string {
if (result.type === 'icmpError' && result.kind === 'destinationHostUnreachable') return 'ICMP 3/1';
return answered ? 'ICMP 0/0' : 'null';
}
const STORIES: Record<string, (engine: NetworkRuntimeEngine) => string> = {
pingLan: (engine) => pingResult(engine.sendIcmpEcho(HOST, '10.0.0.9'), false),
pingRouted: (engine) => {
const result = engine.sendIcmpEcho(HOST, '20.0.0.9');
return pingResult(result, result.type === 'delivered' && result.nodeId !== HOST);
},
udpTwice: () => '',
udpBehind: () => '',
};
/**
* Stories the engine does not play like Java yet, with the reason; each must
* still differ from Java.
*
* udpTwice, udpBehind: Java's transport thread sends one segment after the
* other and holds its queue while ARP waits, so the next send blocks — the
* engine's threads come in the next step.
*/
const NOT_YET_LIKE_JAVA: Record<string, string> = {
udpTwice: "Java's transport thread: the second datagram waits for the first one's ARP",
udpBehind: "Java's transport thread: a datagram to a reachable host waits behind one whose ARP fails",
};
describe('ARP as Java FILIUS runs it', () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'arp-routed.scenario.json'), 'utf8')) as Scenario;
for (const [name, story] of Object.entries(STORIES)) {
it(name, () => {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `arp-java-${name}.java.json`), 'utf8')) as JavaRun;
const { engine, frames } = watchedEngine(scenario);
engine.handle({ type: 'advance', toMilliseconds: 500 });
const called = engine.state.currentTimeMilliseconds;
const result = story(engine);
const returned = engine.state.currentTimeMilliseconds;
engine.handle({ type: 'advance', toMilliseconds: 16_000 });
const expected = gaps(javaFrames(java.frames));
const actual = gaps(frames());
if (name in NOT_YET_LIKE_JAVA) {
expect(JSON.stringify(actual), NOT_YET_LIKE_JAVA[name]).not.toBe(JSON.stringify(expected));
return;
}
const [[javaCalled], [javaReturned, javaOutcome]] = java.events as [[number, string], [number, string]];
expect(javaOutcome.replace(/^.* returned /, '').replace(/ from .*$/, '')).toBe(result);
expect(Math.abs(returned - called - (javaReturned - javaCalled)), 'duration of the call').toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
expect(actual[index]?.text, `frame #${index + 1}`).toBe(expected[index]?.text);
expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `gap before frame #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
}
});
}
});
+2 -11
View File
@@ -53,17 +53,8 @@ function normalized(frames: readonly SeenFrame[]): SeenFrame[] {
}));
}
/**
* Stories the engine does not play like Java yet, with the reason; each must
* still differ from Java (else it belongs to the others) and return the same.
*
* noServer: Java's transport thread sends while the resolver already waits,
* and asks ARP twice, a round trip apart. The engine's ARP (Swift's) blocks
* the call and asks again at once — rebuilt after Java in its own step.
*/
const NOT_YET_LIKE_JAVA: Record<string, string> = {
noServer: "ARP: Java's second request comes a round trip after the first, while the resolver waits",
};
/** Stories the engine does not play like Java yet, with the reason; each must still differ from Java. */
const NOT_YET_LIKE_JAVA: Record<string, string> = {};
describe('DNS as Java FILIUS runs it', () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'dns-lan.scenario.json'), 'utf8')) as Scenario;
+57
View File
@@ -1531,3 +1531,60 @@ describe('DNS', () => {
expect(() => lan({ '10.30.0.2': [] }, true)).toThrow(NotPortedError);
});
});
describe('ARP', () => {
const requestsFor = (engine: NetworkRuntimeEngine, ipAddress: string) =>
engine.state.packetTraces.filter((trace) => trace.detail === `ARP request for ${ipAddress}`).map((trace) => trace.timeMilliseconds);
it('asks twice, a round trip apart, then gives up', () => {
const { engine, sourceNodeId } = routedTopology();
const called = engine.state.currentTimeMilliseconds;
expect(engine.resolveMacAddress(sourceNodeId, '10.0.0.9')).toBeUndefined();
expect(requestsFor(engine, '10.0.0.9')).toEqual([called, called + 2_750]);
expect(engine.state.currentTimeMilliseconds).toBe(called + 2 * 2_750);
});
it('lets a router answer host unreachable for an echo request, not for an echo reply', () => {
const { engine, sourceNodeId } = routedTopology();
const toNowhere = (kind: 'echoRequest' | 'echoReply', identity: number): IPv4Packet => ({
identity,
senderIPAddress: '10.0.0.2',
receiverIPAddress: '20.0.0.9',
timeToLive: 64,
protocol: 'icmp',
payload: { kind: 'icmp', message: icmpMessage(kind, { identifier: identity, sequenceNumber: 1, data: new Uint8Array() }) },
});
const observed = () => (engine.state.icmpObservationsByNodeId.get(sourceNodeId) ?? []).map((each) => each.message.kind);
engine.sendIPv4Packet(sourceNodeId, toNowhere('echoReply', 901));
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
expect(observed()).toEqual([]);
engine.sendIPv4Packet(sourceNodeId, toNowhere('echoRequest', 902));
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
expect(observed()).toEqual(['destinationHostUnreachable']);
});
it('forgets the lookups that wait on stop', () => {
const [firstId, firstPortId, secondId, secondPortId] = [0x1a01, 0x1a02, 0x1a03, 0x1a04].map(uuid) as [string, string, string, string];
const input = (secondIp: string) => ({
nodes: [pc(firstId, firstPortId), pc(secondId, secondPortId)],
links: [link(firstId, firstPortId, secondId, secondPortId)],
deviceConfigurations: new Map([
[firstId, device('10.0.0.2')],
[secondId, device(secondIp)],
]),
});
const engine = startedEngine(input('10.0.0.8'));
const socket = engine.bindUdpSocket(firstId, { remoteIPAddress: '10.0.0.9', remotePort: 9 })!;
engine.sendUdp(socket, new TextEncoder().encode('lost'));
engine.handle({ type: 'stop' });
engine.handle({ type: 'start', snapshot: topologySnapshot(input('10.0.0.9')), seed: 14, initialTimeMilliseconds: 0 });
expect(engine.resolveMacAddress(firstId, '10.0.0.9')).toBeDefined();
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
expect(engine.state.packetTraces.filter((trace) => trace.layer === 'network' && trace.operation === 'sent')).toEqual([]);
});
});
+10 -1
View File
@@ -62,6 +62,15 @@ function javaText(frame: JavaFrame): string {
return `${macs} UDP ${ends} ${frame.payload}`;
}
/** The ICMP type numbers Java logs. */
const ICMP_TYPES: Record<string, number> = {
echoReply: 0,
destinationNetworkUnreachable: 3,
destinationHostUnreachable: 3,
echoRequest: 8,
timeExceeded: 11,
};
const FLAG_LETTERS: [number, string][] = [
[TCP_FLAG.synchronize, 'S'],
[TCP_FLAG.acknowledgement, 'A'],
@@ -77,7 +86,7 @@ function engineText(frame: EthernetFrame): string {
return `${macs} ARP ${arp.operation} ${arp.senderIPAddress} > ${arp.targetIPAddress}`;
}
const packet = frame.payload.packet;
if (packet.payload.kind === 'icmp') return `${macs} ICMP`;
if (packet.payload.kind === 'icmp') return `${macs} ICMP ${ICMP_TYPES[packet.payload.message.kind]}`;
if (packet.payload.kind === 'tcp') {
const segment = packet.payload.segment;
const flags = FLAG_LETTERS.filter(([bit]) => (segment.flags & bit) !== 0).map(([, letter]) => letter).join('');
+26 -2
View File
@@ -73,7 +73,7 @@ function observationJson(item: IcmpObservation): Record<string, unknown> {
function run(scenario: Scenario): Record<string, unknown> {
// Swift's cables carry all frames at once; everything else is compared as it is.
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true });
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true, swiftArp: true });
if (scenario.speedPercent !== undefined) engine.setSimulationSpeed(scenario.speedPercent);
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
@@ -301,6 +301,16 @@ const JAVA_INSTEAD_OF_SWIFT: Record<string, string> = {
'tcp-closing': 'TCP: TIME_WAIT lasts 3 round trips and ends with the next segment; the other end closes by itself',
};
/**
* Scenarios that follow Java from a trace on: the traces before it must match
* Swift, the rest must differ (the Java comparisons check that behaviour).
*/
const SWIFT_UNTIL_TRACE: Record<string, { readonly trace: number; readonly reason: string }> = {
'firewall-and-loss': { trace: 36, reason: "a ping without an answer waits a round trip (Java's sendProbe), Swift returns at once" },
'routed-two-networks': { trace: 156, reason: 'the router answers host unreachable when ARP has given up after two round trips, Swift at once' },
'switch-lan': { trace: 155, reason: 'ARP for an address no card has asks again after a round trip, then host unreachable' },
};
describe('same result as the Swift engine', () => {
it('corrects Swift only where it errs', () => {
for (const [name, correct] of Object.entries(SWIFT_ERRORS)) {
@@ -321,7 +331,21 @@ describe('same result as the Swift engine', () => {
});
}
for (const name of scenarioNames.filter((each) => !(each in JAVA_INSTEAD_OF_SWIFT))) {
for (const [name, { trace, reason }] of Object.entries(SWIFT_UNTIL_TRACE)) {
it(`${name} matches Swift until trace #${trace}, then follows Java: ${reason}`, () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario;
const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record<string, unknown>;
const actual = run(scenario);
const expectedTraces = swift.traces as unknown[];
const actualTraces = actual.traces as unknown[];
for (let index = 0; index < trace - 1; index += 1) {
expect(actualTraces[index], `trace event #${index + 1}`).toEqual(expectedTraces[index]);
}
expect(actualTraces[trace - 1]).not.toEqual(expectedTraces[trace - 1]);
});
}
for (const name of scenarioNames.filter((each) => !(each in JAVA_INSTEAD_OF_SWIFT) && !(each in SWIFT_UNTIL_TRACE))) {
it(name, () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario;
const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record<string, unknown>;