feat(core): the command line's network commands ping, host, nslookup, arp and netstat after Java, measured against Java

TerminalRun types lines into Java's command line on terminal-net at set
times; the engine types the same lines. New in the engine: an ICMP echo
in a program's thread (the first ICMP message after the send, whatever
it is), Java's route choice, the ARP table with Java's broadcast row, DNS
in a program's thread, ICMP broadcasts of a node's own network, and an
ARP wait that ends after what else happens at that millisecond.

Departures from Java: an echo is answered from the address asked
(RFC 792), network unreachable carries the packet with its sender, ping
names the host and takes only -b before it, arp and netstat list in the
order learned, netstat lists a UDP server's listening socket only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 08:03:10 +00:00
co-authored by Claude Opus 5.5
parent 016f9461df
commit 495c449ef2
22 changed files with 33994 additions and 42 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,210 @@
{
"frames": {
"00000000-0000-0000-0000-000000000401": [],
"00000000-0000-0000-0000-000000000402": [],
"00000000-0000-0000-0000-000000000403": [],
"00000000-0000-0000-0000-000000000404": [],
"00000000-0000-0000-0000-000000000405": [],
"00000000-0000-0000-0000-000000000405#1": [],
"00000000-0000-0000-0000-000000000406": []
},
"rows": {
"00000000-0000-0000-0000-000000000401": [],
"00000000-0000-0000-0000-000000000402": [],
"00000000-0000-0000-0000-000000000403": [],
"00000000-0000-0000-0000-000000000404": [],
"00000000-0000-0000-0000-000000000405": [],
"00000000-0000-0000-0000-000000000405#1": [],
"00000000-0000-0000-0000-000000000406": []
},
"layeredRows": {
"00000000-0000-0000-0000-000000000401": [],
"00000000-0000-0000-0000-000000000402": [],
"00000000-0000-0000-0000-000000000403": [],
"00000000-0000-0000-0000-000000000404": [],
"00000000-0000-0000-0000-000000000405": [],
"00000000-0000-0000-0000-000000000405#1": [],
"00000000-0000-0000-0000-000000000406": []
},
"acts": [
[
500,
"00000000-0000-0000-0000-000000000401",
"ping 192.168.0.20"
],
[
6000,
"00000000-0000-0000-0000-000000000401",
"ping localhost"
],
[
11500,
"00000000-0000-0000-0000-000000000401",
"ping 127.0.0.1"
]
],
"events": [
[
500,
"00000000-0000-0000-0000-000000000401",
"call",
"ping 192.168.0.20"
],
[
502,
"00000000-0000-0000-0000-000000000401",
"busy",
"true"
],
[
502,
"00000000-0000-0000-0000-000000000401",
"text",
"PING 192.168.0.20 (192.168.0.20)"
],
[
508,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=1 ttl=64 time=4ms"
],
[
1504,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=2 ttl=64 time=0ms"
],
[
2505,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=3 ttl=64 time=1ms"
],
[
3504,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=4 ttl=64 time=0ms"
],
[
4506,
"00000000-0000-0000-0000-000000000401",
"text",
"\n--- 192.168.0.20 Paketstatistik ---\n4 Paket(e) gesendet, 4 Paket(e) empfangen, 0% Paketverlust\n"
],
[
4506,
"00000000-0000-0000-0000-000000000401",
"busy",
"false"
],
[
6000,
"00000000-0000-0000-0000-000000000401",
"call",
"ping localhost"
],
[
6001,
"00000000-0000-0000-0000-000000000401",
"busy",
"true"
],
[
6001,
"00000000-0000-0000-0000-000000000401",
"text",
"PING localhost (127.0.0.1)"
],
[
6001,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=1 ttl=64 time=0ms"
],
[
7002,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=2 ttl=64 time=1ms"
],
[
8001,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=3 ttl=64 time=0ms"
],
[
9002,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=4 ttl=64 time=1ms"
],
[
10001,
"00000000-0000-0000-0000-000000000401",
"text",
"\n--- localhost Paketstatistik ---\n4 Paket(e) gesendet, 4 Paket(e) empfangen, 0% Paketverlust\n"
],
[
10001,
"00000000-0000-0000-0000-000000000401",
"busy",
"false"
],
[
11500,
"00000000-0000-0000-0000-000000000401",
"call",
"ping 127.0.0.1"
],
[
11500,
"00000000-0000-0000-0000-000000000401",
"busy",
"true"
],
[
11500,
"00000000-0000-0000-0000-000000000401",
"text",
"PING 127.0.0.1 (127.0.0.1)"
],
[
11501,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=1 ttl=64 time=1ms"
],
[
12500,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=2 ttl=64 time=0ms"
],
[
13501,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=3 ttl=64 time=1ms"
],
[
14501,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=4 ttl=64 time=0ms"
],
[
15503,
"00000000-0000-0000-0000-000000000401",
"text",
"\n--- 127.0.0.1 Paketstatistik ---\n4 Paket(e) gesendet, 4 Paket(e) empfangen, 0% Paketverlust\n"
],
[
15503,
"00000000-0000-0000-0000-000000000401",
"busy",
"false"
]
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,202 @@
{
"seed": 91,
"nodes": [
{
"id": "00000000-0000-0000-0000-000000000401",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000411",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000402",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000412",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000403",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000413",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000404",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000414",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000405",
"kind": "router",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000451",
"label": "eth0"
},
{
"id": "00000000-0000-0000-0000-000000000452",
"label": "eth1"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000406",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000416",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000407",
"kind": "networkSwitch",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000471",
"label": "sw1"
},
{
"id": "00000000-0000-0000-0000-000000000472",
"label": "sw2"
},
{
"id": "00000000-0000-0000-0000-000000000473",
"label": "sw3"
},
{
"id": "00000000-0000-0000-0000-000000000474",
"label": "sw4"
},
{
"id": "00000000-0000-0000-0000-000000000475",
"label": "sw5"
}
]
}
],
"links": [
{
"id": "00000000-0000-0000-0000-00000000F401",
"sourceNode": "00000000-0000-0000-0000-000000000401",
"sourcePort": "00000000-0000-0000-0000-000000000411",
"targetNode": "00000000-0000-0000-0000-000000000407",
"targetPort": "00000000-0000-0000-0000-000000000471"
},
{
"id": "00000000-0000-0000-0000-00000000F402",
"sourceNode": "00000000-0000-0000-0000-000000000402",
"sourcePort": "00000000-0000-0000-0000-000000000412",
"targetNode": "00000000-0000-0000-0000-000000000407",
"targetPort": "00000000-0000-0000-0000-000000000472"
},
{
"id": "00000000-0000-0000-0000-00000000F403",
"sourceNode": "00000000-0000-0000-0000-000000000403",
"sourcePort": "00000000-0000-0000-0000-000000000413",
"targetNode": "00000000-0000-0000-0000-000000000407",
"targetPort": "00000000-0000-0000-0000-000000000473"
},
{
"id": "00000000-0000-0000-0000-00000000F404",
"sourceNode": "00000000-0000-0000-0000-000000000404",
"sourcePort": "00000000-0000-0000-0000-000000000414",
"targetNode": "00000000-0000-0000-0000-000000000407",
"targetPort": "00000000-0000-0000-0000-000000000474"
},
{
"id": "00000000-0000-0000-0000-00000000F405",
"sourceNode": "00000000-0000-0000-0000-000000000405",
"sourcePort": "00000000-0000-0000-0000-000000000451",
"targetNode": "00000000-0000-0000-0000-000000000407",
"targetPort": "00000000-0000-0000-0000-000000000475"
},
{
"id": "00000000-0000-0000-0000-00000000F406",
"sourceNode": "00000000-0000-0000-0000-000000000405",
"sourcePort": "00000000-0000-0000-0000-000000000452",
"targetNode": "00000000-0000-0000-0000-000000000406",
"targetPort": "00000000-0000-0000-0000-000000000416"
}
],
"devices": {
"00000000-0000-0000-0000-000000000401": {
"ip": "192.168.0.20",
"mask": "255.255.255.0",
"gateway": "192.168.0.1",
"dns": "192.168.0.10"
},
"00000000-0000-0000-0000-000000000402": {
"ip": "192.168.0.10",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000403": {
"ip": "192.168.0.30",
"mask": "255.255.255.0",
"gateway": "192.168.0.1",
"dns": "192.168.0.10"
},
"00000000-0000-0000-0000-000000000404": {
"ip": "192.168.0.31",
"mask": "255.255.255.0",
"gateway": "192.168.0.1",
"dns": "192.168.0.10"
},
"00000000-0000-0000-0000-000000000405": {
"ip": "192.168.0.1",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000406": {
"ip": "10.0.0.2",
"mask": "255.255.255.0",
"gateway": "10.0.0.1",
"dns": "192.168.0.53"
}
},
"interfaces": [
{
"node": "00000000-0000-0000-0000-000000000405",
"port": "00000000-0000-0000-0000-000000000451",
"ip": "192.168.0.1",
"mask": "255.255.255.0"
},
{
"node": "00000000-0000-0000-0000-000000000405",
"port": "00000000-0000-0000-0000-000000000452",
"ip": "10.0.0.1",
"mask": "255.255.255.0"
}
],
"dnsServers": {
"00000000-0000-0000-0000-000000000402": {
"records": [
"www.filius.de A 192.168.0.30",
"filius.de A 192.168.0.31",
"filius.de MX mail.filius.de",
"mail.filius.de A 192.168.0.30",
"multi.filius.de A 192.168.0.30",
"multi.filius.de A 192.168.0.31",
"fern.filius.de A 10.0.0.2",
"weg.filius.de A 192.168.0.99",
"post.filius.de MX mail.filius.de"
]
}
}
}
+5
View File
@@ -217,6 +217,11 @@ describe('the command line as Java FILIUS answers it', () => {
const cases = javaCases(language);
const text = (key: TerminalTextKey) => TERMINAL_TEXTS[language][key];
it(`has Java's texts (${language})`, () => {
const javaTexts = (JSON.parse(readFileSync(join(here, `terminal.${language}.java.json`), 'utf8')) as { texts: Record<string, string> }).texts;
for (const [key, value] of Object.entries(TERMINAL_TEXTS[language])) expect(value, key).toBe(javaTexts[key]);
});
it(`corrects Java only where it errs (${language})`, () => {
for (const [name, fix] of Object.entries(JAVA_BUGS)) {
const javaCase = cases.find((each) => each.name === name)!;
+411
View File
@@ -0,0 +1,411 @@
// The command line's network commands as Java FILIUS runs them.
// tools/java-oracle's TerminalRun typed lines into FILIUS' command line on
// the hosts of terminal-net at set times and wrote every frame each card
// saw, Java's tables of them, and everything each command line told its
// window (terminal-java-<story>.java.json). The engine types the same lines
// at the same times (apps/terminal.ts): each card's frames in the same
// order, with the same content and rows, at about the same time; the same
// texts and busy marks, at about the same time.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
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 { snapshotOf, type Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
const CLIENT = '00000000-0000-0000-0000-000000000401';
const DNS = '00000000-0000-0000-0000-000000000402';
const WEB = '00000000-0000-0000-0000-000000000403';
const ECHO = '00000000-0000-0000-0000-000000000404';
const FAR = '00000000-0000-0000-0000-000000000406';
const HOSTS = [CLIENT, DNS, WEB, ECHO, FAR];
type Told = ['text' | 'busy', string];
interface JavaRun extends JavaTables {
frames: Record<string, JavaFrame[]>;
acts: [number, string, string][];
/** time, host, kind (call, text or busy), text */
events: [number, string, string, string][];
}
const STORIES = ['pingLan', 'pingRouted', 'pingNobody', 'pingUnreachable', 'pingBroadcast', 'pingSelf', 'pingErrors', 'interrupt', 'dns', 'dnsTimeout', 'arp', 'netstat'] as const;
type Lines = Record<string, [number, Told][]>;
function javaLines(java: JavaRun): Lines {
const lines: Lines = Object.fromEntries(HOSTS.map((host) => [host, []]));
for (const [time, host, kind, text] of java.events) {
if (kind === 'call') continue;
lines[host]!.push([time, [kind as Told[0], text]]);
}
return lines;
}
function engineLines(engine: NetworkRuntimeEngine): Lines {
return Object.fromEntries(
HOSTS.map((host) => [
host,
engine.terminal(host)!.log.all.map((line): [number, Told] => [line.timeMilliseconds, line.kind === 'text' ? ['text', line.text] : ['busy', String(line.busy)]]),
]),
);
}
/**
* A text with placeholders: a client's port; a ping's time (compared on its
* own); the rows of an ARP or netstat table sorted (Java lists them in a
* HashMap's order, the engine in the order learned, or made).
*/
function lineText(text: string): string {
const table = /^(\| [^\n]*\|\n-+\n)((?:\|[^\n]*\n)*)(-+\n)?$/.exec(text);
// Sorted within each protocol of netstat's, the protocols in the order they come (TCP, then UDP, in Java too).
const rows = table?.[2]!.split('\n').filter(Boolean) ?? [];
const protocols = [...new Set(rows.map((row) => /^\| (TCP|UDP) /.exec(row)?.[1] ?? ''))];
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');
}
const pingTime = (text: string) => Number(/time=(\d+)ms/.exec(text)?.[1] ?? 0);
/** The identifier a ping draws at random (Java: new Random()). */
const cellText = (text: string) => text.replace(/Identifier: \d+/, 'Identifier: x').replace(/identifier=\d+/, 'identifier=x');
/** Java's run with what each line told replaced where Java errs. */
function withTold(java: JavaRun, change: (host: string, text: string, time: number) => string | undefined): JavaRun {
return {
...java,
events: java.events.flatMap(([time, host, kind, text]): [number, string, string, string][] => {
if (kind !== 'text') return [[time, host, kind, text]];
const changed = change(host, text, time);
return changed === undefined ? [] : [[time, host, kind, changed]];
}),
};
}
/** The answers to an echo from `from` to the client, in Java's frames and rows, as from `to`. */
function withEchoReplySource(java: JavaRun, from: string, to: string, after: number): JavaRun {
const frames = Object.fromEntries(
Object.entries(java.frames).map(([card, list]) => [
card,
list.map((frame) => (frame.icmp === 0 && frame.sourceIp === from && frame.time >= after ? { ...frame, sourceIp: to } : frame)),
]),
);
const replies = (card: string) =>
new Set(java.frames[card]!.flatMap((frame, index) => (frame.icmp === 0 && frame.sourceIp === from && frame.time >= after ? [String(index + 1)] : [])));
const rows = (tables: Record<string, (string | null)[][]> | undefined) =>
tables === undefined
? undefined
: Object.fromEntries(
Object.entries(tables).map(([card, list]) => [card, list.map((row) => (replies(card).has(row[0]!) && row[1] === from ? [row[0]!, to, ...row.slice(2)] : row))]),
);
return { ...java, frames, rows: rows(java.rows), layeredRows: rows(java.layeredRows) };
}
/** Java's tables with each cell changed. */
function withCells(java: JavaRun, change: (cell: string) => string): JavaRun {
const tables = (list: Record<string, (string | null)[][]> | undefined) =>
list === undefined
? undefined
: Object.fromEntries(Object.entries(list).map(([card, rows]) => [card, rows.map((row) => row.map((cell) => (cell === null ? null : change(cell))))]));
return { ...java, rows: tables(java.rows), layeredRows: tables(java.layeredRows) };
}
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';
/**
* Where Java errs: its run corrected, as Java means it.
* - Java's ping names its first parameter where it means the host (-b in the
* statistics of ping -b), and takes any first word for -b (ping -x pings).
* - An echo's answer comes from the address asked (RFC 792: the addresses
* reversed): Java answers from 127.0.0.1 when the host's own address is
* asked, from the near card when a router's far address is.
* - netstat on a UDP server lists its listening socket only: Java also lists
* 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).
*/
const JAVA_BUGS: Partial<Record<(typeof STORIES)[number], (java: JavaRun) => JavaRun>> = {
pingBroadcast: (java) =>
withTold(java, (host, text, time) => {
const target = host === CLIENT && time >= 7_000 ? '255.255.255.255' : '192.168.0.255';
return text.replace('--- -b ', `--- ${target} `).replace('From -b (', `From ${target} (`);
}),
pingErrors: (java) => {
const typed = java.acts.find(([, , line]) => line.startsWith('ping -x'))![0];
const corrected = withTold(java, (_, text, time) => (time < typed ? text : undefined));
return {
...corrected,
frames: Object.fromEntries(Object.entries(java.frames).map(([card, list]) => [card, list.filter((frame) => frame.time < typed)])),
events: [...corrected.events.filter(([time, , kind]) => time < typed || kind !== 'busy'), [typed, CLIENT, 'text', USAGE_PING]],
};
},
pingSelf: (java) => withTold(java, (_, text, time) => (time < 6_000 ? text.replace('From 127.0.0.1:', 'From 192.168.0.20:') : text)),
pingRouted: (java) => {
const typed = java.acts.find(([, , line]) => line === 'ping 10.0.0.1')![0];
return withEchoReplySource(
withTold(java, (_, text, time) => (time >= typed ? text.replace('From 192.168.0.1:', 'From 10.0.0.1:') : text)),
'192.168.0.1',
'10.0.0.1',
typed,
);
},
pingUnreachable: (java) => withCells(java, (cell) => cell.replace('sender=null;', 'sender=192.168.0.20;')),
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. */
const UNTIL: Partial<Record<(typeof STORIES)[number], (java: JavaRun) => number>> = {
pingErrors: (java) => java.acts.find(([, , line]) => line.startsWith('ping -x'))![0],
};
/** The hosts that answer a broadcast echo at once: which answer comes first is a race of Java's threads. */
const BROADCAST_ANSWER = /From 192\.168\.0\.(1|10|30|31): icmp_seq/;
/**
* pingBroadcast: the hosts answer each broadcast echo at once, and the order
* of their ARP requests and answers on the cables is a race of Java's
* threads. Its frames are compared second by second (each ping's), as a
* set; Java's tables, which go frame by frame, are left out.
*/
const RACING = new Set(['pingBroadcast']);
const bySecond = (frames: SeenFrame[]): SeenFrame[] =>
frames
.map((frame) => ({ time: Math.floor((frame.time - 900) / 1_000) * 1_000 + 900, text: frame.text }))
.sort((lhs, rhs) => lhs.time - rhs.time || lhs.text.localeCompare(rhs.text));
describe('the command line on the network as Java FILIUS runs it', () => {
it('corrects Java only where it errs, and cuts only after the story began', () => {
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun;
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
expect(UNTIL[name as (typeof STORIES)[number]]?.(java) ?? Number.POSITIVE_INFINITY, name).toBeGreaterThan(1_000);
}
});
// ICMPThread.waitForIcmpResponse misses its wake-up when the answer is there before it waits (a race of Java's
// threads: an echo to the host itself, the host unreachable a host gives itself), and waits a whole round trip.
// The engine answers at once; a recording with a missed wake-up is made again (tools/java-oracle/run.sh).
it("was recorded without a missed wake-up of Java's", () => {
const told = (name: string) =>
(JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun).events.filter(([, , kind]) => kind === 'text');
for (const [, , , text] of told('pingSelf')) expect(pingTime(text), `pingSelf: ${JSON.stringify(text)}`).toBeLessThan(1_000);
const timeouts = told('pingNobody').filter(([, , , text]) => text.includes('Timeout!'));
for (const [index, [time, , , text]] of timeouts.slice(1).entries()) {
if (text.includes('icmp_seq=1 ')) continue;
expect(time - timeouts[index]![0], 'pingNobody: two ARP round trips per echo').toBeLessThan(6_000);
}
});
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
for (const name of STORIES) {
it(name, () => {
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun;
const java = JAVA_BUGS[name]?.(measured) ?? measured;
const snapshot = withSnapshot(snapshotOf(scenario), {
language: 'de',
terminalNodeIds: new Set(HOSTS),
...(name === 'netstat'
? {
echoServersByNodeId: new Map([
[ECHO, { port: 55555, isActive: true }],
[DNS, { port: 55555, isActive: true }],
]),
simpleClientsByNodeId: new Map([[CLIENT, { targetAddress: '', targetPort: 55555 }]]),
}
: {}),
});
const { engine } = watchedEngine(scenario, snapshot);
const acts: [number, () => void][] = java.acts.map(([time, host, line]) => [
time,
() => {
if (line === '^C') engine.terminal(host)!.interrupt();
else if (line === 'disconnect') engine.simpleClient(CLIENT)!.disconnect();
else engine.terminal(host)!.enter(line);
},
]);
if (name === 'netstat') acts.unshift([500, () => engine.simpleClient(CLIENT)!.connect('192.168.0.31', 55555)]);
for (const [time, act] of acts) {
engine.handle({ type: 'advance', toMilliseconds: time });
act();
}
engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 });
const racing = RACING.has(name);
expectSameCaptures(engine, scenario, java.frames, {
text: (text) => withClientPortText(text).replace(/ID=\d+/g, 'ID=x'),
...(racing ? { normalized: bySecond, starts: { java: 0, engine: 0 } } : { tables: java }),
cell: cellText,
until: UNTIL[name]?.(measured),
});
if (racing) {
// ICMP.sendBroadcast: with TTL 1, to every card.
const echoes = engine.capture.frames(scenario.nodes.find((node) => node.id === CLIENT)!.ports[0]!.id).flatMap(({ frame }) => {
const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined;
return packet?.payload.kind === 'icmp' && packet.payload.message.kind === 'echoRequest' ? [[frame.destinationMacAddress, packet.timeToLive]] : [];
});
expect(echoes).toEqual(Array.from({ length: 8 }, () => ['FF:FF:FF:FF:FF:FF', 1]));
}
const expected = javaLines(java);
const actual = engineLines(engine);
const told = ([kind, text]: Told): Told => [kind, lineText(name === 'pingBroadcast' ? text.replace(BROADCAST_ANSWER, 'From <broadcast>: icmp_seq') : text)];
for (const host of HOSTS) {
expect(
actual[host]!.map(([, line]) => told(line)),
`${host}'s lines`,
).toEqual(expected[host]!.map(([, line]) => told(line)));
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(pingTime(line[1]) - pingTime(engineLine[1])), `${host}'s ping time in ${JSON.stringify(line[1])}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
}
}
});
}
});
describe('the command line on the network beyond the stories', () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
const started = () => watchedEngine(scenario, withSnapshot(snapshotOf(scenario), { language: 'de', terminalNodeIds: new Set(HOSTS) })).engine;
const texts = (engine: NetworkRuntimeEngine) => engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [line.text] : []));
const echoIdentifiers = (engine: NetworkRuntimeEngine) =>
engine.capture.frames(scenario.nodes.find((node) => node.id === CLIENT)!.ports[0]!.id).flatMap(({ frame }) => {
const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined;
return packet?.payload.kind === 'icmp' && packet.payload.message.kind === 'echoRequest' ? [packet.payload.message.identifier] : [];
});
it('sends no echo for a ping whose Ctrl+C came before it began', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('host www.filius.de');
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
engine.terminal(CLIENT)!.interrupt();
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
expect(texts(engine).slice(-2)).toEqual([
'PING 192.168.0.31 (192.168.0.31)',
'\n--- 192.168.0.31 Paketstatistik ---\n0 Paket(e) gesendet, 0 Paket(e) empfangen, 0% Paketverlust\n',
]);
expect(echoIdentifiers(engine)).toEqual([]);
});
it("counts no answer to another ping's echo, and waits out its second", () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
// An echo reply with the first echo's number, but another identifier (a multiple of 23 is drawn), first in the queue.
const reply = { kind: 'echoReply', identifier: 1, sequenceNumber: 1, data: new Uint8Array() } as const;
const packet = {
identity: 0,
senderIPAddress: '192.168.0.31',
receiverIPAddress: '192.168.0.20',
timeToLive: 64,
protocol: 'icmp',
payload: { kind: 'icmp', message: reply },
} as const;
const queue = engine.state.icmpObservationsByNodeId;
queue.set(CLIENT, [...(queue.get(CLIENT) ?? []), { nodeId: CLIENT, packet, message: reply }]);
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
const lines = engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [[line.timeMilliseconds, line.text] as const] : []));
expect(lines.map(([, text]) => text.replace(/time=\d+ms/, 'time=…ms'))).toEqual([
'PING 192.168.0.31 (192.168.0.31)',
'\nFrom 192.168.0.31: icmp_seq=2 ttl=64 time=…ms',
'\nFrom 192.168.0.31: icmp_seq=3 ttl=64 time=…ms',
'\nFrom 192.168.0.31: icmp_seq=4 ttl=64 time=…ms',
'\n--- 192.168.0.31 Paketstatistik ---\n4 Paket(e) gesendet, 3 Paket(e) empfangen, 25% Paketverlust\n',
]);
expect(lines[1]![0]).toBeGreaterThanOrEqual(1_000);
});
it('goes on with the next identifier, from one drawn at random', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
engine.terminal(CLIENT)!.enter('ping 192.168.0.30');
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
const identifiers = echoIdentifiers(engine);
expect(identifiers).toHaveLength(8);
expect(identifiers[0]! % 23).toBe(0);
expect(identifiers[0]!).toBeLessThanOrEqual(42 * 23);
expect(identifiers).toEqual([...Array(4).fill(identifiers[0]), ...Array(4).fill(identifiers[0]! + 1)]);
});
/** An ICMP message put first in the client's queue, as if it had come (an answer to another echo). */
const queueEchoReply = (engine: NetworkRuntimeEngine, sequenceNumber: number) => {
const reply = { kind: 'echoReply', identifier: 1, sequenceNumber, data: new Uint8Array() } as const;
const packet = {
identity: 0,
senderIPAddress: '192.168.0.31',
receiverIPAddress: '192.168.0.20',
timeToLive: 64,
protocol: 'icmp',
payload: { kind: 'icmp', message: reply },
} as const;
const queue = engine.state.icmpObservationsByNodeId;
queue.set(CLIENT, [...(queue.get(CLIENT) ?? []), { nodeId: CLIENT, packet, message: reply }]);
};
it('takes an echo reply with another number for a timeout, and sends the next echo at once', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
queueEchoReply(engine, 9);
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
const lines = engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [[line.timeMilliseconds, line.text] as const] : []));
expect(lines[1]![1]).toBe('\nFrom 192.168.0.31 (192.168.0.31): icmp_seq=1 -- Timeout!');
// The next echo goes at once, and the late answer to the first comes first: a timeout too.
expect(lines[2]![1]).toBe('\nFrom 192.168.0.31 (192.168.0.31): icmp_seq=2 -- Timeout!');
expect(lines[2]![0] - lines[1]![0]).toBeLessThan(500);
});
it('takes the first ICMP message that came, not the last', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.99');
// First the answer to another echo, then (when ARP gives up) the host unreachable the client gives itself.
queueEchoReply(engine, 1);
engine.handle({ type: 'advance', toMilliseconds: 30_000 });
expect(texts(engine).slice(1)).toEqual([
'\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=2 -- Timeout!',
'\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=3 -- Timeout!',
'\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=4 -- Timeout!',
'\n--- 192.168.0.99 Paketstatistik ---\n4 Paket(e) gesendet, 0 Paket(e) empfangen, 100% Paketverlust\n',
]);
});
it('rounds the loss (Math.round)', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
queueEchoReply(engine, 1);
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
queueEchoReply(engine, 2);
engine.handle({ type: 'advance', toMilliseconds: 2_500 });
engine.terminal(CLIENT)!.interrupt();
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
expect(texts(engine).at(-1)).toBe('\n--- 192.168.0.31 Paketstatistik ---\n3 Paket(e) gesendet, 1 Paket(e) empfangen, 67% Paketverlust\n');
});
it("refuses the broadcast of the route's network, whatever its mask", () => {
const wide = { ...scenario, devices: { ...scenario.devices, [CLIENT]: { ...scenario.devices![CLIENT]!, mask: '255.255.0.0' } } } as Scenario;
const engine = watchedEngine(wide, withSnapshot(snapshotOf(wide), { language: 'de', terminalNodeIds: new Set([CLIENT]) })).engine;
engine.terminal(CLIENT)!.enter('ping 192.168.255.255');
expect(texts(engine)).toEqual(['Ping zu Broadcast-Adressen wird nicht unterstützt.']);
});
it('draws the first identifier at random', () => {
const drawn = new Set(Array.from({ length: 10 }, (_, seed) => new NetworkRuntimeEngine(seed).drawIcmpIdentifier()));
expect(drawn.size).toBeGreaterThan(1);
});
it('routes by the first of rows with the same mask (determineRouteFromStaticRoutingTable)', () => {
const snapshot = withSnapshot(snapshotOf(scenario), {
manualRoutesByNodeId: new Map([[CLIENT, [{ destinationNetwork: '192.168.0.0', subnetMask: '255.255.255.0', gateway: '192.168.0.99', interfaceIPAddress: '192.168.0.20' }]]]),
});
const engine = watchedEngine(scenario, snapshot).engine;
const rows = engine.forwardingTable(CLIENT).filter((row) => row.destinationNetwork === '192.168.0.0' && row.subnetMask === '255.255.255.0');
expect(rows).toHaveLength(2);
expect(engine.routeRow(CLIENT, '192.168.0.31')).toEqual(rows[0]);
});
});