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
+19 -4
View File
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { TCP_FLAG, type Id, type NetworkRuntimeEngine, type TcpSegment } from '../src/index.js';
import { loseFirst } from './fixtures.js';
import { GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, watchedEngine, type JavaFrame, type SeenFrame } from './javaRun.js';
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, watchedEngine, withClientPortText, type JavaFrame, type SeenFrame } from './javaRun.js';
import type { Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
@@ -241,6 +241,17 @@ const JAVA_UNTIL: Record<string, (java: JavaRun) => number> = {
lostSynAck: (java) => java.states.client!.find(([, state]) => state === 'SYN_SENT')![0] + JAVA_TIMEOUT_MILLISECONDS - GAP_TOLERANCE_MILLISECONDS,
};
/**
* Where each card's capture is compared only up to a time (Java's), beyond
* JAVA_UNTIL. closedPort: JAVA_BUGS adds the client's two later SYNs to its
* own card, not where else they show — the switch has forgotten the server
* by then and floods them to every card. Compared up to the second SYN.
*/
const CAPTURE_UNTIL: Record<string, (java: JavaRun) => number> = {
...JAVA_UNTIL,
closedPort: (java) => java.states.client!.find(([, state]) => state === 'SYN_SENT')![0] + JAVA_TIMEOUT_MILLISECONDS - GAP_TOLERANCE_MILLISECONDS,
};
describe('TCP as Java FILIUS runs it', () => {
it('corrects Java only where it errs', () => {
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
@@ -250,7 +261,7 @@ describe('TCP as Java FILIUS runs it', () => {
});
it('cuts only where Java goes on with its error', () => {
for (const [name, until] of Object.entries(JAVA_UNTIL)) {
for (const [name, until] of Object.entries(CAPTURE_UNTIL)) {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `tcp-java-${name}.java.json`), 'utf8')) as JavaRun;
const cut = until(java);
const later = [...Object.values(java.frames).flat().map((frame) => frame.time), ...Object.values(java.states).flat().map(([time]) => time)];
@@ -288,13 +299,17 @@ describe('TCP as Java FILIUS runs it', () => {
);
}
expectSameCaptures(engine, scenario, java.frames, { text: withClientPortText, until: CAPTURE_UNTIL[name]?.(measured) });
const shift = (states: [number, string][], syn: number): [number, string][] => before(states.map(([time, state]) => [time - syn, state]));
// From SYN_SENT: Java's first CLOSED is when the socket was made.
const javaClient = java.states.client!.filter(([, state], index) => !(index === 0 && state === 'CLOSED'));
expectSameStates('client', shift(engineStates(engine, story.client, false), engineSyn), shift(javaClient, javaSyn));
if (java.states.server !== undefined) {
// A listening server from its first change: Java's first LISTEN is when it began to listen.
const javaServer = story.serverListening === true ? java.states.server.slice(1) : java.states.server;
// A listening server from its first change: Java's first LISTEN is when it began to listen (its
// poll may have seen the socket CLOSED before).
const listening = java.states.server.findIndex(([, state]) => state === 'LISTEN');
const javaServer = story.serverListening === true ? java.states.server.slice(listening + 1) : java.states.server;
expectSameStates('server', shift(engineStates(engine, SERVER, story.serverListening !== true), engineSyn), shift(javaServer, javaSyn));
}
});