GnutellaRun lets Java's PeerToPeerAnwendung join peers on terminal-net. The core's gnutella.ts does the same: a ping in Java's text format to port 6346, a pong back, the ping sent on to the other peers and their pongs back the way it came, at most the program's number of peers. Departure from Java: a ping that comes a second time gets no pong and its connection stays (Java's worker throws on the missing pong). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
10 KiB
TypeScript
218 lines
10 KiB
TypeScript
// The Gnutella program as Java FILIUS runs it. tools/java-oracle's
|
|
// GnutellaRun let FILIUS' Gnutella program (PeerToPeerAnwendung) join peers
|
|
// on terminal-net and wrote every frame each card saw, Java's tables of
|
|
// them, each program's known peers whenever it told its window, and at the
|
|
// end each program's peers (gnutella-java-<story>.java.json). The engine
|
|
// plays the same calls at the same times (apps/gnutella.ts): each card's
|
|
// frames in the same order, with the same content and rows, at about the
|
|
// same time; the same peers, in the same order, 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 { withSnapshot, type NetworkRuntimeEngine } from '../src/index.js';
|
|
import {
|
|
engineText,
|
|
expectSameCaptures,
|
|
GAP_TOLERANCE_MILLISECONDS,
|
|
javaText,
|
|
scenarioCards,
|
|
watchedEngine,
|
|
withClientPortText,
|
|
type JavaFrame,
|
|
type JavaTables,
|
|
} 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');
|
|
|
|
const CLIENT = '00000000-0000-0000-0000-000000000401';
|
|
const WEB = '00000000-0000-0000-0000-000000000403';
|
|
const ECHO = '00000000-0000-0000-0000-000000000404';
|
|
const FAR = '00000000-0000-0000-0000-000000000406';
|
|
const PEERS = [CLIENT, WEB, ECHO, FAR];
|
|
|
|
interface JavaRun extends JavaTables {
|
|
frames: Record<string, JavaFrame[]>;
|
|
acts: [number, string, string][];
|
|
/** time, host, kind (call or peers), text */
|
|
events: [number, string, string, string][];
|
|
programs: Record<string, { peers: string[]; results: string[] }>;
|
|
}
|
|
|
|
/** The stories, and how many connections each makes one after the other (Java looks every 100 ms whether one is made). */
|
|
const STORIES: Record<string, { readonly looks: number }> = {
|
|
pair: { looks: 1 },
|
|
chain: { looks: 2 },
|
|
twoPaths: { looks: 3 },
|
|
full: { looks: 3 },
|
|
reset: { looks: 1 },
|
|
errors: { looks: 1 },
|
|
};
|
|
|
|
/** The ids of Java's own pings, in the order they were made: the engine draws its own, these take their place. */
|
|
function javaPingIds(java: JavaRun): number[] {
|
|
const pings = Object.values(java.frames)
|
|
.flat()
|
|
.flatMap((frame) => {
|
|
const match = /^(\d+)\/\/0x00\/\/0\/\//.exec(frame.payload ?? '');
|
|
return match === null ? [] : [[frame.time, Number(match[1])] as const];
|
|
})
|
|
.sort((lhs, rhs) => lhs[0] - rhs[0]);
|
|
return [...new Set(pings.map(([, guid]) => guid))];
|
|
}
|
|
|
|
/** Each program's known peers as it told them, each change once (Java tells its window also when nothing changed, and an empty list at its start). */
|
|
function javaPeers(java: JavaRun): Record<string, [number, string][]> {
|
|
const peers: Record<string, [number, string][]> = Object.fromEntries(PEERS.map((host) => [host, []]));
|
|
for (const [time, host, kind, text] of java.events) {
|
|
if (kind !== 'peers') continue;
|
|
const list = peers[host]!;
|
|
if ((list.at(-1)?.[1] ?? '') === text) continue;
|
|
list.push([time, text]);
|
|
}
|
|
return peers;
|
|
}
|
|
|
|
function enginePeers(engine: NetworkRuntimeEngine): Record<string, [number, string][]> {
|
|
return Object.fromEntries(PEERS.map((host) => [host, engine.gnutella(host)!.log.all.map((line): [number, string] => [line.timeMilliseconds, line.peers.join(',')])]));
|
|
}
|
|
|
|
/**
|
|
* Where Java errs, compared only up to a time (Java's): twoPaths, from the
|
|
* first ping that comes a second time — Java's worker throws on the
|
|
* missing pong and closes the connection (gnutella.ts);
|
|
* errors, before the engine's second SYN to the closed port.
|
|
*/
|
|
/**
|
|
* twoPaths, full: many connections at once, and at a card the frames of two
|
|
* of them, or the two directions of one, may come in either order a few ten
|
|
* milliseconds apart (Java's threads over several hops). Each card's frames
|
|
* are compared connection by connection and direction by direction — the
|
|
* frames between the two hosts on the server's port, in the order the
|
|
* connections began — with their data; ARP on its own.
|
|
*/
|
|
const BY_CONNECTION = new Set(['twoPaths', 'full']);
|
|
|
|
interface FlowFrame {
|
|
readonly time: number;
|
|
/** The frame's text with its real ports, which tell the connection. */
|
|
readonly raw: string;
|
|
/** The text compared: a client's port as "client", a packet's id as "x". */
|
|
readonly text: string;
|
|
}
|
|
|
|
/** A card's frames by connection: its two hosts and the server's port (a client's port is drawn), each connection's frames in order. */
|
|
function connections(frames: readonly FlowFrame[]): Map<string, FlowFrame[][]> {
|
|
const byKey = new Map<string, Map<string, FlowFrame[]>>();
|
|
for (const frame of frames) {
|
|
const tcp = / TCP (\S+):(\d+) > (\S+):(\d+) /.exec(frame.raw);
|
|
// A connection by its client's end, and each of its directions on its own (TCP orders a direction, not the two).
|
|
const [key, connection] =
|
|
tcp === null ? ['arp', 'arp'] : tcp[2] === '6346' ? [`${tcp[3]}>${tcp[1]}:6346 answers`, `${tcp[3]}:${tcp[4]}`] : [`${tcp[1]}>${tcp[3]}:6346`, `${tcp[1]}:${tcp[2]}`];
|
|
const flows = byKey.get(key) ?? new Map<string, FlowFrame[]>();
|
|
byKey.set(key, flows);
|
|
flows.set(connection, [...(flows.get(connection) ?? []), frame]);
|
|
}
|
|
return new Map([...byKey].map(([key, flows]) => [key, [...flows.values()]]));
|
|
}
|
|
|
|
/**
|
|
* When a ping first comes to a host a second time (the same id to the same
|
|
* address, on another connection; a frame shows on both its cards); before
|
|
* it, by more than a timing difference.
|
|
*/
|
|
function secondPing(java: JavaRun): number {
|
|
const connectionsByPing = new Map<string, Set<string>>();
|
|
for (const frame of Object.values(java.frames)
|
|
.flat()
|
|
.sort((lhs, rhs) => lhs.time - rhs.time)) {
|
|
const match = /^(\d+)\/\/0x00\//.exec(frame.payload ?? '');
|
|
if (match === null) continue;
|
|
const ping = `${match[1]}>${frame.destinationIp}`;
|
|
const connections = connectionsByPing.get(ping) ?? new Set<string>();
|
|
connections.add(`${frame.sourceIp}:${frame.sourcePort}`);
|
|
connectionsByPing.set(ping, connections);
|
|
if (connections.size > 1) return frame.time - 150;
|
|
}
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
const UNTIL: Record<string, (java: JavaRun) => number> = {
|
|
// The SYN to a closed port goes once in Java, three times in the engine (tcpJava.test.ts, closedPort).
|
|
errors: (java) => java.frames[CLIENT]!.find((frame) => frame.tcpFlags === 'S')!.time + 1_000,
|
|
twoPaths: secondPing,
|
|
};
|
|
|
|
describe('the Gnutella program as Java FILIUS runs it', () => {
|
|
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
|
|
|
|
for (const [name, story] of Object.entries(STORIES)) {
|
|
it(name, () => {
|
|
const java = JSON.parse(readFileSync(join(scenarioDirectory, `gnutella-java-${name}.java.json`), 'utf8')) as JavaRun;
|
|
const snapshot = withSnapshot(snapshotOf(scenario), {
|
|
gnutellaByNodeId: new Map(PEERS.map((host) => [host, { maximumKnownPeers: name !== 'full' ? 3 : host === ECHO ? 2 : host === FAR ? 3 : 1 }])),
|
|
});
|
|
const { engine } = watchedEngine(scenario, snapshot);
|
|
const ids = javaPingIds(java);
|
|
engine.drawGnutellaGuid = () => ids.shift() ?? 1;
|
|
for (const [time, host, call] of java.acts) {
|
|
engine.handle({ type: 'advance', toMilliseconds: time });
|
|
if (call.startsWith('join ')) engine.gnutella(host)!.join(call.slice('join '.length));
|
|
else engine.gnutella(host)!.reset();
|
|
}
|
|
engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 10_000 });
|
|
|
|
const tolerance = GAP_TOLERANCE_MILLISECONDS + story.looks * POLL_MILLISECONDS;
|
|
const until = UNTIL[name]?.(java) ?? Number.POSITIVE_INFINITY;
|
|
if (BY_CONNECTION.has(name)) {
|
|
const portOf = new Map(scenarioCards(scenario).map((card) => [card.key, card.portId]));
|
|
const data = (text: string | undefined) => withClientPortText(text ?? '').replace(/^\d+\/\/0x/, 'x//0x');
|
|
for (const [card, list] of Object.entries(java.frames)) {
|
|
const expected = connections(
|
|
list
|
|
.filter((frame) => frame.time < until)
|
|
.map((frame) => ({ time: frame.time, raw: javaText(frame), text: `${withClientPortText(javaText(frame))} ${data(frame.payload)}` })),
|
|
);
|
|
const captured = engine.capture.frames(portOf.get(card)!).filter((each) => each.timeMilliseconds < until);
|
|
const actual = connections(
|
|
captured.map(({ timeMilliseconds, frame }) => {
|
|
const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined;
|
|
const bytes = packet?.payload.kind === 'tcp' ? packet.payload.segment.payload : new Uint8Array();
|
|
const text = engineText(frame);
|
|
return { time: timeMilliseconds, raw: text, text: `${withClientPortText(text)} ${data(bytes.length === 0 ? undefined : new TextDecoder().decode(bytes))}` };
|
|
}),
|
|
);
|
|
expect([...actual.keys()].sort(), `${card}'s connections`).toEqual([...expected.keys()].sort());
|
|
for (const [key, flows] of expected) {
|
|
const texts = (each: FlowFrame[][]) => each.map((flow) => flow.map((frame) => frame.text));
|
|
expect(texts(actual.get(key)!), `${card} ${key}`).toEqual(texts(flows));
|
|
for (const [index, flow] of flows.entries()) {
|
|
for (const [at, frame] of flow.entries()) {
|
|
expect(Math.abs(actual.get(key)![index]![at]!.time - frame.time), `${card} ${key} #${index + 1} frame ${at + 1}`).toBeLessThanOrEqual(tolerance);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
expectSameCaptures(engine, scenario, java.frames, { text: withClientPortText, tables: java, until: UNTIL[name]?.(java), tolerance });
|
|
}
|
|
|
|
const expected = javaPeers(java);
|
|
const actual = enginePeers(engine);
|
|
for (const host of PEERS) {
|
|
expect(
|
|
actual[host]!.map(([, peers]) => peers),
|
|
`${host}'s peers`,
|
|
).toEqual(expected[host]!.map(([, peers]) => peers));
|
|
for (const [index, [javaTime]] of expected[host]!.entries()) {
|
|
expect(Math.abs(javaTime - actual[host]![index]![0]), `${host}'s peers #${index + 1}`).toBeLessThanOrEqual(tolerance);
|
|
}
|
|
expect(engine.gnutella(host)!.peers, `${host}'s peers at the end`).toEqual(java.programs[host]!.peers);
|
|
}
|
|
});
|
|
}
|
|
});
|