feat: F4.6g4 the Gnutella window, measured against Java's

GnutellaWindowRun uses Java's Gnutella window on terminal-net beside the
line of GnutellaRun's story "search". The core cancels a search as Java
does (every connection of the program's own requests closed, passed-on
queries kept), lets the number of peers change and lists /peer2peer's
files; the room takes the window's actions and sends its view; the editor
shows Java's four tabs with Java's symbols. Not adopted from Java: a number
of peers of one digit only, a results table that keeps the last search's
rows, a files table that lags behind the folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 21:39:29 +00:00
co-authored by Claude Opus 5.5
parent 2b85e7432c
commit 5224e1a28b
24 changed files with 2211 additions and 17 deletions
+56 -3
View File
@@ -49,7 +49,10 @@ import {
NetworkRuntimeEngine,
captureRows,
firewallRuleText,
gnutellaPeerCountOfText,
gnutellaResultRow,
interfaceKey,
IP_ADDRESS_PATTERN,
portRuleSender,
stableGeneratedMacAddress,
type CapturedFrame,
@@ -76,6 +79,7 @@ import {
type DnsServerView,
type EchoServerView,
type FirewallView,
type GnutellaView,
type SimpleClientView,
type TraceFrame,
type WebServerView,
@@ -218,7 +222,8 @@ export class SimulationRoom {
* What the run left of each node that Java's project keeps: its files (when
* they changed since the last write), whether its servers run, a web
* server's virtual hosts, an e-mail server's domain and accounts, an
* e-mail program's account and folders, a firewall, a DHCP client's address.
* e-mail program's account and folders, a firewall, Gnutella's number of
* peers, a DHCP client's address.
*/
private runState(): Map<Id, NodeRunState> {
const snapshot = this.engine.state.topologySnapshot;
@@ -256,6 +261,8 @@ export class SimulationRoom {
}
const client = this.engine.simpleClient(id);
if (client !== undefined) settings.simpleClient = { targetAddress: client.targetAddress, targetPort: client.targetPort };
const gnutella = this.engine.gnutella(id);
if (gnutella !== undefined) settings.gnutella = { maximumKnownPeers: gnutella.maximumKnownPeers };
settings.emailClient = snapshot.emailClientsByNodeId.get(id);
settings.firewall = snapshot.firewallConfigurationsByNodeId.get(id);
if (snapshot.dhcpClientConfigurationsByNodeId.get(id)?.isEnabled === true) settings.device = snapshot.deviceConfigurations.get(id);
@@ -468,6 +475,8 @@ export class SimulationRoom {
return this.actInDnsServer(input);
case 'firewall':
return this.actInFirewall(input);
case 'gnutella':
return this.actInGnutella(input);
default:
return 'unknownAction';
}
@@ -503,6 +512,44 @@ export class SimulationRoom {
}
}
/** The Gnutella window: "Netz beitreten" with the address, "Netz verlassen", "Suchen" with the words, "Stopp!", "Liste leeren", "Herunterladen" of the row chosen, the number of peers saved. */
private actInGnutella(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const peer = this.engine.gnutella(input.node);
if (peer === undefined) return 'unknownApp';
const { args } = input;
switch (input.action) {
case 'join':
if (typeof args.address !== 'string' || !IP_ADDRESS_PATTERN.test(args.address)) return 'badArgs';
peer.join(args.address);
return undefined;
case 'leave':
peer.reset();
return undefined;
case 'search':
if (typeof args.criteria !== 'string') return 'badArgs';
peer.search(args.criteria);
return undefined;
case 'cancelSearch':
peer.cancelSearch();
return undefined;
case 'clearResults':
peer.clearResults();
return undefined;
case 'download':
if (!Number.isInteger(args.row) || (args.row as number) < 0 || (args.row as number) >= peer.results.length) return 'badArgs';
peer.download(args.row as number);
return undefined;
case 'setMaximumPeers': {
const count = Number.isInteger(args.count) ? gnutellaPeerCountOfText(String(args.count)) : undefined;
if (count === undefined) return 'badArgs';
peer.setMaximumKnownPeers(count);
return undefined;
}
default:
return 'unknownAction';
}
}
/** A computer's firewall window: its three boxes, "Hinzufügen" with the port, the protocol and the senders chosen, "Löschen" of the row chosen. */
private actInFirewall(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const configuration = this.engine.personalFirewall(input.node);
@@ -715,6 +762,12 @@ export class SimulationRoom {
const view: DnsServerView = { active: server.isActive, recursive: server.recursiveResolutionEnabled, rows: { A: rows('A'), MX: rows('MX'), NS: rows('NS') } };
return view;
}
case 'gnutella': {
const peer = this.engine.gnutella(node);
if (peer === undefined) return null;
const view: GnutellaView = { peers: peer.peers, results: peer.results.map(gnutellaResultRow), files: peer.fileRows(), maximumKnownPeers: peer.maximumKnownPeers };
return view;
}
case 'firewall': {
const configuration = this.engine.personalFirewall(node);
if (configuration === undefined) return null;
@@ -817,8 +870,8 @@ export class SimulationRoom {
client.viewSent.set(topic, key);
client.send(message({ kind: 'view', node: node!, app: app as AppKind, view }));
}
// The browser's lines are its view; the DNS server's window has none.
if (app === 'webBrowser' || app === 'dnsServer') continue;
// The browser's lines are its view; the DNS server's and Gnutella's windows have none.
if (app === 'webBrowser' || app === 'dnsServer' || app === 'gnutella') continue;
}
const sent = client.appSent.get(topic);
if (app === 'terminal') {
+113
View File
@@ -0,0 +1,113 @@
// The Gnutella window in the room, on three computers of one network: its
// view (the known peers, the results as the table shows them, the files of
// /peer2peer, the number of peers), its actions, a downloaded file in the
// document, and what a run leaves in the project. The program's steps
// against Java's window are packages/core/test/gnutellaWindow.test.ts.
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { addDevice, changeFiles, connect, emptyProject, fileText, readProject, setHostAddress, setSoftware, writeProject } from '@mdg-filius/doc';
import type { GnutellaView, HostMessage } from '@mdg-filius/protocol';
import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js';
function setUp() {
const doc = new Y.Doc();
writeProject(doc, emptyProject());
let counter = 0;
const newId = () => `port-${++counter}`;
addDevice(doc, { kind: 'networkSwitch', id: 'switch', name: 'Switch', x: 100, y: 100, newId }, 'me');
for (const [id, address] of [
['a', '192.168.0.20'],
['b', '192.168.0.30'],
['c', '192.168.0.31'],
] as const) {
addDevice(doc, { kind: 'pc', id, name: id, x: 0, y: 0, newId }, 'me');
connect(doc, { node: id }, { node: 'switch' }, `cable-${id}`, 'me');
setHostAddress(doc, id, 'ipAddress', address, 'me');
setSoftware(doc, id, { install: ['gnutella'], remove: [] }, 'de', 'me');
}
changeFiles(doc, 'c', 'me', (files) => files.writeTextFile('/peer2peer/notizen.txt', 'Notiz'));
let now = 0;
const room = new SimulationRoom(doc, { now: () => now, seed: () => 3 });
const views = new Map<string, GnutellaView | null>();
const errors: string[] = [];
const lines: unknown[] = [];
const client = room.connect((message: HostMessage) => {
if (message.kind === 'view' && message.app === 'gnutella') views.set(message.node, message.view as GnutellaView | null);
if (message.kind === 'app' && message.app === 'gnutella') lines.push(...message.lines);
if (message.kind === 'error') errors.push(message.code);
});
const send = (fields: object) => room.receive(client, { v: 1, ...fields });
send({ kind: 'sub', topics: ['app:a/gnutella', 'app:b/gnutella', 'app:switch/gnutella'] });
send({ kind: 'start' });
const run = (milliseconds: number) => {
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
now += TICK_MILLISECONDS;
room.tick();
}
};
run(TICK_MILLISECONDS);
const act = (node: string, action: string, args: object = {}) => send({ kind: 'input', node, app: 'gnutella', action, args, reqId: action });
return { doc, views, errors, lines, act, run, send };
}
describe('the Gnutella window in the room', () => {
it('shows the peers, the results and the files, and takes its actions', () => {
const { doc, views, errors, lines, act, run } = setUp();
const view = () => views.get('a')!;
expect(view()).toEqual({ peers: [], results: [], files: [], maximumKnownPeers: 3 });
act('b', 'join', { address: '192.168.0.31' });
run(3000);
act('a', 'join', { address: '192.168.0.30' });
run(3000);
// The ping goes on to .31, whose pong makes it a peer too.
expect(view().peers).toEqual(['192.168.0.30', '192.168.0.31']);
act('a', 'search', { criteria: 'notiz' });
run(3000);
expect(view().results).toEqual([['192.168.0.31', 'notizen.txt', ' 5 B']]);
act('a', 'download', { row: 0 });
run(3000);
expect(view().files).toEqual([['notizen.txt', 'txt']]);
// In the document's file at once.
expect(fileText(doc, 'a', '/peer2peer/notizen.txt')?.toString()).toBe('Notiz');
act('a', 'clearResults');
expect(view().results).toEqual([]);
// "Stopp!" at once: the hits on their way are lost.
act('a', 'search', { criteria: 'notiz' });
act('a', 'cancelSearch');
run(3000);
expect(view().results).toEqual([]);
act('a', 'setMaximumPeers', { count: 12 });
expect(view().maximumKnownPeers).toBe(12);
act('a', 'leave');
expect(view().peers).toEqual([]);
expect(errors).toEqual([]);
// The window has no lines: none are sent.
expect(lines).toEqual([]);
});
it('leaves in the project what the run made of the number of peers', () => {
const { doc, act, send } = setUp();
act('a', 'setMaximumPeers', { count: 7 });
send({ kind: 'stop' });
expect(readProject(doc).gnutellaConfigurations.get('a')).toEqual({ maximumKnownPeers: 7 });
expect(readProject(doc).gnutellaConfigurations.get('b')).toEqual({ maximumKnownPeers: 3 });
});
it('refuses what the window never hands over', () => {
const { views, act, errors } = setUp();
act('a', 'join', { address: '192.168.0.300' });
act('a', 'join', { address: 5 });
act('a', 'search', { criteria: 3 });
act('a', 'download', { row: 0 });
act('a', 'download', { row: -1 });
act('a', 'download', { row: 0.5 });
act('a', 'setMaximumPeers', { count: -1 });
act('a', 'setMaximumPeers', { count: '4' });
act('a', 'setMaximumPeers', { count: 2_147_483_648 });
act('a', 'restart');
act('switch', 'leave');
expect(errors).toEqual(['badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'unknownAction', 'unknownApp']);
expect(views.get('switch')).toBeNull();
});
});
+3 -2
View File
@@ -196,7 +196,8 @@ describe('a simulation room', () => {
expect(reason({ node: 'nobody', app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownNode' });
expect(reason({ node: node('Switch 1').id, app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownApp' });
expect(reason({ node: notebook, app: 'terminal', action: 'paint' })).toMatchObject({ code: 'unknownAction' });
expect(reason({ node: notebook, app: 'gnutella', action: 'search' })).toMatchObject({ code: 'unknownAction' });
// A program the computer does not have.
expect(reason({ node: notebook, app: 'gnutella', action: 'search' })).toMatchObject({ code: 'unknownApp' });
expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 7 } })).toMatchObject({ code: 'badArgs' });
expect(reason({ node: notebook, app: 'terminal', action: 'interrupt' })).toEqual({ v: 1, kind: 'ack', reqId: 'r' });
send({ kind: 'nonsense', reqId: 'q' });
@@ -458,7 +459,7 @@ describe("a simulation room's browser and web server windows", () => {
[server, 'webServer', 'setVirtualHost', { row: 0, column: 1, value: 2 }, 'badArgs'],
[server, 'webServer', 'restart', {}, 'unknownAction'],
[notebook, 'webServer', 'setActive', { active: true }, 'unknownApp'],
[server, 'gnutella', 'join', {}, 'unknownAction'],
[server, 'gnutella', 'join', { address: '192.168.0.1' }, 'unknownApp'],
];
for (const [target, app, action, args, code] of cases) {
send({ kind: 'input', node: target, app, action, args, reqId: `${app}.${action}.${JSON.stringify(args)}` });