// 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-.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 LogLine, type NetworkRuntimeEngine } from '../src/index.js'; import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables } from './javaRun.js'; import { clientText, CONNECT_FAILURES, isPolled, POLL_MILLISECONDS, serverText } from './programLines.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'; type Program = 'server' | 'client' | 'other'; interface JavaRun extends JavaTables { frames: Record; /** Time, program, and a line of its window or one of the story's calls ("call …", "returned …"). */ events: [number, Program, string][]; } 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 = { 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; 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 = (log: readonly LogLine[], 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 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 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); } } }); } });