feat(core): UDP sockets in the simulation core

engine.ts ports the UDP part of Swift's "UDP sockets and Router RIP": a
socket bound to a given port or to a free one drawn from 49152–65534 with
Java's random numbers on Swift's seed; sending, also as a limited broadcast
on every interface (or the bound one) with TTL 1; delivery into the queue
of every socket on the destination port, which then answers to the last
sender; receiving, with a timeout that advances the virtual clock; closing.
Stopping forgets sockets and queues. RIP stays not ported; Swift's DHCP
handling of a datagram has nothing to do until DHCP is in the snapshot.

The oracle gets the steps bindUdp, sendUdp, receiveUdp and closeSocket on
both sides and reports the sockets. Two new scenarios — a LAN, and two
networks through a router with a UDP firewall rule and a broadcast that
does not cross it — match the Swift engine trace for trace, the random
ports included. Swift's four UDP engine tests are ported, with a test for
stopping. 11 mutation probes were all caught (one only after the stop test
was added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 09:54:04 +00:00
co-authored by Claude Opus 5.5
parent c8f2e9e225
commit e80be3bbdf
15 changed files with 5862 additions and 6 deletions
+111 -2
View File
@@ -1,8 +1,9 @@
// Ported from filius-ipad ios/FiliusPadTests/TopologyNetworkRuntimeEngineTests.swift:
// the event queue, the virtual clock, Java-compatible randomness, restart,
// Ethernet/switch/ARP/IPv4/ICMP, routing errors, the firewall, packet loss
// and trace retention. The remaining Swift tests (UDP, TCP, DHCP, DNS, RIP,
// NAT, remote link, packet viewer) follow with their protocol slices.
// and trace retention, UDP sockets. The remaining Swift tests (TCP, DHCP,
// DNS, RIP, NAT, remote link, packet viewer) follow with their protocol
// slices.
import { describe, expect, it } from 'vitest';
import {
@@ -191,6 +192,114 @@ describe('event queue and virtual clock', () => {
expect(engine.allocateFrameIdentity()).toBe(1);
expect(engine.allocatePacketIdentity()).toBe(1);
});
it('forgets UDP sockets on stop, binds none while stopped, and numbers them from one again', () => {
const nodeId = uuid(0x13);
const portId = uuid(0x14);
const input = { nodes: [pc(nodeId, portId)], links: [], deviceConfigurations: new Map([[nodeId, device('10.2.0.1')]]) };
const engine = startedEngine(input);
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBe(uuid(1));
expect(engine.bindUdpSocket(nodeId)).toBe(uuid(2));
engine.handle({ type: 'stop' });
expect(engine.state.socketsById.size).toBe(0);
expect(engine.state.udpReceiveQueuesBySocketId.size).toBe(0);
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBeUndefined();
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed: 14, initialTimeMilliseconds: 0 });
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBe(uuid(1));
});
});
describe('UDP sockets', () => {
const text = (payload: Uint8Array | undefined) => new TextDecoder().decode(payload ?? new Uint8Array());
it('binds, delivers and releases a port', () => {
const [sourceNodeId, sourcePortId, targetNodeId, targetPortId] = [0x15b1, 0x15b2, 0x15b3, 0x15b4].map(uuid) as [string, string, string, string];
const engine = startedEngine({
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
deviceConfigurations: new Map([
[sourceNodeId, device('192.168.5.10')],
[targetNodeId, device('192.168.5.20')],
]),
});
const automatic = engine.bindUdpSocket(targetNodeId);
const automaticPort = automatic === undefined ? undefined : engine.state.socketsById.get(automatic)?.localPort;
expect(automaticPort).toBeGreaterThanOrEqual(49_152);
expect(automaticPort).toBeLessThan(65_535);
const receiver = engine.bindUdpSocket(targetNodeId, { localPort: 9000 });
const sender = engine.bindUdpSocket(sourceNodeId, { localPort: 8000, remoteIPAddress: '192.168.5.20', remotePort: 9000 });
expect(receiver).toBeDefined();
expect(sender).toBeDefined();
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeUndefined();
engine.sendUdp(sender!, new TextEncoder().encode('hello'));
const received = engine.receiveUdp(receiver!);
expect(text(received?.datagram.payload)).toBe('hello');
expect(received?.senderIPAddress).toBe('192.168.5.10');
engine.closeSocket(receiver!);
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeDefined();
});
it('draws ephemeral ports without disturbing the seeded RIP jitter', () => {
const nodeId = uuid(0x15f1);
const input = { nodes: [pc(nodeId, uuid(0x15f2))], links: [], deviceConfigurations: new Map([[nodeId, device('192.168.10.10')]]) };
const socketEngine = startedEngine(input);
const ripOnlyEngine = startedEngine(input);
expect(socketEngine.bindUdpSocket(nodeId)).toBeDefined();
expect(socketEngine.nextRipJitterMilliseconds()).toBe(ripOnlyEngine.nextRipJitterMilliseconds());
});
it('broadcasts on every interface, and the receiver replies to its last peer', () => {
const [routerId, routerPortA, routerPortB, pcAId, pcAPort, pcBId, pcBPort] = [0x15e1, 0x15e2, 0x15e3, 0x15e4, 0x15e5, 0x15e6, 0x15e7].map(
uuid,
) as [string, string, string, string, string, string, string];
const engine = startedEngine(
{
nodes: [
{ id: routerId, kind: 'router', ports: [portSnapshot(routerPortA, 'rt1'), portSnapshot(routerPortB, 'rt2')] },
pc(pcAId, pcAPort),
pc(pcBId, pcBPort),
],
links: [link(routerId, routerPortA, pcAId, pcAPort), link(routerId, routerPortB, pcBId, pcBPort)],
deviceConfigurations: new Map([
[pcAId, device('10.0.0.2')],
[pcBId, device('20.0.0.2')],
]),
interfaceConfigurations: new Map([
[interfaceKey(routerId, routerPortA), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
[interfaceKey(routerId, routerPortB), { ipAddress: '20.0.0.1', subnetMask: '255.255.255.0' }],
]),
},
15,
);
const sender = engine.bindUdpSocket(routerId, { localPort: 8000 })!;
const receiverA = engine.bindUdpSocket(pcAId, { localPort: 9000 })!;
const receiverB = engine.bindUdpSocket(pcBId, { localPort: 9000 })!;
engine.sendUdp(sender, new TextEncoder().encode('broadcast'), { ipAddress: '255.255.255.255', port: 9000 });
expect(engine.receiveUdp(receiverA)?.senderIPAddress).toBe('10.0.0.1');
expect(engine.receiveUdp(receiverB)?.senderIPAddress).toBe('20.0.0.1');
expect(engine.state.socketsById.get(receiverA)?.remoteIPAddress).toBe('10.0.0.1');
expect(engine.state.socketsById.get(receiverA)?.remotePort).toBe(8000);
engine.sendUdp(receiverA, new TextEncoder().encode('reply'));
expect(text(engine.receiveUdp(sender)?.datagram.payload)).toBe('reply');
});
it('advances the virtual clock for a receive timeout and returns nothing', () => {
const nodeId = uuid(0x15d1);
const engine = startedEngine({ nodes: [pc(nodeId, uuid(0x15d2))], links: [], deviceConfigurations: new Map([[nodeId, device('10.1.0.1')]]) });
const socketId = engine.bindUdpSocket(nodeId, { localPort: 9999 })!;
expect(engine.receiveUdp(socketId, 250)).toBeUndefined();
expect(engine.state.currentTimeMilliseconds).toBe(250);
});
});
describe('Java-compatible randomness', () => {
+58
View File
@@ -67,6 +67,14 @@ interface Scenario {
to?: number;
enabled?: boolean;
percent?: number;
// UDP: a socket is named by the id bindUdp returned.
port?: number;
localIp?: string;
remoteIp?: string;
remotePort?: number;
socket?: string;
payload?: string;
timeout?: number;
}[];
}
@@ -239,6 +247,44 @@ function run(scenario: Scenario): Record<string, unknown> {
engine.setSimulationSpeed(step.percent!);
results.push(engine.simulationSpeed.percent);
break;
case 'bindUdp':
results.push(
engine.bindUdpSocket(step.node!, {
...(step.port === undefined ? {} : { localPort: step.port }),
...(step.localIp === undefined ? {} : { localIPAddress: step.localIp }),
...(step.remoteIp === undefined ? {} : { remoteIPAddress: step.remoteIp }),
...(step.remotePort === undefined ? {} : { remotePort: step.remotePort }),
}) ?? null,
);
break;
case 'sendUdp': {
const result = engine.sendUdp(step.socket!, new TextEncoder().encode(step.payload ?? ''), {
...(step.target === undefined ? {} : { ipAddress: step.target }),
...(step.port === undefined ? {} : { port: step.port }),
});
results.push(result === undefined ? null : deliveryJson(result));
break;
}
case 'receiveUdp': {
const item = engine.receiveUdp(step.socket!, step.timeout ?? 0);
results.push(
item === undefined
? null
: {
sender: item.senderIPAddress,
receiver: item.receiverIPAddress,
sourcePort: item.datagram.sourcePort,
destinationPort: item.datagram.destinationPort,
payload: new TextDecoder().decode(item.datagram.payload),
at: item.receivedAtMilliseconds,
},
);
break;
}
case 'closeSocket':
engine.closeSocket(step.socket!);
results.push(!engine.state.socketsById.has(step.socket!));
break;
default:
throw new Error(`unknown step ${step.op}`);
}
@@ -259,6 +305,18 @@ function run(scenario: Scenario): Record<string, unknown> {
Object.fromEntries([...table].map(([mac, port]) => [mac, upper(port)])),
),
icmpObservations: mapOf(state.icmpObservationsByNodeId, (items) => items.map(observationJson)),
sockets: [...state.socketsById.values()]
.sort((lhs, rhs) => (upper(lhs.id) < upper(rhs.id) ? -1 : 1))
.map((socket) => ({
id: upper(socket.id),
node: upper(socket.nodeId),
protocol: socket.protocol,
localIp: socket.localIPAddress ?? null,
localPort: socket.localPort,
remoteIp: socket.remoteIPAddress ?? null,
remotePort: socket.remotePort ?? null,
queued: state.udpReceiveQueuesBySocketId.get(socket.id)?.length ?? 0,
})),
firewallDecisions: state.firewallDecisions.map((decision) => ({
packet: decision.packetIdentity,
node: upper(decision.nodeId),
@@ -80,6 +80,9 @@
"packet" : 7,
"type" : "delivered"
}
],
"sockets" : [
],
"switchTables" : {
"00000000-0000-0000-0000-000000000405" : {
@@ -151,6 +151,9 @@
"type" : "delivered"
},
"02:00:00:00:02:22"
],
"sockets" : [
],
"switchTables" : {
@@ -204,6 +204,9 @@
"packet" : 13,
"type" : "delivered"
}
],
"sockets" : [
],
"switchTables" : {
@@ -795,6 +795,9 @@
"packet" : 3,
"type" : "delivered"
}
],
"sockets" : [
],
"switchTables" : {
"00000000-0000-0000-0000-000000000503" : {
@@ -99,6 +99,9 @@
"packet" : 12,
"type" : "delivered"
}
],
"sockets" : [
],
"switchTables" : {
"00000000-0000-0000-0000-000000000104" : {
@@ -0,0 +1,209 @@
{
"seed": 21,
"nodes": [
{
"id": "00000000-0000-0000-0000-000000000101",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000111",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000102",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000112",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000103",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000113",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000104",
"kind": "networkSwitch",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000121",
"label": "sw1"
},
{
"id": "00000000-0000-0000-0000-000000000122",
"label": "sw2"
},
{
"id": "00000000-0000-0000-0000-000000000123",
"label": "sw3"
},
{
"id": "00000000-0000-0000-0000-000000000124",
"label": "sw4"
}
]
}
],
"links": [
{
"id": "00000000-0000-0000-0000-00000000F001",
"sourceNode": "00000000-0000-0000-0000-000000000101",
"sourcePort": "00000000-0000-0000-0000-000000000111",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000121"
},
{
"id": "00000000-0000-0000-0000-00000000F002",
"sourceNode": "00000000-0000-0000-0000-000000000102",
"sourcePort": "00000000-0000-0000-0000-000000000112",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000122"
},
{
"id": "00000000-0000-0000-0000-00000000F003",
"sourceNode": "00000000-0000-0000-0000-000000000103",
"sourcePort": "00000000-0000-0000-0000-000000000113",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000123"
}
],
"devices": {
"00000000-0000-0000-0000-000000000101": {
"ip": "192.168.0.10",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000102": {
"ip": "192.168.0.20",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000103": {
"ip": "192.168.0.30",
"mask": "255.255.255.0"
}
},
"switches": {
"00000000-0000-0000-0000-000000000104": {
"ssid": "lab",
"retention": 5000
}
},
"steps": [
{
"op": "bindUdp",
"node": "00000000-0000-0000-0000-000000000101",
"port": 5000
},
{
"op": "bindUdp",
"node": "00000000-0000-0000-0000-000000000102"
},
{
"op": "bindUdp",
"node": "00000000-0000-0000-0000-000000000101",
"port": 5000
},
{
"op": "bindUdp",
"node": "00000000-0000-0000-0000-000000000101",
"localIp": "10.9.9.9"
},
{
"op": "bindUdp",
"node": "00000000-0000-0000-0000-000000000103",
"port": 7000,
"localIp": "192.168.0.30"
},
{
"op": "sendUdp",
"socket": "00000000-0000-0000-0000-000000000002",
"target": "192.168.0.10",
"port": 5000,
"payload": "hallo"
},
{
"op": "receiveUdp",
"socket": "00000000-0000-0000-0000-000000000001"
},
{
"op": "receiveUdp",
"socket": "00000000-0000-0000-0000-000000000001",
"timeout": 200
},
{
"op": "sendUdp",
"socket": "00000000-0000-0000-0000-000000000001",
"payload": "antwort"
},
{
"op": "receiveUdp",
"socket": "00000000-0000-0000-0000-000000000002",
"timeout": 200
},
{
"op": "receiveUdp",
"socket": "00000000-0000-0000-0000-000000000002"
},
{
"op": "receiveUdp",
"socket": "00000000-0000-0000-0000-000000000002",
"timeout": 500
},
{
"op": "sendUdp",
"socket": "00000000-0000-0000-0000-000000000002",
"target": "192.168.0.30",
"port": 6000,
"payload": "niemand"
},
{
"op": "advance",
"to": 2000
},
{
"op": "sendUdp",
"socket": "00000000-0000-0000-0000-000000000003",
"target": "255.255.255.255",
"port": 5000,
"payload": "an alle"
},
{
"op": "advance",
"to": 3000
},
{
"op": "receiveUdp",
"socket": "00000000-0000-0000-0000-000000000001"
},
{
"op": "sendUdp",
"socket": "00000000-0000-0000-0000-000000000003",
"payload": "ohne Ziel"
},
{
"op": "closeSocket",
"socket": "00000000-0000-0000-0000-000000000001"
},
{
"op": "sendUdp",
"socket": "00000000-0000-0000-0000-000000000001",
"target": "192.168.0.20",
"port": 5000,
"payload": "geschlossen"
},
{
"op": "bindUdp",
"node": "00000000-0000-0000-0000-000000000101",
"port": 5000
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
{
"seed": 22,
"nodes": [
{
"id": "00000000-0000-0000-0000-000000000201",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000211",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000202",
"kind": "router",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000221",
"label": "rt1"
},
{
"id": "00000000-0000-0000-0000-000000000222",
"label": "rt2"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000203",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000213",
"label": "eth0"
}
]
}
],
"links": [
{
"id": "00000000-0000-0000-0000-00000000F004",
"sourceNode": "00000000-0000-0000-0000-000000000201",
"sourcePort": "00000000-0000-0000-0000-000000000211",
"targetNode": "00000000-0000-0000-0000-000000000202",
"targetPort": "00000000-0000-0000-0000-000000000221"
},
{
"id": "00000000-0000-0000-0000-00000000F005",
"sourceNode": "00000000-0000-0000-0000-000000000202",
"sourcePort": "00000000-0000-0000-0000-000000000222",
"targetNode": "00000000-0000-0000-0000-000000000203",
"targetPort": "00000000-0000-0000-0000-000000000213"
}
],
"devices": {
"00000000-0000-0000-0000-000000000201": {
"ip": "10.0.0.2",
"mask": "255.255.255.0",
"gateway": "10.0.0.1"
},
"00000000-0000-0000-0000-000000000202": {
"ip": "10.0.0.1",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000203": {
"ip": "20.0.0.2",
"mask": "255.255.255.0",
"gateway": "20.0.0.1"
}
},
"interfaces": [
{
"node": "00000000-0000-0000-0000-000000000202",
"port": "00000000-0000-0000-0000-000000000221",
"ip": "10.0.0.1",
"mask": "255.255.255.0"
},
{
"node": "00000000-0000-0000-0000-000000000202",
"port": "00000000-0000-0000-0000-000000000222",
"ip": "20.0.0.1",
"mask": "255.255.255.0"
}
],
"steps": [
{
"op": "bindUdp",
"node": "00000000-0000-0000-0000-000000000203",
"port": 5000
},
{
"op": "bindUdp",
"node": "00000000-0000-0000-0000-000000000203",
"port": 5001
},
{
"op": "bindUdp",
"node": "00000000-0000-0000-0000-000000000201"
},
{
"op": "bindUdp",
"node": "00000000-0000-0000-0000-000000000202",
"port": 5000
},
{
"op": "sendUdp",
"socket": "00000000-0000-0000-0000-000000000003",
"target": "20.0.0.2",
"port": 5000,
"payload": "durch den Router"
},
{
"op": "receiveUdp",
"socket": "00000000-0000-0000-0000-000000000001",
"timeout": 500
},
{
"op": "sendUdp",
"socket": "00000000-0000-0000-0000-000000000003",
"target": "20.0.0.2",
"port": 5001,
"payload": "verboten"
},
{
"op": "receiveUdp",
"socket": "00000000-0000-0000-0000-000000000002",
"timeout": 500
},
{
"op": "sendUdp",
"socket": "00000000-0000-0000-0000-000000000003",
"target": "255.255.255.255",
"port": 5000,
"payload": "Broadcast"
},
{
"op": "receiveUdp",
"socket": "00000000-0000-0000-0000-000000000004",
"timeout": 500
},
{
"op": "receiveUdp",
"socket": "00000000-0000-0000-0000-000000000001",
"timeout": 500
},
{
"op": "sendUdp",
"socket": "00000000-0000-0000-0000-000000000001",
"payload": "zurueck"
},
{
"op": "receiveUdp",
"socket": "00000000-0000-0000-0000-000000000003",
"timeout": 500
}
],
"firewalls": {
"00000000-0000-0000-0000-000000000202": {
"active": true,
"defaultPolicy": "accept",
"filterUdp": true,
"rules": [
{
"protocol": "udp",
"port": 5001,
"action": "drop"
}
]
}
}
}
File diff suppressed because it is too large Load Diff