feat(core): traceroute and tcpdump after Java, measured against Java
TerminalRun adds nine stories: traceroute in the LAN, through the router, unreachable, without a route, to no one, to the host itself, errors and Ctrl+C; tcpdump on two hosts. traceroute counts failures across hops as Java does and takes an error that carries its probe; tcpdump tells each frame's highest capture row with its time of day, at once. Departures from Java: traceroute tells its summary before the end mark (Java after it, where its window shows no text) and calls a host at the 20th hop reached. arpsend is not built. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { NetworkRuntimeEngine, withSnapshot } from '../src/index.js';
|
||||
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables, type SeenFrame } from './javaRun.js';
|
||||
import { POLL_MILLISECONDS } from './programLines.js';
|
||||
import { snapshotOf, type Scenario } from './scenario.js';
|
||||
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
@@ -33,7 +34,29 @@ interface JavaRun extends JavaTables {
|
||||
events: [number, string, string, string][];
|
||||
}
|
||||
|
||||
const STORIES = ['pingLan', 'pingRouted', 'pingNobody', 'pingUnreachable', 'pingBroadcast', 'pingSelf', 'pingErrors', 'interrupt', 'dns', 'dnsTimeout', 'arp', 'netstat'] as const;
|
||||
const STORIES = [
|
||||
'pingLan',
|
||||
'pingRouted',
|
||||
'pingNobody',
|
||||
'pingUnreachable',
|
||||
'pingBroadcast',
|
||||
'pingSelf',
|
||||
'pingErrors',
|
||||
'interrupt',
|
||||
'dns',
|
||||
'dnsTimeout',
|
||||
'arp',
|
||||
'netstat',
|
||||
'traceLan',
|
||||
'traceRouted',
|
||||
'traceUnreachable',
|
||||
'traceNoRoute',
|
||||
'traceNobody',
|
||||
'traceSelf',
|
||||
'traceErrors',
|
||||
'traceInterrupt',
|
||||
'tcpdump',
|
||||
] as const;
|
||||
|
||||
type Lines = Record<string, [number, Told][]>;
|
||||
|
||||
@@ -68,7 +91,12 @@ function lineText(text: string): string {
|
||||
const protocol = (row: string) => protocols.indexOf(/^\| (TCP|UDP) /.exec(row)?.[1] ?? '');
|
||||
rows.sort((lhs, rhs) => protocol(lhs) - protocol(rhs) || (lhs < rhs ? -1 : lhs > rhs ? 1 : 0));
|
||||
const sorted = table === null ? text : `${table[1]}${rows.join('\n')}\n${table[3] ?? ''}`;
|
||||
return withClientPortText(sorted).replace(/time=\d+ms/, 'time=…ms');
|
||||
// tcpdump: a frame's time of day, a ping's identifier, a DNS query's ID.
|
||||
return withClientPortText(sorted)
|
||||
.replace(/time=\d+ms/, 'time=…ms')
|
||||
.replace(/^\n {2}\d{2}:\d{2}:\d{2}\.\d{3} /, '\n <time> ')
|
||||
.replace(/Identifier: \d+/, 'Identifier: x')
|
||||
.replace(/ID=\d+/, 'ID=x');
|
||||
}
|
||||
|
||||
const pingTime = (text: string) => Number(/time=(\d+)ms/.exec(text)?.[1] ?? 0);
|
||||
@@ -116,6 +144,21 @@ function withCells(java: JavaRun, change: (cell: string) => string): JavaRun {
|
||||
return { ...java, rows: tables(java.rows), layeredRows: tables(java.layeredRows) };
|
||||
}
|
||||
|
||||
/** traceroute: the summary told before the end mark (Java tells it after, where its window shows no more text). */
|
||||
function withSummaryBeforeEnd(java: JavaRun): JavaRun {
|
||||
const events = [...java.events];
|
||||
for (let index = 0; index + 1 < events.length; index += 1) {
|
||||
const [time, host, kind, text] = events[index]!;
|
||||
const next = events.findIndex((each, at) => at > index && each[1] === host);
|
||||
if (kind !== 'busy' || text !== 'false' || next === -1 || events[next]![2] !== 'text' || !events[next]![3].startsWith('\n\n')) continue;
|
||||
const summary = events[next]!;
|
||||
events.splice(next, 1);
|
||||
events.splice(index, 0, [time, host, 'text', summary[3]]);
|
||||
index += 1;
|
||||
}
|
||||
return { ...java, events };
|
||||
}
|
||||
|
||||
const USAGE_PING = 'ping - test connection to other computer\nUsage: ping <remote host> \n <remote host> the remote address as IP address or host name\n';
|
||||
|
||||
/**
|
||||
@@ -129,6 +172,9 @@ const USAGE_PING = 'ping - test connection to other computer\nUsage: ping <remot
|
||||
* a socket for each address and port it ever answered (never closed).
|
||||
* - A router's network unreachable carries the echo as it would have
|
||||
* forwarded it, with its sender (Java's is half made: sender null).
|
||||
* - traceroute tells its summary before the end mark (withSummaryBeforeEnd);
|
||||
* the host unreachable a host gives itself comes from its address (Java:
|
||||
* 127.0.0.1, as its answer to itself).
|
||||
*/
|
||||
const JAVA_BUGS: Partial<Record<(typeof STORIES)[number], (java: JavaRun) => JavaRun>> = {
|
||||
pingBroadcast: (java) =>
|
||||
@@ -156,12 +202,39 @@ const JAVA_BUGS: Partial<Record<(typeof STORIES)[number], (java: JavaRun) => Jav
|
||||
);
|
||||
},
|
||||
pingUnreachable: (java) => withCells(java, (cell) => cell.replace('sender=null;', 'sender=192.168.0.20;')),
|
||||
traceLan: withSummaryBeforeEnd,
|
||||
traceRouted: (java) => {
|
||||
const typed = java.acts.find(([, , line]) => line === 'traceroute 10.0.0.1')![0];
|
||||
return withEchoReplySource(
|
||||
withSummaryBeforeEnd(withTold(java, (_, text, time) => (time >= typed && text === '192.168.0.1' ? '10.0.0.1' : text))),
|
||||
'192.168.0.1',
|
||||
'10.0.0.1',
|
||||
typed,
|
||||
);
|
||||
},
|
||||
traceUnreachable: withSummaryBeforeEnd,
|
||||
traceNoRoute: (java) => withSummaryBeforeEnd(withCells(java, (cell) => cell.replace('sender=null;', 'sender=192.168.0.20;'))),
|
||||
traceNobody: (java) => withSummaryBeforeEnd(withTold(java, (_, text) => text.replace('127.0.0.1', '192.168.0.20'))),
|
||||
traceSelf: (java) => withSummaryBeforeEnd(withTold(java, (_, text, time) => (time < 1_500 ? text.replace('127.0.0.1', '192.168.0.20') : text))),
|
||||
traceInterrupt: withSummaryBeforeEnd,
|
||||
netstat: (java) => withTold(java, (host, text) => (host === DNS ? text.replace(/\| UDP {5}\| {4}192\.168\.0\.10:53 {4}\|[^\n]*CONNECTED +\|\n/g, '') : text)),
|
||||
};
|
||||
|
||||
/** Where the frames are compared only up to a time (Java's): pingErrors, the ping Java sends for ping -x. */
|
||||
/**
|
||||
* Where the frames are compared only up to a time (Java's): pingErrors, the
|
||||
* ping Java sends for ping -x; traceUnreachable, from the sixth probe on:
|
||||
* Java's router logs it 2.5 s late. Its ICMP thread waits for ARP inside
|
||||
* the lock of its buffer (ProtokollThread.run), the card's thread blocks on
|
||||
* handing it the next frame, and the frames behind are read, and logged,
|
||||
* only when ARP gives up; the engine logs a frame when it comes. (The texts
|
||||
* are the same either way.)
|
||||
*/
|
||||
/** traceroute's sixth probe: its numbers count on from 42 * 23. */
|
||||
const TRACEROUTE_SIXTH_PROBE = 42 * 23 + 6;
|
||||
|
||||
const UNTIL: Partial<Record<(typeof STORIES)[number], (java: JavaRun) => number>> = {
|
||||
pingErrors: (java) => java.acts.find(([, , line]) => line.startsWith('ping -x'))![0],
|
||||
traceUnreachable: (java) => java.frames[CLIENT]!.find((frame) => frame.icmp === 8 && frame.sequence === TRACEROUTE_SIXTH_PROBE)!.time + 50,
|
||||
};
|
||||
|
||||
/** The hosts that answer a broadcast echo at once: which answer comes first is a race of Java's threads. */
|
||||
@@ -211,7 +284,7 @@ describe('the command line on the network as Java FILIUS runs it', () => {
|
||||
const snapshot = withSnapshot(snapshotOf(scenario), {
|
||||
language: 'de',
|
||||
terminalNodeIds: new Set(HOSTS),
|
||||
...(name === 'netstat'
|
||||
...(name === 'netstat' || name === 'tcpdump'
|
||||
? {
|
||||
echoServersByNodeId: new Map([
|
||||
[ECHO, { port: 55555, isActive: true }],
|
||||
@@ -227,6 +300,7 @@ describe('the command line on the network as Java FILIUS runs it', () => {
|
||||
() => {
|
||||
if (line === '^C') engine.terminal(host)!.interrupt();
|
||||
else if (line === 'disconnect') engine.simpleClient(CLIENT)!.disconnect();
|
||||
else if (line === 'connect') engine.simpleClient(CLIENT)!.connect('192.168.0.31', 55555);
|
||||
else engine.terminal(host)!.enter(line);
|
||||
},
|
||||
]);
|
||||
@@ -264,7 +338,10 @@ describe('the command line on the network as Java FILIUS runs it', () => {
|
||||
for (const [index, [javaTime, line]] of expected[host]!.entries()) {
|
||||
const [engineTime, engineLine] = actual[host]![index]!;
|
||||
const late = javaTime - engineTime;
|
||||
expect(Math.abs(late), `${host}'s line ${JSON.stringify(line[1].slice(0, 60))}: Java ${late} ms later`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
|
||||
expect(Math.abs(late), `${host}'s line ${JSON.stringify(line[1].slice(0, 60))}: Java ${late} ms later`).toBeLessThanOrEqual(
|
||||
// tcpdump: Java looks for new frames every 100 ms.
|
||||
GAP_TOLERANCE_MILLISECONDS + (name === 'tcpdump' ? POLL_MILLISECONDS : 0),
|
||||
);
|
||||
expect(Math.abs(pingTime(line[1]) - pingTime(engineLine[1])), `${host}'s ping time in ${JSON.stringify(line[1])}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
|
||||
}
|
||||
}
|
||||
@@ -408,4 +485,106 @@ describe('the command line on the network beyond the stories', () => {
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(engine.routeRow(CLIENT, '192.168.0.31')).toEqual(rows[0]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 19 routers in a row, the host behind the last: 20 hops. Java says the
|
||||
* host could not be reached (it asks ttl >= 20 after the loop, also when
|
||||
* the 20th hop answered); measured only up to two hops (traceRouted).
|
||||
*/
|
||||
it('says a host was reached at the 20th hop', () => {
|
||||
const id = (number: number) => `00000000-0000-0000-0000-${String(number).padStart(12, '0')}`;
|
||||
const ROUTERS = 19;
|
||||
const client = id(1);
|
||||
const host = id(2);
|
||||
const router = (index: number) => id(100 + index);
|
||||
const port = (node: number, side: number) => id(1000 + node * 2 + side);
|
||||
const nodes: Scenario['nodes'] = [
|
||||
{ id: client, kind: 'pc', ports: [{ id: port(1, 0), label: 'eth0' }] },
|
||||
{ id: host, kind: 'pc', ports: [{ id: port(2, 0), label: 'eth0' }] },
|
||||
...Array.from({ length: ROUTERS }, (_, index) => ({
|
||||
id: router(index + 1),
|
||||
kind: 'router' as const,
|
||||
ports: [
|
||||
{ id: port(100 + index + 1, 0), label: 'eth0' },
|
||||
{ id: port(100 + index + 1, 1), label: 'eth1' },
|
||||
],
|
||||
})),
|
||||
];
|
||||
const ends = [port(1, 0), ...Array.from({ length: ROUTERS }, (_, index) => [port(101 + index, 0), port(101 + index, 1)]).flat(), port(2, 0)];
|
||||
const owner = (portId: string) => nodes.find((node) => node.ports.some((each) => each.id === portId))!.id;
|
||||
const links: Scenario['links'] = Array.from({ length: ROUTERS + 1 }, (_, index) => ({
|
||||
id: id(5000 + index),
|
||||
sourceNode: owner(ends[2 * index]!),
|
||||
sourcePort: ends[2 * index]!,
|
||||
targetNode: owner(ends[2 * index + 1]!),
|
||||
targetPort: ends[2 * index + 1]!,
|
||||
}));
|
||||
// Network k is 10.k.0.0/24; router i has 10.i.0.1 towards the client and 10.(i+1).0.2 towards the host.
|
||||
const mask = '255.255.255.0';
|
||||
const chain: Scenario = {
|
||||
seed: 7,
|
||||
nodes,
|
||||
links,
|
||||
devices: {
|
||||
[client]: { ip: '10.1.0.10', mask, gateway: '10.1.0.1' },
|
||||
[host]: { ip: `10.${ROUTERS + 1}.0.10`, mask, gateway: `10.${ROUTERS + 1}.0.2` },
|
||||
...Object.fromEntries(Array.from({ length: ROUTERS }, (_, index) => [router(index + 1), { ip: `10.${index + 1}.0.1`, mask }])),
|
||||
},
|
||||
interfaces: Array.from({ length: ROUTERS }, (_, index) => [
|
||||
{ node: router(index + 1), port: port(101 + index, 0), ip: `10.${index + 1}.0.1`, mask },
|
||||
{ node: router(index + 1), port: port(101 + index, 1), ip: `10.${index + 2}.0.2`, mask },
|
||||
]).flat(),
|
||||
routes: Object.fromEntries(
|
||||
Array.from({ length: ROUTERS }, (_, index) => {
|
||||
const number = index + 1;
|
||||
const back = number === 1 ? [] : [{ destination: '10.1.0.0', mask, gateway: `10.${number}.0.2`, interface: `10.${number}.0.1` }];
|
||||
const on = number === ROUTERS ? [] : [{ destination: `10.${ROUTERS + 1}.0.0`, mask, gateway: `10.${number + 1}.0.1`, interface: `10.${number + 1}.0.2` }];
|
||||
return [router(number), [...back, ...on]];
|
||||
}),
|
||||
),
|
||||
};
|
||||
const engine = watchedEngine(chain, withSnapshot(snapshotOf(chain), { language: 'de', terminalNodeIds: new Set([client]) })).engine;
|
||||
engine.terminal(client)!.enter(`traceroute 10.${ROUTERS + 1}.0.10`);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 120_000 });
|
||||
const told = engine.terminal(client)!.log.all.flatMap((line) => (line.kind === 'text' ? [line.text] : []));
|
||||
expect(told.slice(-3)).toEqual([' 20 ', '10.20.0.10', '\n\n10.20.0.10 wurde nach 20 Sprüngen (hops) erreicht.']);
|
||||
});
|
||||
|
||||
it("takes no echo reply of another identifier for traceroute's probe", () => {
|
||||
const engine = started();
|
||||
engine.terminal(CLIENT)!.enter('traceroute 192.168.0.31');
|
||||
queueEchoReply(engine, 42 * 23 + 1);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||||
// The first probe gets the other reply: a star (and then the late answer to it comes first for the next).
|
||||
expect(texts(engine).slice(1, 3)).toEqual([' 1 ', '* ']);
|
||||
});
|
||||
|
||||
it('dumps only the frames from its start, each with its time of day', () => {
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
|
||||
// The first frame comes about 110 ms after 3 s: at a time of day with two digits of milliseconds (Java pads to three).
|
||||
const base = new Date(2026, 9, 2, 10, 37, 6, 900).getTime();
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { wallClock: () => base + engine.state.currentTimeMilliseconds });
|
||||
engine.handle({
|
||||
type: 'start',
|
||||
snapshot: withSnapshot(snapshotOf(scenario), { language: 'de', terminalNodeIds: new Set(HOSTS) }),
|
||||
seed: scenario.seed,
|
||||
initialTimeMilliseconds: 0,
|
||||
});
|
||||
engine.terminal(CLIENT)!.enter('host www.filius.de');
|
||||
engine.handle({ type: 'advance', toMilliseconds: 3_000 });
|
||||
engine.terminal(ECHO)!.enter('tcpdump');
|
||||
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
|
||||
engine.handle({ type: 'advance', toMilliseconds: 3_900 });
|
||||
const card = scenario.nodes.find((node) => node.id === ECHO)!.ports[0]!.id;
|
||||
const first = engine.capture.frames(card).find((frame) => frame.timeMilliseconds >= 3_000)!;
|
||||
const time = new Date(base + first.timeMilliseconds);
|
||||
const two = (value: number) => String(value).padStart(2, '0');
|
||||
expect(time.getMilliseconds()).toBeLessThan(100);
|
||||
const expected = `${two(time.getHours())}:${two(time.getMinutes())}:${two(time.getSeconds())}.${String(time.getMilliseconds()).padStart(3, '0')}`;
|
||||
const dumped = engine.terminal(ECHO)!.log.all.flatMap((line) => (line.kind === 'text' ? [line.text] : []));
|
||||
// The ARP request of the lookup before is not dumped: the ping's ARP request first.
|
||||
expect(dumped[1]).toBe(
|
||||
`\n ${expected} ARP 192.168.0.20 > 192.168.0.31 Suche nach MAC f\u00fcr 192.168.0.31 [op=REQUEST, sender=02:00:00:00:04:11|192.168.0.20, target=FF:FF:FF:FF:FF:FF|192.168.0.31]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user