// 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); }); });