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:
@@ -14,8 +14,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.
|
||||
//
|
||||
// "UDP sockets and Router RIP" (lines ~4472–5187, the
|
||||
// UDP sockets; RIP not yet)
|
||||
//
|
||||
// Not ported yet — each place throws NotPortedError instead of guessing:
|
||||
// gateway NAT, UDP and TCP delivery, RIP routing, remote links.
|
||||
// gateway NAT, TCP delivery, RIP routing, remote links. DHCP is not in the
|
||||
// snapshot yet, so Swift's DHCP handling of a datagram has nothing to do.
|
||||
|
||||
import { compareIds, type Id } from '../ids.js';
|
||||
import { isPcClassEndpoint, stableGeneratedMacAddress } from '../model/node.js';
|
||||
@@ -47,6 +51,7 @@ import {
|
||||
type IcmpMessage,
|
||||
type IcmpMessageKind,
|
||||
type IPv4Packet,
|
||||
type UdpDatagram,
|
||||
} from './packets.js';
|
||||
import { JavaCompatibleRandom } from './random.js';
|
||||
import { routeTableRows, type RouteRow } from './routeTable.js';
|
||||
@@ -225,6 +230,38 @@ export interface FirewallDecision {
|
||||
|
||||
export type RuntimePhase = 'stopped' | 'running';
|
||||
|
||||
export type SocketProtocol = 'udp' | 'tcp';
|
||||
|
||||
/** Swift's TopologyRuntimeSocketRecord (UDP so far). */
|
||||
export interface SocketRecord {
|
||||
readonly id: Id;
|
||||
readonly nodeId: Id;
|
||||
readonly protocol: SocketProtocol;
|
||||
readonly localIPAddress: string | undefined;
|
||||
/** 0…65535 */
|
||||
readonly localPort: number;
|
||||
remoteIPAddress: string | undefined;
|
||||
remotePort: number | undefined;
|
||||
}
|
||||
|
||||
/** A datagram waiting in a UDP socket's queue. */
|
||||
export interface UdpReceivedDatagram {
|
||||
readonly senderIPAddress: string;
|
||||
readonly receiverIPAddress: string;
|
||||
readonly datagram: UdpDatagram;
|
||||
readonly receivedAtMilliseconds: number;
|
||||
}
|
||||
|
||||
export const UDP_EPHEMERAL_PORT_LOWER_BOUND = 49_152;
|
||||
export const UDP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE = 65_535;
|
||||
export const RIP_DESTINATION_PORT = 520;
|
||||
export const RIP_SOURCE_PORT = 521;
|
||||
|
||||
/** Swift's deterministicSocketID: the sequence number as the UUID's last twelve hex digits. */
|
||||
export function deterministicSocketId(sequence: number): Id {
|
||||
return `00000000-0000-0000-0000-${sequence.toString(16).toUpperCase().padStart(12, '0')}`;
|
||||
}
|
||||
|
||||
export interface RuntimeState {
|
||||
phase: RuntimePhase;
|
||||
currentTimeMilliseconds: number;
|
||||
@@ -242,6 +279,9 @@ export interface RuntimeState {
|
||||
deliveredIPv4PacketsByNodeId: Map<Id, DeliveredIPv4Packet[]>;
|
||||
icmpObservationsByNodeId: Map<Id, IcmpObservation[]>;
|
||||
firewallDecisions: FirewallDecision[];
|
||||
socketsById: Map<Id, SocketRecord>;
|
||||
udpReceiveQueuesBySocketId: Map<Id, UdpReceivedDatagram[]>;
|
||||
nextSocketSequenceNumber: number;
|
||||
nextEventSequenceNumber: number;
|
||||
nextFrameIdentity: number;
|
||||
nextPacketIdentity: number;
|
||||
@@ -266,6 +306,9 @@ function initialState(seed: bigint): RuntimeState {
|
||||
deliveredIPv4PacketsByNodeId: new Map(),
|
||||
icmpObservationsByNodeId: new Map(),
|
||||
firewallDecisions: [],
|
||||
socketsById: new Map(),
|
||||
udpReceiveQueuesBySocketId: new Map(),
|
||||
nextSocketSequenceNumber: 1,
|
||||
nextEventSequenceNumber: 0,
|
||||
nextFrameIdentity: 1,
|
||||
nextPacketIdentity: 1,
|
||||
@@ -398,6 +441,8 @@ export class NetworkRuntimeEngine {
|
||||
state.switchSeenFrameIdentitiesByNodeId.clear();
|
||||
state.deliveredIPv4PacketsByNodeId.clear();
|
||||
state.icmpObservationsByNodeId.clear();
|
||||
state.socketsById.clear();
|
||||
state.udpReceiveQueuesBySocketId.clear();
|
||||
state.firewallDecisions = [];
|
||||
return [this.output({ type: 'stopped' })];
|
||||
}
|
||||
@@ -603,6 +648,209 @@ export class NetworkRuntimeEngine {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── UDP sockets ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A UDP socket on `nodeId`: on `localPort`, else on a free port drawn at
|
||||
* random from the ephemeral range; undefined while stopped, when the port
|
||||
* is taken or the node has no interface with `localIPAddress`.
|
||||
*/
|
||||
bindUdpSocket(
|
||||
nodeId: Id,
|
||||
options: { localPort?: number; localIPAddress?: string; remoteIPAddress?: string; remotePort?: number } = {},
|
||||
): Id | undefined {
|
||||
const state = this.stateValue;
|
||||
if (state.phase !== 'running') return undefined;
|
||||
let reservedPort: number;
|
||||
if (options.localPort !== undefined) {
|
||||
if (this.isUdpPortReserved(nodeId, options.localPort)) return undefined;
|
||||
reservedPort = options.localPort;
|
||||
} else {
|
||||
const port = this.reserveFreeUdpPort(nodeId);
|
||||
if (port === undefined) return undefined;
|
||||
reservedPort = port;
|
||||
}
|
||||
if (
|
||||
options.localIPAddress !== undefined &&
|
||||
!this.networkInterfaces(nodeId).some((networkInterface) => networkInterface.ipAddress === options.localIPAddress)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const id = deterministicSocketId(state.nextSocketSequenceNumber);
|
||||
state.nextSocketSequenceNumber += 1;
|
||||
state.socketsById.set(id, {
|
||||
id,
|
||||
nodeId,
|
||||
protocol: 'udp',
|
||||
localIPAddress: options.localIPAddress,
|
||||
localPort: reservedPort,
|
||||
remoteIPAddress: options.remoteIPAddress,
|
||||
remotePort: options.remotePort,
|
||||
});
|
||||
state.udpReceiveQueuesBySocketId.set(id, []);
|
||||
return id;
|
||||
}
|
||||
|
||||
private isUdpPortReserved(nodeId: Id, port: number): boolean {
|
||||
// Swift also counts a gateway's NAT mappings; gateways do not run here yet.
|
||||
for (const socket of this.stateValue.socketsById.values()) {
|
||||
if (socket.nodeId === nodeId && socket.protocol === 'udp' && socket.localPort === port) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private reserveFreeUdpPort(nodeId: Id): number | undefined {
|
||||
const span = UDP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE - UDP_EPHEMERAL_PORT_LOWER_BOUND;
|
||||
for (let attempt = 0; attempt < span; attempt++) {
|
||||
const port = UDP_EPHEMERAL_PORT_LOWER_BOUND + this.udpPortRandom.nextInt(span);
|
||||
if (!this.isUdpPortReserved(nodeId, port)) return port;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
closeSocket(socketId: Id): void {
|
||||
if (this.stateValue.socketsById.get(socketId)?.protocol === 'tcp') throw new NotPortedError('TCP sockets');
|
||||
this.stateValue.socketsById.delete(socketId);
|
||||
this.stateValue.udpReceiveQueuesBySocketId.delete(socketId);
|
||||
}
|
||||
|
||||
/** Forgets a socket without closing it (Swift's discardSocket; TCP children once TCP is ported). */
|
||||
discardSocket(socketId: Id): void {
|
||||
this.stateValue.socketsById.delete(socketId);
|
||||
this.stateValue.udpReceiveQueuesBySocketId.delete(socketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The oldest datagram in the socket's queue; with a timeout, the clock
|
||||
* advances up to it when the queue is empty, as a blocking receive in Java.
|
||||
*/
|
||||
receiveUdp(socketId: Id, timeoutMilliseconds = 0): UdpReceivedDatagram | undefined {
|
||||
const next = () => this.stateValue.udpReceiveQueuesBySocketId.get(socketId)?.shift();
|
||||
const datagram = next();
|
||||
if (datagram !== undefined) return datagram;
|
||||
if (timeoutMilliseconds <= 0 || this.stateValue.phase !== 'running') return undefined;
|
||||
const deadline = this.stateValue.currentTimeMilliseconds + timeoutMilliseconds;
|
||||
if (!Number.isSafeInteger(deadline)) return undefined;
|
||||
this.advance(deadline);
|
||||
return next();
|
||||
}
|
||||
|
||||
/** Sends from a UDP socket to its remote end, or to the given one. */
|
||||
sendUdp(
|
||||
socketId: Id,
|
||||
payload: Uint8Array,
|
||||
destination: { ipAddress?: string; port?: number } = {},
|
||||
): IPv4DeliveryResult | undefined {
|
||||
const socket = this.stateValue.socketsById.get(socketId);
|
||||
if (socket === undefined || socket.protocol !== 'udp') return undefined;
|
||||
const destinationIPAddress = destination.ipAddress ?? socket.remoteIPAddress;
|
||||
const destinationPort = destination.port ?? socket.remotePort;
|
||||
if (destinationIPAddress === undefined || destinationPort === undefined) return undefined;
|
||||
const interfaces = this.networkInterfaces(socket.nodeId);
|
||||
if (interfaces.length === 0) return undefined;
|
||||
const boundInterface =
|
||||
socket.localIPAddress === undefined
|
||||
? undefined
|
||||
: interfaces.find((networkInterface) => networkInterface.ipAddress === socket.localIPAddress);
|
||||
const packetIdentity = this.allocatePacketIdentity();
|
||||
const datagram: UdpDatagram = { sourcePort: socket.localPort, destinationPort, payload };
|
||||
const traceSent = (networkInterface: NetworkInterface, identity: number) =>
|
||||
this.recordTrace({
|
||||
packetIdentity: identity,
|
||||
nodeId: socket.nodeId,
|
||||
interfaceId: networkInterface.portId,
|
||||
direction: 'outbound',
|
||||
layer: 'transport',
|
||||
operation: 'sent',
|
||||
afterHeaders: udpHeaders(datagram),
|
||||
});
|
||||
|
||||
if (destinationIPAddress === LIMITED_BROADCAST_IP_ADDRESS) {
|
||||
for (const networkInterface of boundInterface === undefined ? interfaces : [boundInterface]) {
|
||||
const packet: IPv4Packet = {
|
||||
identity: packetIdentity,
|
||||
senderIPAddress: networkInterface.ipAddress,
|
||||
receiverIPAddress: destinationIPAddress,
|
||||
timeToLive: 1,
|
||||
protocol: 'udp',
|
||||
payload: { kind: 'udp', datagram },
|
||||
};
|
||||
traceSent(networkInterface, packet.identity);
|
||||
this.sendLimitedBroadcastPacket(socket.nodeId, networkInterface, packet);
|
||||
}
|
||||
return { type: 'delivered', packetIdentity, nodeId: socket.nodeId };
|
||||
}
|
||||
|
||||
let networkInterface: NetworkInterface;
|
||||
if (boundInterface !== undefined) {
|
||||
networkInterface = boundInterface;
|
||||
} else {
|
||||
const route = this.routeForPacket(socket.nodeId, destinationIPAddress);
|
||||
networkInterface = route.type === 'egress' ? route.interface : interfaces[0]!;
|
||||
}
|
||||
const packet: IPv4Packet = {
|
||||
identity: packetIdentity,
|
||||
senderIPAddress: networkInterface.ipAddress,
|
||||
receiverIPAddress: destinationIPAddress,
|
||||
timeToLive: 64,
|
||||
protocol: 'udp',
|
||||
payload: { kind: 'udp', datagram },
|
||||
};
|
||||
traceSent(networkInterface, packet.identity);
|
||||
return this.sendIPv4Packet(socket.nodeId, packet, networkInterface.portId);
|
||||
}
|
||||
|
||||
private sendLimitedBroadcastPacket(fromNodeId: Id, networkInterface: NetworkInterface, packet: IPv4Packet): void {
|
||||
const broadcastPacket: IPv4Packet = {
|
||||
...packet,
|
||||
senderIPAddress: networkInterface.ipAddress,
|
||||
receiverIPAddress: LIMITED_BROADCAST_IP_ADDRESS,
|
||||
timeToLive: 1,
|
||||
};
|
||||
const frame: EthernetFrame = {
|
||||
identity: this.allocateFrameIdentity(),
|
||||
sourceMacAddress: networkInterface.macAddress,
|
||||
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
|
||||
payload: { kind: 'ipv4', packet: broadcastPacket },
|
||||
};
|
||||
this.recordIPv4Send(broadcastPacket, frame, fromNodeId, networkInterface.portId, 'sent');
|
||||
this.sendEthernetFrame(fromNodeId, networkInterface.portId, frame);
|
||||
}
|
||||
|
||||
/** Into the queue of every UDP socket on the destination port, which then answers to the sender. */
|
||||
private deliverUdp(packet: IPv4Packet, datagram: UdpDatagram, nodeId: Id, networkInterface: NetworkInterface): void {
|
||||
if (
|
||||
datagram.destinationPort === RIP_DESTINATION_PORT &&
|
||||
datagram.sourcePort === RIP_SOURCE_PORT &&
|
||||
this.isRipEnabledRouter(nodeId)
|
||||
) {
|
||||
throw new NotPortedError('RIP advertisements');
|
||||
}
|
||||
const state = this.stateValue;
|
||||
const matching = [...state.socketsById.values()]
|
||||
.filter((socket) => socket.nodeId === nodeId && socket.protocol === 'udp' && socket.localPort === datagram.destinationPort)
|
||||
.sort((lhs, rhs) => compareIds(lhs.id, rhs.id));
|
||||
for (const socket of matching) {
|
||||
getOrCreate(state.udpReceiveQueuesBySocketId, socket.id, () => []).push({
|
||||
senderIPAddress: packet.senderIPAddress,
|
||||
receiverIPAddress: packet.receiverIPAddress,
|
||||
datagram,
|
||||
receivedAtMilliseconds: state.currentTimeMilliseconds,
|
||||
});
|
||||
state.socketsById.set(socket.id, { ...socket, remoteIPAddress: packet.senderIPAddress, remotePort: datagram.sourcePort });
|
||||
}
|
||||
this.recordTrace({
|
||||
packetIdentity: packet.identity,
|
||||
nodeId,
|
||||
interfaceId: networkInterface.portId,
|
||||
direction: 'local',
|
||||
layer: 'transport',
|
||||
operation: matching.length === 0 ? 'dropped' : 'accepted',
|
||||
beforeHeaders: udpHeaders(datagram),
|
||||
detail: matching.length === 0 ? 'UDP destination port unbound' : 'UDP queued',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Later slices hook in here ────────────────────────────────────────────
|
||||
|
||||
private initializeRipState(): void {
|
||||
@@ -1527,7 +1775,8 @@ export class NetworkRuntimeEngine {
|
||||
interfaceId: networkInterface.portId,
|
||||
packet,
|
||||
});
|
||||
throw new NotPortedError('UDP delivery');
|
||||
this.deliverUdp(packet, payload.datagram, nodeId, networkInterface);
|
||||
return;
|
||||
case 'tcp':
|
||||
getOrCreate(this.stateValue.deliveredIPv4PacketsByNodeId, nodeId, () => []).push({
|
||||
nodeId,
|
||||
@@ -1930,6 +2179,14 @@ export function ipv4Headers(packet: IPv4Packet): HeaderField[] {
|
||||
];
|
||||
}
|
||||
|
||||
export function udpHeaders(datagram: UdpDatagram): HeaderField[] {
|
||||
return [
|
||||
{ name: 'sourcePort', value: String(datagram.sourcePort) },
|
||||
{ name: 'destinationPort', value: String(datagram.destinationPort) },
|
||||
{ name: 'payloadLength', value: String(datagram.payload.length) },
|
||||
];
|
||||
}
|
||||
|
||||
export function icmpHeaders(message: IcmpMessage): HeaderField[] {
|
||||
return [
|
||||
{ name: 'kind', value: message.kind },
|
||||
|
||||
Reference in New Issue
Block a user