feat(core): programs' threads, and FILIUS' echo server and generic client after Java, measured against Java
Programs run as generators that yield what they wait for (threads.ts); the engine resumes them after every event. TCP connect, send, receive, accept and name resolution exist as such steps; the blocking calls wrap them. EchoRun records eight stories in Java FILIUS, frames, tables and each program's window lines; echoJava.test.ts plays them card by card. Not taken from Java: a connection without a route hangs for ever; a server switched off leaves its connections' last FIN unanswered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
// The echo server and the generic client as Java FILIUS runs them.
|
||||
// tools/java-oracle's EchoRun played stories of the two programs on tcp-lan's
|
||||
// network in Java FILIUS and wrote every frame each card saw, Java's tables
|
||||
// of them, and every line each program wrote into its window
|
||||
// (echo-java-<story>.java.json). The engine plays the same stories with its
|
||||
// programs (apps/echo.ts): each card's frames must come in the same order,
|
||||
// with the same content and rows, at about the same time; each program's
|
||||
// lines must read as Java's (its German message bundle), in the same order,
|
||||
// at about the same time.
|
||||
//
|
||||
// Java's client looks every 100 ms whether it is connected
|
||||
// (TCPSocket.verbinden), its server likewise whether a connection came
|
||||
// (ServerSocket.oeffnen) or it was switched off; the engine at once. Those
|
||||
// lines come in Java up to POLL_MILLISECONDS later.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { withSnapshot, type EchoServerLogEntry, type LogLine, type NetworkRuntimeEngine, type SendFailure, type SimpleClientLogEntry } from '../src/index.js';
|
||||
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables } from './javaRun.js';
|
||||
import { snapshotOf, type Scenario } from './scenario.js';
|
||||
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
|
||||
const SERVER = '00000000-0000-0000-0000-000000000101';
|
||||
const CLIENT = '00000000-0000-0000-0000-000000000102';
|
||||
const OTHER_CLIENT = '00000000-0000-0000-0000-000000000103';
|
||||
const POLL_MILLISECONDS = 100;
|
||||
|
||||
type Program = 'server' | 'client' | 'other';
|
||||
|
||||
interface JavaRun extends JavaTables {
|
||||
frames: Record<string, JavaFrame[]>;
|
||||
/** Time, program, and a line of its window or one of the story's calls ("call …", "returned …"). */
|
||||
events: [number, Program, string][];
|
||||
}
|
||||
|
||||
/** sw_tcpsocket_msg6, msg7, msg8. */
|
||||
const SEND_FAILURES: Record<SendFailure, string> = {
|
||||
notConnected: 'Verbindung nicht aufgebaut!',
|
||||
connectionLost: 'Verbindung wurde unterbrochen',
|
||||
timedOut: 'Zeitüberschreitung während des Versands einer Nachricht',
|
||||
};
|
||||
|
||||
/** A server's line as Java's window shows it (German message bundle). */
|
||||
function serverText(entry: EchoServerLogEntry): string {
|
||||
switch (entry.kind) {
|
||||
case 'acceptingStarted':
|
||||
return 'Annahme von Verbindungsanfragen gestartet';
|
||||
case 'acceptingStopped':
|
||||
return 'Annahme von Verbindungsanfragen beendet';
|
||||
case 'serverSocketClosed':
|
||||
return 'Server-Socket geschlossen';
|
||||
case 'serverSocketFailed':
|
||||
return 'Server-Socket konnte nicht geöffnet werden.';
|
||||
case 'connectionAccepted':
|
||||
return `Verbindung zu ${entry.address}:${entry.port} hergestellt`;
|
||||
case 'received':
|
||||
return `>>${entry.text}`;
|
||||
case 'sent':
|
||||
return `<<${entry.text}`;
|
||||
case 'sendFailed':
|
||||
return SEND_FAILURES[entry.failure];
|
||||
case 'connectionClosed':
|
||||
return `Socket zu ${entry.address}:${entry.port} geschlossen`;
|
||||
}
|
||||
}
|
||||
|
||||
const CONNECT_FAILURES = {
|
||||
unresolved: 'Ziel konnte nicht bestimmt werden!',
|
||||
timedOut: 'Zeitüberschreitung beim Verbindungsaufbau',
|
||||
failed: 'Aktives Öffnen des Sockets fehlgeschlagen',
|
||||
};
|
||||
|
||||
/** A client's line as Java's window shows it. */
|
||||
function clientText(entry: SimpleClientLogEntry): string {
|
||||
switch (entry.kind) {
|
||||
case 'connected':
|
||||
return 'Verbindung hergestellt';
|
||||
case 'connectFailed':
|
||||
return `Fehler beim Verbindungsaufbau\n${CONNECT_FAILURES[entry.failure]}`;
|
||||
case 'disconnected':
|
||||
return 'Verbindung getrennt';
|
||||
case 'notConnected':
|
||||
return 'Fehler beim Verbindungsaufbau';
|
||||
case 'sent':
|
||||
return `<<${entry.text}`;
|
||||
case 'received':
|
||||
return `>>${entry.text}`;
|
||||
case 'sendFailed':
|
||||
return SEND_FAILURES[entry.failure];
|
||||
case 'connectionClosed':
|
||||
return `Socket zu ${entry.address}:${entry.port} getrennt`;
|
||||
case 'receiveFailed':
|
||||
return 'Keine Verbindung hergestellt!';
|
||||
}
|
||||
}
|
||||
|
||||
/** Lines Java writes after one of its 100 ms looks. */
|
||||
const POLLED = new Set(['Verbindung hergestellt', 'Server-Socket geschlossen']);
|
||||
const isPolled = (text: string) => POLLED.has(text) || /^Verbindung zu .* hergestellt$/.test(text);
|
||||
|
||||
type Act = (engine: NetworkRuntimeEngine) => void;
|
||||
const server = (engine: NetworkRuntimeEngine) => engine.echoServer(SERVER)!;
|
||||
const client = (engine: NetworkRuntimeEngine) => engine.simpleClient(CLIENT)!;
|
||||
const other = (engine: NetworkRuntimeEngine) => engine.simpleClient(OTHER_CLIENT)!;
|
||||
|
||||
/** Each story's calls at their times, as EchoRun makes them. */
|
||||
const STORIES: Record<string, [number, Act][]> = {
|
||||
exchange: [
|
||||
[500, (engine) => client(engine).connect('192.168.0.10', 55555)],
|
||||
[2000, (engine) => client(engine).send('Hallo')],
|
||||
[4000, (engine) => client(engine).send('Welt')],
|
||||
[6000, (engine) => client(engine).disconnect()],
|
||||
],
|
||||
closedPort: [[500, (engine) => client(engine).connect('192.168.0.30', 55555)]],
|
||||
noRoute: [[500, (engine) => client(engine).connect('10.0.0.1', 55555)]],
|
||||
notConnected: [[500, (engine) => client(engine).send('Hallo')]],
|
||||
serverOff: [
|
||||
[500, (engine) => client(engine).connect('192.168.0.10', 55555)],
|
||||
[2000, (engine) => server(engine).setActive(false)],
|
||||
[4000, (engine) => client(engine).send('Hallo')],
|
||||
[6000, (engine) => server(engine).setActive(true)],
|
||||
[7000, (engine) => client(engine).connect('192.168.0.10', 55555)],
|
||||
[9000, (engine) => client(engine).send('Welt')],
|
||||
],
|
||||
reconnect: [
|
||||
[500, (engine) => client(engine).connect('192.168.0.10', 55555)],
|
||||
[2000, (engine) => client(engine).disconnect()],
|
||||
[4000, (engine) => client(engine).connect('192.168.0.10', 55555)],
|
||||
[6000, (engine) => client(engine).send('Hallo')],
|
||||
],
|
||||
twoClients: [
|
||||
[500, (engine) => client(engine).connect('192.168.0.10', 55555)],
|
||||
[1000, (engine) => other(engine).connect('192.168.0.10', 55555)],
|
||||
[2000, (engine) => client(engine).send('eins')],
|
||||
[3000, (engine) => other(engine).send('zwei')],
|
||||
[4000, (engine) => client(engine).disconnect()],
|
||||
[5000, (engine) => other(engine).disconnect()],
|
||||
],
|
||||
otherPort: [
|
||||
[500, (engine) => server(engine).setActive(false)],
|
||||
[
|
||||
1500,
|
||||
(engine) => {
|
||||
server(engine).setPort(7);
|
||||
server(engine).setActive(true);
|
||||
},
|
||||
],
|
||||
[2000, (engine) => client(engine).connect('192.168.0.10', 7)],
|
||||
[4000, (engine) => client(engine).send('Hallo')],
|
||||
[6000, (engine) => other(engine).connect('192.168.0.10', 55555)],
|
||||
],
|
||||
};
|
||||
|
||||
/** The windows' lines, time and text, per program. */
|
||||
type Lines = Record<Program, [number, string][]>;
|
||||
|
||||
function javaLines(java: JavaRun): Lines {
|
||||
const lines: Lines = { server: [], client: [], other: [] };
|
||||
for (const [time, program, text] of java.events) {
|
||||
// Anwendung.starten tells its observers nothing (null), which the window skips.
|
||||
if (text === 'null' || text.startsWith('call ') || text.startsWith('returned ')) continue;
|
||||
lines[program].push([time, withClientPortText(text)]);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function engineLines(engine: NetworkRuntimeEngine): Lines {
|
||||
const lines = <E>(log: readonly LogLine<E>[], text: (entry: E) => string): [number, string][] =>
|
||||
log.map((line) => [line.timeMilliseconds, withClientPortText(text(line))]);
|
||||
return {
|
||||
server: lines(server(engine).log.all, serverText),
|
||||
client: lines(client(engine).log.all, clientText),
|
||||
other: lines(other(engine).log.all, clientText),
|
||||
};
|
||||
}
|
||||
|
||||
/** Java's timeout of a TCP attempt (3 round trips at 90 %), and the round trip of a frame between two cards on the switch. */
|
||||
const JAVA_TIMEOUT_MILLISECONDS = 3 * 2_750;
|
||||
const CARD_TO_CARD_MILLISECONDS = 111;
|
||||
|
||||
/** NumberFormat for Locale.GERMANY. */
|
||||
const javaNumber = (value: number) => String(value).replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
||||
|
||||
/** Java's rows of a TCP segment without data (Lauscher.datenVorbereiten), with addresses or ports only. */
|
||||
function tcpRows(number: number, frame: JavaFrame, withAddresses: boolean): (string | null)[][] {
|
||||
const end = (address: string, port: number) => (withAddresses ? `${address}:${port}` : String(port));
|
||||
return [
|
||||
[String(number), frame.source, frame.destination, '', 'Netzzugang', '0x800'],
|
||||
[String(number), frame.sourceIp!, frame.destinationIp!, 'IP', 'Vermittlung', 'Protokoll: 6, TTL: 64'],
|
||||
[
|
||||
String(number),
|
||||
end(frame.sourceIp!, frame.sourcePort!),
|
||||
end(frame.destinationIp!, frame.destinationPort!),
|
||||
'TCP',
|
||||
'Transport',
|
||||
`SEQ: ${javaNumber(frame.sequence!)}, ACK: ${javaNumber(frame.acknowledgement!)}`,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** Java's run with a frame more on a card, in time order, its rows numbered in. */
|
||||
function withJavaFrame(java: JavaRun, card: string, frame: JavaFrame): JavaRun {
|
||||
const frames = java.frames[card]!;
|
||||
const index = frames.filter((each) => each.time <= frame.time).length;
|
||||
const renumbered = (rows: (string | null)[][], withAddresses: boolean) => [
|
||||
...rows.map((row) => (Number(row[0]) > index ? [String(Number(row[0]) + 1), ...row.slice(1)] : row)),
|
||||
...tcpRows(index + 1, frame, withAddresses),
|
||||
];
|
||||
return {
|
||||
...java,
|
||||
frames: { ...java.frames, [card]: [...frames.slice(0, index), frame, ...frames.slice(index)] },
|
||||
rows: { ...java.rows, [card]: renumbered(java.rows![card]!, true) },
|
||||
layeredRows: { ...java.layeredRows, [card]: renumbered(java.layeredRows![card]!, false) },
|
||||
};
|
||||
}
|
||||
|
||||
/** When the program last called verbinden: its SYN goes down then, and the attempt's time runs from there. */
|
||||
const lastConnect = (java: JavaRun, program: Program) => java.events.filter(([, who, text]) => who === program && text.startsWith('call verbinden')).at(-1)![0];
|
||||
|
||||
/** Java's connection attempt with one SYN: its failure two attempts later, when the third has timed out. */
|
||||
function failingAfterThreeAttempts(java: JavaRun, program: Program): JavaRun {
|
||||
return {
|
||||
...java,
|
||||
events: java.events.map(([time, who, text]) =>
|
||||
who === program && text.startsWith('Fehler beim Verbindungsaufbau\n') ? [time + 2 * JAVA_TIMEOUT_MILLISECONDS, who, text] : [time, who, text],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Java errs, per story: its run corrected, as Java means it.
|
||||
*
|
||||
* noRoute: the client's socket finds no route for its SYN; the exception
|
||||
* ends the socket's thread, and verbinden waits for ever for a state that
|
||||
* never comes — the client hangs without a word (and is no longer usable).
|
||||
* The engine's connection fails at once (sw_tcpsocket_msg3).
|
||||
*
|
||||
* closedPort, otherPort: Java's client sends one SYN, not three
|
||||
* (tcpJava.test.ts, closedPort); its connection fails two attempts later in
|
||||
* the engine.
|
||||
*
|
||||
* serverOff: switched off, Java's server frees its port (ServerSocket
|
||||
* .schliessen) while its connections still close; the client's FIN finds
|
||||
* no socket and goes unanswered, the client waits in LAST_ACK in vain. The
|
||||
* engine's server answers it with its ACK.
|
||||
*/
|
||||
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
|
||||
noRoute: (java) => ({
|
||||
...java,
|
||||
events: [...java.events, [java.events.find(([, , text]) => text.startsWith('call verbinden'))![0], 'client', `Fehler beim Verbindungsaufbau\n${CONNECT_FAILURES.failed}`]],
|
||||
}),
|
||||
closedPort: (java) => failingAfterThreeAttempts(java, 'client'),
|
||||
otherPort: (java) => failingAfterThreeAttempts(java, 'other'),
|
||||
serverOff: (java) => {
|
||||
const fin = java.frames[SERVER]!.filter((frame) => frame.tcpFlags === 'F').at(-1)!;
|
||||
const ack: JavaFrame = {
|
||||
...fin,
|
||||
source: fin.destination,
|
||||
destination: fin.source,
|
||||
sourceIp: fin.destinationIp!,
|
||||
destinationIp: fin.sourceIp!,
|
||||
sourcePort: fin.destinationPort!,
|
||||
destinationPort: fin.sourcePort!,
|
||||
tcpFlags: 'A',
|
||||
sequence: java.frames[SERVER]!.find((frame) => frame.tcpFlags === 'F')!.sequence! + 1,
|
||||
acknowledgement: fin.sequence! + 1,
|
||||
};
|
||||
return withJavaFrame(withJavaFrame(java, SERVER, ack), CLIENT, { ...ack, time: fin.time + CARD_TO_CARD_MILLISECONDS });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Where each card's capture is compared only up to a time (Java's).
|
||||
* closedPort, otherPort: the engine's second and third SYN come when the
|
||||
* switch has forgotten the server (tcp-lan: 5 s) and floods them to every
|
||||
* card; compared up to the second.
|
||||
*/
|
||||
const CAPTURE_UNTIL: Record<string, (java: JavaRun) => number> = {
|
||||
closedPort: (java) => lastConnect(java, 'client') + JAVA_TIMEOUT_MILLISECONDS - GAP_TOLERANCE_MILLISECONDS,
|
||||
otherPort: (java) => lastConnect(java, 'other') + JAVA_TIMEOUT_MILLISECONDS - GAP_TOLERANCE_MILLISECONDS,
|
||||
};
|
||||
|
||||
describe('the echo server and the generic client as Java FILIUS runs them', () => {
|
||||
it('cuts only where the engine goes on beyond Java', () => {
|
||||
for (const [name, until] of Object.entries(CAPTURE_UNTIL)) {
|
||||
const java = JSON.parse(readFileSync(join(scenarioDirectory, `echo-java-${name}.java.json`), 'utf8')) as JavaRun;
|
||||
const cut = until(java);
|
||||
expect(Object.values(java.frames).flat().every((frame) => frame.time < cut), name).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('corrects Java only where it errs', () => {
|
||||
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
|
||||
const java = JSON.parse(readFileSync(join(scenarioDirectory, `echo-java-${name}.java.json`), 'utf8')) as JavaRun;
|
||||
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
|
||||
}
|
||||
});
|
||||
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'tcp-lan.scenario.json'), 'utf8')) as Scenario;
|
||||
const snapshot = withSnapshot(snapshotOf(scenario), {
|
||||
echoServersByNodeId: new Map([[SERVER, { port: 55555, isActive: true }]]),
|
||||
simpleClientsByNodeId: new Map([
|
||||
[CLIENT, { targetAddress: '', targetPort: 55555 }],
|
||||
[OTHER_CLIENT, { targetAddress: '', targetPort: 55555 }],
|
||||
]),
|
||||
});
|
||||
|
||||
for (const [name, acts] of Object.entries(STORIES)) {
|
||||
it(name, () => {
|
||||
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `echo-java-${name}.java.json`), 'utf8')) as JavaRun;
|
||||
const java = JAVA_BUGS[name]?.(measured) ?? measured;
|
||||
const { engine } = watchedEngine(scenario, snapshot);
|
||||
for (const [time, act] of acts) {
|
||||
engine.handle({ type: 'advance', toMilliseconds: time });
|
||||
act(engine);
|
||||
}
|
||||
engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 });
|
||||
|
||||
expectSameCaptures(engine, scenario, java.frames, { text: withClientPortText, until: CAPTURE_UNTIL[name]?.(measured), tables: java });
|
||||
|
||||
const expected = javaLines(java);
|
||||
const actual = engineLines(engine);
|
||||
for (const program of ['server', 'client', 'other'] as const) {
|
||||
expect(actual[program].map(([, text]) => text), `${program}'s lines`).toEqual(expected[program].map(([, text]) => text));
|
||||
for (const [index, [javaTime, text]] of expected[program].entries()) {
|
||||
const late = javaTime - actual[program][index]![0];
|
||||
const [least, most] = isPolled(text) ? [-GAP_TOLERANCE_MILLISECONDS, POLL_MILLISECONDS + GAP_TOLERANCE_MILLISECONDS] : [-GAP_TOLERANCE_MILLISECONDS, GAP_TOLERANCE_MILLISECONDS];
|
||||
expect(late, `${program}'s line ${JSON.stringify(text)}: Java ${late} ms later`).toBeGreaterThanOrEqual(least);
|
||||
expect(late, `${program}'s line ${JSON.stringify(text)}: Java ${late} ms later`).toBeLessThanOrEqual(most);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user