feat(core): DHCP client after Java, measured with Java FILIUS
The client follows Java's DHCPClient instead of Swift: one DISCOVER or REQUEST per attempt, an answer that does not fit is read and dropped, the ARP check waits a round trip (an answer for the address ends it at once, then DECLINE), a timeout repeats the state's message, and the fifth turns to a link-local address 169.254.1.x/16 (ARP probe, ARP reply to all); no old address is given back. DHCP round trip times follow the speed as in Java (50 cables, 2750 ms at 90 %; Swift: fixed 2500 ms). Not taken from Java: the client gives up after five occupied link-local addresses (Java searches on without end), and an ARP packet for another address does not end the check. dhcpJava.test.ts compares the four DHCP scenarios with DhcpRun's Java runs; the Swift comparison only checks that they differ. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
// DHCP as Java FILIUS runs it. tools/java-oracle's DhcpRun built each
|
||||
// scenario's network in Java FILIUS, ran it and wrote what every network card
|
||||
// saw (<name>.java.json, FILIUS' own packet log). The engine runs the same
|
||||
// network. For each DHCP client, the frames that concern it must come in the
|
||||
// same order, with the same content, at about the same distance from each
|
||||
// other, and every host must end with the same addresses.
|
||||
//
|
||||
// A frame counts when it first shows on any card (sent, or received), in
|
||||
// Java and in the engine alike. Not only on the client's own card: there,
|
||||
// FILIUS' log misses the DISCOVER of the client that sends second when two
|
||||
// start together (measured in four of four runs of dhcp-lan; the server
|
||||
// receives it) — a fault of the log, not of the network. Java logs no frame
|
||||
// identity; the same text within DUPLICATE_WINDOW_MILLISECONDS is one frame
|
||||
// (measured: a frame reaches the last card within some 130 ms; the closest
|
||||
// two distinct frames of the same text are the DISCOVERs around a DECLINE,
|
||||
// some 520 ms apart).
|
||||
//
|
||||
// Java runs on threads in real time, at the default speed of 90 % (55 ms a
|
||||
// cable, a round trip time of 2750 ms). Its gaps match the engine's within a
|
||||
// few milliseconds, except where a card sends two frames at once: Java's
|
||||
// cable carries one frame at a time (SimplexVerbindung sleeps the delay with
|
||||
// the port's buffer locked, and the sender waits), the engine's cable all of
|
||||
// them together — up to three cables' delay, measured. A gap may differ by
|
||||
// GAP_TOLERANCE_MILLISECONDS; a wrong round trip time (250 ms) may not. Java draws the link-local
|
||||
// address with Math.random; it is compared as 169.254.1.x. Where several
|
||||
// clients start together, which one is first varies in Java (the first
|
||||
// thread pays for loading the classes); a client is therefore compared
|
||||
// without the other clients' frames and the answers to them.
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { NetworkRuntimeEngine, stableMacAddress, type EthernetFrame } from '../src/index.js';
|
||||
import { snapshotOf, type Scenario } from './scenario.js';
|
||||
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
|
||||
const GAP_TOLERANCE_MILLISECONDS = 200;
|
||||
const DUPLICATE_WINDOW_MILLISECONDS = 300;
|
||||
|
||||
interface JavaFrame {
|
||||
time: number;
|
||||
source: string;
|
||||
destination: string;
|
||||
arp?: number;
|
||||
senderIp?: string;
|
||||
targetIp?: string;
|
||||
sourceIp?: string;
|
||||
destinationIp?: string;
|
||||
protocol?: number;
|
||||
sourcePort?: number;
|
||||
destinationPort?: number;
|
||||
payload?: string;
|
||||
icmp?: number;
|
||||
}
|
||||
|
||||
interface JavaRun {
|
||||
frames: Record<string, JavaFrame[]>;
|
||||
devices: Record<string, [string, string, string, string]>;
|
||||
}
|
||||
|
||||
interface SeenFrame {
|
||||
readonly time: number;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
const LINK_LOCAL = /169\.254\.1\.\d+/g;
|
||||
|
||||
function javaText(frame: JavaFrame): string {
|
||||
const macs = `${frame.source} > ${frame.destination}`.toUpperCase();
|
||||
if (frame.arp !== undefined) return `${macs} ARP ${frame.arp === 1 ? 'request' : 'reply'} ${frame.senderIp} > ${frame.targetIp}`;
|
||||
if (frame.icmp !== undefined) return `${macs} ICMP ${frame.icmp}`;
|
||||
return `${macs} UDP ${frame.sourceIp}:${frame.sourcePort} > ${frame.destinationIp}:${frame.destinationPort} ${frame.payload}`;
|
||||
}
|
||||
|
||||
function engineText(frame: EthernetFrame): string {
|
||||
const macs = `${frame.sourceMacAddress} > ${frame.destinationMacAddress}`.toUpperCase();
|
||||
if (frame.payload.kind === 'arp') {
|
||||
const arp = frame.payload.packet;
|
||||
return `${macs} ARP ${arp.operation} ${arp.senderIPAddress} > ${arp.targetIPAddress}`;
|
||||
}
|
||||
const packet = frame.payload.packet;
|
||||
if (packet.payload.kind !== 'udp') return `${macs} ${packet.payload.kind.toUpperCase()}`;
|
||||
const datagram = packet.payload.datagram;
|
||||
return `${macs} UDP ${packet.senderIPAddress}:${datagram.sourcePort} > ${packet.receiverIPAddress}:${datagram.destinationPort} ${new TextDecoder().decode(datagram.payload)}`;
|
||||
}
|
||||
|
||||
/** The engine's run of the scenario's network, without its steps, for `milliseconds`. */
|
||||
function runEngine(scenario: Scenario, milliseconds: number) {
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed);
|
||||
const framesByIdentity = new Map<number, EthernetFrame>();
|
||||
const send = engine.sendEthernetFrame.bind(engine);
|
||||
engine.sendEthernetFrame = (fromNodeId, outgoingPortId, frame) => {
|
||||
framesByIdentity.set(frame.identity, frame);
|
||||
send(fromNodeId, outgoingPortId, frame);
|
||||
};
|
||||
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
|
||||
engine.handle({ type: 'advance', toMilliseconds: milliseconds });
|
||||
|
||||
// Every frame when it first shows on a host's card (as FILIUS logs it: sent, or received).
|
||||
const hosts = new Set(scenario.nodes.filter((node) => node.kind !== 'networkSwitch').map((node) => node.id));
|
||||
const firstSeen = new Map<number, SeenFrame>();
|
||||
for (const event of engine.state.packetTraces) {
|
||||
if (event.layer !== 'physical' || !hosts.has(event.nodeId) || event.frameIdentity === undefined) continue;
|
||||
if (!((event.direction === 'outbound' && event.operation === 'sent') || (event.direction === 'inbound' && event.operation === 'received'))) continue;
|
||||
if (firstSeen.has(event.frameIdentity)) continue;
|
||||
firstSeen.set(event.frameIdentity, { time: event.timeMilliseconds, text: engineText(framesByIdentity.get(event.frameIdentity)!) });
|
||||
}
|
||||
const devices = Object.fromEntries(
|
||||
[...engine.state.topologySnapshot.deviceConfigurations].map(([node, device]) => [
|
||||
node,
|
||||
[device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer],
|
||||
]),
|
||||
);
|
||||
return { frames: [...firstSeen.values()], devices };
|
||||
}
|
||||
|
||||
/** Java's frames of all cards, each once: the same text within the window is the same frame. */
|
||||
function javaFrames(java: JavaRun): SeenFrame[] {
|
||||
const all = Object.values(java.frames)
|
||||
.flat()
|
||||
.map((frame) => ({ time: frame.time, text: javaText(frame) }))
|
||||
.sort((lhs, rhs) => lhs.time - rhs.time);
|
||||
const frames: SeenFrame[] = [];
|
||||
for (const frame of all) {
|
||||
if (frames.some((each) => each.text === frame.text && frame.time - each.time <= DUPLICATE_WINDOW_MILLISECONDS)) continue;
|
||||
frames.push(frame);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** Consecutive frames without the other clients' own: the text, and the time since the one before. */
|
||||
function steps(frames: readonly SeenFrame[], otherClientMacs: readonly string[]) {
|
||||
const theirs = (frame: SeenFrame) =>
|
||||
otherClientMacs.some((mac) => frame.text.startsWith(`${mac} >`) || frame.text.includes(`chaddr=${mac}`));
|
||||
const own = frames.filter((frame) => !theirs(frame));
|
||||
return own.map((frame, index) => ({
|
||||
text: frame.text.replace(LINK_LOCAL, '169.254.1.x'),
|
||||
gap: index === 0 ? 0 : frame.time - own[index - 1]!.time,
|
||||
}));
|
||||
}
|
||||
|
||||
function clientMac(scenario: Scenario, nodeId: string): string {
|
||||
const port = scenario.nodes.find((node) => node.id === nodeId)!.ports[0]!;
|
||||
return (port.mac ?? stableMacAddress(port.id)).toUpperCase();
|
||||
}
|
||||
|
||||
const runs = readdirSync(scenarioDirectory)
|
||||
.filter((name) => name.endsWith('.java.json'))
|
||||
.map((name) => name.replace('.java.json', ''))
|
||||
.sort();
|
||||
|
||||
describe('DHCP as Java FILIUS runs it', () => {
|
||||
it('has Java runs', () => {
|
||||
expect(runs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const name of runs) {
|
||||
it(name, () => {
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario;
|
||||
const java = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.java.json`), 'utf8')) as JavaRun;
|
||||
const lastJavaFrame = Math.max(...Object.values(java.frames).flatMap((frames) => frames.map((frame) => frame.time)));
|
||||
const engine = runEngine(scenario, lastJavaFrame + 10_000);
|
||||
|
||||
const clients = scenario.dhcpClients ?? [];
|
||||
for (const client of clients) {
|
||||
const others = clients.filter((each) => each !== client).map((each) => clientMac(scenario, each));
|
||||
const expected = steps(javaFrames(java), others);
|
||||
const actual = steps(engine.frames, others);
|
||||
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
|
||||
expect(actual[index]?.text, `${client} frame #${index + 1}`).toBe(expected[index]?.text);
|
||||
expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `${client} gap before frame #${index + 1}`).toBeLessThanOrEqual(
|
||||
GAP_TOLERANCE_MILLISECONDS,
|
||||
);
|
||||
}
|
||||
}
|
||||
const linkLocal = (devices: Record<string, string[]>) =>
|
||||
Object.fromEntries(Object.entries(devices).map(([node, device]) => [node, device.map((value) => value.replace(LINK_LOCAL, '169.254.1.x'))]));
|
||||
expect(linkLocal(engine.devices)).toEqual(linkLocal(java.devices));
|
||||
});
|
||||
}
|
||||
|
||||
it('builds the MACs as DhcpRun does', () => {
|
||||
expect(stableMacAddress('00000000-0000-0000-0000-000000000112')).toBe('02:00:00:00:01:12');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user