feat(core): Gnutella joining after Java, measured against Java

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>
This commit is contained in:
2026-10-02 12:29:54 +00:00
co-authored by Claude Opus 5.5
parent 9d42b90157
commit b4a95e6cac
18 changed files with 37937 additions and 3 deletions
+147
View File
@@ -0,0 +1,147 @@
// The Gnutella program (apps/gnutella.ts) beyond what Java's runs show
// (gnutellaJava.test.ts): its packets as Java writes and reads them, a ping
// that comes twice, the files it offers.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { GNUTELLA_FOLDER, gnutellaPacket, gnutellaText, pongPayloadLength, VirtualFileSystem, withSnapshot, type NetworkRuntimeEngine } from '../src/index.js';
import { watchedEngine } from './javaRun.js';
import { snapshotOf, type Scenario } from './scenario.js';
const scenario = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'scenarios', 'terminal-net.scenario.json'), 'utf8')) as Scenario;
const CLIENT = '00000000-0000-0000-0000-000000000401';
const ECHO = '00000000-0000-0000-0000-000000000404';
describe('Gnutella packets as Java writes and reads them', () => {
it('writes a ping and a pong with their fields joined by "//"', () => {
expect(gnutellaText({ kind: 'ping', guid: 42, hops: 1, ttl: 7, payloadLength: 0, ipAddress: '192.168.0.30' })).toBe('42//0x00//1//7//0//192.168.0.30');
expect(gnutellaText({ kind: 'pong', guid: 42, hops: 0, ttl: 8, payloadLength: 77, ipAddress: '10.0.0.2', port: 6346, files: 2, bytes: 15 })).toBe(
'42//0x01//0//8//77//10.0.0.2//6346//2//15',
);
});
it('reads them back, skipping empty fields as a StringTokenizer on "/" does', () => {
expect(gnutellaPacket('42///0x00//1//7//0//192.168.0.30/')).toEqual({ kind: 'ping', guid: 42, hops: 1, ttl: 7, payloadLength: 0, ipAddress: '192.168.0.30' });
expect(gnutellaPacket('42//0x01//0//8//77//10.0.0.2//6346//2//15')).toEqual({
kind: 'pong',
guid: 42,
hops: 0,
ttl: 8,
payloadLength: 77,
ipAddress: '10.0.0.2',
port: 6346,
files: 2,
bytes: 15,
});
});
it('reads no packet from a text that is none, or of another kind', () => {
expect(['', 'GET /x HTTP/1.1', '42//0x00//1//7//0', 'x//0x00//1//7//0//a', '42//0x80//1//7//0//1//txt', '42//0x01//0//8//77//10.0.0.2//6346//2'].map(gnutellaPacket)).toEqual(
Array(6).fill(undefined),
);
});
it("counts a pong's payload in Java's bits: the address's characters, then the least power of two for each number", () => {
expect(pongPayloadLength('10.0.0.2', 6346, 0, 0)).toBe(77);
expect(pongPayloadLength('192.168.0.31', 6346, 0, 0)).toBe(109);
expect(pongPayloadLength('1.2.3.4', 6346, 1, 5)).toBe(7 * 8 + 13 + 0 + 3);
});
});
describe('the Gnutella program in the engine', () => {
const started = (files?: VirtualFileSystem) =>
watchedEngine(
scenario,
withSnapshot(snapshotOf(scenario), {
gnutellaByNodeId: new Map([[ECHO, { maximumKnownPeers: 3 }]]),
...(files === undefined ? {} : { fileSystemsByNodeId: new Map([[ECHO, files]]) }),
}),
).engine;
/** A connection of the client's to .31's port 6346, a ping sent on it; what came back. */
const ping = (engine: NetworkRuntimeEngine, text: string) => {
const socketId = engine.openTcpClientSocket(CLIENT, '192.168.0.31', 6346)!;
expect(engine.connectTcp(socketId)).toBe(true);
expect(engine.sendTcp(socketId, new TextEncoder().encode(text))).toBe(true);
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 3_000 });
const answers: string[] = [];
for (let answer = engine.receiveTcp(socketId); answer !== undefined; answer = engine.receiveTcp(socketId)) answers.push(new TextDecoder().decode(answer));
return { socketId, answers };
};
it('makes its folder of files to share', () => {
const engine = started();
expect(engine.state.topologySnapshot.fileSystemsByNodeId.get(ECHO)!.entry(GNUTELLA_FOLDER).content.kind).toBe('directory');
});
it('answers a ping once, and keeps the connection of the second (Java closes it)', () => {
const engine = started();
const first = ping(engine, '7//0x00//0//8//0//192.168.0.20');
expect(first.answers).toEqual(['7//0x01//0//8//109//192.168.0.31//6346//0//0']);
const second = ping(engine, '7//0x00//0//8//0//192.168.0.20');
expect(second.answers).toEqual([]);
expect(engine.state.socketsById.get(second.socketId)?.tcpState).toBe('ESTABLISHED');
expect(engine.gnutella(ECHO)!.peers).toEqual(['192.168.0.20']);
});
it("counts the files of its folder and their size in a pong (a text's in characters)", () => {
const files = new VirtualFileSystem();
files.createDirectory(GNUTELLA_FOLDER);
files.writeTextFile(`${GNUTELLA_FOLDER}/lied.txt`, 'Hallo Welt');
files.writeBinaryFile(`${GNUTELLA_FOLDER}/daten.bin`, new Uint8Array(5));
files.createDirectory(`${GNUTELLA_FOLDER}/ordner`);
files.writeTextFile('/anderswo.txt', 'nicht geteilt');
const engine = started(files);
expect(ping(engine, '9//0x00//0//8//0//192.168.0.20').answers).toEqual([`9//0x01//0//8//${pongPayloadLength('192.168.0.31', 6346, 2, 15)}//192.168.0.31//6346//2//15`]);
});
it('joins no one at its own address, and forgets its peers when reset', () => {
const engine = started();
engine.gnutella(ECHO)!.join('192.168.0.31');
engine.handle({ type: 'advance', toMilliseconds: 3_000 });
expect(engine.capture.frames(scenario.nodes.find((node) => node.id === ECHO)!.ports[0]!.id)).toEqual([]);
ping(engine, '5//0x00//0//8//0//192.168.0.20');
engine.gnutella(ECHO)!.reset();
expect(engine.gnutella(ECHO)!.peers).toEqual([]);
expect(engine.gnutella(ECHO)!.log.all.map((line) => line.peers)).toEqual([['192.168.0.20'], []]);
});
it('closes a connection whose text has no packet head, and leaves a packet of another kind (Java: its worker throws on the one)', () => {
const engine = started();
const garbage = ping(engine, 'hallo');
expect(garbage.answers).toEqual([]);
expect(engine.state.socketsById.get(garbage.socketId)?.tcpState).not.toBe('ESTABLISHED');
const other = ping(engine, '3//0x42//0//8//0');
expect(other.answers).toEqual([]);
expect(engine.state.socketsById.get(other.socketId)?.tcpState).toBe('ESTABLISHED');
});
it('sends a ping on once, and not when its time to live runs out', () => {
const WEB = '00000000-0000-0000-0000-000000000403';
const engine = watchedEngine(
scenario,
withSnapshot(snapshotOf(scenario), {
gnutellaByNodeId: new Map([
[ECHO, { maximumKnownPeers: 3 }],
[WEB, { maximumKnownPeers: 3 }],
]),
}),
).engine;
engine.gnutella(WEB)!.join('192.168.0.31');
engine.handle({ type: 'advance', toMilliseconds: 3_000 });
const webCard = scenario.nodes.find((node) => node.id === WEB)!.ports[0]!.id;
const forwarded = () =>
engine.capture.frames(webCard).filter(({ frame }) => {
const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined;
return packet?.payload.kind === 'tcp' && packet.senderIPAddress === '192.168.0.31' && new TextDecoder().decode(packet.payload.segment.payload).includes('//0x00//');
}).length;
expect(forwarded()).toBe(0);
ping(engine, '11//0x00//7//1//0//192.168.0.20');
expect(forwarded()).toBe(0);
ping(engine, '12//0x00//0//8//0//192.168.0.20');
ping(engine, '12//0x00//0//8//0//192.168.0.20');
expect(forwarded()).toBe(1);
});
});