feat(core): modems run — the pair in the project, and a modem of another project hands its frames on (K0–K2)

Measured first: tools/java-oracle's ModemRun joins two networks by a pair
of Java modems over TCP on localhost. The modem has no card and adds no
delay of its own — a frame takes the cables on both sides, nothing more.

The core pairs the two enabled modems on a port (Swift's inProject, with
its conditions and cycle suppression) on Java's timing: no 20 ms. The
modem example of FILIUS now pings through instead of failing the core.
A modem of another project (relay) gives its frames to whoever runs the
engine and puts on its cable what comes back, with this engine's frame
and packet numbers; a frame dies after MAXIMUM_RELAY_HOPS trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 18:12:27 +00:00
co-authored by Claude Opus 5.5
parent 6f583616f6
commit c85b0e45da
19 changed files with 1743 additions and 44 deletions
+14 -3
View File
@@ -139,14 +139,25 @@ export interface GatewayPortForwardingRow {
}
/**
* The in-project pairing of two remote links (Java Modem). Swift's LAN
* transport settings belong to its Network-framework controller and are not
* part of the port.
* Where a modem's partner is when it is in another project (server F's
* relay): this modem offers a connection under a code derived from its
* project and itself, or joins the modem that offers `code`. Java's modem
* names the other FILIUS by IP address and port; a code takes their place.
*/
export type RemoteLinkRelay = { readonly role: 'offer' } | { readonly role: 'join'; readonly code: string };
/**
* A modem (Java Modem, Swift remote link): paired in the project with the one
* other modem on its port (`pairIdentifier`), or, with `relay`, with a modem
* of another project. Swift's LAN transport settings belong to its
* Network-framework controller and are not part of the port.
*/
export interface RemoteLinkConfiguration {
readonly pairIdentifier: string;
readonly latencyMilliseconds: number;
readonly isEnabled: boolean;
/** A modem of another project; none: the pair in this project. */
readonly relay?: RemoteLinkRelay;
}
export const DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS = 20;
+181 -25
View File
@@ -52,8 +52,12 @@
// Calls that block in Java (resolving an address, waiting for an echo reply)
// advance the virtual clock inside the call, exactly as the Swift port does.
//
// Not ported yet — each place throws NotPortedError instead of guessing:
// remote links.
// Modems (Java Modem, Swift remote links) after Java's timing: a modem has no
// card and no delay of its own; a frame that reaches it leaves its partner at
// once (Java's TCP between the two FILIUS). The pair after Swift's inProject:
// the two enabled modems on a port. A modem of another project (`relay`)
// hands its frames to whoever runs the engine (server F's relay) and puts on
// its cable what comes from there.
import { compareIds, type Id } from '../ids.js';
import { forwardsIPv4ByDefault, isPcClassEndpoint, stableGeneratedMacAddress } from '../model/node.js';
@@ -67,6 +71,7 @@ import type {
InterfaceConfiguration,
InterfaceKey,
ManualRoute,
RemoteLinkConfiguration,
} from './config.js';
import {
ALL_PORTS,
@@ -88,6 +93,7 @@ import {
TCP_FLAG,
type ArpPacket,
type EthernetFrame,
type EthernetPayload,
type IcmpMessage,
type IcmpMessageKind,
type IPv4Packet,
@@ -148,6 +154,28 @@ export const LOCALHOST_IP_ADDRESS = '127.0.0.1';
/** The seed a simulation uses unless told otherwise. */
export const DEFAULT_SEED = 0xf11a5n;
/** How often a frame may go through modems into other projects before it is dropped (two pairs between the same networks make a circle). */
export const MAXIMUM_RELAY_HOPS = 16;
/** How a modem stands (Swift TopologyRemoteLinkRuntimeCondition, without its LAN transport). */
export type RemoteLinkCondition =
| { readonly kind: 'stopped' }
| { readonly kind: 'missingConfiguration' }
| { readonly kind: 'disabled' }
| { readonly kind: 'unpaired' }
| { readonly kind: 'ambiguous'; readonly enabledNodeCount: number }
/** Its own cable or its partner's missing. */
| { readonly kind: 'detached'; readonly partnerNodeId?: Id }
| { readonly kind: 'active'; readonly partnerNodeId: Id }
/** A modem of another project, on its cable: whether it has a partner, the relay knows. */
| { readonly kind: 'relay' };
/** A modem's pair in the project, trimmed; undefined when it names none. */
function pairOf(configuration: RemoteLinkConfiguration): string | undefined {
const pair = configuration.pairIdentifier.trim();
return pair === '' ? undefined : pair;
}
/** A part of the Swift engine that has not been carried over yet. */
export class NotPortedError extends Error {
constructor(feature: string) {
@@ -193,22 +221,7 @@ export type ScheduledEventKind =
readonly latencyMilliseconds: number;
readonly frame: EthernetFrame;
}
| {
readonly kind: 'remoteLinkDelivery';
readonly sourceNodeId: Id;
readonly partnerNodeId: Id;
readonly partnerPortId: Id;
readonly pairIdentifier: string;
readonly latencyMilliseconds: number;
readonly frame: EthernetFrame;
}
| {
readonly kind: 'lanRemoteLinkTransmission';
readonly nodeId: Id;
readonly pairIdentifier: string;
readonly latencyMilliseconds: number;
readonly frame: EthernetFrame;
};
;
export interface ScheduledEvent {
readonly id: number;
@@ -729,6 +742,8 @@ export interface RuntimeState {
switchForwardingTablesByNodeId: Map<Id, Map<string, Id>>;
switchForwardingUpdatedAtMillisecondsByNodeId: Map<Id, Map<string, number>>;
switchSeenFrameIdentitiesByNodeId: Map<Id, Set<number>>;
/** The frames each modem passed on (Swift's remote link cycle suppression). */
remoteLinkSeenFrameIdentitiesByNodeId: Map<Id, Set<number>>;
deliveredIPv4PacketsByNodeId: Map<Id, DeliveredIPv4Packet[]>;
icmpObservationsByNodeId: Map<Id, IcmpObservation[]>;
firewallDecisions: FirewallDecision[];
@@ -772,6 +787,7 @@ function initialState(seed: bigint): RuntimeState {
switchForwardingTablesByNodeId: new Map(),
switchForwardingUpdatedAtMillisecondsByNodeId: new Map(),
switchSeenFrameIdentitiesByNodeId: new Map(),
remoteLinkSeenFrameIdentitiesByNodeId: new Map(),
deliveredIPv4PacketsByNodeId: new Map(),
icmpObservationsByNodeId: new Map(),
firewallDecisions: [],
@@ -867,7 +883,6 @@ function isNetworkDeliveryEvent(kind: ScheduledEventKind): boolean {
return (
kind.kind === 'ethernetLinkDeparture' ||
kind.kind === 'ethernetLinkDelivery' ||
kind.kind === 'remoteLinkDelivery' ||
kind.kind === 'arpAnswered'
);
}
@@ -951,6 +966,8 @@ export class NetworkRuntimeEngine {
private readonly gnutellaPeersByNodeId = new Map<Id, GnutellaPeer>();
/** The time of day a program writes into a mail (Java's new Date()). */
private readonly wallClock: () => number;
/** Where a modem of another project's frames go (the constructor's `relay`). */
private readonly relay: ((nodeId: Id, frame: EthernetFrame) => void) | undefined;
/** Each node's firewall log; it outlives a stop and a new start, as Java's log page does. */
private readonly firewallLogsByNodeId = new Map<Id, LogLine<FirewallLogEntry>[]>();
/** Accepted connections a program has taken (acceptTcpSteps). */
@@ -973,6 +990,9 @@ export class NetworkRuntimeEngine {
*
* `wallClock`: the time of day in milliseconds, which a program writes
* into a mail (the e-mail server's notice); the host's clock by default.
*
* `relay`: takes the frames a modem of another project passes on (server
* F's relay); without it they are lost, as at a modem that is not connected.
*/
constructor(
seed: bigint | number = DEFAULT_SEED,
@@ -984,9 +1004,11 @@ export class NetworkRuntimeEngine {
readonly swiftRouters?: boolean;
readonly captureRetention?: CaptureRetentionPolicy;
readonly wallClock?: () => number;
readonly relay?: (nodeId: Id, frame: EthernetFrame) => void;
} = {},
) {
this.wallClock = options.wallClock ?? Date.now;
this.relay = options.relay;
this.packetCapture = new PacketCapture(options.captureRetention ?? PRODUCTION_CAPTURE_RETENTION);
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
this.swiftArp = options.swiftArp === true;
@@ -1096,6 +1118,7 @@ export class NetworkRuntimeEngine {
state.switchForwardingTablesByNodeId.clear();
state.switchForwardingUpdatedAtMillisecondsByNodeId.clear();
state.switchSeenFrameIdentitiesByNodeId.clear();
state.remoteLinkSeenFrameIdentitiesByNodeId.clear();
state.deliveredIPv4PacketsByNodeId.clear();
state.icmpObservationsByNodeId.clear();
state.socketsById.clear();
@@ -1698,9 +1721,6 @@ export class NetworkRuntimeEngine {
wake?.();
return;
}
case 'remoteLinkDelivery':
case 'lanRemoteLinkTransmission':
throw new NotPortedError('remote links');
}
}
@@ -4568,9 +4588,10 @@ export class NetworkRuntimeEngine {
return this.stateValue.topologySnapshot.nodes.find((node) => node.id === nodeId);
}
/** Java logs a frame on a card; a switch has none. */
/** Java logs a frame on a card; a switch and a modem have none. */
private captureOnCard(nodeId: Id, portId: Id, frame: EthernetFrame): void {
if (this.nodeSnapshot(nodeId)?.kind === 'networkSwitch') return;
const kind = this.nodeSnapshot(nodeId)?.kind;
if (kind === 'networkSwitch' || kind === 'remoteLink') return;
this.packetCapture.record(portId, this.stateValue.currentTimeMilliseconds, frame);
}
@@ -4592,7 +4613,10 @@ export class NetworkRuntimeEngine {
this.forwardFrameThroughSwitch(frame, node, incomingPortId);
return;
}
if (node.kind === 'remoteLink') throw new NotPortedError('remote links');
if (node.kind === 'remoteLink') {
this.forwardFrameThroughRemoteLink(frame, node, incomingPortId);
return;
}
this.recordTrace({
frameIdentity: frame.identity,
@@ -4633,6 +4657,138 @@ export class NetworkRuntimeEngine {
// ── Switch ───────────────────────────────────────────────────────────────
// ── Modems ──────────────────────────────────────────────────────────────
/**
* How a modem stands (Swift's remoteLinkCondition): its partner in the
* project, or why it has none; `relay` for a modem of another project,
* whose partner only the relay knows. Undefined for anything but a modem.
*/
remoteLinkCondition(nodeId: Id): RemoteLinkCondition | undefined {
const node = this.nodeSnapshot(nodeId);
if (node === undefined || node.kind !== 'remoteLink') return undefined;
if (this.stateValue.phase !== 'running') return { kind: 'stopped' };
const configuration = this.stateValue.topologySnapshot.remoteLinkConfigurationsByNodeId.get(nodeId);
const pairIdentifier = configuration === undefined ? undefined : pairOf(configuration);
if (configuration === undefined || (pairIdentifier === undefined && configuration.relay === undefined)) return { kind: 'missingConfiguration' };
if (!configuration.isEnabled) return { kind: 'disabled' };
const attached = (id: Id) => {
const port = this.nodeSnapshot(id)?.ports[0];
return port !== undefined && this.attachedEndpoint(id, port.id) !== undefined;
};
if (configuration.relay !== undefined) return attached(nodeId) ? { kind: 'relay' } : { kind: 'detached' };
const matches = this.stateValue.topologySnapshot.nodes
.filter((candidate) => {
if (candidate.kind !== 'remoteLink') return false;
const other = this.stateValue.topologySnapshot.remoteLinkConfigurationsByNodeId.get(candidate.id);
return other !== undefined && other.isEnabled && other.relay === undefined && pairOf(other) === pairIdentifier;
})
.map((candidate) => candidate.id);
if (matches.length > 2) return { kind: 'ambiguous', enabledNodeCount: matches.length };
const partnerNodeId = matches.length === 2 ? matches.find((id) => id !== nodeId) : undefined;
if (partnerNodeId === undefined) return { kind: 'unpaired' };
return attached(nodeId) && attached(partnerNodeId) ? { kind: 'active', partnerNodeId } : { kind: 'detached', partnerNodeId };
}
/**
* A frame reached a modem on its cable: it leaves the partner modem at once
* (Java's modems pass it over TCP; no delay of their own), or goes to the
* relay for a modem of another project. Lost when the modem has no partner,
* as at Java's modem that is not connected.
*/
private forwardFrameThroughRemoteLink(frame: EthernetFrame, node: NodeSnapshot, incomingPortId: Id): void {
const drop = (detail: string) =>
this.recordTrace({
frameIdentity: frame.identity,
packetIdentity: packetIdentityInFrame(frame),
nodeId: node.id,
interfaceId: incomingPortId,
direction: 'inbound',
layer: 'dataLink',
operation: 'dropped',
beforeHeaders: ethernetHeaders(frame),
detail,
});
const seen = getOrCreate(this.stateValue.remoteLinkSeenFrameIdentitiesByNodeId, node.id, () => new Set());
if (seen.has(frame.identity)) {
drop('remote link cycle suppression');
return;
}
seen.add(frame.identity);
const condition = this.remoteLinkCondition(node.id);
if (condition?.kind === 'relay') {
const hops = frame.relayHops ?? 0;
if (hops >= MAXIMUM_RELAY_HOPS) {
drop('remote link relay hop limit');
return;
}
if (this.relay === undefined) {
drop('remote link relay unavailable');
return;
}
this.recordTrace({
frameIdentity: frame.identity,
packetIdentity: packetIdentityInFrame(frame),
nodeId: node.id,
interfaceId: incomingPortId,
direction: 'outbound',
layer: 'dataLink',
operation: 'forwarded',
beforeHeaders: ethernetHeaders(frame),
detail: 'remote link relayed',
});
this.relay(node.id, { ...frame, relayHops: hops + 1 });
return;
}
if (condition?.kind !== 'active') {
drop(`remote link ${condition?.kind ?? 'missingConfiguration'}`);
return;
}
const partnerPortId = this.nodeSnapshot(condition.partnerNodeId)!.ports[0]!.id;
getOrCreate(this.stateValue.remoteLinkSeenFrameIdentitiesByNodeId, condition.partnerNodeId, () => new Set()).add(frame.identity);
this.recordTrace({
frameIdentity: frame.identity,
packetIdentity: packetIdentityInFrame(frame),
nodeId: condition.partnerNodeId,
interfaceId: partnerPortId,
direction: 'outbound',
layer: 'dataLink',
operation: 'forwarded',
beforeHeaders: ethernetHeaders(frame),
detail: `remote link delivered pair=${pairOf(this.stateValue.topologySnapshot.remoteLinkConfigurationsByNodeId.get(node.id)!)}`,
});
this.sendEthernetFrame(condition.partnerNodeId, partnerPortId, frame);
}
/**
* A frame that came from the partner of a modem of another project (server
* F's relay): it leaves the modem on its cable, with this engine's numbers
* for the frame and its packet — the other engine counts its own, and a
* switch would take a number it saw before for the same frame. False when
* the simulation does not run or the modem cannot take it.
*/
receiveRelayedFrame(nodeId: Id, frame: EthernetFrame): boolean {
if (this.remoteLinkCondition(nodeId)?.kind !== 'relay') return false;
const portId = this.nodeSnapshot(nodeId)!.ports[0]!.id;
const payload: EthernetPayload =
frame.payload.kind === 'ipv4' ? { kind: 'ipv4', packet: { ...frame.payload.packet, identity: this.allocatePacketIdentity() } } : frame.payload;
const local: EthernetFrame = { ...frame, identity: this.allocateFrameIdentity(), payload };
getOrCreate(this.stateValue.remoteLinkSeenFrameIdentitiesByNodeId, nodeId, () => new Set()).add(local.identity);
this.recordTrace({
frameIdentity: local.identity,
packetIdentity: packetIdentityInFrame(local),
nodeId,
interfaceId: portId,
direction: 'inbound',
layer: 'dataLink',
operation: 'received',
afterHeaders: ethernetHeaders(local),
detail: 'remote link relayed',
});
this.sendEthernetFrame(nodeId, portId, local);
return true;
}
private forwardFrameThroughSwitch(frame: EthernetFrame, switchNode: NodeSnapshot, incomingPortId: Id): void {
const state = this.stateValue;
const seen = state.switchSeenFrameIdentitiesByNodeId.get(switchNode.id);
+2
View File
@@ -8,6 +8,8 @@ export interface EthernetFrame {
readonly sourceMacAddress: string;
readonly destinationMacAddress: string;
readonly payload: EthernetPayload;
/** How often the frame went through a modem into another project (server F's relay); none: never. */
readonly relayHops?: number;
}
export type EthernetPayload =
+5
View File
@@ -19,6 +19,7 @@ import type {
InterfaceKey,
ManualRoute,
SimpleClientConfiguration,
RemoteLinkConfiguration,
SwitchConfiguration,
WebServerConfiguration,
EmailServerAccount,
@@ -95,6 +96,8 @@ export interface TopologySnapshot {
/** A home router's port forwarding (Java GatewayFirmware.staticNAT), by node. */
readonly portForwardingRowsByNodeId: ReadonlyMap<Id, readonly GatewayPortForwardingRow[]>;
readonly switchConfigurationsByNodeId: ReadonlyMap<Id, SwitchConfiguration>;
/** The modems' pairs and relays (Java Modem), by node. */
readonly remoteLinkConfigurationsByNodeId: ReadonlyMap<Id, RemoteLinkConfiguration>;
/** 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. */
@@ -132,6 +135,7 @@ export interface TopologySnapshotInput {
firewallConfigurationsByNodeId?: ReadonlyMap<Id, FirewallConfiguration>;
portForwardingRowsByNodeId?: ReadonlyMap<Id, readonly GatewayPortForwardingRow[]>;
switchConfigurationsByNodeId?: ReadonlyMap<Id, SwitchConfiguration>;
remoteLinkConfigurationsByNodeId?: ReadonlyMap<Id, RemoteLinkConfiguration>;
ipForwardingByNodeId?: ReadonlyMap<Id, boolean>;
echoServersByNodeId?: ReadonlyMap<Id, EchoServerSnapshot>;
simpleClientsByNodeId?: ReadonlyMap<Id, SimpleClientConfiguration>;
@@ -159,6 +163,7 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
firewallConfigurationsByNodeId: input.firewallConfigurationsByNodeId ?? new Map(),
portForwardingRowsByNodeId: input.portForwardingRowsByNodeId ?? new Map(),
switchConfigurationsByNodeId: input.switchConfigurationsByNodeId ?? new Map(),
remoteLinkConfigurationsByNodeId: input.remoteLinkConfigurationsByNodeId ?? new Map(),
ipForwardingByNodeId: input.ipForwardingByNodeId ?? new Map(),
echoServersByNodeId: input.echoServersByNodeId ?? new Map(),
simpleClientsByNodeId: input.simpleClientsByNodeId ?? new Map(),
+1 -1
View File
@@ -51,7 +51,7 @@ export function uuid(suffix: number): Id {
return `00000000-0000-0000-0000-${suffix.toString(16).toUpperCase().padStart(12, '0')}`;
}
const FRAME_EVENTS = new Set(['ethernetLinkDeparture', 'ethernetLinkDelivery', 'remoteLinkDelivery', 'arpAnswered']);
const FRAME_EVENTS = new Set(['ethernetLinkDeparture', 'ethernetLinkDelivery', 'arpAnswered']);
/**
* Advances until every frame in flight has arrived — what Swift's send did
+3 -3
View File
@@ -135,7 +135,7 @@ export function watchedEngine(scenario: Scenario, snapshot: TopologySnapshot = s
/** Every frame when it first shows on a host's card (sent, or received). */
const frames = (): SeenFrame[] => {
const hosts = new Set(scenario.nodes.filter((node) => node.kind !== 'networkSwitch').map((node) => node.id));
const hosts = new Set(scenario.nodes.filter((node) => node.kind !== 'networkSwitch' && node.kind !== 'remoteLink').map((node) => node.id));
const firstSeen = new Map<number, SeenFrame>();
for (const event of engine.state.packetTraces) {
if (event.layer !== 'physical' || !hosts.has(event.nodeId) || event.frameIdentity === undefined) continue;
@@ -185,10 +185,10 @@ export interface CaptureCard {
readonly portId: string;
}
/** A scenario's cards: its hosts' ports, under the node's id for the first card and `id#index` for the others (ScenarioNetwork). */
/** A scenario's cards: its hosts' ports, under the node's id for the first card and `id#index` for the others (ScenarioNetwork); switches and modems have none. */
export function scenarioCards(scenario: Scenario): CaptureCard[] {
return scenario.nodes
.filter((node) => node.kind !== 'networkSwitch')
.filter((node) => node.kind !== 'networkSwitch' && node.kind !== 'remoteLink')
.flatMap((node) => node.ports.map((port, index) => ({ key: index === 0 ? node.id : `${node.id}#${index}`, portId: port.id })));
}
+164
View File
@@ -0,0 +1,164 @@
// Modems as Java FILIUS runs them. tools/java-oracle's ModemRun joined two
// networks by a pair of modems (a server and a client over TCP on localhost)
// and pinged across (modem-java-<story>.java.json): the modem has no card,
// so no capture of its own, and adds no delay — a frame takes the cables on
// both sides, nothing more. The engine pairs the two modems on a port and must
// show every card the same frames at about the same times.
//
// Beside Java: the pair's cycle suppression, three modems on a port, a
// partner without its cable (Swift's conditions; Java's modems have no pair
// in the project).
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 NetworkRuntimeEngine } from '../src/index.js';
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, type JavaFrame, type JavaTables } from './javaRun.js';
import { snapshotOf, type Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
const id = (number: number) => `00000000-0000-0000-0000-000000000${number}`;
const HOST = id(401);
const SERVER_MODEM = id(405);
const CLIENT_MODEM = id(406);
interface JavaRun extends JavaTables {
frames: Record<string, JavaFrame[]>;
events: [number, string][];
}
/** A ping from HOST in Java's words: the ICMP answer's type and code, or null when none came in time. */
function ping(engine: NetworkRuntimeEngine, target: string, sequenceNumber: number): string {
const result = engine.sendIcmpEcho(HOST, target, { sequenceNumber });
if (result.type === 'icmpError' && result.kind === 'destinationHostUnreachable') return 'ICMP 3/1';
const answered = (engine.state.icmpObservationsByNodeId.get(HOST) ?? []).some((observation) => observation.message.kind === 'echoReply' && observation.message.sequenceNumber === sequenceNumber);
return answered ? 'ICMP 0/0' : 'null';
}
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'modem-lan.scenario.json'), 'utf8')) as Scenario;
/** Without the client modem's configuration: the server modem has no partner, as Java's that is not connected. */
const unpaired = () => {
const snapshot = snapshotOf(scenario);
return withSnapshot(snapshot, { remoteLinkConfigurationsByNodeId: new Map([...snapshot.remoteLinkConfigurationsByNodeId].filter(([node]) => node !== CLIENT_MODEM)) });
};
const STORIES: Record<string, { readonly pings: number; readonly snapshot?: () => ReturnType<typeof snapshotOf> }> = {
ping: { pings: 2 },
unconnected: { pings: 1, snapshot: unpaired },
};
/** Java's events without its answer's sender ("from …"), which the engine's results do not name. */
const javaEvent = (event: string) => event.replace(/ from .*$/, '');
describe('modems as Java FILIUS runs them', () => {
for (const [name, story] of Object.entries(STORIES)) {
it(name, () => {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `modem-java-${name}.java.json`), 'utf8')) as JavaRun;
const { engine } = watchedEngine(scenario, story.snapshot?.());
engine.handle({ type: 'advance', toMilliseconds: 500 });
const events: [number, string][] = [];
for (let sequence = 1; sequence <= story.pings; sequence += 1) {
events.push([engine.state.currentTimeMilliseconds, 'sendProbe 10.0.0.2 called']);
const result = ping(engine, '10.0.0.2', sequence);
events.push([engine.state.currentTimeMilliseconds, `sendProbe 10.0.0.2 returned ${result}`]);
}
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
expect(events.map(([, event]) => event)).toEqual(java.events.map(([, event]) => javaEvent(event)));
for (const [index, [time]] of events.entries()) {
expect(Math.abs(time - events[0]![0] - (java.events[index]![0] - java.events[0]![0])), `time of ${events[index]![1]}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
}
expectSameCaptures(engine, scenario, java.frames, { tables: java });
// A modem has no card: nothing in a capture of its port.
expect(engine.capture.frames(id(451))).toEqual([]);
expect(engine.capture.frames(id(461))).toEqual([]);
});
}
});
describe('a pair of modems in the project', () => {
it('stands active while running, stopped otherwise', () => {
const { engine } = watchedEngine(scenario);
expect(engine.remoteLinkCondition(SERVER_MODEM)).toEqual({ kind: 'active', partnerNodeId: CLIENT_MODEM });
expect(engine.remoteLinkCondition(CLIENT_MODEM)).toEqual({ kind: 'active', partnerNodeId: SERVER_MODEM });
expect(engine.remoteLinkCondition(HOST)).toBeUndefined();
engine.handle({ type: 'stop' });
expect(engine.remoteLinkCondition(SERVER_MODEM)).toEqual({ kind: 'stopped' });
});
it('adds no delay of its own: across the pair a frame takes three cables, as two take two', () => {
const { engine } = watchedEngine(scenario);
engine.handle({ type: 'advance', toMilliseconds: 500 });
ping(engine, '10.0.0.2', 1);
const sent = engine.capture.frames(id(411))[0]!.timeMilliseconds;
// The ARP request: 10.0.0.3 behind the switch (two cables), 10.0.0.2 behind the pair (three).
const overSwitch = engine.capture.frames(id(417))[0]!.timeMilliseconds - sent;
const overPair = engine.capture.frames(id(413))[0]!.timeMilliseconds - sent;
expect(overSwitch).toBeGreaterThan(0);
expect(overPair).toBe((overSwitch / 2) * 3);
});
it('pairs only enabled modems', () => {
const snapshot = snapshotOf(scenario);
const off = withSnapshot(snapshot, {
remoteLinkConfigurationsByNodeId: new Map(
[...snapshot.remoteLinkConfigurationsByNodeId].map(([node, configuration]) => [node, node === CLIENT_MODEM ? { ...configuration, isEnabled: false } : configuration]),
),
});
const { engine } = watchedEngine(scenario, off);
expect(engine.remoteLinkCondition(SERVER_MODEM)).toEqual({ kind: 'unpaired' });
expect(engine.remoteLinkCondition(CLIENT_MODEM)).toEqual({ kind: 'disabled' });
});
it('has no partner when it is alone on its port, and drops what comes', () => {
const { engine } = watchedEngine(scenario, unpaired());
expect(engine.remoteLinkCondition(SERVER_MODEM)).toEqual({ kind: 'unpaired' });
engine.handle({ type: 'advance', toMilliseconds: 500 });
ping(engine, '10.0.0.2', 1);
expect(engine.state.packetTraces.some((event) => event.nodeId === SERVER_MODEM && event.operation === 'dropped' && event.detail === 'remote link unpaired')).toBe(true);
expect(engine.capture.frames(id(413))).toEqual([]);
});
it('is ambiguous with a third modem on the port, and passes nothing', () => {
const third = id(408);
const snapshot = snapshotOf(scenario);
const crowded = withSnapshot(snapshot, {
nodes: [...snapshot.nodes, { id: third, kind: 'remoteLink', ports: [{ id: id(481), label: 'remote0', macAddress: '02:00:00:00:04:81' }] }],
remoteLinkConfigurationsByNodeId: new Map([...snapshot.remoteLinkConfigurationsByNodeId, [third, { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true }]]),
});
const { engine } = watchedEngine(scenario, crowded);
expect(engine.remoteLinkCondition(SERVER_MODEM)).toEqual({ kind: 'ambiguous', enabledNodeCount: 3 });
engine.handle({ type: 'advance', toMilliseconds: 500 });
expect(ping(engine, '10.0.0.2', 1)).toBe('ICMP 3/1');
});
it('is detached when its partner has no cable', () => {
const snapshot = snapshotOf(scenario);
const { engine } = watchedEngine(scenario, withSnapshot(snapshot, { links: snapshot.links.filter((link) => link.sourceNodeId !== CLIENT_MODEM) }));
expect(engine.remoteLinkCondition(SERVER_MODEM)).toEqual({ kind: 'detached', partnerNodeId: CLIENT_MODEM });
engine.handle({ type: 'advance', toMilliseconds: 500 });
expect(ping(engine, '10.0.0.2', 1)).toBe('ICMP 3/1');
});
it('passes a frame once: both modems on one switch make a circle that ends at the modems', () => {
// The client modem on the switch's third port instead of at 10.0.0.2: a broadcast goes into the server modem, out of the client modem, back into the switch.
const snapshot = snapshotOf(scenario);
const circle = withSnapshot(snapshot, {
nodes: snapshot.nodes.map((node) => (node.id === id(404) ? { ...node, ports: [...node.ports, { id: id(444), label: 'sw4', macAddress: '02:00:00:00:04:44' }] } : node)),
links: [
...snapshot.links.filter((link) => link.targetNodeId !== id(403)),
{ id: '00000000-0000-0000-0000-00000000F405', sourceNodeId: CLIENT_MODEM, sourcePortId: id(461), targetNodeId: id(404), targetPortId: id(444) },
],
});
const { engine } = watchedEngine(scenario, circle);
engine.handle({ type: 'advance', toMilliseconds: 500 });
expect(ping(engine, '10.0.0.3', 1)).toBe('ICMP 0/0');
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
// The switch floods the request to both modems: the client modem already passed it on as the server modem's partner.
expect(engine.state.packetTraces.some((event) => event.nodeId === CLIENT_MODEM && event.detail === 'remote link cycle suppression')).toBe(true);
// 10.0.0.3 saw the request once, not again and again.
expect(engine.capture.frames(id(417)).filter((each) => each.frame.payload.kind === 'arp' && each.frame.payload.packet.operation === 'request')).toHaveLength(1);
});
});
+266
View File
@@ -0,0 +1,266 @@
// A modem of another project (server F's relay): the engine hands what
// reaches such a modem to `relay` and puts on the modem's cable what comes
// back through receiveRelayedFrame. Here two engines stand for two projects,
// joined by a queue that delivers between their steps, as F's threads do.
import { describe, expect, it } from 'vitest';
import {
deviceConfiguration,
MAXIMUM_RELAY_HOPS,
NetworkRuntimeEngine,
portSnapshot,
topologySnapshot,
type EthernetFrame,
type Id,
type NodeKind,
type RemoteLinkRelay,
type TopologySnapshot,
} from '../src/index.js';
const id = (number: number) => `00000000-0000-0000-0000-${String(number).padStart(12, '0')}`;
interface Device {
readonly id: number;
readonly kind: NodeKind;
readonly ports: number;
readonly ip?: string;
readonly relay?: RemoteLinkRelay;
}
/** A project: its devices (port n of device d is d * 10 + n) and cables between ports. */
function project(devices: readonly Device[], cables: readonly [number, number][]): TopologySnapshot {
const owner = new Map(devices.flatMap((device) => Array.from({ length: device.ports }, (_, index) => [device.id * 10 + index, device.id] as const)));
return topologySnapshot({
nodes: devices.map((device) => ({
id: id(device.id),
kind: device.kind,
ports: Array.from({ length: device.ports }, (_, index) => portSnapshot(id(device.id * 10 + index), `p${index}`)),
})),
links: cables.map(([source, target], index) => ({
id: id(900_000 + source * 1000 + target + index),
sourceNodeId: id(owner.get(source)!),
sourcePortId: id(source),
targetNodeId: id(owner.get(target)!),
targetPortId: id(target),
})),
deviceConfigurations: new Map(devices.filter((device) => device.ip !== undefined).map((device) => [id(device.id), deviceConfiguration(device.ip!, '255.255.255.0', '', '')])),
remoteLinkConfigurationsByNodeId: new Map(
devices
.filter((device) => device.kind === 'remoteLink')
.map((device) => [id(device.id), { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true, ...(device.relay === undefined ? {} : { relay: device.relay }) }]),
),
});
}
/**
* Two engines whose relay modems are paired as `pairs` says (A's modem →
* B's); what one relays reaches the other at its next step. `relayed`
* counts what went across.
*/
function joined(a: TopologySnapshot, b: TopologySnapshot, pairs: readonly [number, number][]) {
const queue: { to: 'a' | 'b'; node: Id; frame: EthernetFrame }[] = [];
const toB = new Map(pairs.map(([from, to]) => [id(from), id(to)]));
const toA = new Map(pairs.map(([from, to]) => [id(to), id(from)]));
let relayed = 0;
const engineA = new NetworkRuntimeEngine(7, undefined, {
relay: (node, frame) => {
relayed += 1;
queue.push({ to: 'b', node: toB.get(node)!, frame });
},
});
const engineB = new NetworkRuntimeEngine(8, undefined, {
relay: (node, frame) => {
relayed += 1;
queue.push({ to: 'a', node: toA.get(node)!, frame });
},
});
engineA.handle({ type: 'start', snapshot: a, seed: 7, initialTimeMilliseconds: 0 });
engineB.handle({ type: 'start', snapshot: b, seed: 8, initialTimeMilliseconds: 0 });
/** Both engines a millisecond further, then what is queued delivered. */
const step = () => {
for (const engine of [engineA, engineB]) engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 1 });
for (const { to, node, frame } of queue.splice(0)) (to === 'a' ? engineA : engineB).receiveRelayedFrame(node, frame);
};
const run = (milliseconds: number) => {
for (let index = 0; index < milliseconds; index += 1) step();
};
return { engineA, engineB, run, relayed: () => relayed };
}
/** A UDP datagram from `from` to `target` (a send returns at once, as in Java; ARP runs while the engines step). */
function sendUdp(engine: NetworkRuntimeEngine, from: number, target: string): void {
const socket = engine.bindUdpSocket(id(from), { remoteIPAddress: target, remotePort: 9 })!;
engine.sendUdp(socket, new TextEncoder().encode('hallo'));
}
/** Whether a card captured a frame of `kind` from `sender`'s address. */
function captured(engine: NetworkRuntimeEngine, port: number, kind: 'arp reply' | 'udp', sender: string): boolean {
return engine.capture
.frames(id(port))
.some(({ frame }) =>
kind === 'arp reply'
? frame.payload.kind === 'arp' && frame.payload.packet.operation === 'reply' && frame.payload.packet.senderIPAddress === sender
: frame.payload.kind === 'ipv4' && frame.payload.packet.protocol === 'udp' && frame.payload.packet.senderIPAddress === sender,
);
}
const A = project(
[
{ id: 1, kind: 'pc', ports: 1, ip: '10.0.0.1' },
{ id: 2, kind: 'remoteLink', ports: 1, relay: { role: 'offer' } },
],
[[10, 20]],
);
const B = project(
[
{ id: 3, kind: 'remoteLink', ports: 1, relay: { role: 'join', code: 'K7F3QX' } },
{ id: 4, kind: 'networkSwitch', ports: 3 },
{ id: 5, kind: 'pc', ports: 1, ip: '10.0.0.2' },
],
[
[30, 40],
[41, 50],
],
);
describe('a modem of another project', () => {
it('stands as relay on its cable, detached without', () => {
const { engineA } = joined(A, B, [[2, 3]]);
expect(engineA.remoteLinkCondition(id(2))).toEqual({ kind: 'relay' });
const alone = new NetworkRuntimeEngine(7);
alone.handle({ type: 'start', snapshot: project([{ id: 2, kind: 'remoteLink', ports: 1, relay: { role: 'offer' } }], []), seed: 7, initialTimeMilliseconds: 0 });
expect(alone.remoteLinkCondition(id(2))).toEqual({ kind: 'detached' });
});
it('joins two projects: ARP goes across and back, then the datagram', () => {
const { engineA, engineB, run } = joined(A, B, [[2, 3]]);
sendUdp(engineA, 1, '10.0.0.2');
run(3_000);
expect(captured(engineA, 10, 'arp reply', '10.0.0.2')).toBe(true);
expect(captured(engineB, 50, 'udp', '10.0.0.1')).toBe(true);
// A modem has no card; each side captures only its own cards.
expect(engineA.capture.frames(id(20))).toEqual([]);
});
it('numbers a frame from the other project anew, so that a switch does not take it for one it saw', () => {
// B with a second computer on the switch: their datagrams go between them, unicast, not across.
const busyB = project(
[
{ id: 3, kind: 'remoteLink', ports: 1, relay: { role: 'join', code: 'K7F3QX' } },
{ id: 4, kind: 'networkSwitch', ports: 3 },
{ id: 5, kind: 'pc', ports: 1, ip: '10.0.0.2' },
{ id: 9, kind: 'pc', ports: 1, ip: '10.0.0.3' },
],
[
[30, 40],
[41, 50],
[42, 90],
],
);
const { engineA, engineB, run } = joined(A, busyB, [[2, 3]]);
for (let index = 0; index < 5; index += 1) {
sendUdp(engineB, 5, '10.0.0.3');
run(500);
}
// B's switch has seen the number A's next frame will carry.
expect(engineB.state.switchSeenFrameIdentitiesByNodeId.get(id(4))!.has(engineA.state.nextFrameIdentity)).toBe(true);
sendUdp(engineA, 1, '10.0.0.2');
run(3_000);
expect(captured(engineB, 50, 'udp', '10.0.0.1')).toBe(true);
expect(engineB.state.packetTraces.some((event) => event.detail === 'switch cycle suppression')).toBe(false);
});
it(`drops a frame after ${MAXIMUM_RELAY_HOPS} trips between projects: two pairs between the same networks make no endless circle`, () => {
const a = project(
[
{ id: 1, kind: 'pc', ports: 1, ip: '10.0.0.1' },
{ id: 6, kind: 'networkSwitch', ports: 3 },
{ id: 2, kind: 'remoteLink', ports: 1, relay: { role: 'offer' } },
{ id: 7, kind: 'remoteLink', ports: 1, relay: { role: 'offer' } },
],
[
[10, 60],
[61, 20],
[62, 70],
],
);
const b = project(
[
{ id: 3, kind: 'remoteLink', ports: 1, relay: { role: 'join', code: 'AAAAAA' } },
{ id: 8, kind: 'remoteLink', ports: 1, relay: { role: 'join', code: 'BBBBBB' } },
{ id: 4, kind: 'networkSwitch', ports: 2 },
],
[
[30, 40],
[80, 41],
],
);
const { engineA, engineB, run, relayed } = joined(a, b, [
[2, 3],
[7, 8],
]);
sendUdp(engineA, 1, '10.0.0.9');
run(20_000);
const dropped = [engineA, engineB].flatMap((engine) => engine.state.packetTraces).filter((event) => event.detail === 'remote link relay hop limit');
expect(dropped.length).toBeGreaterThan(0);
// Two ARP requests (Java asks twice), each going round until its trips are used up.
expect(relayed()).toBeLessThanOrEqual(2 * 2 * MAXIMUM_RELAY_HOPS);
});
it('is no partner for a modem of this project on the same port', () => {
const engine = new NetworkRuntimeEngine(7);
const mixed = project(
[
{ id: 1, kind: 'pc', ports: 1, ip: '10.0.0.1' },
{ id: 2, kind: 'remoteLink', ports: 1 },
{ id: 3, kind: 'remoteLink', ports: 1, relay: { role: 'offer' } },
{ id: 5, kind: 'pc', ports: 1, ip: '10.0.0.2' },
],
[
[10, 20],
[30, 50],
],
);
engine.handle({ type: 'start', snapshot: mixed, seed: 7, initialTimeMilliseconds: 0 });
expect(engine.remoteLinkCondition(id(2))).toEqual({ kind: 'unpaired' });
});
it('loses what reaches it without a relay, as an unconnected modem does', () => {
const engine = new NetworkRuntimeEngine(7);
engine.handle({ type: 'start', snapshot: A, seed: 7, initialTimeMilliseconds: 0 });
sendUdp(engine, 1, '10.0.0.2');
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
expect(engine.state.packetTraces.some((event) => event.nodeId === id(2) && event.detail === 'remote link relay unavailable')).toBe(true);
});
it('takes nothing from the relay while stopped, nor at a modem of this project', () => {
const { engineA, engineB } = joined(A, B, [[2, 3]]);
const frame: EthernetFrame = {
identity: 1,
sourceMacAddress: '02:00:00:00:00:01',
destinationMacAddress: 'FF:FF:FF:FF:FF:FF',
payload: {
kind: 'arp',
packet: { operation: 'request', senderMacAddress: '02:00:00:00:00:01', senderIPAddress: '10.0.0.1', targetMacAddress: '00:00:00:00:00:00', targetIPAddress: '10.0.0.2' },
},
};
expect(engineB.receiveRelayedFrame(id(5), frame)).toBe(false);
expect(engineB.receiveRelayedFrame(id(3), frame)).toBe(true);
engineA.handle({ type: 'stop' });
expect(engineA.receiveRelayedFrame(id(2), frame)).toBe(false);
const inProject = new NetworkRuntimeEngine(7);
inProject.handle({
type: 'start',
snapshot: project(
[
{ id: 2, kind: 'remoteLink', ports: 1 },
{ id: 1, kind: 'pc', ports: 1, ip: '10.0.0.1' },
],
[[10, 20]],
),
seed: 7,
initialTimeMilliseconds: 0,
});
expect(inProject.receiveRelayedFrame(id(2), frame)).toBe(false);
});
});
+8
View File
@@ -58,6 +58,11 @@ export interface Scenario {
portForwarding?: Record<string, [string, string, string, string][]>;
/** Routers with RIP switched on. */
rip?: string[];
/**
* Modems: Java pairs a server and a client on a port over TCP; the core
* pairs the two modems on a port (the mode is Java's only).
*/
modems?: Record<string, { mode: 'server' | 'client'; port: number }>;
/** Records as `name TYPE target` (Java's hinzuRecord: TTL 3600). */
dnsServers?: Record<string, { records: string[]; recursive?: boolean }>;
steps: {
@@ -124,6 +129,9 @@ export function snapshotOf(scenario: Scenario): TopologySnapshot {
targetNodeId: link.targetNode,
targetPortId: link.targetPort,
})),
remoteLinkConfigurationsByNodeId: new Map(
Object.entries(scenario.modems ?? {}).map(([node, modem]) => [node, { pairIdentifier: `java-modem-port-${modem.port}`, latencyMilliseconds: 20, isEnabled: true }]),
),
deviceConfigurations: new Map(
Object.entries(scenario.devices ?? {}).map(([node, device]) => [
node,
@@ -0,0 +1,580 @@
{
"frames": {
"00000000-0000-0000-0000-000000000401": [
{
"time": 734,
"source": "02:00:00:00:04:11",
"destination": "FF:FF:FF:FF:FF:FF",
"arp": 1,
"senderIp": "10.0.0.1",
"targetIp": "10.0.0.2"
},
{
"time": 1124,
"source": "02:00:00:00:04:13",
"destination": "02:00:00:00:04:11",
"arp": 2,
"senderIp": "10.0.0.2",
"targetIp": "10.0.0.1"
},
{
"time": 1129,
"source": "02:00:00:00:04:11",
"destination": "02:00:00:00:04:13",
"icmp": 8,
"icmpCode": 0,
"sourceIp": "10.0.0.1",
"destinationIp": "10.0.0.2",
"sequence": 1
},
{
"time": 1466,
"source": "02:00:00:00:04:13",
"destination": "02:00:00:00:04:11",
"icmp": 0,
"icmpCode": 0,
"sourceIp": "10.0.0.2",
"destinationIp": "10.0.0.1",
"sequence": 1
},
{
"time": 1469,
"source": "02:00:00:00:04:11",
"destination": "02:00:00:00:04:13",
"icmp": 8,
"icmpCode": 0,
"sourceIp": "10.0.0.1",
"destinationIp": "10.0.0.2",
"sequence": 2
},
{
"time": 1803,
"source": "02:00:00:00:04:13",
"destination": "02:00:00:00:04:11",
"icmp": 0,
"icmpCode": 0,
"sourceIp": "10.0.0.2",
"destinationIp": "10.0.0.1",
"sequence": 2
}
],
"00000000-0000-0000-0000-000000000403": [
{
"time": 915,
"source": "02:00:00:00:04:11",
"destination": "FF:FF:FF:FF:FF:FF",
"arp": 1,
"senderIp": "10.0.0.1",
"targetIp": "10.0.0.2"
},
{
"time": 915,
"source": "02:00:00:00:04:13",
"destination": "02:00:00:00:04:11",
"arp": 2,
"senderIp": "10.0.0.2",
"targetIp": "10.0.0.1"
},
{
"time": 1298,
"source": "02:00:00:00:04:11",
"destination": "02:00:00:00:04:13",
"icmp": 8,
"icmpCode": 0,
"sourceIp": "10.0.0.1",
"destinationIp": "10.0.0.2",
"sequence": 1
},
{
"time": 1299,
"source": "02:00:00:00:04:13",
"destination": "02:00:00:00:04:11",
"icmp": 0,
"icmpCode": 0,
"sourceIp": "10.0.0.2",
"destinationIp": "10.0.0.1",
"sequence": 1
},
{
"time": 1636,
"source": "02:00:00:00:04:11",
"destination": "02:00:00:00:04:13",
"icmp": 8,
"icmpCode": 0,
"sourceIp": "10.0.0.1",
"destinationIp": "10.0.0.2",
"sequence": 2
},
{
"time": 1636,
"source": "02:00:00:00:04:13",
"destination": "02:00:00:00:04:11",
"icmp": 0,
"icmpCode": 0,
"sourceIp": "10.0.0.2",
"destinationIp": "10.0.0.1",
"sequence": 2
}
],
"00000000-0000-0000-0000-000000000407": [
{
"time": 858,
"source": "02:00:00:00:04:11",
"destination": "FF:FF:FF:FF:FF:FF",
"arp": 1,
"senderIp": "10.0.0.1",
"targetIp": "10.0.0.2"
}
]
},
"rows": {
"00000000-0000-0000-0000-000000000401": [
[
"1",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
],
[
"2",
"02:00:00:00:04:13",
"02:00:00:00:04:11",
"",
"Netzzugang",
"0x806"
],
[
"2",
"10.0.0.2",
"10.0.0.1",
"ARP",
"Vermittlung",
"MAC ist 02:00:00:00:04:13 [op=REPLY, sender=02:00:00:00:04:13|10.0.0.2, target=02:00:00:00:04:11|10.0.0.1]"
],
[
"3",
"02:00:00:00:04:11",
"02:00:00:00:04:13",
"",
"Netzzugang",
"0x800"
],
[
"3",
"10.0.0.1",
"10.0.0.2",
"ICMP",
"Vermittlung",
"ICMP Echo Request (ping), TTL: 64, Identifier: 1, Seq.-No.: 1"
],
[
"4",
"02:00:00:00:04:13",
"02:00:00:00:04:11",
"",
"Netzzugang",
"0x800"
],
[
"4",
"10.0.0.2",
"10.0.0.1",
"ICMP",
"Vermittlung",
"ICMP Echo Reply (pong), TTL: 64, Identifier: 1, Seq.-No.: 1"
],
[
"5",
"02:00:00:00:04:11",
"02:00:00:00:04:13",
"",
"Netzzugang",
"0x800"
],
[
"5",
"10.0.0.1",
"10.0.0.2",
"ICMP",
"Vermittlung",
"ICMP Echo Request (ping), TTL: 64, Identifier: 1, Seq.-No.: 2"
],
[
"6",
"02:00:00:00:04:13",
"02:00:00:00:04:11",
"",
"Netzzugang",
"0x800"
],
[
"6",
"10.0.0.2",
"10.0.0.1",
"ICMP",
"Vermittlung",
"ICMP Echo Reply (pong), TTL: 64, Identifier: 1, Seq.-No.: 2"
]
],
"00000000-0000-0000-0000-000000000403": [
[
"1",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
],
[
"2",
"02:00:00:00:04:13",
"02:00:00:00:04:11",
"",
"Netzzugang",
"0x806"
],
[
"2",
"10.0.0.2",
"10.0.0.1",
"ARP",
"Vermittlung",
"MAC ist 02:00:00:00:04:13 [op=REPLY, sender=02:00:00:00:04:13|10.0.0.2, target=02:00:00:00:04:11|10.0.0.1]"
],
[
"3",
"02:00:00:00:04:11",
"02:00:00:00:04:13",
"",
"Netzzugang",
"0x800"
],
[
"3",
"10.0.0.1",
"10.0.0.2",
"ICMP",
"Vermittlung",
"ICMP Echo Request (ping), TTL: 64, Identifier: 1, Seq.-No.: 1"
],
[
"4",
"02:00:00:00:04:13",
"02:00:00:00:04:11",
"",
"Netzzugang",
"0x800"
],
[
"4",
"10.0.0.2",
"10.0.0.1",
"ICMP",
"Vermittlung",
"ICMP Echo Reply (pong), TTL: 64, Identifier: 1, Seq.-No.: 1"
],
[
"5",
"02:00:00:00:04:11",
"02:00:00:00:04:13",
"",
"Netzzugang",
"0x800"
],
[
"5",
"10.0.0.1",
"10.0.0.2",
"ICMP",
"Vermittlung",
"ICMP Echo Request (ping), TTL: 64, Identifier: 1, Seq.-No.: 2"
],
[
"6",
"02:00:00:00:04:13",
"02:00:00:00:04:11",
"",
"Netzzugang",
"0x800"
],
[
"6",
"10.0.0.2",
"10.0.0.1",
"ICMP",
"Vermittlung",
"ICMP Echo Reply (pong), TTL: 64, Identifier: 1, Seq.-No.: 2"
]
],
"00000000-0000-0000-0000-000000000407": [
[
"1",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
]
]
},
"layeredRows": {
"00000000-0000-0000-0000-000000000401": [
[
"1",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
],
[
"2",
"02:00:00:00:04:13",
"02:00:00:00:04:11",
"",
"Netzzugang",
"0x806"
],
[
"2",
"10.0.0.2",
"10.0.0.1",
"ARP",
"Vermittlung",
"MAC ist 02:00:00:00:04:13 [op=REPLY, sender=02:00:00:00:04:13|10.0.0.2, target=02:00:00:00:04:11|10.0.0.1]"
],
[
"3",
"02:00:00:00:04:11",
"02:00:00:00:04:13",
"",
"Netzzugang",
"0x800"
],
[
"3",
"10.0.0.1",
"10.0.0.2",
"ICMP",
"Vermittlung",
"ICMP Echo Request (ping), TTL: 64, Identifier: 1, Seq.-No.: 1"
],
[
"4",
"02:00:00:00:04:13",
"02:00:00:00:04:11",
"",
"Netzzugang",
"0x800"
],
[
"4",
"10.0.0.2",
"10.0.0.1",
"ICMP",
"Vermittlung",
"ICMP Echo Reply (pong), TTL: 64, Identifier: 1, Seq.-No.: 1"
],
[
"5",
"02:00:00:00:04:11",
"02:00:00:00:04:13",
"",
"Netzzugang",
"0x800"
],
[
"5",
"10.0.0.1",
"10.0.0.2",
"ICMP",
"Vermittlung",
"ICMP Echo Request (ping), TTL: 64, Identifier: 1, Seq.-No.: 2"
],
[
"6",
"02:00:00:00:04:13",
"02:00:00:00:04:11",
"",
"Netzzugang",
"0x800"
],
[
"6",
"10.0.0.2",
"10.0.0.1",
"ICMP",
"Vermittlung",
"ICMP Echo Reply (pong), TTL: 64, Identifier: 1, Seq.-No.: 2"
]
],
"00000000-0000-0000-0000-000000000403": [
[
"1",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
],
[
"2",
"02:00:00:00:04:13",
"02:00:00:00:04:11",
"",
"Netzzugang",
"0x806"
],
[
"2",
"10.0.0.2",
"10.0.0.1",
"ARP",
"Vermittlung",
"MAC ist 02:00:00:00:04:13 [op=REPLY, sender=02:00:00:00:04:13|10.0.0.2, target=02:00:00:00:04:11|10.0.0.1]"
],
[
"3",
"02:00:00:00:04:11",
"02:00:00:00:04:13",
"",
"Netzzugang",
"0x800"
],
[
"3",
"10.0.0.1",
"10.0.0.2",
"ICMP",
"Vermittlung",
"ICMP Echo Request (ping), TTL: 64, Identifier: 1, Seq.-No.: 1"
],
[
"4",
"02:00:00:00:04:13",
"02:00:00:00:04:11",
"",
"Netzzugang",
"0x800"
],
[
"4",
"10.0.0.2",
"10.0.0.1",
"ICMP",
"Vermittlung",
"ICMP Echo Reply (pong), TTL: 64, Identifier: 1, Seq.-No.: 1"
],
[
"5",
"02:00:00:00:04:11",
"02:00:00:00:04:13",
"",
"Netzzugang",
"0x800"
],
[
"5",
"10.0.0.1",
"10.0.0.2",
"ICMP",
"Vermittlung",
"ICMP Echo Request (ping), TTL: 64, Identifier: 1, Seq.-No.: 2"
],
[
"6",
"02:00:00:00:04:13",
"02:00:00:00:04:11",
"",
"Netzzugang",
"0x800"
],
[
"6",
"10.0.0.2",
"10.0.0.1",
"ICMP",
"Vermittlung",
"ICMP Echo Reply (pong), TTL: 64, Identifier: 1, Seq.-No.: 2"
]
],
"00000000-0000-0000-0000-000000000407": [
[
"1",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
]
]
},
"events": [
[
728,
"sendProbe 10.0.0.2 called"
],
[
1468,
"sendProbe 10.0.0.2 returned ICMP 0/0 from 10.0.0.2"
],
[
1468,
"sendProbe 10.0.0.2 called"
],
[
1804,
"sendProbe 10.0.0.2 returned ICMP 0/0 from 10.0.0.2"
]
]
}
@@ -0,0 +1,193 @@
{
"frames": {
"00000000-0000-0000-0000-000000000401": [
{
"time": 717,
"source": "02:00:00:00:04:11",
"destination": "FF:FF:FF:FF:FF:FF",
"arp": 1,
"senderIp": "10.0.0.1",
"targetIp": "10.0.0.2"
},
{
"time": 3468,
"source": "02:00:00:00:04:11",
"destination": "FF:FF:FF:FF:FF:FF",
"arp": 1,
"senderIp": "10.0.0.1",
"targetIp": "10.0.0.2"
}
],
"00000000-0000-0000-0000-000000000403": [],
"00000000-0000-0000-0000-000000000407": [
{
"time": 838,
"source": "02:00:00:00:04:11",
"destination": "FF:FF:FF:FF:FF:FF",
"arp": 1,
"senderIp": "10.0.0.1",
"targetIp": "10.0.0.2"
},
{
"time": 3579,
"source": "02:00:00:00:04:11",
"destination": "FF:FF:FF:FF:FF:FF",
"arp": 1,
"senderIp": "10.0.0.1",
"targetIp": "10.0.0.2"
}
]
},
"rows": {
"00000000-0000-0000-0000-000000000401": [
[
"1",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
],
[
"2",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"2",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
]
],
"00000000-0000-0000-0000-000000000403": [],
"00000000-0000-0000-0000-000000000407": [
[
"1",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
],
[
"2",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"2",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
]
]
},
"layeredRows": {
"00000000-0000-0000-0000-000000000401": [
[
"1",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
],
[
"2",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"2",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
]
],
"00000000-0000-0000-0000-000000000403": [],
"00000000-0000-0000-0000-000000000407": [
[
"1",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"1",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
],
[
"2",
"02:00:00:00:04:11",
"FF:FF:FF:FF:FF:FF",
"",
"Netzzugang",
"0x806"
],
[
"2",
"10.0.0.1",
"10.0.0.2",
"ARP",
"Vermittlung",
"Suche nach MAC für 10.0.0.2 [op=REQUEST, sender=02:00:00:00:04:11|10.0.0.1, target=FF:FF:FF:FF:FF:FF|10.0.0.2]"
]
]
},
"events": [
[
713,
"sendProbe 10.0.0.2 called"
],
[
6224,
"sendProbe 10.0.0.2 returned ICMP 3/1 from 127.0.0.1"
]
]
}
@@ -0,0 +1,128 @@
{
"seed": 67,
"nodes": [
{
"id": "00000000-0000-0000-0000-000000000401",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000411",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000403",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000413",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000407",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000417",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000404",
"kind": "networkSwitch",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000441",
"label": "sw1"
},
{
"id": "00000000-0000-0000-0000-000000000442",
"label": "sw2"
},
{
"id": "00000000-0000-0000-0000-000000000443",
"label": "sw3"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000405",
"kind": "remoteLink",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000451",
"label": "remote0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000406",
"kind": "remoteLink",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000461",
"label": "remote0"
}
]
}
],
"links": [
{
"id": "00000000-0000-0000-0000-00000000F401",
"sourceNode": "00000000-0000-0000-0000-000000000401",
"sourcePort": "00000000-0000-0000-0000-000000000411",
"targetNode": "00000000-0000-0000-0000-000000000404",
"targetPort": "00000000-0000-0000-0000-000000000441"
},
{
"id": "00000000-0000-0000-0000-00000000F402",
"sourceNode": "00000000-0000-0000-0000-000000000407",
"sourcePort": "00000000-0000-0000-0000-000000000417",
"targetNode": "00000000-0000-0000-0000-000000000404",
"targetPort": "00000000-0000-0000-0000-000000000442"
},
{
"id": "00000000-0000-0000-0000-00000000F403",
"sourceNode": "00000000-0000-0000-0000-000000000404",
"sourcePort": "00000000-0000-0000-0000-000000000443",
"targetNode": "00000000-0000-0000-0000-000000000405",
"targetPort": "00000000-0000-0000-0000-000000000451"
},
{
"id": "00000000-0000-0000-0000-00000000F404",
"sourceNode": "00000000-0000-0000-0000-000000000406",
"sourcePort": "00000000-0000-0000-0000-000000000461",
"targetNode": "00000000-0000-0000-0000-000000000403",
"targetPort": "00000000-0000-0000-0000-000000000413"
}
],
"devices": {
"00000000-0000-0000-0000-000000000401": {
"ip": "10.0.0.1",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000403": {
"ip": "10.0.0.2",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000407": {
"ip": "10.0.0.3",
"mask": "255.255.255.0"
}
},
"modems": {
"00000000-0000-0000-0000-000000000405": {
"mode": "server",
"port": 12345
},
"00000000-0000-0000-0000-000000000406": {
"mode": "client",
"port": 12345
}
},
"steps": []
}
+1
View File
@@ -59,6 +59,7 @@ export function projectSnapshot(project: ImportedProject): TopologySnapshot {
firewallConfigurationsByNodeId: project.firewallConfigurationsByNodeId,
portForwardingRowsByNodeId: project.portForwardingRowsByNodeId,
switchConfigurationsByNodeId: project.switchConfigurations,
remoteLinkConfigurationsByNodeId: project.remoteLinkConfigurations,
ipForwardingByNodeId: project.ipForwardingByNodeId,
echoServersByNodeId: echoServers,
simpleClientsByNodeId: project.simpleClientConfigurations,
+40 -10
View File
@@ -7,7 +7,7 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { NetworkRuntimeEngine, type Id } from '@mdg-filius/core';
import { docSnapshot, readProject, writeProject } from '@mdg-filius/doc';
@@ -27,6 +27,22 @@ function exampleDoc(file = 'java-examples/internetwork_komplett.fls'): Y.Doc {
}
/** A room on a clock the test moves, and a client that keeps what it gets. */
/**
* A core that fails at the first frame `node` sends — as it once did at a
* modem, before the core had modems. Every test lets it go again.
*/
function failingAt(node: Id): void {
const send = NetworkRuntimeEngine.prototype.sendEthernetFrame;
vi.spyOn(NetworkRuntimeEngine.prototype, 'sendEthernetFrame').mockImplementation(function (this: NetworkRuntimeEngine, from, port, frame) {
if (from === node) throw new Error('core broke');
send.call(this, from, port, frame);
});
}
afterEach(() => {
vi.restoreAllMocks();
});
function setUp(doc = exampleDoc()) {
let now = 1_000_000;
const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED, epoch: () => 1_700_000_000_000 + now });
@@ -102,11 +118,12 @@ describe('a simulation room', () => {
});
it('forgets who started it when the core fails', () => {
const { room, run, node } = setUp(exampleDoc('java-examples/modem_beispiel.fls'));
const { room, run, node } = setUp();
failingAt(node('Webserver 5.10').id);
const mia: HostMessage[] = [];
const miaId = room.connect((message) => mia.push(message), 'Mia');
room.receive(miaId, { v: 1, kind: 'start' });
room.receive(miaId, { v: 1, kind: 'input', node: node('Notebook 0.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' });
room.receive(miaId, { v: 1, kind: 'input', node: node('Notebook 1.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r' });
run(10_000);
expect(ofKind(mia, 'state').at(-1)).toMatchObject({ mode: 'design' });
expect(ofKind(mia, 'state').at(-1)).not.toHaveProperty('startedBy');
@@ -287,16 +304,16 @@ describe('a simulation room', () => {
});
it('tells every client when the core fails, and goes back to design mode', () => {
// FILIUS' modem example: remote links are not in the core yet, it throws at the first frame to a modem.
const { room, got, send, run, node } = setUp(exampleDoc('java-examples/modem_beispiel.fls'));
const { room, got, send, run, node } = setUp();
failingAt(node('Webserver 5.10').id);
const other: HostMessage[] = [];
room.connect((message) => other.push(message));
const ping = () => send({ kind: 'input', node: node('Notebook 0.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' });
const ping = () => send({ kind: 'input', node: node('Notebook 1.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r' });
send({ kind: 'start' });
ping();
run(10_000);
for (const each of [got, other]) {
expect(ofKind(each, 'error')).toEqual([{ v: 1, kind: 'error', code: 'engineFailed', detail: expect.stringContaining('remote links') }]);
expect(ofKind(each, 'error')).toEqual([{ v: 1, kind: 'error', code: 'engineFailed', detail: 'core broke' }]);
expect(ofKind(each, 'state').at(-1)).toMatchObject({ mode: 'design' });
}
expect(room.currentMode).toBe('design');
@@ -307,6 +324,18 @@ describe('a simulation room', () => {
expect(ofKind(got, 'error')).toHaveLength(2);
});
it("runs FILIUS' modem example: a ping goes through the pair of modems", () => {
const { got, send, run, node } = setUp(exampleDoc('java-examples/modem_beispiel.fls'));
const notebook = node('Notebook 0.10');
send({ kind: 'sub', topics: [`app:${notebook.id}/terminal`] });
send({ kind: 'start' });
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' });
run(10_000);
expect(ofKind(got, 'error')).toEqual([]);
const text = ofKind(got, 'app').flatMap((each) => each.lines as { text?: string }[]).map((line) => line.text ?? '').join('');
expect(text).toContain('4 Paket(e) gesendet, 4 Paket(e) empfangen');
});
it('sends a card anew to a client that watches it again', () => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');
@@ -340,14 +369,15 @@ describe('a simulation room', () => {
});
it('reads the document anew at the start after a failure', () => {
const doc = exampleDoc('java-examples/modem_beispiel.fls');
const doc = exampleDoc();
const { got, send, run, node } = setUp(doc);
const ping = () => send({ kind: 'input', node: node('Notebook 0.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' });
failingAt(node('Webserver 5.10').id);
const ping = () => send({ kind: 'input', node: node('Notebook 1.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r' });
send({ kind: 'start' });
ping();
run(10_000);
expect(ofKind(got, 'error')).toHaveLength(1);
// Without the modems' cables, nothing reaches a modem: the next start runs.
// Without the cables, nothing reaches the web server, which fails the core: the next start runs.
doc.getMap('links').clear();
send({ kind: 'start' });
ping();