feat(core): programs' threads, and FILIUS' echo server and generic client after Java, measured against Java

Programs run as generators that yield what they wait for (threads.ts); the
engine resumes them after every event. TCP connect, send, receive, accept
and name resolution exist as such steps; the blocking calls wrap them.
EchoRun records eight stories in Java FILIUS, frames, tables and each
program's window lines; echoJava.test.ts plays them card by card. Not
taken from Java: a connection without a route hangs for ever; a server
switched off leaves its connections' last FIN unanswered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 16:27:44 +00:00
co-authored by Claude Opus 5.5
parent 0abce60aef
commit de3a1d4dfe
24 changed files with 13454 additions and 28 deletions
+3
View File
@@ -17,6 +17,9 @@ export * from './runtime/speed.js';
export * from './runtime/trace.js';
export * from './runtime/capture.js';
export * from './runtime/captureRows.js';
export * from './runtime/threads.js';
export * from './runtime/apps/program.js';
export * from './runtime/apps/echo.js';
export * from './util/equal.js';
export * from './util/javaIp.js';
export * from './util/swiftString.js';
+268
View File
@@ -0,0 +1,268 @@
// FILIUS' echo server and generic client (filius.software.clientserver:
// ServerAnwendung, ServerMitarbeiter, ServerBaustein, ServerBausteinMitarbeiter;
// ClientAnwendung, ClientBaustein). The server takes connections on its port
// while it is switched on and sends each message back; the client connects
// to a server, sends what it is given and shows what comes back.
//
// Better than Java: the server notices at once that it was switched off, and
// the client at once that it is connected (Java looks every 100 ms); a
// connection to an address without a route fails (Java's client waits for
// ever, without a word).
import type { Id } from '../../ids.js';
import type { NetworkRuntimeEngine, TcpSendResult } from '../engine.js';
import { pause, waitUntil, type Steps } from '../threads.js';
import { CommandThread, ProgramLog } from './program.js';
/** Why a send failed (TCPSocket.senden's exceptions). */
export type SendFailure = Exclude<TcpSendResult, 'sent'>;
export type EchoServerLogEntry =
/** sw_serveranwendung_msg2: switched on. */
| { readonly kind: 'acceptingStarted' }
/** sw_serveranwendung_msg1: switched off. */
| { readonly kind: 'acceptingStopped' }
/** sw_serveranwendung_msg3: the listening socket closed after the server was switched off. */
| { readonly kind: 'serverSocketClosed' }
/** sw_serveranwendung_msg6: the port is taken; the server switches itself off. */
| { readonly kind: 'serverSocketFailed' }
/** sw_serveranwendung_msg4, msg5: connected to the client at its address and port. */
| { readonly kind: 'connectionAccepted'; readonly address: string; readonly port: number }
/** ">>" and the message. */
| { readonly kind: 'received'; readonly text: string }
/** "<<" and the message, once it was acknowledged. */
| { readonly kind: 'sent'; readonly text: string }
| { readonly kind: 'sendFailed'; readonly failure: SendFailure }
/** sw_servermitarbeiter_msg1, msg2: the connection to the client at its address and port closed. */
| { readonly kind: 'connectionClosed'; readonly address: string; readonly port: number };
/** Why a connection could not be made (shown after sw_clientbaustein_msg1). */
export type ConnectFailure =
/** sw_socket_msg1: the name has no address. */
| 'unresolved'
/** sw_tcpsocket_msg2: the server did not answer. */
| 'timedOut'
/** sw_tcpsocket_msg3: no route, or no free port. */
| 'failed';
export type SimpleClientLogEntry =
/** sw_clientbaustein_msg2. */
| { readonly kind: 'connected' }
/** sw_clientbaustein_msg1 and the reason. */
| { readonly kind: 'connectFailed'; readonly failure: ConnectFailure }
/** sw_clientbaustein_msg3: the user disconnected. */
| { readonly kind: 'disconnected' }
/** sw_clientbaustein_msg4: a send without a connection. */
| { readonly kind: 'notConnected' }
| { readonly kind: 'sent'; readonly text: string }
| { readonly kind: 'received'; readonly text: string }
| { readonly kind: 'sendFailed'; readonly failure: SendFailure }
/** sw_clientbaustein_msg5, msg6: the connection to the server at its address and port ended. */
| { readonly kind: 'connectionClosed'; readonly address: string; readonly port: number }
/** sw_tcpsocket_msg9: the receive found no connection. */
| { readonly kind: 'receiveFailed' };
const encode = (text: string) => new TextEncoder().encode(text);
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
export class EchoServer {
readonly log: ProgramLog<EchoServerLogEntry>;
private active: boolean;
private portValue: number;
private listenerId: Id | undefined;
private readonly connections = new Set<Id>();
constructor(
private readonly engine: NetworkRuntimeEngine,
private readonly nodeId: Id,
configuration: { readonly port: number; readonly isActive: boolean },
) {
this.log = new ProgramLog(engine);
this.portValue = configuration.port;
this.active = configuration.isActive;
engine.startThread(this.acceptConnections());
}
get isActive(): boolean {
return this.active;
}
get port(): number {
return this.portValue;
}
/** Takes effect when the server next opens its socket, as in Java. */
setPort(port: number): void {
this.portValue = port;
}
/** ServerAnwendung.setAktiv: switched off, it closes its socket and every connection. */
setActive(active: boolean): void {
this.active = active;
if (!active) {
if (this.listenerId !== undefined) this.engine.closeTcpSocket(this.listenerId);
for (const socketId of this.connections) this.engine.closeTcpSocket(socketId);
this.connections.clear();
this.log.write({ kind: 'acceptingStopped' });
} else {
this.log.write({ kind: 'acceptingStarted' });
}
this.engine.resumeThreads();
}
/** ServerAnwendung.annehmenVerbindungen. */
private *acceptConnections(): Steps<never> {
for (;;) {
if (this.active) {
if (this.listenerId === undefined) {
this.listenerId = this.engine.openTcpServerSocket(this.nodeId, this.portValue);
if (this.listenerId === undefined) {
this.log.write({ kind: 'serverSocketFailed' });
this.setActive(false);
continue;
}
}
const accepted = yield* this.engine.acceptTcpSteps(this.listenerId);
const socket = accepted === undefined ? undefined : this.engine.state.socketsById.get(accepted);
if (accepted !== undefined && socket?.remoteIPAddress !== undefined && socket.remotePort !== undefined) {
this.connections.add(accepted);
this.engine.startThread(this.serve(accepted, socket.remoteIPAddress, socket.remotePort));
this.log.write({ kind: 'connectionAccepted', address: socket.remoteIPAddress, port: socket.remotePort });
}
} else {
if (this.listenerId !== undefined) {
// After the connections' threads have seen theirs close (Java's loop looks later than they do).
yield pause(this.engine.state.currentTimeMilliseconds);
this.engine.closeTcpSocket(this.listenerId);
this.log.write({ kind: 'serverSocketClosed' });
}
this.listenerId = undefined;
yield* waitUntil(() => this.active);
}
}
}
/** ServerMitarbeiter.run with ServerBausteinMitarbeiter: each message back to where it came from. */
private *serve(socketId: Id, address: string, port: number): Steps {
for (;;) {
const received = yield* this.engine.receiveTcpSteps(socketId);
if (received.type !== 'message') break;
const text = decode(received.payload);
this.log.write({ kind: 'received', text });
const result = yield* this.engine.sendTcpSteps(socketId, encode(text));
this.log.write(result === 'sent' ? { kind: 'sent', text } : { kind: 'sendFailed', failure: result });
}
this.engine.closeTcpSocket(socketId);
this.connections.delete(socketId);
this.log.write({ kind: 'connectionClosed', address, port });
}
}
export class SimpleClient {
readonly log: ProgramLog<SimpleClientLogEntry>;
private targetAddressValue: string;
private targetPortValue: number;
private socketId: Id | undefined;
/** The program's thread (Anwendung): connects, then receives until the connection ends. */
private readonly commands: CommandThread;
/** The window's: its sends, one after the other (Java's senden blocks the window). */
private readonly sends: CommandThread;
constructor(
private readonly engine: NetworkRuntimeEngine,
private readonly nodeId: Id,
configuration: { readonly targetAddress: string; readonly targetPort: number },
) {
this.log = new ProgramLog(engine);
this.targetAddressValue = configuration.targetAddress;
this.targetPortValue = configuration.targetPort;
this.commands = new CommandThread(engine);
this.sends = new CommandThread(engine);
}
get targetAddress(): string {
return this.targetAddressValue;
}
get targetPort(): number {
return this.targetPortValue;
}
get isConnected(): boolean {
return this.socketId !== undefined && this.engine.state.socketsById.get(this.socketId)?.tcpState === 'ESTABLISHED';
}
/** ClientBaustein.verbinden: to the server at the address (or name) and port. */
connect(address: string, port: number): void {
this.targetAddressValue = address;
this.targetPortValue = port;
this.commands.hand(() => this.openConnection(address, port));
this.commands.hand(() => this.receiveMessages());
this.engine.resumeThreads();
}
/** ClientBaustein.trennen. */
disconnect(): void {
if (this.socketId !== undefined) {
this.engine.closeTcpSocket(this.socketId);
this.log.write({ kind: 'disconnected' });
}
this.engine.resumeThreads();
}
/** ClientBaustein.senden. */
send(text: string): void {
this.sends.hand(() => this.sendMessage(text));
this.engine.resumeThreads();
}
/**
* ClientBaustein.initialisiereSocket. Java does nothing while connected;
* it never is here: the command runs after the last connection's receive
* loop, which ends only with the connection.
*/
private *openConnection(address: string, port: number): Steps {
const ipAddress = yield* this.engine.resolveHostSteps(this.nodeId, address);
if (ipAddress === undefined) {
this.socketId = undefined;
this.log.write({ kind: 'connectFailed', failure: 'unresolved' });
return;
}
const socketId = this.engine.openTcpClientSocket(this.nodeId, ipAddress, port);
const result = socketId === undefined ? 'invalidSocket' : yield* this.engine.connectTcpSteps(socketId);
if (result === 'connected') {
this.socketId = socketId;
this.log.write({ kind: 'connected' });
return;
}
this.socketId = undefined;
this.log.write({ kind: 'connectFailed', failure: result === 'timedOut' ? 'timedOut' : 'failed' });
}
/** ClientBaustein.empfangeNachricht: until the connection ends. */
private *receiveMessages(): Steps {
while (this.isConnected) {
const socketId = this.socketId!;
const received = yield* this.engine.receiveTcpSteps(socketId);
if (received.type === 'message') {
this.log.write({ kind: 'received', text: decode(received.payload) });
} else if (received.type === 'closed') {
this.engine.closeTcpSocket(socketId);
const socket = this.engine.state.socketsById.get(socketId);
this.log.write({ kind: 'connectionClosed', address: socket?.remoteIPAddress ?? '', port: socket?.remotePort ?? 0 });
} else {
this.log.write({ kind: 'receiveFailed' });
this.socketId = undefined;
}
}
}
private *sendMessage(text: string): Steps {
if (!this.isConnected) {
this.log.write({ kind: 'notConnected' });
return;
}
const result = yield* this.engine.sendTcpSteps(this.socketId!, encode(text));
this.log.write(result === 'sent' ? { kind: 'sent', text } : { kind: 'sendFailed', failure: result });
}
}
+43
View File
@@ -0,0 +1,43 @@
// What FILIUS' programs share (filius.software.Anwendung): the lines a
// program writes into its window, and the program's own thread that runs
// the commands handed to it one after the other (Anwendung.ausfuehren).
import type { NetworkRuntimeEngine } from '../engine.js';
import { waitUntil, type Steps } from '../threads.js';
/** A line of a program's window: what happened, structured, so that the editor says it in the viewer's language. */
export type LogLine<Entry> = Entry & { readonly timeMilliseconds: number };
export class ProgramLog<Entry> {
private readonly lines: LogLine<Entry>[] = [];
constructor(private readonly engine: NetworkRuntimeEngine) {}
write(entry: Entry): void {
this.lines.push({ ...entry, timeMilliseconds: this.engine.state.currentTimeMilliseconds });
}
get all(): readonly LogLine<Entry>[] {
return this.lines;
}
}
/** A thread that runs the commands handed to it, each to its end, in the order they came. */
export class CommandThread {
private readonly commands: (() => Steps<unknown>)[] = [];
constructor(engine: NetworkRuntimeEngine) {
engine.startThread(this.run());
}
hand(command: () => Steps<unknown>): void {
this.commands.push(command);
}
private *run(): Steps<never> {
for (;;) {
yield* waitUntil(() => this.commands.length > 0);
yield* this.commands.shift()!();
}
}
}
+208 -19
View File
@@ -111,6 +111,8 @@ import { JavaCompatibleRandom } from './random.js';
import { routeTableRows, type RouteRow } from './routeTable.js';
import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js';
import { RuntimeSpeed } from './speed.js';
import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js';
import { EchoServer, SimpleClient } from './apps/echo.js';
import {
PRODUCTION_TRACE_RETENTION,
type HeaderField,
@@ -152,6 +154,8 @@ export type ScheduledEventKind =
/** The lookups waiting for an address wake up, after what else happens at that time. */
| { readonly kind: 'arpAnswered'; readonly nodeId: Id; readonly ipAddress: string; readonly macAddress: string }
| { readonly kind: 'tcpTimeout'; readonly sessionId: Id }
/** A program's thread waited until this time (threads.ts). */
| { readonly kind: 'applicationWake'; readonly wakeId: number }
/** A DNS query waited five round trips for its answer (DNSQueryAgent). */
| { readonly kind: 'dnsQueryTimeout'; readonly socketId: Id }
| {
@@ -333,10 +337,17 @@ export interface TcpSession {
closingWaitsLeft: number | undefined;
receiveAssembly: Uint8Array;
receivedMessages: Uint8Array[];
/** Set when data went unacknowledged after the last attempt and the socket closed for it. */
dataTimedOut?: boolean;
}
export type TcpConnectionResult = 'connected' | 'invalidSocket' | 'unreachable' | 'timedOut';
export type TcpSendResult = 'sent' | 'notConnected' | 'connectionLost' | 'timedOut';
/** What a program's receive gets (receiveTcpSteps). */
export type TcpReceived = { readonly type: 'message'; readonly payload: Uint8Array } | { readonly type: 'closed' } | { readonly type: 'notConnected' };
/** Java's DNSQueryAgent waits five round trips for an answer. */
export const DNS_QUERY_TIMEOUT_ROUND_TRIPS = 5;
export type DnsResolution = { readonly type: 'answered'; readonly records: readonly DnsMessageRecord[] } | { readonly type: 'timedOut' };
@@ -850,6 +861,31 @@ export class NetworkRuntimeEngine {
private readonly swiftArp: boolean;
private readonly swiftSends: boolean;
private readonly packetCapture: PacketCapture;
/** The programs' threads (threads.ts), and the wake-ups they wait for, by id. */
private readonly applicationThreads = new ApplicationThreads({
nowMilliseconds: () => this.stateValue.currentTimeMilliseconds,
scheduleWake: (deadlineMilliseconds, wake) => {
const wakeId = this.nextApplicationWakeId++;
this.applicationWakes.set(wakeId, wake);
this.schedule(deadlineMilliseconds, { kind: 'applicationWake', wakeId });
},
asEvent: (step) => {
const was = this.isProcessingScheduledEvents;
this.isProcessingScheduledEvents = true;
try {
step();
} finally {
this.isProcessingScheduledEvents = was;
}
},
});
private readonly applicationWakes = new Map<number, () => void>();
/** The programs, by node (initializePrograms). */
private readonly echoServersByNodeId = new Map<Id, EchoServer>();
private readonly simpleClientsByNodeId = new Map<Id, SimpleClient>();
/** Accepted connections a program has taken (acceptTcpSteps). */
private readonly handedOutTcpSocketIds = new Set<Id>();
private nextApplicationWakeId = 1;
/**
* `swiftCables`: a cable carries all frames at once, as in the Swift
@@ -938,11 +974,15 @@ export class NetworkRuntimeEngine {
this.linkLocalRandom = new JavaCompatibleRandom(seed ^ 0x4c4c_4144n);
this.dnsIdRandom = new JavaCompatibleRandom(seed ^ 0x444e_5349n);
this.isProcessingScheduledEvents = false;
this.applicationThreads.clear();
this.applicationWakes.clear();
this.handedOutTcpSocketIds.clear();
this.cableBusyUntilByPort.clear();
this.initializeRipState(seed);
this.initializeDhcpState();
this.initializeDnsState();
this.initializeNatState();
this.initializePrograms();
return [this.output({ type: 'started' })];
}
@@ -952,6 +992,11 @@ export class NetworkRuntimeEngine {
state.phase = 'stopped';
state.globalPacketLossEnabled = false;
this.isProcessingScheduledEvents = false;
this.applicationThreads.clear();
this.applicationWakes.clear();
this.handedOutTcpSocketIds.clear();
this.echoServersByNodeId.clear();
this.simpleClientsByNodeId.clear();
this.arpLookups = [];
this.protocolThreads.clear();
this.dnsQueriesBySocketId.clear();
@@ -996,6 +1041,61 @@ export class NetworkRuntimeEngine {
}
}
/** The node's echo server while the simulation runs, if it has one installed. */
echoServer(nodeId: Id): EchoServer | undefined {
return this.echoServersByNodeId.get(nodeId);
}
/** The node's generic client while the simulation runs, if it has one installed. */
simpleClient(nodeId: Id): SimpleClient | undefined {
return this.simpleClientsByNodeId.get(nodeId);
}
/**
* Each node's programs start with the simulation (Betriebssystem.starten),
* from the snapshot; what they show starts empty.
*/
private initializePrograms(): void {
const snapshot = this.stateValue.topologySnapshot;
this.echoServersByNodeId.clear();
this.simpleClientsByNodeId.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));
const simpleClient = snapshot.simpleClientsByNodeId.get(node.id);
if (simpleClient !== undefined) this.simpleClientsByNodeId.set(node.id, new SimpleClient(this, node.id, simpleClient));
}
}
/** Begins a program's thread (threads.ts): it runs at once up to its first wait. */
startThread(steps: Steps<unknown>): void {
if (this.stateValue.phase !== 'running') return;
this.applicationThreads.start(steps);
}
/** Resumes the threads whose wait is over: after a call from outside that changed what they wait for. */
resumeThreads(): void {
this.applicationThreads.resume();
}
/** Runs steps to their end, waiting on the virtual clock as a caller of Java blocks. */
private runBlocking<T>(steps: Steps<T>): T {
for (let next = steps.next(); ; next = steps.next()) {
if (next.done === true) return next.value;
const wait: Wait = next.value;
const until = wait.until ?? (() => false);
if (wait.deadlineMilliseconds !== undefined) {
this.advanceUntil(until, wait.deadlineMilliseconds);
continue;
}
while (this.stateValue.phase === 'running' && !until()) {
const deadline = this.earliestDeadline(() => true);
if (deadline === undefined) throw new Error('blocking call waits for nothing that can happen');
this.advance(deadline);
}
}
}
allocateFrameIdentity(): number {
return this.stateValue.nextFrameIdentity++;
}
@@ -1071,6 +1171,7 @@ export class NetworkRuntimeEngine {
this.stateValue.currentTimeMilliseconds = event.deadlineMilliseconds;
this.ageAllSwitchSatEntries();
this.processScheduledRuntimeEvent(event.kind);
this.applicationThreads.resume();
outputs.push(this.output({ type: 'fired', event }));
}
@@ -1160,6 +1261,12 @@ export class NetworkRuntimeEngine {
case 'tcpTimeout':
this.handleTcpTimeout(kind.sessionId);
return;
case 'applicationWake': {
const wake = this.applicationWakes.get(kind.wakeId);
this.applicationWakes.delete(kind.wakeId);
wake?.();
return;
}
case 'remoteLinkDelivery':
case 'lanRemoteLinkTransmission':
throw new NotPortedError('remote links');
@@ -1667,6 +1774,32 @@ export class NetworkRuntimeEngine {
return { type: 'resolved', address: findDnsRecords(domainName, 'A', resolution.records)[0]?.data };
}
/** Resolver.holeIPAdresse in a program's thread. */
*resolveDnsAddressSteps(nodeId: Id, name: string): Steps<DnsAddressResolution> {
const domainName = name.endsWith('.') ? name : `${name}.`;
let lookup: DnsLookup | undefined;
const server = this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer;
this.beginDnsResolution(nodeId, domainName, 'A', server, (result) => {
lookup = result;
});
yield* waitUntil(() => lookup !== undefined || this.stateValue.phase !== 'running');
const result = lookup as DnsLookup | undefined;
if (result === undefined || result.type === 'timedOut') return { type: 'timedOut' };
return { type: 'resolved', address: findDnsRecords(domainName, 'A', [...result.answers, ...result.authority])[0]?.data };
}
/**
* The address a socket of Java's goes to (Socket's constructor): the
* address itself when it is one (IP.ipCheck), else the name's A record;
* undefined when there is none or the server did not answer.
*/
*resolveHostSteps(nodeId: Id, address: string): Steps<string | undefined> {
const ipAddress = javaIpCheck(address);
if (ipAddress !== null) return ipAddress;
const resolution = yield* this.resolveDnsAddressSteps(nodeId, address);
return resolution.type === 'resolved' ? resolution.address : undefined;
}
/** Resolver.holeIPAdresseMailServer: the address of the domain's mail exchange. */
resolveDnsMailServer(nodeId: Id, domain: string): DnsAddressResolution {
const domainName = domain.endsWith('.') ? domain : `${domain}.`;
@@ -1818,6 +1951,11 @@ export class NetworkRuntimeEngine {
/** The three-way handshake from a closed client socket, waiting on the virtual clock as Java blocks. */
connectTcpWithResult(socketId: Id): TcpConnectionResult {
return this.runBlocking(this.connectTcpSteps(socketId));
}
/** The three-way handshake from a closed client socket, in a program's thread (TCPSocket.verbinden). */
*connectTcpSteps(socketId: Id): Steps<TcpConnectionResult> {
const state = this.stateValue;
const socket = state.socketsById.get(socketId);
if (
@@ -1839,10 +1977,13 @@ export class NetworkRuntimeEngine {
) {
return 'invalidSocket';
}
// No route to the server: Java's socket thread dies of the
// RouteNotFoundException and verbinden waits for ever; the core fails at once.
if (this.routeForPacket(socket.nodeId, socket.remoteIPAddress).type === 'unavailable') return 'unreachable';
this.updateTcpState(socketId, 'SYN_SENT');
this.resetTcpSendAttempts(socketId);
const initialDelivery = this.transmitTcpSegment(socketId, TCP_FLAG.synchronize, new Uint8Array(), 0, 'TCP SYN');
const waitedForTimeout = this.advanceTcpUntilSettled(socketId, { waitingStates: ['SYN_SENT'] });
const waitedForTimeout = yield* this.tcpSettledSteps(socketId, { waitingStates: ['SYN_SENT'] });
if (state.socketsById.get(socketId)?.tcpState === 'ESTABLISHED') return 'connected';
if (initialDelivery === undefined || initialDelivery.type !== 'delivered') return 'unreachable';
return waitedForTimeout ? 'timedOut' : 'unreachable';
@@ -1850,8 +1991,19 @@ export class NetworkRuntimeEngine {
/** Sends in segments of at most TCP_MAXIMUM_SEGMENT_SIZE bytes, each acknowledged before the next. */
sendTcp(socketId: Id, payload: Uint8Array): boolean {
return this.runBlocking(this.sendTcpSteps(socketId, payload)) === 'sent';
}
/**
* sendTcp in a program's thread (TCPSocket.senden): `sent` when every
* segment was acknowledged; `notConnected` when the socket was not
* connected at the call, `connectionLost` when it stopped being so
* meanwhile, `timedOut` when a segment's last attempt went unanswered
* (Java's messages sw_tcpsocket_msg6, msg7, msg8).
*/
*sendTcpSteps(socketId: Id, payload: Uint8Array): Steps<TcpSendResult> {
const state = this.stateValue;
if (state.socketsById.get(socketId)?.tcpState !== 'ESTABLISHED') return false;
if (state.socketsById.get(socketId)?.tcpState !== 'ESTABLISHED') return 'notConnected';
const chunks: Uint8Array[] = [];
if (payload.length === 0) {
chunks.push(new Uint8Array());
@@ -1861,15 +2013,17 @@ export class NetworkRuntimeEngine {
}
}
for (const [index, chunk] of chunks.entries()) {
if (state.socketsById.get(socketId)?.tcpState !== 'ESTABLISHED') return false;
if (state.socketsById.get(socketId)?.tcpState !== 'ESTABLISHED') return 'connectionLost';
this.resetTcpSendAttempts(socketId);
const flags = index === chunks.length - 1 ? TCP_FLAG.push : 0;
this.transmitTcpSegment(socketId, flags, chunk, 0, `TCP data segment ${index + 1}/${chunks.length}`);
this.advanceTcpUntilSettled(socketId, { waitingForAcknowledgement: true });
yield* this.tcpSettledSteps(socketId, { waitingForAcknowledgement: true });
const session = state.tcpSessionsById.get(socketId);
if (session === undefined || session.state !== 'ESTABLISHED' || session.lastSentSegment !== undefined) return false;
if (session === undefined || session.state !== 'ESTABLISHED' || session.lastSentSegment !== undefined) {
return session?.dataTimedOut === true ? 'timedOut' : 'connectionLost';
}
}
return true;
return 'sent';
}
/** The oldest whole message (up to a PSH) received on the socket. */
@@ -1879,6 +2033,41 @@ export class NetworkRuntimeEngine {
return session.receivedMessages.shift();
}
/**
* TCPSocket.empfangen in a program's thread: the next whole message, as
* soon as there is one; `closed` once the connection is no longer
* established (Java: null), `notConnected` when it was not at the call
* and nothing is left (Java: VerbindungsException). Better than Java: a
* message that came before the connection closed is still handed out
* (Java drops what its thread had not taken yet).
*/
*receiveTcpSteps(socketId: Id): Steps<TcpReceived> {
const messages = () => this.stateValue.tcpSessionsById.get(socketId)?.receivedMessages ?? [];
const established = () => this.stateValue.socketsById.get(socketId)?.tcpState === 'ESTABLISHED';
if (messages().length === 0 && !established()) return { type: 'notConnected' };
yield* waitUntil(() => messages().length > 0 || !established() || this.stateValue.phase !== 'running');
const payload = messages().shift();
return payload === undefined ? { type: 'closed' } : { type: 'message', payload };
}
/**
* ServerSocket.oeffnen in a program's thread: the listener's next
* connection, once established; undefined when the listener closes.
* Java looks every 100 ms whether its socket is connected; the core at once.
*/
*acceptTcpSteps(listenerId: Id): Steps<Id | undefined> {
const state = this.stateValue;
const next = () =>
(state.tcpAcceptedSocketIdsByListenerId.get(listenerId) ?? []).find(
(id) => !this.handedOutTcpSocketIds.has(id) && state.socketsById.get(id)?.tcpState === 'ESTABLISHED',
);
const listening = () => state.socketsById.get(listenerId)?.tcpState === 'LISTEN';
yield* waitUntil(() => next() !== undefined || !listening() || state.phase !== 'running');
const accepted = next();
if (accepted !== undefined) this.handedOutTcpSocketIds.add(accepted);
return accepted;
}
closeTcpSocket(socketId: Id): void {
const socket = this.stateValue.socketsById.get(socketId);
if (socket === undefined || socket.protocol !== 'tcp' || socket.tcpState === undefined) return;
@@ -2302,6 +2491,7 @@ export class NetworkRuntimeEngine {
// ends senden with schliessen, an orderly close with FIN. A connection
// attempt that fails closes at once.
if (socket.tcpState === 'ESTABLISHED') {
session.dataTimedOut = true;
this.closeTcpSocket(socketId);
} else {
this.updateTcpState(socketId, 'CLOSED');
@@ -2345,10 +2535,15 @@ export class NetworkRuntimeEngine {
* Advances the clock to the socket's timeouts while it waits in one of
* the states or for an acknowledgement; whether it advanced at all.
*/
private advanceTcpUntilSettled(
/**
* Waits while the socket is in one of `waitingStates`, or while its data
* wait for their acknowledgement, as long as an attempt's time runs; true
* when it waited for one.
*/
private *tcpSettledSteps(
socketId: Id,
options: { waitingStates?: readonly TcpSocketState[]; waitingForAcknowledgement?: boolean },
): boolean {
): Steps<boolean> {
const waiting = () => {
const socket = this.stateValue.socketsById.get(socketId);
if (socket === undefined || socket.tcpState === undefined) return false;
@@ -2361,17 +2556,11 @@ export class NetworkRuntimeEngine {
this.stateValue.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined;
return waitingOnState || waitingOnAck;
};
let advancedToTimeout = false;
while (this.stateValue.phase === 'running' && waiting()) {
const deadlines = this.stateValue.pendingEvents
.filter((event) => event.kind.kind === 'tcpTimeout' && event.kind.sessionId === socketId)
.map((event) => event.deadlineMilliseconds);
if (deadlines.length === 0) return advancedToTimeout;
advancedToTimeout = true;
// Until the answer comes, or the attempt's time is up.
this.advanceUntil(() => !waiting(), Math.min(...deadlines));
}
return advancedToTimeout;
const attemptRuns = () => this.stateValue.pendingEvents.some((event) => event.kind.kind === 'tcpTimeout' && event.kind.sessionId === socketId);
if (this.stateValue.phase !== 'running' || !waiting() || !attemptRuns()) return false;
// Until the answer comes, or the last attempt's time is up.
yield* waitUntil(() => this.stateValue.phase !== 'running' || !waiting() || !attemptRuns());
return true;
}
private updateTcpState(socketId: Id, newState: TcpSocketState): void {
+15
View File
@@ -12,14 +12,21 @@ import type {
DhcpClientConfiguration,
DhcpServerConfiguration,
DnsServerConfiguration,
EchoServerConfiguration,
FirewallConfiguration,
GatewayPortForwardingRow,
InterfaceConfiguration,
InterfaceKey,
ManualRoute,
SimpleClientConfiguration,
SwitchConfiguration,
} from './config.js';
/** An installed echo server: its port, and whether it is switched on (Java: ServerBaustein.aktiv). */
export interface EchoServerSnapshot extends EchoServerConfiguration {
readonly isActive: boolean;
}
export interface PortSnapshot {
readonly id: Id;
readonly label: string;
@@ -65,6 +72,10 @@ export interface TopologySnapshot {
readonly switchConfigurationsByNodeId: ReadonlyMap<Id, SwitchConfiguration>;
/** Nodes whose IP forwarding differs from their kind's (forwardsIPv4ByDefault): a host that forwards. */
readonly ipForwardingByNodeId: ReadonlyMap<Id, boolean>;
/** The installed echo servers (ServerBaustein), by node. */
readonly echoServersByNodeId: ReadonlyMap<Id, EchoServerSnapshot>;
/** The installed generic clients (ClientBaustein) with the server they last talked to, by node. */
readonly simpleClientsByNodeId: ReadonlyMap<Id, SimpleClientConfiguration>;
}
export interface TopologySnapshotInput {
@@ -81,6 +92,8 @@ export interface TopologySnapshotInput {
portForwardingRowsByNodeId?: ReadonlyMap<Id, readonly GatewayPortForwardingRow[]>;
switchConfigurationsByNodeId?: ReadonlyMap<Id, SwitchConfiguration>;
ipForwardingByNodeId?: ReadonlyMap<Id, boolean>;
echoServersByNodeId?: ReadonlyMap<Id, EchoServerSnapshot>;
simpleClientsByNodeId?: ReadonlyMap<Id, SimpleClientConfiguration>;
}
export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot {
@@ -98,6 +111,8 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
portForwardingRowsByNodeId: input.portForwardingRowsByNodeId ?? new Map(),
switchConfigurationsByNodeId: input.switchConfigurationsByNodeId ?? new Map(),
ipForwardingByNodeId: input.ipForwardingByNodeId ?? new Map(),
echoServersByNodeId: input.echoServersByNodeId ?? new Map(),
simpleClientsByNodeId: input.simpleClientsByNodeId ?? new Map(),
};
}
+103
View File
@@ -0,0 +1,103 @@
// A program's threads (Java: each Anwendung is a Thread, a server one more
// per connection). The core writes a thread as a generator that yields what
// it waits for, where Java's thread blocks: a condition, a time, or both.
// After every event the engine resumes each thread whose wait is over, in
// the order the threads began; a thread never blocks the engine.
//
// Java's threads often look only now and then (TCPSocket.verbinden every
// 100 ms); a thread here wakes at once, as soon as its condition holds.
/** What a thread waits for: until the condition holds, or until the time, whichever comes first. */
export interface Wait {
readonly until?: () => boolean;
readonly deadlineMilliseconds?: number;
}
export type Steps<T = void> = Generator<Wait, T, void>;
/** A pause: the thread goes on at the time, after what else happens then. */
export function pause(untilMilliseconds: number): Wait {
return { deadlineMilliseconds: untilMilliseconds };
}
interface Thread {
readonly steps: Steps<unknown>;
wait: Wait | undefined;
/** Counts the thread's waits; a wake-up belongs to the wait it was set for. */
waitNumber: number;
woken: boolean;
}
export interface ThreadHost {
readonly nowMilliseconds: () => number;
/** A wake-up at the time, as an event after those already due then. */
readonly scheduleWake: (deadlineMilliseconds: number, wake: () => void) => void;
/** Runs a step of a thread as within an event: sends queue, nothing blocks. */
readonly asEvent: (step: () => void) => void;
}
export class ApplicationThreads {
private threads: Thread[] = [];
private resuming = false;
constructor(private readonly host: ThreadHost) {}
/** Begins a thread: it runs at once up to its first wait. */
start(steps: Steps<unknown>): void {
const thread: Thread = { steps, wait: undefined, waitNumber: 0, woken: false };
this.threads.push(thread);
this.step(thread);
this.resume();
}
/** Resumes each thread whose wait is over, until none is. */
resume(): void {
if (this.resuming) return;
this.resuming = true;
try {
for (;;) {
const ready = this.threads.find((thread) => thread.woken || thread.wait?.until?.() === true);
if (ready === undefined) return;
this.step(ready);
}
} finally {
this.resuming = false;
}
}
/** Ends every thread (the simulation stops). */
clear(): void {
this.threads = [];
}
get count(): number {
return this.threads.length;
}
private step(thread: Thread): void {
this.host.asEvent(() => {
const next = thread.steps.next();
if (next.done === true) {
this.threads = this.threads.filter((each) => each !== thread);
return;
}
const wait = next.value;
thread.wait = wait;
thread.woken = false;
thread.waitNumber += 1;
const waitNumber = thread.waitNumber;
if (wait.deadlineMilliseconds !== undefined) {
this.host.scheduleWake(Math.max(wait.deadlineMilliseconds, this.host.nowMilliseconds()), () => {
if (thread.waitNumber === waitNumber && this.threads.includes(thread)) thread.woken = true;
});
}
});
}
}
/** Waits until the condition holds (not at all when it does), or until the time; whether it holds. */
export function* waitUntil(condition: () => boolean, deadlineMilliseconds?: number): Steps<boolean> {
if (condition()) return true;
yield deadlineMilliseconds === undefined ? { until: condition } : { until: condition, deadlineMilliseconds };
return condition();
}