feat(core): packet capture per card after Java's Lauscher, measured card by card

The engine keeps Java's packet capture (capture.ts): per network card
every frame it sent (when it leaves on the cable) or received (also one
for another card), once, with time and number; switches log nothing;
frames the packet loss dropped are kept apart; the capture outlives a
stop and a new start, as in Java, until clearCapture. Per port instead
of per MAC, at most 10,000 frames a card.

expectSameCaptures compares each card's capture with Java's list of that
card in the ARP, WLAN, DNS, TCP, NAT, RIP and single-client DHCP runs.
It found that ScenarioNetwork ignored a switch's learning time (Java ran
300 s, the core the scenarios' 5 s); the oracle sets it now, and the TCP
and DHCP runs are recorded again. A core test found that a new start
numbers frames from 1 again and the capture took them as seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 08:53:37 +00:00
co-authored by Claude Opus 5.5
parent 65ccd9ddd7
commit 088b62469f
24 changed files with 796 additions and 344 deletions
+119
View File
@@ -26,6 +26,7 @@ import {
JavaCompatibleRandom,
ripSeed,
NetworkRuntimeEngine,
captureRetentionPolicy,
portSnapshot,
stableMacAddress,
switchConfiguration,
@@ -2385,3 +2386,121 @@ describe('Home router NAT', () => {
expect(engine.state.natTablesByNodeId.size).toBe(0);
});
});
describe("packet capture (Java's Lauscher)", () => {
const hostA = uuid(0x2e01);
const portA = uuid(0x2e11);
const hostB = uuid(0x2e02);
const portB = uuid(0x2e12);
const switchId = uuid(0x2e04);
const switchPorts = [1, 2].map((index) => portSnapshot(uuid(0x2e20 + index), `sw${index}`));
const network = {
nodes: [pc(hostA, portA), pc(hostB, portB), { id: switchId, kind: 'networkSwitch' as const, ports: switchPorts }],
links: [link(hostA, portA, switchId, switchPorts[0]!.id), link(hostB, portB, switchId, switchPorts[1]!.id)],
deviceConfigurations: new Map([
[hostA, device('10.0.0.1')],
[hostB, device('10.0.0.2')],
]),
};
const kinds = (engine: NetworkRuntimeEngine, portId: string) =>
engine.capture.frames(portId).map(({ number, frame }) => {
const what = frame.payload.kind === 'arp' ? `arp ${frame.payload.packet.operation}` : frame.payload.packet.payload.kind;
return `${number} ${frame.sourceMacAddress === stableMacAddress(portId) ? 'sent' : 'received'} ${what}`;
});
const arpRequest = (engine: NetworkRuntimeEngine): EthernetFrame => ({
identity: engine.allocateFrameIdentity(),
sourceMacAddress: stableMacAddress(portA),
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
payload: {
kind: 'arp',
packet: { operation: 'request', senderMacAddress: stableMacAddress(portA), senderIPAddress: '10.0.0.1', targetMacAddress: UNSPECIFIED_MAC_ADDRESS, targetIPAddress: '10.0.0.9' },
},
});
it('logs every frame a card sends or receives, numbered, and nothing on a switch', () => {
const engine = startedEngine(network);
engine.sendIcmpEcho(hostA, '10.0.0.2');
letFramesArrive(engine);
expect(kinds(engine, portA)).toEqual(['1 sent arp request', '2 received arp reply', '3 sent icmp', '4 received icmp']);
expect(kinds(engine, portB)).toEqual(['1 received arp request', '2 sent arp reply', '3 received icmp', '4 sent icmp']);
expect(switchPorts.map((port) => engine.capture.frames(port.id))).toEqual([[], []]);
// When the card sent it, when the other card received it: two cables later.
const [sent] = engine.capture.frames(portA);
const [received] = engine.capture.frames(portB);
expect(received!.timeMilliseconds - sent!.timeMilliseconds).toBe(2 * engine.simulationSpeed.linkTransmissionDelayMilliseconds);
});
it('logs a frame on a card once, also a frame for another card', () => {
const engine = startedEngine(network);
const frame = { ...arpRequest(engine), destinationMacAddress: '02:00:00:00:00:FE' };
engine.sendEthernetFrame(hostA, portA, frame);
engine.sendEthernetFrame(hostA, portA, frame);
letFramesArrive(engine);
expect(kinds(engine, portA)).toEqual(['1 sent arp request']);
// The switch floods the unknown destination; B's card logs what is not addressed to it.
expect(kinds(engine, portB)).toEqual(['1 received arp request']);
});
it('keeps the frames the packet loss dropped apart; the sender logged them', () => {
const engine = startedEngine(network);
engine.setGlobalPacketLossEnabled(true);
engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
letFramesArrive(engine);
expect(kinds(engine, portA)).toEqual(['1 sent arp request']);
expect(engine.capture.frames(portB)).toEqual([]);
expect(engine.capture.droppedFrames().map((each) => each.timeMilliseconds)).toEqual([engine.simulationSpeed.linkTransmissionDelayMilliseconds]);
});
it('logs a frame flooded onto two cables once for each cable that drops it', () => {
const hostC = uuid(0x2e03);
const portC = uuid(0x2e13);
const ports = [1, 2, 3].map((index) => portSnapshot(uuid(0x2e30 + index), `sw${index}`));
const engine = startedEngine({
nodes: [pc(hostA, portA), pc(hostB, portB), pc(hostC, portC), { id: switchId, kind: 'networkSwitch', ports }],
links: [link(hostA, portA, switchId, ports[0]!.id), link(hostB, portB, switchId, ports[1]!.id), link(hostC, portC, switchId, ports[2]!.id)],
});
engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
// The switch has it and floods it; then the loss begins.
engine.handle({ type: 'advance', toMilliseconds: engine.simulationSpeed.linkTransmissionDelayMilliseconds });
engine.setGlobalPacketLossEnabled(true);
letFramesArrive(engine);
expect(engine.capture.droppedFrames()).toHaveLength(2);
expect([portB, portC].map((port) => engine.capture.frames(port))).toEqual([[], []]);
});
it('outlives a stop and a new start, as in Java, until it is cleared', () => {
const engine = startedEngine(network);
engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
letFramesArrive(engine);
engine.handle({ type: 'stop' });
expect(kinds(engine, portA)).toEqual(['1 sent arp request']);
engine.handle({ type: 'start', snapshot: topologySnapshot(network), seed: 14, initialTimeMilliseconds: 1_000 });
engine.setGlobalPacketLossEnabled(true);
engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
letFramesArrive(engine);
expect(engine.capture.frames(portA).map((each) => [each.number, each.timeMilliseconds])).toEqual([
[1, 0],
[2, 1_000],
]);
expect(engine.capture.droppedFrames()).toHaveLength(1);
engine.clearCapture();
expect(engine.capture.portIds()).toEqual([]);
expect(engine.capture.droppedFrames()).toEqual([]);
});
it('keeps at most the retention, dropping the oldest at once; a frame keeps its number', () => {
const engine = new NetworkRuntimeEngine(14, undefined, { captureRetention: captureRetentionPolicy(3, 2) });
engine.handle({ type: 'start', snapshot: topologySnapshot(network), seed: 14, initialTimeMilliseconds: 0 });
for (let count = 0; count < 4; count += 1) engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
letFramesArrive(engine);
expect(engine.capture.frames(portA).map((each) => each.number)).toEqual([3, 4]);
expect(() => captureRetentionPolicy(2, 3)).toThrow(RangeError);
});
});