feat(core): Gnutella search and download after Java, measured against Java

GnutellaRun adds a line of four peers: a search goes on as a query, each
peer answers with a hit for each matching file and the hits come back the
way the query came; a hit's file is fetched by HTTP on port 6346. The
file type names of the model move from the project export and import into
the core, so that the download uses them too.

Departures from Java: a peer that sent hits keeps its connection, so that
the hits of farther peers come back too (Java closes it and loses them),
and sizes are in characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 14:59:26 +00:00
co-authored by Claude Opus 5.5
parent b4a95e6cac
commit ce5b10c1f3
13 changed files with 39934 additions and 130 deletions
+144 -2
View File
@@ -6,7 +6,17 @@ 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 {
GNUTELLA_FOLDER,
gnutellaPacket,
gnutellaText,
pongPayloadLength,
queryHitPayloadLength,
queryPayloadLength,
VirtualFileSystem,
withSnapshot,
type NetworkRuntimeEngine,
} from '../src/index.js';
import { watchedEngine } from './javaRun.js';
import { snapshotOf, type Scenario } from './scenario.js';
@@ -38,11 +48,27 @@ describe('Gnutella packets as Java writes and reads them', () => {
});
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(
expect(['', 'GET /x HTTP/1.1', '42//0x00//1//7//0', 'x//0x00//1//7//0//a', '42//0x80//1//7//0//1', '42//0x01//0//8//77//10.0.0.2//6346//2'].map(gnutellaPacket)).toEqual(
Array(6).fill(undefined),
);
});
it('writes and reads a query and a hit, an empty text as one blank', () => {
const query = { kind: 'query', guid: 5, hops: 0, ttl: 8, payloadLength: queryPayloadLength('1', ''), minimumSpeed: '1', criteria: '' } as const;
expect(gnutellaText(query)).toBe('5//0x80//0//8//8//1// ');
expect(gnutellaPacket('5//0x80//1//7//40//1//lied')).toEqual({ kind: 'query', guid: 5, hops: 1, ttl: 7, payloadLength: 40, minimumSpeed: '1', criteria: 'lied' });
const hit = { kind: 'queryHit', guid: 5, hops: 0, ttl: 8, payloadLength: 125, hits: 1, port: 6346, ipAddress: '192.168.0.31', speed: '2', result: '', servent: ' ' } as const;
expect(gnutellaText(hit)).toBe('5//0x81//0//8//125//1//6346//192.168.0.31//2// // ');
expect(gnutellaPacket('5//0x81//1//7//125//1//6346//192.168.0.31//2//lied.txt: 10 B// ')).toEqual({ ...hit, hops: 1, ttl: 7, result: 'lied.txt: 10 B' });
expect(gnutellaPacket('5//0x81//1//7//125//1//6346//192.168.0.31//2//lied.txt: 10 B')).toBeUndefined();
});
it("counts a query's and a hit's payload in Java's bits (a hit's without the hit itself, as Java's worker leaves it)", () => {
expect(queryPayloadLength('1', 'lied')).toBe(40);
expect(queryHitPayloadLength(1, 6346, '192.168.0.31', '2', ' ')).toBe(125);
expect(queryHitPayloadLength(3, 6346, '10.0.0.2', '2', ' ')).toBe(2 + 13 + 64 + 8 + 8);
});
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);
@@ -144,4 +170,120 @@ describe('the Gnutella program in the engine', () => {
ping(engine, '12//0x00//0//8//0//192.168.0.20');
expect(forwarded()).toBe(1);
});
it('gives a file of its folder by HTTP with its type, and 404 for one it has not', () => {
const files = new VirtualFileSystem();
files.createDirectory(GNUTELLA_FOLDER);
files.writeTextFile(`${GNUTELLA_FOLDER}/Lied der Woche.txt`, 'La la la');
files.writeImageFile(`${GNUTELLA_FOLDER}/bild.png`, new Uint8Array([1, 2, 3]), 'image/png');
const engine = started(files);
expect(ping(engine, 'GET Lied%20der%20Woche.txt HTTP/1.1\nHost: 192.168.0.31\n').answers).toEqual(['HTTP/1.1 200 OK\nContent-type: txt\n\nLa la la']);
expect(ping(engine, 'GET BILD.PNG HTTP/1.1\nHost: 192.168.0.31\n').answers).toEqual(['HTTP/1.1 200 OK\nContent-type: png\n\nAQID']);
expect(ping(engine, 'GET fehlt.txt HTTP/1.1\nHost: 192.168.0.31\n').answers).toEqual(['HTTP/1.1 404 Not Found\n']);
});
it('answers a query once, and sends it on only while it lives', () => {
const files = new VirtualFileSystem();
files.createDirectory(GNUTELLA_FOLDER);
files.writeTextFile(`${GNUTELLA_FOLDER}/lied.txt`, 'Hallo Welt');
files.writeTextFile(`${GNUTELLA_FOLDER}/Lied2.TXT`, 'Hallo');
const engine = started(files);
const first = ping(engine, '8//0x80//0//8//40//1//LIED');
expect(first.answers.map((answer) => answer.replace(/^\d+\/\/0x81\/\/0\/\/8\/\/\d+\/\//, ''))).toEqual([
'2//6346//192.168.0.31//2//lied.txt: 10 B// ',
'2//6346//192.168.0.31//2//Lied2.TXT: 5 B// ',
]);
expect(ping(engine, '8//0x80//0//8//40//1//LIED').answers).toEqual([]);
expect(ping(engine, '9//0x80//0//8//40//1//xyz').answers).toEqual([]);
});
it('tells its window when it clears its results', () => {
const engine = started();
engine.gnutella(ECHO)!.clearResults();
expect(engine.gnutella(ECHO)!.log.all.map((line) => line.kind)).toEqual(['results']);
});
it('downloads an image as an image, over a file of the name in another case (keeping that name)', () => {
const WEB = '00000000-0000-0000-0000-000000000403';
const echoFiles = new VirtualFileSystem();
echoFiles.createDirectory(GNUTELLA_FOLDER);
echoFiles.writeImageFile(`${GNUTELLA_FOLDER}/bild.png`, new Uint8Array([1, 2, 3]), 'image/png');
const webFiles = new VirtualFileSystem();
webFiles.createDirectory(GNUTELLA_FOLDER);
webFiles.writeTextFile(`${GNUTELLA_FOLDER}/BILD.png`, 'alt');
const engine = watchedEngine(
scenario,
withSnapshot(snapshotOf(scenario), {
gnutellaByNodeId: new Map([
[ECHO, { maximumKnownPeers: 3 }],
[WEB, { maximumKnownPeers: 3 }],
]),
fileSystemsByNodeId: new Map([
[ECHO, echoFiles],
[WEB, webFiles],
]),
}),
).engine;
const web = engine.gnutella(WEB)!;
web.join('192.168.0.31');
engine.handle({ type: 'advance', toMilliseconds: 3_000 });
web.search('bild');
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
expect(web.results).toEqual(['192.168.0.31/bild.png: 3 B']);
web.download(0);
engine.handle({ type: 'advance', toMilliseconds: 9_000 });
expect(webFiles.entriesInCreationOrder(GNUTELLA_FOLDER).map((entry) => [entry.path, entry.content])).toEqual([
[`${GNUTELLA_FOLDER}/BILD.png`, { kind: 'image', data: new Uint8Array([1, 2, 3]), mediaType: 'image/png' }],
]);
});
describe('with a peer that answers as a test wants', () => {
const DNS = '00000000-0000-0000-0000-000000000402';
/** A peer on .10 made of bare sockets: to each connection, the answers given for it, in order. */
const fakePeer = (engine: NetworkRuntimeEngine, answersByConnection: string[][]) => {
const listener = engine.openTcpServerSocket(DNS, 6346)!;
const received: string[] = [];
engine.startThread(
(function* () {
for (const answers of answersByConnection) {
const socketId = (yield* engine.acceptTcpSteps(listener))!;
const message = yield* engine.receiveTcpSteps(socketId);
if (message.type === 'message') received.push(new TextDecoder().decode(message.payload));
for (const answer of answers) yield* engine.sendTcpSteps(socketId, new TextEncoder().encode(answer));
}
})(),
);
return received;
};
it('takes a result once, also when it comes twice', () => {
const engine = watchedEngine(scenario, withSnapshot(snapshotOf(scenario), { gnutellaByNodeId: new Map([[CLIENT, { maximumKnownPeers: 3 }]]) })).engine;
const ids = [11, 12];
engine.drawGnutellaGuid = () => ids.shift()!;
const hit = '12//0x81//0//8//125//1//6346//192.168.0.10//2//a.txt: 1 B// ';
fakePeer(engine, [['11//0x01//0//8//109//192.168.0.10//6346//1//1'], [hit, hit]]);
engine.gnutella(CLIENT)!.join('192.168.0.10');
engine.handle({ type: 'advance', toMilliseconds: 3_000 });
engine.gnutella(CLIENT)!.search('a');
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
expect(engine.gnutella(CLIENT)!.results).toEqual(['192.168.0.10/a.txt: 1 B']);
});
it('sends back a hit only for a query it sent on, and only while the hit lives; a query only while it lives', () => {
const engine = started();
const hit = (guid: number, ttl: number) => `${guid}//0x81//0//${ttl}//125//1//6346//192.168.0.10//2//a.txt: 1 B// `;
// .31 learns .10 as a peer from .10's ping; then each query .31 sends on to .10 gets the hits given.
const received = fakePeer(engine, [[hit(21, 8), hit(99, 8)], [hit(22, 0)], []]);
const join = engine.openTcpClientSocket(DNS, '192.168.0.31', 6346)!;
expect(engine.connectTcp(join)).toBe(true);
engine.sendTcp(join, new TextEncoder().encode('20//0x00//0//8//0//192.168.0.10'));
engine.handle({ type: 'advance', toMilliseconds: 3_000 });
expect(engine.gnutella(ECHO)!.peers).toEqual(['192.168.0.10']);
expect(ping(engine, '21//0x80//0//8//16//1//a').answers).toEqual([hit(21, 7).replace('//0//7//', '//1//7//')]);
expect(ping(engine, '22//0x80//0//8//16//1//a').answers).toEqual([]);
// A query at the end of its time to live is answered, not sent on.
ping(engine, '23//0x80//7//0//16//1//a');
expect(received.map((text) => text.split('//').slice(0, 4).join('//'))).toEqual(['21//0x80//1//7', '22//0x80//1//7']);
});
});
});