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
+1
View File
@@ -27,6 +27,7 @@ export * from './runtime/apps/firewallSettings.js';
export * from './runtime/apps/emailServer.js';
export * from './runtime/apps/emailClient.js';
export * from './runtime/apps/filePrograms.js';
export * from './runtime/apps/gnutella.js';
export * from './runtime/apps/terminal.js';
export * from './runtime/apps/terminalTexts.js';
export * from './runtime/email.js';
+280
View File
@@ -0,0 +1,280 @@
// FILIUS' Gnutella program (filius.software.dateiaustausch: PeerToPeerAnwendung
// with its PeerToPeerServer on port 6346 and PeerToPeerClient): a host joins
// the network by a ping to one peer, which answers with a pong, takes the
// host as a peer and sends the ping on to its own peers, whose pongs come
// back the same way; each knows at most its number of peers. The messages
// are Java's text packets (PeerToPeerPaket.toString), each on a connection
// of its own that stays open, as Java's do.
//
// Departures from Java (gnutellaJava.test.ts names each):
// - a ping that comes a second time gets no pong, and its connection stays
// (Java means so, but its worker throws on the missing pong and closes the
// connection).
import type { Id } from '../../ids.js';
import type { GnutellaConfiguration } from '../config.js';
import type { NetworkRuntimeEngine } from '../engine.js';
import type { Steps } from '../threads.js';
import { ProgramLog } from './program.js';
import { TcpServerProgram } from './server.js';
/** PeerToPeerServer, PeerToPeerClientMitarbeiter: Gnutella's port. */
export const GNUTELLA_PORT = 6346;
/** Where the program keeps its files to share (PeerToPeerAnwendung.setSystemSoftware). */
export const GNUTELLA_FOLDER = '/peer2peer';
/** A packet's head (PeerToPeerPaket): its id, kind, hops, time to live and payload length in bits. */
interface GnutellaHead {
readonly guid: number;
readonly hops: number;
readonly ttl: number;
readonly payloadLength: number;
}
/** PingPaket: from the host with this address. */
export interface PingPacket extends GnutellaHead {
readonly kind: 'ping';
readonly ipAddress: string;
}
/** PongPaket: the answering host, its port, its files and their bytes (Java calls them KBs). */
export interface PongPacket extends GnutellaHead {
readonly kind: 'pong';
readonly ipAddress: string;
readonly port: number;
readonly files: number;
readonly bytes: number;
}
export type GnutellaPacket = PingPacket | PongPacket;
const PAYLOAD_KIND = { ping: '0x00', pong: '0x01' } as const;
/** PeerToPeerPaket.anzahlBenoetigterBits: the least exponent whose power of two is at least the number. */
function bitsFor(value: number): number {
let exponent = 0;
while (2 ** exponent < value) exponent += 1;
return exponent;
}
/** PongPaket.payloadLengthBerechnen. */
export function pongPayloadLength(ipAddress: string, port: number, files: number, bytes: number): number {
return ipAddress.length * 8 + bitsFor(port) + bitsFor(files) + bitsFor(bytes);
}
/** PeerToPeerPaket.toString and its subclasses': the fields joined by "//". */
export function gnutellaText(packet: GnutellaPacket): string {
const head = [packet.guid, PAYLOAD_KIND[packet.kind], packet.hops, packet.ttl, packet.payloadLength];
const body = packet.kind === 'ping' ? [packet.ipAddress] : [packet.ipAddress, packet.port, packet.files, packet.bytes];
return [...head, ...body].join('//');
}
/** A packet's fields, as Java's constructors read them: a StringTokenizer on "/", empty fields skipped. */
const fieldsOf = (text: string) => text.split('/').filter((field) => field !== '');
const numberAt = (fields: readonly string[], index: number) => (/^-?\d+$/.test(fields[index] ?? '') ? Number(fields[index]) : undefined);
/** PeerToPeerPaket(String): whether the text has a packet's head (its id, kind, hops, time to live, payload length). */
export function hasGnutellaHead(text: string): boolean {
const fields = fieldsOf(text);
return fields[1] !== undefined && [0, 2, 3, 4].every((index) => numberAt(fields, index) !== undefined);
}
/** A packet from its text; undefined for any other kind or a text that is no packet. */
export function gnutellaPacket(text: string): GnutellaPacket | undefined {
const fields = fieldsOf(text);
const number = (index: number) => numberAt(fields, index);
const [guid, hops, ttl, payloadLength] = [number(0), number(2), number(3), number(4)];
if (guid === undefined || hops === undefined || ttl === undefined || payloadLength === undefined) return undefined;
const head = { guid, hops, ttl, payloadLength };
if (fields[1] === PAYLOAD_KIND.ping && fields[5] !== undefined) return { kind: 'ping', ...head, ipAddress: fields[5] };
const [port, files, bytes] = [number(6), number(7), number(8)];
if (fields[1] === PAYLOAD_KIND.pong && fields[5] !== undefined && port !== undefined && files !== undefined && bytes !== undefined) {
return { kind: 'pong', ...head, ipAddress: fields[5], port, files, bytes };
}
return undefined;
}
/** What the program tells its window: its known peers, whenever they change. */
export type GnutellaLogEntry = { readonly kind: 'peers'; readonly peers: readonly string[] };
/** PeerToPeerServer: answers pings on 6346; a connection's worker remembers the id of its last packet. */
class GnutellaServer extends TcpServerProgram {
/** Each connection's last packet id (PeerToPeerServerMitarbeiter.guid), to send answers back on. */
private readonly lastGuidBySocket = new Map<Id, number>();
constructor(
engine: NetworkRuntimeEngine,
nodeId: Id,
private readonly peer: GnutellaPeer,
) {
super(engine, nodeId, { port: GNUTELLA_PORT, isActive: true });
}
/** Never asked: each connection runs its own dialogue (converse). */
protected answer(): string {
return '';
}
protected override *converse(socketId: Id, address: string): Steps {
for (;;) {
const received = yield* this.engine.receiveTcpSteps(socketId);
if (received.type !== 'message') {
this.lastGuidBySocket.delete(socketId);
return;
}
const text = new TextDecoder().decode(received.payload);
// PeerToPeerServerMitarbeiter.verarbeiteNachricht: a text without a packet's head throws, and the worker closes the
// connection; a packet of another kind is left.
if (!hasGnutellaHead(text)) {
this.lastGuidBySocket.delete(socketId);
return;
}
const packet = gnutellaPacket(text);
if (packet === undefined) continue;
this.lastGuidBySocket.set(socketId, packet.guid);
if (packet.kind === 'ping') yield* this.peer.answerPing(packet, address, (text) => this.engine.sendTcpSteps(socketId, new TextEncoder().encode(text)));
}
}
/** PeerToPeerServer.sendePaket: on every connection whose last packet had this id. */
sendBack(packet: GnutellaPacket): void {
for (const [socketId, guid] of this.lastGuidBySocket) {
if (guid !== packet.guid) continue;
const engine = this.engine;
engine.startThread(
(function* (): Steps {
yield* engine.sendTcpSteps(socketId, new TextEncoder().encode(gnutellaText(packet)));
})(),
);
}
}
}
export class GnutellaPeer {
readonly log: ProgramLog<GnutellaLogEntry>;
private peersValue: string[] = [];
private readonly maximumKnownPeers: number;
/** The ids of the program's own pings (eigeneAnfragen), of others' (fremdeAnfragen), and of those it sent on (schonmalVerschicktListe). */
private readonly ownRequests = new Set<number>();
private readonly foreignRequests = new Set<number>();
private readonly alreadySent = new Set<number>();
private readonly server: GnutellaServer;
constructor(
private readonly engine: NetworkRuntimeEngine,
private readonly nodeId: Id,
configuration: GnutellaConfiguration,
) {
this.log = new ProgramLog(engine);
this.maximumKnownPeers = configuration.maximumKnownPeers;
this.server = new GnutellaServer(engine, nodeId, this);
}
/** The known peers (holeBekanntePeerToPeerTeilnehmer), in the order they came. */
get peers(): readonly string[] {
return this.peersValue;
}
/** The node's first address (Betriebssystem.primaryIPAdresse). */
private get ownAddress(): string {
return this.engine.networkInterfaces(this.nodeId)[0]?.ipAddress ?? '';
}
/** beitretenNetzwerk: a ping to the peer at the address, not to the host itself. */
join(address: string): void {
if (address === this.ownAddress) return;
const ping: PingPacket = { kind: 'ping', guid: this.engine.drawGnutellaGuid(), hops: 0, ttl: 8, payloadLength: 0, ipAddress: this.ownAddress };
this.ownRequests.add(ping.guid);
this.engine.startThread(this.sendTo(address, ping));
}
/** resetNetwork: no known peers. */
reset(): void {
this.peersValue = [];
this.log.write({ kind: 'peers', peers: [] });
}
/** hinzuTeilnehmer: a new peer while there is room. */
private addPeer(address: string): void {
if (this.peersValue.includes(address) || this.peersValue.length >= this.maximumKnownPeers) return;
this.peersValue = [...this.peersValue, address];
this.log.write({ kind: 'peers', peers: this.peersValue });
}
/**
* PeerToPeerServerMitarbeiter.verarbeitePing: the sender's host taken as a
* peer, a pong unless the ping was here already, and the ping sent on to
* the other peers.
*/
*answerPing(ping: PingPacket, from: string, send: (text: string) => Steps<unknown>): Steps {
this.addPeer(ping.ipAddress);
// erstellePong: none for a ping sent on before.
if (!this.alreadySent.has(ping.guid)) {
const files = this.sharedFiles();
const bytes = files.reduce((sum, size) => sum + size, 0);
const address = this.ownAddress;
const pong: PongPacket = {
kind: 'pong',
guid: ping.guid,
hops: 0,
ttl: 8,
payloadLength: pongPayloadLength(address, GNUTELLA_PORT, files.length, bytes),
ipAddress: address,
port: GNUTELLA_PORT,
files: files.length,
bytes,
};
yield* send(gnutellaText(pong));
}
this.sendPingOn({ ...ping, hops: ping.hops + 1, ttl: ping.ttl - 1 }, from);
}
/** PeerToPeerAnwendung.sendePing: once per id, while it lives, to each peer but the sender and the ping's host. */
private sendPingOn(ping: PingPacket, from: string): void {
if (ping.ttl <= 0 || this.alreadySent.has(ping.guid)) return;
this.alreadySent.add(ping.guid);
this.foreignRequests.add(ping.guid);
for (const peer of this.peersValue) {
if (peer !== from && peer !== ping.ipAddress) this.engine.startThread(this.sendTo(peer, ping));
}
}
/** verarbeitePong: the program's own ping answered (a new peer), else the pong sent back the way its ping came. */
private takePong(pong: PongPacket): void {
if (this.ownRequests.has(pong.guid)) this.addPeer(pong.ipAddress);
else this.server.sendBack({ ...pong, ttl: pong.ttl - 1, hops: pong.hops + 1 });
}
/** The sizes of the files in /peer2peer (Datei.holeGroesse, a text in characters). */
private sharedFiles(): number[] {
const files = this.engine.state.topologySnapshot.fileSystemsByNodeId.get(this.nodeId);
if (files === undefined || !files.contains(GNUTELLA_FOLDER)) return [];
return files.entriesInCreationOrder(GNUTELLA_FOLDER).flatMap((entry) => {
const content = entry.content;
return content.kind === 'directory' ? [] : [content.kind === 'text' ? content.text.length : content.data.length];
});
}
/**
* PeerToPeerClientMitarbeiter.run: a connection to the peer's port 6346, the
* packet sent, then each answer taken until the connection ends.
*/
private *sendTo(address: string, packet: GnutellaPacket): Steps {
const engine = this.engine;
const socketId = engine.openTcpClientSocket(this.nodeId, address, GNUTELLA_PORT);
if (socketId === undefined || (yield* engine.connectTcpSteps(socketId)) !== 'connected') {
if (socketId !== undefined) engine.requestTcpClose(socketId);
return;
}
yield* engine.sendTcpSteps(socketId, new TextEncoder().encode(gnutellaText(packet)));
for (;;) {
const received = yield* engine.receiveTcpSteps(socketId);
if (received.type !== 'message') break;
const answer = gnutellaPacket(new TextDecoder().decode(received.payload));
if (answer?.kind === 'pong') this.takePong(answer);
}
engine.requestTcpClose(socketId);
}
}
+28
View File
@@ -123,6 +123,7 @@ import { firewallLogPage, forwardingTablePage, ripTablePage, routerIndexPage } f
import { applyFirewallSettings, firewallSettingsPage } from './apps/firewallSettings.js';
import { parentPath, VirtualFileSystem } from '../model/virtualFileSystem.js';
import { EMAIL_SERVER_ACCOUNTS_FILE, serverAccountsFileText } from './emailAccountsFile.js';
import { GNUTELLA_FOLDER, GnutellaPeer } from './apps/gnutella.js';
import {
PRODUCTION_TRACE_RETENTION,
type HeaderField,
@@ -882,6 +883,8 @@ export class NetworkRuntimeEngine {
private dnsIdRandom: JavaCompatibleRandom;
/** Draws the command line's first ICMP identifier (Terminal.setIdentifier: new Random()). */
private icmpIdentifierRandom: JavaCompatibleRandom;
/** Draws Gnutella packet ids (PeerToPeerPaket.guidErstellen: new Random()). */
private gnutellaGuidRandom: JavaCompatibleRandom;
/** Nodes whose ARP table lost its broadcast row (arp -d 255.255.255.255), until the table is reset. */
private readonly arpBroadcastRowRemoved = new Set<Id>();
private runtimeSpeed: RuntimeSpeed;
@@ -930,6 +933,7 @@ export class NetworkRuntimeEngine {
private readonly emailServersByNodeId = new Map<Id, EmailServer>();
private readonly emailClientsByNodeId = new Map<Id, EmailClient>();
private readonly terminalsByNodeId = new Map<Id, Terminal>();
private readonly gnutellaPeersByNodeId = new Map<Id, GnutellaPeer>();
/** The time of day a program writes into a mail (Java's new Date()). */
private readonly wallClock: () => number;
/** Each node's firewall log; it outlives a stop and a new start, as Java's log page does. */
@@ -981,6 +985,7 @@ export class NetworkRuntimeEngine {
this.linkLocalRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4c4c_4144n);
this.dnsIdRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x444e_5349n);
this.icmpIdentifierRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4943_4d50n);
this.gnutellaGuidRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x474e_5554n);
this.runtimeSpeed = new RuntimeSpeed();
this.packetTraceRetentionPolicy = packetTraceRetentionPolicy;
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
@@ -1035,6 +1040,7 @@ export class NetworkRuntimeEngine {
this.linkLocalRandom = new JavaCompatibleRandom(seed ^ 0x4c4c_4144n);
this.dnsIdRandom = new JavaCompatibleRandom(seed ^ 0x444e_5349n);
this.icmpIdentifierRandom = new JavaCompatibleRandom(seed ^ 0x4943_4d50n);
this.gnutellaGuidRandom = new JavaCompatibleRandom(seed ^ 0x474e_5554n);
this.arpBroadcastRowRemoved.clear();
this.isProcessingScheduledEvents = false;
this.applicationThreads.clear();
@@ -1065,6 +1071,7 @@ export class NetworkRuntimeEngine {
this.emailServersByNodeId.clear();
this.emailClientsByNodeId.clear();
this.terminalsByNodeId.clear();
this.gnutellaPeersByNodeId.clear();
this.arpLookups = [];
this.protocolThreads.clear();
this.dnsQueriesBySocketId.clear();
@@ -1161,6 +1168,19 @@ export class NetworkRuntimeEngine {
return this.terminalsByNodeId.get(nodeId);
}
/** The node's Gnutella program while the simulation runs, if it has one installed. */
gnutella(nodeId: Id): GnutellaPeer | undefined {
return this.gnutellaPeersByNodeId.get(nodeId);
}
/** A Gnutella packet's id (PeerToPeerPaket.guidErstellen: Random.nextInt until it is not negative). */
drawGnutellaGuid(): number {
for (;;) {
const value = this.gnutellaGuidRandom.next(32) | 0;
if (value >= 0) return value;
}
}
/** The node's e-mail program while the simulation runs, if it has one installed. */
emailClient(nodeId: Id): EmailClient | undefined {
return this.emailClientsByNodeId.get(nodeId);
@@ -1224,6 +1244,7 @@ export class NetworkRuntimeEngine {
this.emailServersByNodeId.clear();
this.emailClientsByNodeId.clear();
this.terminalsByNodeId.clear();
this.gnutellaPeersByNodeId.clear();
for (const node of snapshot.nodes) {
const echoServer = snapshot.echoServersByNodeId.get(node.id);
if (echoServer !== undefined) this.echoServersByNodeId.set(node.id, new EchoServer(this, node.id, echoServer));
@@ -1251,6 +1272,13 @@ export class NetworkRuntimeEngine {
this.writeEmailServerAccountsFile(nodeId, emailServer.domain, emailServer.accounts);
}
if (snapshot.terminalNodeIds.has(node.id)) this.terminalsByNodeId.set(node.id, new Terminal(this, node.id, this.nodeFiles(node.id)));
const gnutella = snapshot.gnutellaByNodeId.get(node.id);
if (gnutella !== undefined) {
// PeerToPeerAnwendung.setSystemSoftware: the folder of the files to share.
const files = this.nodeFiles(node.id);
if (!files.contains(GNUTELLA_FOLDER)) files.createDirectory(GNUTELLA_FOLDER);
this.gnutellaPeersByNodeId.set(node.id, new GnutellaPeer(this, node.id, gnutella));
}
const emailClient = snapshot.emailClientsByNodeId.get(node.id);
if (emailClient !== undefined) {
const nodeId = node.id;
+5
View File
@@ -23,6 +23,7 @@ import type {
WebServerConfiguration,
EmailServerAccount,
EmailClientConfiguration,
GnutellaConfiguration,
} from './config.js';
import type { VirtualFileSystem } from '../model/virtualFileSystem.js';
import type { PageLanguage } from './apps/routerPages.js';
@@ -107,6 +108,8 @@ export interface TopologySnapshot {
readonly terminalNodeIds: ReadonlySet<Id>;
/** The installed e-mail programs, by node; a run's sent and fetched mails change their folders here. */
readonly emailClientsByNodeId: ReadonlyMap<Id, EmailClientConfiguration>;
/** The installed Gnutella programs (PeerToPeerAnwendung), by node: how many peers each keeps. */
readonly gnutellaByNodeId: ReadonlyMap<Id, GnutellaConfiguration>;
/** Each node's files, which its programs read (a web server its pages). */
readonly fileSystemsByNodeId: ReadonlyMap<Id, VirtualFileSystem>;
/** The language of the pages the devices write themselves (a router's), as Java's in the locale it runs in. */
@@ -133,6 +136,7 @@ export interface TopologySnapshotInput {
webBrowserNodeIds?: ReadonlySet<Id>;
emailServersByNodeId?: ReadonlyMap<Id, EmailServerSnapshot>;
emailClientsByNodeId?: ReadonlyMap<Id, EmailClientConfiguration>;
gnutellaByNodeId?: ReadonlyMap<Id, GnutellaConfiguration>;
terminalNodeIds?: ReadonlySet<Id>;
fileSystemsByNodeId?: ReadonlyMap<Id, VirtualFileSystem>;
language?: PageLanguage;
@@ -159,6 +163,7 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
webBrowserNodeIds: input.webBrowserNodeIds ?? new Set(),
emailServersByNodeId: input.emailServersByNodeId ?? new Map(),
emailClientsByNodeId: input.emailClientsByNodeId ?? new Map(),
gnutellaByNodeId: input.gnutellaByNodeId ?? new Map(),
terminalNodeIds: input.terminalNodeIds ?? new Set(),
fileSystemsByNodeId: input.fileSystemsByNodeId ?? new Map(),
language: input.language ?? 'de',