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();
}
+228
View File
@@ -0,0 +1,228 @@
// Programs' threads (threads.ts) and the echo server and generic client
// (apps/echo.ts) where echoJava.test.ts's stories do not go: how threads
// wake, a taken port, a name, no route, lost data, a new start.
import { describe, expect, it } from 'vitest';
import { deviceConfiguration, pause, waitUntil, type NetworkRuntimeEngine, type Steps, type TopologySnapshotInput } from '../src/index.js';
import { link, pc, startedEngine, uuid } from './fixtures.js';
const SERVER = uuid(0x2001);
const CLIENT = uuid(0x2002);
/** 10.0.0.1 (echo server, DNS server with echo.filius.de) and 10.0.0.2 (client), on one cable. */
function network(changes: Partial<TopologySnapshotInput> = {}): TopologySnapshotInput {
return {
nodes: [pc(SERVER, uuid(0x2011)), pc(CLIENT, uuid(0x2012))],
links: [link(SERVER, uuid(0x2011), CLIENT, uuid(0x2012))],
deviceConfigurations: new Map([
[SERVER, deviceConfiguration('10.0.0.1', '255.255.255.0', '', '')],
[CLIENT, deviceConfiguration('10.0.0.2', '255.255.255.0', '', '10.0.0.1')],
]),
echoServersByNodeId: new Map([[SERVER, { port: 55555, isActive: true }]]),
simpleClientsByNodeId: new Map([[CLIENT, { targetAddress: '', targetPort: 55555 }]]),
...changes,
};
}
const advance = (engine: NetworkRuntimeEngine, toMilliseconds: number) => engine.handle({ type: 'advance', toMilliseconds });
const kinds = (lines: readonly { kind: string }[]) => lines.map((line) => line.kind);
describe("programs' threads", () => {
it('wake when their condition holds, right after the event that made it so', () => {
const engine = startedEngine(network({ echoServersByNodeId: new Map(), simpleClientsByNodeId: new Map() }));
let flag = false;
const woken: number[] = [];
engine.startThread(
(function* (): Steps {
yield* waitUntil(() => flag);
woken.push(engine.state.currentTimeMilliseconds);
})(),
);
engine.handle({ type: 'schedule', deadlineMilliseconds: 300, kind: { kind: 'parityMarker', label: 'a' } });
advance(engine, 200);
flag = true;
expect(woken).toEqual([]);
advance(engine, 1_000);
expect(woken).toEqual([300]);
});
it('go on after a pause, after the threads that are ready at that time', () => {
const engine = startedEngine(network({ echoServersByNodeId: new Map(), simpleClientsByNodeId: new Map() }));
const order: string[] = [];
let ready = false;
engine.startThread(
(function* (): Steps {
yield pause(engine.state.currentTimeMilliseconds);
order.push(`paused ${engine.state.currentTimeMilliseconds}`);
})(),
);
engine.startThread(
(function* (): Steps {
yield* waitUntil(() => ready);
order.push(`waited ${engine.state.currentTimeMilliseconds}`);
})(),
);
ready = true;
engine.resumeThreads();
advance(engine, 0);
expect(order).toEqual(['waited 0', 'paused 0']);
});
it('wake from a wait by its time only once, not again in a later wait', () => {
const engine = startedEngine(network({ echoServersByNodeId: new Map(), simpleClientsByNodeId: new Map() }));
let flag = false;
const woken: string[] = [];
engine.startThread(
(function* (): Steps {
const first = yield* waitUntil(() => flag, 500);
woken.push(`first ${first} ${engine.state.currentTimeMilliseconds}`);
const second = yield* waitUntil(() => false, 2_000);
woken.push(`second ${second} ${engine.state.currentTimeMilliseconds}`);
})(),
);
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: { kind: 'parityMarker', label: 'a' } });
flag = true;
advance(engine, 3_000);
// The first wait ended at 100 by its condition; its wake-up at 500 does not end the second.
expect(woken).toEqual(['first true 100', 'second false 2000']);
});
it('resume in the order they began when the same event wakes them', () => {
const engine = startedEngine(network({ echoServersByNodeId: new Map(), simpleClientsByNodeId: new Map() }));
let flag = false;
const order: string[] = [];
for (const name of ['first', 'second', 'third']) {
engine.startThread(
(function* (): Steps {
yield* waitUntil(() => flag);
order.push(name);
})(),
);
}
flag = true;
engine.resumeThreads();
expect(order).toEqual(['first', 'second', 'third']);
});
it('do not wait for what already holds: no other thread runs in between', () => {
const engine = startedEngine(network({ echoServersByNodeId: new Map(), simpleClientsByNodeId: new Map() }));
let flag = false;
const order: string[] = [];
engine.startThread(
(function* (): Steps {
yield* waitUntil(() => flag);
order.push('woken');
})(),
);
engine.startThread(
(function* (): Steps {
flag = true;
yield* waitUntil(() => true);
order.push('went on');
})(),
);
expect(order).toEqual(['went on', 'woken']);
});
it('end with the simulation', () => {
const engine = startedEngine(network());
expect(engine.echoServer(SERVER)).toBeDefined();
engine.handle({ type: 'stop' });
expect(engine.echoServer(SERVER)).toBeUndefined();
expect(engine.simpleClient(CLIENT)).toBeUndefined();
});
});
describe('the echo server and the generic client', () => {
it('switches the server off when its port is taken (sw_serveranwendung_msg6)', () => {
const engine = startedEngine(network({ echoServersByNodeId: new Map([[SERVER, { port: 7, isActive: false }]]) }));
expect(engine.openTcpServerSocket(SERVER, 7)).toBeDefined();
const server = engine.echoServer(SERVER)!;
server.setActive(true);
advance(engine, 100);
expect(kinds(server.log.all)).toEqual(['acceptingStarted', 'serverSocketFailed', 'acceptingStopped']);
expect(server.isActive).toBe(false);
});
it('connects to a name through the DNS server', () => {
const engine = startedEngine(
network({
dnsServerConfigurationsByNodeId: new Map([[SERVER, { recursiveResolutionEnabled: false, records: [{ name: 'echo.filius.de', type: 'A', ttlSeconds: 3600, target: '10.0.0.1' }] }]]),
}),
);
const client = engine.simpleClient(CLIENT)!;
client.connect('echo.filius.de', 55555);
advance(engine, 3_000);
client.send('Hallo');
advance(engine, 6_000);
expect(kinds(client.log.all)).toEqual(['connected', 'sent', 'received']);
expect(engine.echoServer(SERVER)!.log.all[0]).toMatchObject({ kind: 'connectionAccepted', address: '10.0.0.2' });
});
it('fails to connect to a name no server knows, without a frame (sw_socket_msg1)', () => {
const engine = startedEngine(network({ deviceConfigurations: new Map([[CLIENT, deviceConfiguration('10.0.0.2', '255.255.255.0', '', '')]]) }));
const client = engine.simpleClient(CLIENT)!;
client.connect('echo.filius.de', 55555);
advance(engine, 5_000);
expect(client.log.all).toEqual([{ kind: 'connectFailed', failure: 'unresolved', timeMilliseconds: 0 }]);
expect(engine.capture.frames(uuid(0x2012))).toEqual([]);
});
it('fails at once to connect to an address without a route, without a frame (Java waits for ever)', () => {
const engine = startedEngine(network());
const client = engine.simpleClient(CLIENT)!;
advance(engine, 100);
client.connect('10.9.9.9', 55555);
expect(client.log.all).toEqual([{ kind: 'connectFailed', failure: 'failed', timeMilliseconds: 100 }]);
advance(engine, 5_000);
expect(engine.capture.frames(uuid(0x2012))).toEqual([]);
});
it('reports a send that no one acknowledges and the connection it ends (sw_tcpsocket_msg8)', () => {
const engine = startedEngine(network());
const client = engine.simpleClient(CLIENT)!;
client.connect('10.0.0.1', 55555);
advance(engine, 3_000);
engine.setGlobalPacketLossEnabled(true);
client.send('verloren');
advance(engine, 40_000);
expect(client.log.all.filter((line) => line.kind === 'sendFailed')).toMatchObject([{ kind: 'sendFailed', failure: 'timedOut' }]);
expect(kinds(client.log.all)).toContain('connectionClosed');
expect(client.isConnected).toBe(false);
});
it('starts anew with a new simulation: from the snapshot, with empty windows', () => {
const engine = startedEngine(network());
engine.echoServer(SERVER)!.setActive(false);
engine.simpleClient(CLIENT)!.send('Hallo');
engine.handle({ type: 'stop' });
engine.handle({ type: 'start', snapshot: engine.state.topologySnapshot, seed: 14, initialTimeMilliseconds: 0 });
expect(engine.echoServer(SERVER)!.isActive).toBe(true);
expect(engine.echoServer(SERVER)!.log.all).toEqual([]);
expect(engine.simpleClient(CLIENT)!.log.all).toEqual([]);
});
it('hands out a message that came before the connection closed (Java drops it)', () => {
const engine = startedEngine(network({ echoServersByNodeId: new Map(), simpleClientsByNodeId: new Map() }));
const listener = engine.openTcpServerSocket(SERVER, 80)!;
const client = engine.openTcpClientSocket(CLIENT, '10.0.0.1', 80)!;
expect(engine.connectTcpWithResult(client)).toBe('connected');
const accepted = engine.state.tcpAcceptedSocketIdsByListenerId.get(listener)![0]!;
advance(engine, engine.state.currentTimeMilliseconds + 100);
expect(engine.sendTcp(accepted, new TextEncoder().encode('zuletzt'))).toBe(true);
engine.closeTcpSocket(accepted);
advance(engine, engine.state.currentTimeMilliseconds + 1_000);
expect(engine.state.socketsById.get(client)!.tcpState).not.toBe('ESTABLISHED');
const received: string[] = [];
engine.startThread(
(function* (): Steps {
for (;;) {
const next = yield* engine.receiveTcpSteps(client);
received.push(next.type === 'message' ? new TextDecoder().decode(next.payload) : next.type);
if (next.type !== 'message') return;
}
})(),
);
expect(received).toEqual(['zuletzt', 'notConnected']);
});
});
+337
View File
@@ -0,0 +1,337 @@
// The echo server and the generic client as Java FILIUS runs them.
// tools/java-oracle's EchoRun played stories of the two programs on tcp-lan's
// network in Java FILIUS and wrote every frame each card saw, Java's tables
// of them, and every line each program wrote into its window
// (echo-java-<story>.java.json). The engine plays the same stories with its
// programs (apps/echo.ts): each card's frames must come in the same order,
// with the same content and rows, at about the same time; each program's
// lines must read as Java's (its German message bundle), in the same order,
// at about the same time.
//
// Java's client looks every 100 ms whether it is connected
// (TCPSocket.verbinden), its server likewise whether a connection came
// (ServerSocket.oeffnen) or it was switched off; the engine at once. Those
// lines come in Java up to POLL_MILLISECONDS later.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { withSnapshot, type EchoServerLogEntry, type LogLine, type NetworkRuntimeEngine, type SendFailure, type SimpleClientLogEntry } from '../src/index.js';
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables } from './javaRun.js';
import { snapshotOf, type Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
const SERVER = '00000000-0000-0000-0000-000000000101';
const CLIENT = '00000000-0000-0000-0000-000000000102';
const OTHER_CLIENT = '00000000-0000-0000-0000-000000000103';
const POLL_MILLISECONDS = 100;
type Program = 'server' | 'client' | 'other';
interface JavaRun extends JavaTables {
frames: Record<string, JavaFrame[]>;
/** Time, program, and a line of its window or one of the story's calls ("call …", "returned …"). */
events: [number, Program, string][];
}
/** sw_tcpsocket_msg6, msg7, msg8. */
const SEND_FAILURES: Record<SendFailure, string> = {
notConnected: 'Verbindung nicht aufgebaut!',
connectionLost: 'Verbindung wurde unterbrochen',
timedOut: 'Zeitüberschreitung während des Versands einer Nachricht',
};
/** A server's line as Java's window shows it (German message bundle). */
function serverText(entry: EchoServerLogEntry): string {
switch (entry.kind) {
case 'acceptingStarted':
return 'Annahme von Verbindungsanfragen gestartet';
case 'acceptingStopped':
return 'Annahme von Verbindungsanfragen beendet';
case 'serverSocketClosed':
return 'Server-Socket geschlossen';
case 'serverSocketFailed':
return 'Server-Socket konnte nicht geöffnet werden.';
case 'connectionAccepted':
return `Verbindung zu ${entry.address}:${entry.port} hergestellt`;
case 'received':
return `>>${entry.text}`;
case 'sent':
return `<<${entry.text}`;
case 'sendFailed':
return SEND_FAILURES[entry.failure];
case 'connectionClosed':
return `Socket zu ${entry.address}:${entry.port} geschlossen`;
}
}
const CONNECT_FAILURES = {
unresolved: 'Ziel konnte nicht bestimmt werden!',
timedOut: 'Zeitüberschreitung beim Verbindungsaufbau',
failed: 'Aktives Öffnen des Sockets fehlgeschlagen',
};
/** A client's line as Java's window shows it. */
function clientText(entry: SimpleClientLogEntry): string {
switch (entry.kind) {
case 'connected':
return 'Verbindung hergestellt';
case 'connectFailed':
return `Fehler beim Verbindungsaufbau\n${CONNECT_FAILURES[entry.failure]}`;
case 'disconnected':
return 'Verbindung getrennt';
case 'notConnected':
return 'Fehler beim Verbindungsaufbau';
case 'sent':
return `<<${entry.text}`;
case 'received':
return `>>${entry.text}`;
case 'sendFailed':
return SEND_FAILURES[entry.failure];
case 'connectionClosed':
return `Socket zu ${entry.address}:${entry.port} getrennt`;
case 'receiveFailed':
return 'Keine Verbindung hergestellt!';
}
}
/** Lines Java writes after one of its 100 ms looks. */
const POLLED = new Set(['Verbindung hergestellt', 'Server-Socket geschlossen']);
const isPolled = (text: string) => POLLED.has(text) || /^Verbindung zu .* hergestellt$/.test(text);
type Act = (engine: NetworkRuntimeEngine) => void;
const server = (engine: NetworkRuntimeEngine) => engine.echoServer(SERVER)!;
const client = (engine: NetworkRuntimeEngine) => engine.simpleClient(CLIENT)!;
const other = (engine: NetworkRuntimeEngine) => engine.simpleClient(OTHER_CLIENT)!;
/** Each story's calls at their times, as EchoRun makes them. */
const STORIES: Record<string, [number, Act][]> = {
exchange: [
[500, (engine) => client(engine).connect('192.168.0.10', 55555)],
[2000, (engine) => client(engine).send('Hallo')],
[4000, (engine) => client(engine).send('Welt')],
[6000, (engine) => client(engine).disconnect()],
],
closedPort: [[500, (engine) => client(engine).connect('192.168.0.30', 55555)]],
noRoute: [[500, (engine) => client(engine).connect('10.0.0.1', 55555)]],
notConnected: [[500, (engine) => client(engine).send('Hallo')]],
serverOff: [
[500, (engine) => client(engine).connect('192.168.0.10', 55555)],
[2000, (engine) => server(engine).setActive(false)],
[4000, (engine) => client(engine).send('Hallo')],
[6000, (engine) => server(engine).setActive(true)],
[7000, (engine) => client(engine).connect('192.168.0.10', 55555)],
[9000, (engine) => client(engine).send('Welt')],
],
reconnect: [
[500, (engine) => client(engine).connect('192.168.0.10', 55555)],
[2000, (engine) => client(engine).disconnect()],
[4000, (engine) => client(engine).connect('192.168.0.10', 55555)],
[6000, (engine) => client(engine).send('Hallo')],
],
twoClients: [
[500, (engine) => client(engine).connect('192.168.0.10', 55555)],
[1000, (engine) => other(engine).connect('192.168.0.10', 55555)],
[2000, (engine) => client(engine).send('eins')],
[3000, (engine) => other(engine).send('zwei')],
[4000, (engine) => client(engine).disconnect()],
[5000, (engine) => other(engine).disconnect()],
],
otherPort: [
[500, (engine) => server(engine).setActive(false)],
[
1500,
(engine) => {
server(engine).setPort(7);
server(engine).setActive(true);
},
],
[2000, (engine) => client(engine).connect('192.168.0.10', 7)],
[4000, (engine) => client(engine).send('Hallo')],
[6000, (engine) => other(engine).connect('192.168.0.10', 55555)],
],
};
/** The windows' lines, time and text, per program. */
type Lines = Record<Program, [number, string][]>;
function javaLines(java: JavaRun): Lines {
const lines: Lines = { server: [], client: [], other: [] };
for (const [time, program, text] of java.events) {
// Anwendung.starten tells its observers nothing (null), which the window skips.
if (text === 'null' || text.startsWith('call ') || text.startsWith('returned ')) continue;
lines[program].push([time, withClientPortText(text)]);
}
return lines;
}
function engineLines(engine: NetworkRuntimeEngine): Lines {
const lines = <E>(log: readonly LogLine<E>[], text: (entry: E) => string): [number, string][] =>
log.map((line) => [line.timeMilliseconds, withClientPortText(text(line))]);
return {
server: lines(server(engine).log.all, serverText),
client: lines(client(engine).log.all, clientText),
other: lines(other(engine).log.all, clientText),
};
}
/** Java's timeout of a TCP attempt (3 round trips at 90 %), and the round trip of a frame between two cards on the switch. */
const JAVA_TIMEOUT_MILLISECONDS = 3 * 2_750;
const CARD_TO_CARD_MILLISECONDS = 111;
/** NumberFormat for Locale.GERMANY. */
const javaNumber = (value: number) => String(value).replace(/\B(?=(\d{3})+(?!\d))/g, '.');
/** Java's rows of a TCP segment without data (Lauscher.datenVorbereiten), with addresses or ports only. */
function tcpRows(number: number, frame: JavaFrame, withAddresses: boolean): (string | null)[][] {
const end = (address: string, port: number) => (withAddresses ? `${address}:${port}` : String(port));
return [
[String(number), frame.source, frame.destination, '', 'Netzzugang', '0x800'],
[String(number), frame.sourceIp!, frame.destinationIp!, 'IP', 'Vermittlung', 'Protokoll: 6, TTL: 64'],
[
String(number),
end(frame.sourceIp!, frame.sourcePort!),
end(frame.destinationIp!, frame.destinationPort!),
'TCP',
'Transport',
`SEQ: ${javaNumber(frame.sequence!)}, ACK: ${javaNumber(frame.acknowledgement!)}`,
],
];
}
/** Java's run with a frame more on a card, in time order, its rows numbered in. */
function withJavaFrame(java: JavaRun, card: string, frame: JavaFrame): JavaRun {
const frames = java.frames[card]!;
const index = frames.filter((each) => each.time <= frame.time).length;
const renumbered = (rows: (string | null)[][], withAddresses: boolean) => [
...rows.map((row) => (Number(row[0]) > index ? [String(Number(row[0]) + 1), ...row.slice(1)] : row)),
...tcpRows(index + 1, frame, withAddresses),
];
return {
...java,
frames: { ...java.frames, [card]: [...frames.slice(0, index), frame, ...frames.slice(index)] },
rows: { ...java.rows, [card]: renumbered(java.rows![card]!, true) },
layeredRows: { ...java.layeredRows, [card]: renumbered(java.layeredRows![card]!, false) },
};
}
/** When the program last called verbinden: its SYN goes down then, and the attempt's time runs from there. */
const lastConnect = (java: JavaRun, program: Program) => java.events.filter(([, who, text]) => who === program && text.startsWith('call verbinden')).at(-1)![0];
/** Java's connection attempt with one SYN: its failure two attempts later, when the third has timed out. */
function failingAfterThreeAttempts(java: JavaRun, program: Program): JavaRun {
return {
...java,
events: java.events.map(([time, who, text]) =>
who === program && text.startsWith('Fehler beim Verbindungsaufbau\n') ? [time + 2 * JAVA_TIMEOUT_MILLISECONDS, who, text] : [time, who, text],
),
};
}
/**
* Where Java errs, per story: its run corrected, as Java means it.
*
* noRoute: the client's socket finds no route for its SYN; the exception
* ends the socket's thread, and verbinden waits for ever for a state that
* never comes — the client hangs without a word (and is no longer usable).
* The engine's connection fails at once (sw_tcpsocket_msg3).
*
* closedPort, otherPort: Java's client sends one SYN, not three
* (tcpJava.test.ts, closedPort); its connection fails two attempts later in
* the engine.
*
* serverOff: switched off, Java's server frees its port (ServerSocket
* .schliessen) while its connections still close; the client's FIN finds
* no socket and goes unanswered, the client waits in LAST_ACK in vain. The
* engine's server answers it with its ACK.
*/
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
noRoute: (java) => ({
...java,
events: [...java.events, [java.events.find(([, , text]) => text.startsWith('call verbinden'))![0], 'client', `Fehler beim Verbindungsaufbau\n${CONNECT_FAILURES.failed}`]],
}),
closedPort: (java) => failingAfterThreeAttempts(java, 'client'),
otherPort: (java) => failingAfterThreeAttempts(java, 'other'),
serverOff: (java) => {
const fin = java.frames[SERVER]!.filter((frame) => frame.tcpFlags === 'F').at(-1)!;
const ack: JavaFrame = {
...fin,
source: fin.destination,
destination: fin.source,
sourceIp: fin.destinationIp!,
destinationIp: fin.sourceIp!,
sourcePort: fin.destinationPort!,
destinationPort: fin.sourcePort!,
tcpFlags: 'A',
sequence: java.frames[SERVER]!.find((frame) => frame.tcpFlags === 'F')!.sequence! + 1,
acknowledgement: fin.sequence! + 1,
};
return withJavaFrame(withJavaFrame(java, SERVER, ack), CLIENT, { ...ack, time: fin.time + CARD_TO_CARD_MILLISECONDS });
},
};
/**
* Where each card's capture is compared only up to a time (Java's).
* closedPort, otherPort: the engine's second and third SYN come when the
* switch has forgotten the server (tcp-lan: 5 s) and floods them to every
* card; compared up to the second.
*/
const CAPTURE_UNTIL: Record<string, (java: JavaRun) => number> = {
closedPort: (java) => lastConnect(java, 'client') + JAVA_TIMEOUT_MILLISECONDS - GAP_TOLERANCE_MILLISECONDS,
otherPort: (java) => lastConnect(java, 'other') + JAVA_TIMEOUT_MILLISECONDS - GAP_TOLERANCE_MILLISECONDS,
};
describe('the echo server and the generic client as Java FILIUS runs them', () => {
it('cuts only where the engine goes on beyond Java', () => {
for (const [name, until] of Object.entries(CAPTURE_UNTIL)) {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `echo-java-${name}.java.json`), 'utf8')) as JavaRun;
const cut = until(java);
expect(Object.values(java.frames).flat().every((frame) => frame.time < cut), name).toBe(true);
}
});
it('corrects Java only where it errs', () => {
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `echo-java-${name}.java.json`), 'utf8')) as JavaRun;
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
}
});
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'tcp-lan.scenario.json'), 'utf8')) as Scenario;
const snapshot = withSnapshot(snapshotOf(scenario), {
echoServersByNodeId: new Map([[SERVER, { port: 55555, isActive: true }]]),
simpleClientsByNodeId: new Map([
[CLIENT, { targetAddress: '', targetPort: 55555 }],
[OTHER_CLIENT, { targetAddress: '', targetPort: 55555 }],
]),
});
for (const [name, acts] of Object.entries(STORIES)) {
it(name, () => {
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `echo-java-${name}.java.json`), 'utf8')) as JavaRun;
const java = JAVA_BUGS[name]?.(measured) ?? measured;
const { engine } = watchedEngine(scenario, snapshot);
for (const [time, act] of acts) {
engine.handle({ type: 'advance', toMilliseconds: time });
act(engine);
}
engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 });
expectSameCaptures(engine, scenario, java.frames, { text: withClientPortText, until: CAPTURE_UNTIL[name]?.(measured), tables: java });
const expected = javaLines(java);
const actual = engineLines(engine);
for (const program of ['server', 'client', 'other'] as const) {
expect(actual[program].map(([, text]) => text), `${program}'s lines`).toEqual(expected[program].map(([, text]) => text));
for (const [index, [javaTime, text]] of expected[program].entries()) {
const late = javaTime - actual[program][index]![0];
const [least, most] = isPolled(text) ? [-GAP_TOLERANCE_MILLISECONDS, POLL_MILLISECONDS + GAP_TOLERANCE_MILLISECONDS] : [-GAP_TOLERANCE_MILLISECONDS, GAP_TOLERANCE_MILLISECONDS];
expect(late, `${program}'s line ${JSON.stringify(text)}: Java ${late} ms later`).toBeGreaterThanOrEqual(least);
expect(late, `${program}'s line ${JSON.stringify(text)}: Java ${late} ms later`).toBeLessThanOrEqual(most);
}
}
});
}
});
+7 -3
View File
@@ -30,6 +30,7 @@ import {
type CapturedFrame,
type CaptureLayer,
type EthernetFrame,
type TopologySnapshot,
} from '../src/index.js';
import { snapshotOf, type Scenario } from './scenario.js';
@@ -117,8 +118,11 @@ export function engineText(frame: EthernetFrame): string {
return `${macs} UDP ${packet.senderIPAddress}:${datagram.sourcePort} > ${packet.receiverIPAddress}:${datagram.destinationPort} ${new TextDecoder().decode(datagram.payload)}`;
}
/** A started engine on the scenario's network whose frames can be read afterwards as FILIUS logs them. */
export function watchedEngine(scenario: Scenario) {
/**
* A started engine on the scenario's network (or on `snapshot`, made from
* it) whose frames can be read afterwards as FILIUS logs them.
*/
export function watchedEngine(scenario: Scenario, snapshot: TopologySnapshot = snapshotOf(scenario)) {
const engine = new NetworkRuntimeEngine(scenario.seed);
const framesByIdentity = new Map<number, EthernetFrame>();
const send = engine.sendEthernetFrame.bind(engine);
@@ -126,7 +130,7 @@ export function watchedEngine(scenario: Scenario) {
framesByIdentity.set(frame.identity, frame);
send(fromNodeId, outgoingPortId, frame);
};
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
engine.handle({ type: 'start', snapshot, seed: scenario.seed, initialTimeMilliseconds: 0 });
/** Every frame when it first shows on a host's card (sent, or received). */
const frames = (): SeenFrame[] => {
@@ -0,0 +1,377 @@
{
"frames": {
"00000000-0000-0000-0000-000000000101": [
{
"time": 641,
"source": "02:00:00:00:01:12",
"destination": "FF:FF:FF:FF:FF:FF",
"arp": 1,
"senderIp": "192.168.0.20",
"targetIp": "192.168.0.30"
}
],
"00000000-0000-0000-0000-000000000102": [
{
"time": 519,
"source": "02:00:00:00:01:12",
"destination": "FF:FF:FF:FF:FF:FF",
"arp": 1,
"senderIp": "192.168.0.20",
"targetIp": "192.168.0.30"
},
{
"time": 755,
"source": "02:00:00:00:01:13",
"destination": "02:00:00:00:01:12",
"arp": 2,
"senderIp": "192.168.0.30",
"targetIp": "192.168.0.20"
},
{
"time": 758,
"source": "02:00:00:00:01:12",
"destination": "02:00:00:00:01:13",
"sourceIp": "192.168.0.20",
"destinationIp": "192.168.0.30",
"protocol": 6,
"sourcePort": 58845,
"destinationPort": 55555,
"payload": null,
"tcpFlags": "S",
"sequence": 2000000,
"acknowledgement": 0
}
],
"00000000-0000-0000-0000-000000000103": [
{
"time": 641,
"source": "02:00:00:00:01:12",
"destination": "FF:FF:FF:FF:FF:FF",
"arp": 1,
"senderIp": "192.168.0.20",
"targetIp": "192.168.0.30"
},
{
"time": 643,
"source": "02:00:00:00:01:13",
"destination": "02:00:00:00:01:12",
"arp": 2,
"senderIp": "192.168.0.30",
"targetIp": "192.168.0.20"
},
{
"time": 871,
"source": "02:00:00:00:01:12",
"destination": "02:00:00:00:01:13",
"sourceIp": "192.168.0.20",
"destinationIp": "192.168.0.30",
"protocol": 6,
"sourcePort": 58845,
"destinationPort": 55555,
"payload": null,
"tcpFlags": "S",
"sequence": 2000000,
"acknowledgement": 0
}
]
},
"rows": {
"00000000-0000-0000-0000-000000000101": [
[
"1",
"02:00:00:00:01:12",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"192.168.0.20",
"192.168.0.30",
"ARP",
"Vermittlung",
"Suche nach MAC für 192.168.0.30 [op=REQUEST, sender=02:00:00:00:01:12|192.168.0.20, target=FF:FF:FF:FF:FF:FF|192.168.0.30]"
]
],
"00000000-0000-0000-0000-000000000102": [
[
"1",
"02:00:00:00:01:12",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"192.168.0.20",
"192.168.0.30",
"ARP",
"Vermittlung",
"Suche nach MAC für 192.168.0.30 [op=REQUEST, sender=02:00:00:00:01:12|192.168.0.20, target=FF:FF:FF:FF:FF:FF|192.168.0.30]"
],
[
"2",
"02:00:00:00:01:13",
"02:00:00:00:01:12",
"",
"Netzzugang",
"0x806"
],
[
"2",
"192.168.0.30",
"192.168.0.20",
"ARP",
"Vermittlung",
"MAC ist 02:00:00:00:01:13 [op=REPLY, sender=02:00:00:00:01:13|192.168.0.30, target=02:00:00:00:01:12|192.168.0.20]"
],
[
"3",
"02:00:00:00:01:12",
"02:00:00:00:01:13",
"",
"Netzzugang",
"0x800"
],
[
"3",
"192.168.0.20",
"192.168.0.30",
"IP",
"Vermittlung",
"Protokoll: 6, TTL: 64"
],
[
"3",
"192.168.0.20:58845",
"192.168.0.30:55555",
"TCP",
"Transport",
"SYN, SEQ: 2.000.000"
]
],
"00000000-0000-0000-0000-000000000103": [
[
"1",
"02:00:00:00:01:12",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"192.168.0.20",
"192.168.0.30",
"ARP",
"Vermittlung",
"Suche nach MAC für 192.168.0.30 [op=REQUEST, sender=02:00:00:00:01:12|192.168.0.20, target=FF:FF:FF:FF:FF:FF|192.168.0.30]"
],
[
"2",
"02:00:00:00:01:13",
"02:00:00:00:01:12",
"",
"Netzzugang",
"0x806"
],
[
"2",
"192.168.0.30",
"192.168.0.20",
"ARP",
"Vermittlung",
"MAC ist 02:00:00:00:01:13 [op=REPLY, sender=02:00:00:00:01:13|192.168.0.30, target=02:00:00:00:01:12|192.168.0.20]"
],
[
"3",
"02:00:00:00:01:12",
"02:00:00:00:01:13",
"",
"Netzzugang",
"0x800"
],
[
"3",
"192.168.0.20",
"192.168.0.30",
"IP",
"Vermittlung",
"Protokoll: 6, TTL: 64"
],
[
"3",
"192.168.0.20:58845",
"192.168.0.30:55555",
"TCP",
"Transport",
"SYN, SEQ: 2.000.000"
]
]
},
"layeredRows": {
"00000000-0000-0000-0000-000000000101": [
[
"1",
"02:00:00:00:01:12",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"192.168.0.20",
"192.168.0.30",
"ARP",
"Vermittlung",
"Suche nach MAC für 192.168.0.30 [op=REQUEST, sender=02:00:00:00:01:12|192.168.0.20, target=FF:FF:FF:FF:FF:FF|192.168.0.30]"
]
],
"00000000-0000-0000-0000-000000000102": [
[
"1",
"02:00:00:00:01:12",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"192.168.0.20",
"192.168.0.30",
"ARP",
"Vermittlung",
"Suche nach MAC für 192.168.0.30 [op=REQUEST, sender=02:00:00:00:01:12|192.168.0.20, target=FF:FF:FF:FF:FF:FF|192.168.0.30]"
],
[
"2",
"02:00:00:00:01:13",
"02:00:00:00:01:12",
"",
"Netzzugang",
"0x806"
],
[
"2",
"192.168.0.30",
"192.168.0.20",
"ARP",
"Vermittlung",
"MAC ist 02:00:00:00:01:13 [op=REPLY, sender=02:00:00:00:01:13|192.168.0.30, target=02:00:00:00:01:12|192.168.0.20]"
],
[
"3",
"02:00:00:00:01:12",
"02:00:00:00:01:13",
"",
"Netzzugang",
"0x800"
],
[
"3",
"192.168.0.20",
"192.168.0.30",
"IP",
"Vermittlung",
"Protokoll: 6, TTL: 64"
],
[
"3",
"58845",
"55555",
"TCP",
"Transport",
"SYN, SEQ: 2.000.000"
]
],
"00000000-0000-0000-0000-000000000103": [
[
"1",
"02:00:00:00:01:12",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"192.168.0.20",
"192.168.0.30",
"ARP",
"Vermittlung",
"Suche nach MAC für 192.168.0.30 [op=REQUEST, sender=02:00:00:00:01:12|192.168.0.20, target=FF:FF:FF:FF:FF:FF|192.168.0.30]"
],
[
"2",
"02:00:00:00:01:13",
"02:00:00:00:01:12",
"",
"Netzzugang",
"0x806"
],
[
"2",
"192.168.0.30",
"192.168.0.20",
"ARP",
"Vermittlung",
"MAC ist 02:00:00:00:01:13 [op=REPLY, sender=02:00:00:00:01:13|192.168.0.30, target=02:00:00:00:01:12|192.168.0.20]"
],
[
"3",
"02:00:00:00:01:12",
"02:00:00:00:01:13",
"",
"Netzzugang",
"0x800"
],
[
"3",
"192.168.0.20",
"192.168.0.30",
"IP",
"Vermittlung",
"Protokoll: 6, TTL: 64"
],
[
"3",
"58845",
"55555",
"TCP",
"Transport",
"SYN, SEQ: 2.000.000"
]
]
},
"events": [
[
211,
"server",
"null"
],
[
212,
"client",
"null"
],
[
213,
"other",
"null"
],
[
500,
"client",
"call verbinden 192.168.0.30:55555"
],
[
8810,
"client",
"Fehler beim Verbindungsaufbau\nZeitüberschreitung beim Verbindungsaufbau"
]
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
{
"frames": {
"00000000-0000-0000-0000-000000000101": [],
"00000000-0000-0000-0000-000000000102": [],
"00000000-0000-0000-0000-000000000103": []
},
"rows": {
"00000000-0000-0000-0000-000000000101": [],
"00000000-0000-0000-0000-000000000102": [],
"00000000-0000-0000-0000-000000000103": []
},
"layeredRows": {
"00000000-0000-0000-0000-000000000101": [],
"00000000-0000-0000-0000-000000000102": [],
"00000000-0000-0000-0000-000000000103": []
},
"events": [
[
210,
"server",
"null"
],
[
211,
"client",
"null"
],
[
211,
"other",
"null"
],
[
501,
"client",
"call verbinden 10.0.0.1:55555"
]
]
}
@@ -0,0 +1,49 @@
{
"frames": {
"00000000-0000-0000-0000-000000000101": [],
"00000000-0000-0000-0000-000000000102": [],
"00000000-0000-0000-0000-000000000103": []
},
"rows": {
"00000000-0000-0000-0000-000000000101": [],
"00000000-0000-0000-0000-000000000102": [],
"00000000-0000-0000-0000-000000000103": []
},
"layeredRows": {
"00000000-0000-0000-0000-000000000101": [],
"00000000-0000-0000-0000-000000000102": [],
"00000000-0000-0000-0000-000000000103": []
},
"events": [
[
208,
"server",
"null"
],
[
209,
"client",
"null"
],
[
210,
"other",
"null"
],
[
500,
"client",
"call senden Hallo"
],
[
500,
"client",
"Fehler beim Verbindungsaufbau"
],
[
500,
"client",
"returned senden"
]
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -4,9 +4,11 @@
//
// Only the cables count: a host's SSID connects nothing at run time, as in
// Java (Swift joins a wireless host to the switch of its SSID; not taken).
// A DNS server runs only when it is switched on (Java: DNSServer.aktiv).
// A DNS server runs only when it is switched on (Java: DNSServer.aktiv); an
// echo server is there either way, switched on or off (it can be switched at
// run time).
import { portSnapshot, topologySnapshot, type DnsServerConfiguration, type Id, type TopologySnapshot } from '@mdg-filius/core';
import { portSnapshot, topologySnapshot, type DnsServerConfiguration, type EchoServerSnapshot, type Id, type TopologySnapshot } from '@mdg-filius/core';
import type { ImportedProject } from './importProject.js';
export function projectSnapshot(project: ImportedProject): TopologySnapshot {
@@ -14,6 +16,10 @@ export function projectSnapshot(project: ImportedProject): TopologySnapshot {
for (const [nodeId, configuration] of project.dnsServerConfigurations) {
if (project.activePrograms.get(nodeId)?.has('dnsServer') === true) dnsServers.set(nodeId, configuration);
}
const echoServers = new Map<Id, EchoServerSnapshot>();
for (const [nodeId, configuration] of project.echoServerConfigurations) {
echoServers.set(nodeId, { ...configuration, isActive: project.activePrograms.get(nodeId)?.has('echoServer') === true });
}
return topologySnapshot({
nodes: project.graph.nodes.map((node) => ({
id: node.id,
@@ -32,5 +38,7 @@ export function projectSnapshot(project: ImportedProject): TopologySnapshot {
portForwardingRowsByNodeId: project.portForwardingRowsByNodeId,
switchConfigurationsByNodeId: project.switchConfigurations,
ipForwardingByNodeId: project.ipForwardingByNodeId,
echoServersByNodeId: echoServers,
simpleClientsByNodeId: project.simpleClientConfigurations,
});
}
+23
View File
@@ -726,6 +726,29 @@ describe('importProject rules', () => {
expect([...projectSnapshot(project).dnsServerConfigurationsByNodeId.keys()]).toEqual([on]);
});
it('runs every echo server, switched on or off, and every generic client (projectSnapshot)', () => {
const source = flsNode('Rechner').source;
const echoServer = (isActive: boolean, port: number): FlsApplication => ({ key: 'echo', javaClass: 'filius.software.clientserver.ServerBaustein', source, isActive, port });
const client: FlsApplication = { key: 'client', javaClass: 'filius.software.clientserver.ClientBaustein', source, targetAddress: '10.0.0.1', targetPort: 7 };
const project = importProject(
topology({
nodes: [
flsNode('Rechner', { interfaces: [nic('10.0.0.1')], system: { applications: [echoServer(true, 55555)] } }),
flsNode('Rechner', { interfaces: [nic('10.0.0.2')], system: { applications: [echoServer(false, 7), client] } }),
],
}),
counter(),
);
const [on, off] = project.graph.nodes.map((node) => node.id);
const snapshot = projectSnapshot(project);
expect([...snapshot.echoServersByNodeId]).toEqual([
[on, { port: 55555, isActive: true }],
[off, { port: 7, isActive: false }],
]);
expect([...snapshot.simpleClientsByNodeId]).toEqual([[off, { targetAddress: '10.0.0.1', targetPort: 7 }]]);
});
it("keeps a node's IP forwarding where it differs from its kind's: a host that forwards, a router that does not", () => {
const system = (ipForwardingEnabled: boolean) => ({ manualRoutes: [], ipForwardingEnabled });
const project = importProject(