feat(core): ARP after Java, measured with Java FILIUS
An ARP lookup asks, waits a round trip, asks once more and gives up after another round trip; then host unreachable, observed at the sender itself or sent back by the router (for ICMP only for an echo request). A waiting sender wakes after what else happens at that time. A ping then waits a round trip for its answer, counted from when it left. Swift asks twice at once while events run and lets the packet wait without end when the address exists anywhere in the network; the option swiftArp keeps that for the Swift comparison, and three scenarios are compared with Swift until the trace where ARP fails or a ping goes unanswered. arpJava.test.ts compares ArpRun: both pings match; the two UDP stories wait for Java's transport thread (next step). DNS without a server now matches Java too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+231
-135
@@ -17,6 +17,9 @@
|
||||
// processScheduledRuntimeEvent, dropForGlobalPacketLoss (lines ~4753–4850)
|
||||
// A cable carries one frame after the other, as Java's SimplexVerbindung
|
||||
// (Swift: all at once; the option swiftCables keeps that for the comparison).
|
||||
// ARP after Java's holeARPTabellenEintrag: a lookup asks twice, a round trip
|
||||
// each, then host unreachable (Swift: all at once, or without end when the
|
||||
// address exists anywhere; the option swiftArp keeps that for the comparison).
|
||||
// DNS after Java (filius.software.dns: DNSServer, DNSServerMitarbeiter,
|
||||
// Resolver, DNSQueryAgent), not Swift's typed format; recursion not yet.
|
||||
// Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht,
|
||||
@@ -52,7 +55,6 @@ import {
|
||||
firewallConfiguration,
|
||||
interfaceKey,
|
||||
} from './config.js';
|
||||
import { deepEqual } from '../util/equal.js';
|
||||
import { maskAddress, parseIPv4, sameSubnet } from './ipv4.js';
|
||||
import {
|
||||
forwardingClone,
|
||||
@@ -120,6 +122,10 @@ export type ScheduledEventKind =
|
||||
| { readonly kind: 'ripBeacon'; readonly nodeId: Id }
|
||||
| { readonly kind: 'dhcpClientStart'; readonly nodeId: Id }
|
||||
| { readonly kind: 'dhcpTimeout'; readonly nodeId: Id }
|
||||
/** An ARP lookup waited a round trip for its answer. */
|
||||
| { readonly kind: 'arpTimeout'; readonly lookupId: number }
|
||||
/** The lookups waiting for an address wake up, after what else happens at that time. */
|
||||
| { readonly kind: 'arpAnswered'; readonly nodeId: Id; readonly ipAddress: string; readonly macAddress: string }
|
||||
| { readonly kind: 'tcpTimeout'; readonly sessionId: Id }
|
||||
| {
|
||||
/** A frame that waited in the port's output buffer leaves: the cable was busy. */
|
||||
@@ -522,10 +528,26 @@ type PacketRoute =
|
||||
| { readonly type: 'egress'; readonly interface: NetworkInterface; readonly nextHop: string }
|
||||
| { readonly type: 'unavailable' };
|
||||
|
||||
interface PendingIPv4Transmission {
|
||||
readonly packet: IPv4Packet;
|
||||
readonly interface: NetworkInterface;
|
||||
readonly operation: TraceOperation;
|
||||
/** Java's IP asks ARP this many times, a round trip each (holeARPTabellenEintrag(ziel, 2, …)). */
|
||||
export const ARP_ATTEMPTS = 2;
|
||||
|
||||
interface ArpLookup {
|
||||
readonly id: number;
|
||||
readonly nodeId: Id;
|
||||
readonly ipAddress: string;
|
||||
readonly networkInterface: NetworkInterface;
|
||||
attemptsLeft: number;
|
||||
/** Swift, for the comparison: a lookup made while events run waits in a list emptied by the answer itself. */
|
||||
readonly swiftQueued: boolean;
|
||||
readonly done: (macAddress: string | undefined) => void;
|
||||
}
|
||||
|
||||
/** What a sender learns when its packet has left, or when it could not. */
|
||||
interface IPv4SendCallbacks {
|
||||
/** The packet left (or failed) — at once, or when ARP has answered or given up. */
|
||||
readonly onFinished?: (result: IPv4DeliveryResult) => void;
|
||||
/** ARP found no one for the next hop (Java: the sender sends host unreachable). */
|
||||
readonly onUnresolved?: () => void;
|
||||
}
|
||||
|
||||
function toSeed(seed: bigint | number): bigint {
|
||||
@@ -534,12 +556,14 @@ function toSeed(seed: bigint | number): bigint {
|
||||
|
||||
/** A departure waits for the delivery of the frame before it, so draining the deliveries reaches it. */
|
||||
function isNetworkDeliveryEvent(kind: ScheduledEventKind): boolean {
|
||||
return kind.kind === 'ethernetLinkDeparture' || kind.kind === 'ethernetLinkDelivery' || kind.kind === 'remoteLinkDelivery';
|
||||
return (
|
||||
kind.kind === 'ethernetLinkDeparture' ||
|
||||
kind.kind === 'ethernetLinkDelivery' ||
|
||||
kind.kind === 'remoteLinkDelivery' ||
|
||||
kind.kind === 'arpAnswered'
|
||||
);
|
||||
}
|
||||
|
||||
function isRemoteLinkWaitEvent(kind: ScheduledEventKind): boolean {
|
||||
return isNetworkDeliveryEvent(kind) || kind.kind === 'lanRemoteLinkTransmission';
|
||||
}
|
||||
|
||||
function getOrCreate<K, V>(map: Map<K, V>, key: K, create: () => V): V {
|
||||
let value = map.get(key);
|
||||
@@ -562,10 +586,13 @@ export class NetworkRuntimeEngine {
|
||||
private runtimeSpeed: RuntimeSpeed;
|
||||
private readonly packetTraceRetentionPolicy: TraceRetentionPolicy;
|
||||
private isProcessingScheduledEvents = false;
|
||||
private pendingIPv4TransmissionsByArpKey = new Map<string, PendingIPv4Transmission[]>();
|
||||
/** ARP lookups waiting for their answer (Java: a thread in holeARPTabellenEintrag). */
|
||||
private arpLookups: ArpLookup[] = [];
|
||||
private nextArpLookupId = 1;
|
||||
/** Per cable end (node and port): when the last frame sent there arrives. */
|
||||
private cableBusyUntilByPort = new Map<string, number>();
|
||||
private readonly cablesCarryOneFrameAtATime: boolean;
|
||||
private readonly swiftArp: boolean;
|
||||
|
||||
/**
|
||||
* `swiftCables`: a cable carries all frames at once, as in the Swift
|
||||
@@ -577,9 +604,10 @@ export class NetworkRuntimeEngine {
|
||||
constructor(
|
||||
seed: bigint | number = DEFAULT_SEED,
|
||||
packetTraceRetentionPolicy: TraceRetentionPolicy = PRODUCTION_TRACE_RETENTION,
|
||||
options: { readonly swiftCables?: boolean } = {},
|
||||
options: { readonly swiftCables?: boolean; readonly swiftArp?: boolean } = {},
|
||||
) {
|
||||
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
|
||||
this.swiftArp = options.swiftArp === true;
|
||||
const normalizedSeed = toSeed(seed);
|
||||
this.stateValue = initialState(normalizedSeed);
|
||||
this.random = new JavaCompatibleRandom(normalizedSeed);
|
||||
@@ -626,7 +654,6 @@ export class NetworkRuntimeEngine {
|
||||
this.linkLocalRandom = new JavaCompatibleRandom(seed ^ 0x4c4c_4144n);
|
||||
this.dnsIdRandom = new JavaCompatibleRandom(seed ^ 0x444e_5349n);
|
||||
this.isProcessingScheduledEvents = false;
|
||||
this.pendingIPv4TransmissionsByArpKey.clear();
|
||||
this.cableBusyUntilByPort.clear();
|
||||
this.initializeRipState();
|
||||
this.initializeDhcpState();
|
||||
@@ -641,7 +668,7 @@ export class NetworkRuntimeEngine {
|
||||
state.phase = 'stopped';
|
||||
state.globalPacketLossEnabled = false;
|
||||
this.isProcessingScheduledEvents = false;
|
||||
this.pendingIPv4TransmissionsByArpKey.clear();
|
||||
this.arpLookups = [];
|
||||
state.pendingEvents = [];
|
||||
state.arpCachesByNodeId.clear();
|
||||
state.switchForwardingTablesByNodeId.clear();
|
||||
@@ -834,6 +861,12 @@ export class NetworkRuntimeEngine {
|
||||
case 'dhcpTimeout':
|
||||
this.handleDhcpClientTimeout(kind.nodeId);
|
||||
return;
|
||||
case 'arpTimeout':
|
||||
this.handleArpTimeout(kind.lookupId);
|
||||
return;
|
||||
case 'arpAnswered':
|
||||
this.completeArpLookups(kind.nodeId, kind.ipAddress, kind.macAddress);
|
||||
return;
|
||||
case 'tcpTimeout':
|
||||
this.handleTcpTimeout(kind.sessionId);
|
||||
return;
|
||||
@@ -2651,15 +2684,15 @@ export class NetworkRuntimeEngine {
|
||||
|
||||
/**
|
||||
* The MAC address for `targetIPAddress`, asking by ARP broadcast when it is
|
||||
* not cached. Blocks like Java: the frames travel (virtual time passes)
|
||||
* before the call returns.
|
||||
* not cached. Blocks like Java: each request waits a round trip for its
|
||||
* answer (virtual time passes) before the next, or before the call gives up.
|
||||
*/
|
||||
resolveMacAddress(
|
||||
nodeId: Id,
|
||||
targetIPAddress: string,
|
||||
options: { preferredInterfaceId?: Id | undefined; maxRetries?: number; selectInterfaceByAddress?: boolean } = {},
|
||||
): string | undefined {
|
||||
const maxRetries = options.maxRetries ?? 2;
|
||||
const maxRetries = options.maxRetries ?? ARP_ATTEMPTS;
|
||||
const selectInterfaceByAddress = options.selectInterfaceByAddress ?? true;
|
||||
const interfaces = this.networkInterfaces(nodeId);
|
||||
if (interfaces.length === 0) return undefined;
|
||||
@@ -2676,13 +2709,76 @@ export class NetworkRuntimeEngine {
|
||||
}
|
||||
if (selectedInterface === undefined) return undefined;
|
||||
|
||||
for (let attempt = 0; attempt < Math.max(0, maxRetries); attempt += 1) {
|
||||
this.sendArpRequest(nodeId, selectedInterface, targetIPAddress);
|
||||
this.advanceRemoteLinkEventsUntilArpCacheResolves(nodeId, targetIPAddress);
|
||||
const resolved = this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress);
|
||||
if (resolved !== undefined) return resolved.macAddress;
|
||||
let finished = false;
|
||||
let resolved: string | undefined;
|
||||
this.lookUpMacAddress(nodeId, targetIPAddress, selectedInterface, maxRetries, (macAddress) => {
|
||||
finished = true;
|
||||
resolved = macAddress;
|
||||
});
|
||||
if (!finished && !this.isProcessingScheduledEvents) {
|
||||
this.advanceUntil(() => finished, this.stateValue.currentTimeMilliseconds + (maxRetries + 1) * this.runtimeSpeed.roundTripTimeMilliseconds);
|
||||
}
|
||||
return undefined;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Java's holeARPTabellenEintrag as a lookup of its own: a known address at
|
||||
* once; otherwise an ARP request and a round trip's wait for the answer,
|
||||
* `attempts` times, then undefined. Java wakes on any change of the ARP
|
||||
* table and gives up when it finds nothing; the lookup waits for its
|
||||
* address (as the DHCP client's check does).
|
||||
*/
|
||||
private lookUpMacAddress(
|
||||
nodeId: Id,
|
||||
ipAddress: string,
|
||||
networkInterface: NetworkInterface,
|
||||
attempts: number,
|
||||
done: (macAddress: string | undefined) => void,
|
||||
): void {
|
||||
const known = this.knownMacAddress(nodeId, ipAddress);
|
||||
if (known !== undefined || attempts <= 0) {
|
||||
done(known);
|
||||
return;
|
||||
}
|
||||
const swiftQueued = this.swiftArp && this.isProcessingScheduledEvents;
|
||||
const lookup: ArpLookup = { id: this.nextArpLookupId++, nodeId, ipAddress, networkInterface, attemptsLeft: attempts - 1, swiftQueued, done };
|
||||
this.arpLookups.push(lookup);
|
||||
if (swiftQueued) {
|
||||
// Swift, only for the comparison: every request at once, then the packet waits without end.
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) this.sendArpRequest(nodeId, networkInterface, ipAddress);
|
||||
lookup.attemptsLeft = 0;
|
||||
return;
|
||||
}
|
||||
this.schedule(this.stateValue.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds, { kind: 'arpTimeout', lookupId: lookup.id });
|
||||
this.sendArpRequest(nodeId, networkInterface, ipAddress);
|
||||
}
|
||||
|
||||
/** The node learned `ipAddress`: every lookup waiting for it has its answer. */
|
||||
private completeArpLookups(nodeId: Id, ipAddress: string, macAddress: string, onlySwiftQueued = false): void {
|
||||
const answered = this.arpLookups.filter(
|
||||
(lookup) => lookup.nodeId === nodeId && lookup.ipAddress === ipAddress && (!onlySwiftQueued || lookup.swiftQueued),
|
||||
);
|
||||
if (answered.length === 0) return;
|
||||
const ids = new Set(answered.map((lookup) => lookup.id));
|
||||
this.arpLookups = this.arpLookups.filter((lookup) => !ids.has(lookup.id));
|
||||
this.stateValue.pendingEvents = this.stateValue.pendingEvents.filter(
|
||||
(event) => !(event.kind.kind === 'arpTimeout' && ids.has(event.kind.lookupId)),
|
||||
);
|
||||
for (const lookup of answered) lookup.done(macAddress);
|
||||
}
|
||||
|
||||
/** No answer within the round trip: ask again, or give up. */
|
||||
private handleArpTimeout(lookupId: number): void {
|
||||
const lookup = this.arpLookups.find((each) => each.id === lookupId);
|
||||
if (lookup === undefined) return;
|
||||
if (lookup.attemptsLeft > 0) {
|
||||
lookup.attemptsLeft -= 1;
|
||||
this.schedule(this.stateValue.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds, { kind: 'arpTimeout', lookupId });
|
||||
this.sendArpRequest(lookup.nodeId, lookup.networkInterface, lookup.ipAddress);
|
||||
return;
|
||||
}
|
||||
this.arpLookups = this.arpLookups.filter((each) => each.id !== lookupId);
|
||||
lookup.done(undefined);
|
||||
}
|
||||
|
||||
/** The MAC address for `ipAddress` without asking: localhost, one of the node's own, or cached. */
|
||||
@@ -2724,39 +2820,6 @@ export class NetworkRuntimeEngine {
|
||||
this.sendEthernetFrame(nodeId, networkInterface.portId, frame);
|
||||
}
|
||||
|
||||
private advanceRemoteLinkEventsUntilArpCacheResolves(nodeId: Id, targetIPAddress: string): void {
|
||||
if (this.isProcessingScheduledEvents) return;
|
||||
this.advancePendingRemoteLinkEvents(
|
||||
() => this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress) !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private advanceRemoteLinkEventsUntilIcmpObservation(
|
||||
nodeId: Id,
|
||||
observationCount: number,
|
||||
identifier: number,
|
||||
sequenceNumber: number,
|
||||
): void {
|
||||
if (this.isProcessingScheduledEvents) return;
|
||||
this.advancePendingRemoteLinkEvents(() => {
|
||||
const newObservations = (this.stateValue.icmpObservationsByNodeId.get(nodeId) ?? []).slice(observationCount);
|
||||
return newObservations.some(
|
||||
(observation) =>
|
||||
observation.message.identifier === identifier && observation.message.sequenceNumber === sequenceNumber,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private advancePendingRemoteLinkEvents(condition: () => boolean): void {
|
||||
const maximumEvents = 1_024;
|
||||
let processedEventCount = 0;
|
||||
while (!condition() && processedEventCount < maximumEvents) {
|
||||
const nextDeadline = this.earliestDeadline(isRemoteLinkWaitEvent);
|
||||
if (nextDeadline === undefined) return;
|
||||
this.advance(nextDeadline);
|
||||
processedEventCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private earliestDeadline(filter: (kind: ScheduledEventKind) => boolean): number | undefined {
|
||||
let earliest: number | undefined;
|
||||
@@ -3086,7 +3149,16 @@ export class NetworkRuntimeEngine {
|
||||
macAddress: packet.senderMacAddress,
|
||||
updatedAtMilliseconds: this.stateValue.currentTimeMilliseconds,
|
||||
});
|
||||
this.flushIPv4TransmissionsAwaitingArp(nodeId, packet.senderIPAddress, packet.senderMacAddress);
|
||||
// Java's waiting thread wakes when the ARP thread is done (Swift, for the comparison: at once).
|
||||
this.completeArpLookups(nodeId, packet.senderIPAddress, packet.senderMacAddress, true);
|
||||
if (this.arpLookups.some((lookup) => lookup.nodeId === nodeId && lookup.ipAddress === packet.senderIPAddress)) {
|
||||
this.schedule(this.stateValue.currentTimeMilliseconds, {
|
||||
kind: 'arpAnswered',
|
||||
nodeId,
|
||||
ipAddress: packet.senderIPAddress,
|
||||
macAddress: packet.senderMacAddress,
|
||||
});
|
||||
}
|
||||
this.dhcpClientLearnedAddress(nodeId, packet.senderIPAddress);
|
||||
}
|
||||
this.recordTrace({
|
||||
@@ -3191,8 +3263,27 @@ export class NetworkRuntimeEngine {
|
||||
detail: 'ICMP echo request',
|
||||
});
|
||||
const observationCount = this.stateValue.icmpObservationsByNodeId.get(fromNodeId)?.length ?? 0;
|
||||
const result = this.sendIPv4Packet(fromNodeId, packet, sourceInterface.portId);
|
||||
this.advanceRemoteLinkEventsUntilIcmpObservation(fromNodeId, observationCount, identifier, sequenceNumber);
|
||||
const answered = () =>
|
||||
(this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? [])
|
||||
.slice(observationCount)
|
||||
.some((observation) => observation.message.identifier === identifier && observation.message.sequenceNumber === sequenceNumber);
|
||||
let sent: IPv4DeliveryResult | undefined;
|
||||
let sentAt = this.stateValue.currentTimeMilliseconds;
|
||||
const handedOn = this.sendIPv4PacketWithOptions(fromNodeId, packet, sourceInterface.portId, true, 'sent', {
|
||||
onFinished: (outcome) => {
|
||||
sent = outcome;
|
||||
sentAt = this.stateValue.currentTimeMilliseconds;
|
||||
},
|
||||
});
|
||||
if (!this.isProcessingScheduledEvents) {
|
||||
// Java's sendProbe: the caller waits for ARP (up to two round trips),
|
||||
// then a round trip for the answer (ICMPThread.waitForIcmpResponse),
|
||||
// counted from when the packet left — the engine may have run past it.
|
||||
const rtt = this.runtimeSpeed.roundTripTimeMilliseconds;
|
||||
this.advanceUntil(() => sent !== undefined, this.stateValue.currentTimeMilliseconds + (ARP_ATTEMPTS + 1) * rtt);
|
||||
if (sentAt + rtt > this.stateValue.currentTimeMilliseconds) this.advanceUntil(answered, sentAt + rtt);
|
||||
}
|
||||
const result = sent ?? handedOn;
|
||||
const newObservations = (this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? []).slice(observationCount);
|
||||
const observation = newObservations.findLast(
|
||||
(candidate) => candidate.message.identifier === identifier && candidate.message.sequenceNumber === sequenceNumber,
|
||||
@@ -3254,8 +3345,78 @@ export class NetworkRuntimeEngine {
|
||||
preferredInterfaceId: Id | undefined,
|
||||
allowIcmpErrorGeneration: boolean,
|
||||
operation: TraceOperation,
|
||||
callbacks: IPv4SendCallbacks = {},
|
||||
): IPv4DeliveryResult {
|
||||
const route = this.routeForPacket(fromNodeId, packet.receiverIPAddress, preferredInterfaceId);
|
||||
if (route.type === 'egress') return this.sendIPv4ToNextHop(fromNodeId, packet, route, allowIcmpErrorGeneration, operation, callbacks);
|
||||
const result = this.sendIPv4Directly(fromNodeId, packet, route, preferredInterfaceId, allowIcmpErrorGeneration, operation);
|
||||
callbacks.onFinished?.(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* To the next hop, once ARP knows it; until then the packet waits for its
|
||||
* lookup and the result says it was handed on. When ARP finds no one: host
|
||||
* unreachable — observed at the sender itself, or sent back by the router
|
||||
* (onUnresolved).
|
||||
*/
|
||||
private sendIPv4ToNextHop(
|
||||
fromNodeId: Id,
|
||||
packet: IPv4Packet,
|
||||
route: { readonly interface: NetworkInterface; readonly nextHop: string },
|
||||
allowIcmpErrorGeneration: boolean,
|
||||
operation: TraceOperation,
|
||||
callbacks: IPv4SendCallbacks,
|
||||
): IPv4DeliveryResult {
|
||||
let finished: IPv4DeliveryResult | undefined;
|
||||
this.lookUpMacAddress(fromNodeId, route.nextHop, route.interface, ARP_ATTEMPTS, (macAddress) => {
|
||||
if (macAddress !== undefined) {
|
||||
this.transmitIPv4Packet(fromNodeId, packet, route.interface, macAddress, operation);
|
||||
finished = { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||||
} else {
|
||||
this.recordTrace({
|
||||
packetIdentity: packet.identity,
|
||||
nodeId: fromNodeId,
|
||||
interfaceId: route.interface.portId,
|
||||
direction: 'outbound',
|
||||
layer: 'network',
|
||||
operation: 'dropped',
|
||||
beforeHeaders: ipv4Headers(packet),
|
||||
detail: `no ARP answer for ${route.nextHop}`,
|
||||
});
|
||||
if (allowIcmpErrorGeneration) {
|
||||
this.observeLocalIcmpError(fromNodeId, route.interface.portId, 'destinationHostUnreachable', packet);
|
||||
finished = { type: 'icmpError', packetIdentity: packet.identity, nodeId: fromNodeId, kind: 'destinationHostUnreachable' };
|
||||
} else {
|
||||
callbacks.onUnresolved?.();
|
||||
finished = { type: 'dropped', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||||
}
|
||||
}
|
||||
callbacks.onFinished?.(finished);
|
||||
});
|
||||
if (finished === undefined && this.swiftArp && this.isProcessingScheduledEvents) {
|
||||
this.recordTrace({
|
||||
packetIdentity: packet.identity,
|
||||
nodeId: fromNodeId,
|
||||
interfaceId: route.interface.portId,
|
||||
direction: 'outbound',
|
||||
layer: 'network',
|
||||
operation: 'created',
|
||||
beforeHeaders: ipv4Headers(packet),
|
||||
detail: `waiting for ARP resolution of ${route.nextHop}`,
|
||||
});
|
||||
}
|
||||
return finished ?? { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||||
}
|
||||
|
||||
private sendIPv4Directly(
|
||||
fromNodeId: Id,
|
||||
packet: IPv4Packet,
|
||||
route: PacketRoute,
|
||||
preferredInterfaceId: Id | undefined,
|
||||
allowIcmpErrorGeneration: boolean,
|
||||
operation: TraceOperation,
|
||||
): IPv4DeliveryResult {
|
||||
switch (route.type) {
|
||||
case 'local': {
|
||||
const networkInterface = route.interface ?? this.networkInterfaces(fromNodeId)[0];
|
||||
@@ -3281,30 +3442,8 @@ export class NetworkRuntimeEngine {
|
||||
return { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||||
}
|
||||
|
||||
case 'egress': {
|
||||
const destinationMacAddress = this.resolveMacAddress(fromNodeId, route.nextHop, {
|
||||
preferredInterfaceId: route.interface.portId,
|
||||
maxRetries: 2,
|
||||
});
|
||||
if (destinationMacAddress === undefined) {
|
||||
if (this.isProcessingScheduledEvents && this.hasConfiguredInterface(route.nextHop)) {
|
||||
this.enqueueIPv4TransmissionAwaitingArp(fromNodeId, route.nextHop, packet, route.interface, operation);
|
||||
return { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||||
}
|
||||
if (allowIcmpErrorGeneration) {
|
||||
this.observeLocalIcmpError(fromNodeId, route.interface.portId, 'destinationHostUnreachable', packet);
|
||||
return {
|
||||
type: 'icmpError',
|
||||
packetIdentity: packet.identity,
|
||||
nodeId: fromNodeId,
|
||||
kind: 'destinationHostUnreachable',
|
||||
};
|
||||
}
|
||||
return { type: 'dropped', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||||
}
|
||||
this.transmitIPv4Packet(fromNodeId, packet, route.interface, destinationMacAddress, operation);
|
||||
return { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||||
}
|
||||
case 'egress':
|
||||
return this.sendIPv4ToNextHop(fromNodeId, packet, route, allowIcmpErrorGeneration, operation, {});
|
||||
|
||||
case 'unavailable':
|
||||
if (allowIcmpErrorGeneration) {
|
||||
@@ -3320,46 +3459,6 @@ export class NetworkRuntimeEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private hasConfiguredInterface(ipAddress: string): boolean {
|
||||
return this.stateValue.topologySnapshot.nodes.some((node) =>
|
||||
this.networkInterfaces(node.id).some((candidate) => candidate.ipAddress === ipAddress),
|
||||
);
|
||||
}
|
||||
|
||||
private enqueueIPv4TransmissionAwaitingArp(
|
||||
fromNodeId: Id,
|
||||
nextHop: string,
|
||||
packet: IPv4Packet,
|
||||
networkInterface: NetworkInterface,
|
||||
operation: TraceOperation,
|
||||
): void {
|
||||
const key = arpKey(fromNodeId, nextHop);
|
||||
const transmission: PendingIPv4Transmission = { packet, interface: networkInterface, operation };
|
||||
const pending = this.pendingIPv4TransmissionsByArpKey.get(key);
|
||||
if (pending?.some((candidate) => deepEqual(candidate, transmission)) === true) return;
|
||||
getOrCreate(this.pendingIPv4TransmissionsByArpKey, key, () => []).push(transmission);
|
||||
this.recordTrace({
|
||||
packetIdentity: packet.identity,
|
||||
nodeId: fromNodeId,
|
||||
interfaceId: networkInterface.portId,
|
||||
direction: 'outbound',
|
||||
layer: 'network',
|
||||
operation: 'created',
|
||||
beforeHeaders: ipv4Headers(packet),
|
||||
detail: `waiting for ARP resolution of ${nextHop}`,
|
||||
});
|
||||
}
|
||||
|
||||
private flushIPv4TransmissionsAwaitingArp(nodeId: Id, ipAddress: string, macAddress: string): void {
|
||||
const key = arpKey(nodeId, ipAddress);
|
||||
const transmissions = this.pendingIPv4TransmissionsByArpKey.get(key);
|
||||
if (transmissions === undefined) return;
|
||||
this.pendingIPv4TransmissionsByArpKey.delete(key);
|
||||
for (const transmission of transmissions) {
|
||||
this.transmitIPv4Packet(nodeId, transmission.packet, transmission.interface, macAddress, transmission.operation);
|
||||
}
|
||||
}
|
||||
|
||||
private transmitIPv4Packet(
|
||||
fromNodeId: Id,
|
||||
packet: IPv4Packet,
|
||||
@@ -3451,15 +3550,16 @@ export class NetworkRuntimeEngine {
|
||||
afterHeaders: ipv4Headers(forwarded),
|
||||
detail: 'TTL decremented',
|
||||
});
|
||||
const result = this.sendIPv4PacketWithOptions(nodeId, forwarded, undefined, false, 'forwarded');
|
||||
if (result.type === 'dropped') {
|
||||
const route = this.routeForPacket(nodeId, packetForProcessing.receiverIPAddress);
|
||||
this.emitIcmpError(
|
||||
nodeId,
|
||||
networkInterface,
|
||||
route.type === 'unavailable' ? 'destinationNetworkUnreachable' : 'destinationHostUnreachable',
|
||||
packet,
|
||||
);
|
||||
// Java: a router whose ARP finds no one answers host unreachable; for
|
||||
// ICMP only an echo request (ICMP.sendUnicastToNextHop).
|
||||
const echoOrNotIcmp = packet.payload.kind !== 'icmp' || packet.payload.message.kind === 'echoRequest';
|
||||
const result = this.sendIPv4PacketWithOptions(nodeId, forwarded, undefined, false, 'forwarded', {
|
||||
onUnresolved: () => {
|
||||
if (echoOrNotIcmp) this.emitIcmpError(nodeId, networkInterface, 'destinationHostUnreachable', packet);
|
||||
},
|
||||
});
|
||||
if (result.type === 'dropped' && this.routeForPacket(nodeId, packetForProcessing.receiverIPAddress).type === 'unavailable') {
|
||||
this.emitIcmpError(nodeId, networkInterface, 'destinationNetworkUnreachable', packet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3900,10 +4000,6 @@ function portKey(nodeId: Id, portId: Id): string {
|
||||
return `${nodeId}|${portId}`;
|
||||
}
|
||||
|
||||
function arpKey(nodeId: Id, ipAddress: string): string {
|
||||
return `${nodeId}|${ipAddress}`;
|
||||
}
|
||||
|
||||
function bestInterface(targetIPAddress: string, interfaces: readonly NetworkInterface[]): NetworkInterface | undefined {
|
||||
let selected: NetworkInterface | undefined;
|
||||
let selectedMask: number | undefined;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// ARP as Java FILIUS runs it. tools/java-oracle's ArpRun sent to addresses no
|
||||
// card has on arp-routed's network in Java FILIUS and wrote every frame each
|
||||
// card saw and when each call started and ended (arp-java-<story>.java.json).
|
||||
// The engine makes the same calls: the frames must come in the same order,
|
||||
// with the same content, at about the same distance from each other
|
||||
// (javaRun.ts), the calls must return the same, after about as long.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IPv4DeliveryResult, NetworkRuntimeEngine } from '../src/index.js';
|
||||
import { GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, watchedEngine, type JavaFrame } from './javaRun.js';
|
||||
import type { Scenario } from './scenario.js';
|
||||
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
const HOST = '00000000-0000-0000-0000-000000000301';
|
||||
|
||||
interface JavaRun {
|
||||
frames: Record<string, JavaFrame[]>;
|
||||
events: [number, string][];
|
||||
}
|
||||
|
||||
/** A ping's result in Java's words: the ICMP answer's type and code, or null when none came. */
|
||||
function pingResult(result: IPv4DeliveryResult, answered: boolean): string {
|
||||
if (result.type === 'icmpError' && result.kind === 'destinationHostUnreachable') return 'ICMP 3/1';
|
||||
return answered ? 'ICMP 0/0' : 'null';
|
||||
}
|
||||
|
||||
const STORIES: Record<string, (engine: NetworkRuntimeEngine) => string> = {
|
||||
pingLan: (engine) => pingResult(engine.sendIcmpEcho(HOST, '10.0.0.9'), false),
|
||||
pingRouted: (engine) => {
|
||||
const result = engine.sendIcmpEcho(HOST, '20.0.0.9');
|
||||
return pingResult(result, result.type === 'delivered' && result.nodeId !== HOST);
|
||||
},
|
||||
udpTwice: () => '',
|
||||
udpBehind: () => '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Stories the engine does not play like Java yet, with the reason; each must
|
||||
* still differ from Java.
|
||||
*
|
||||
* udpTwice, udpBehind: Java's transport thread sends one segment after the
|
||||
* other and holds its queue while ARP waits, so the next send blocks — the
|
||||
* engine's threads come in the next step.
|
||||
*/
|
||||
const NOT_YET_LIKE_JAVA: Record<string, string> = {
|
||||
udpTwice: "Java's transport thread: the second datagram waits for the first one's ARP",
|
||||
udpBehind: "Java's transport thread: a datagram to a reachable host waits behind one whose ARP fails",
|
||||
};
|
||||
|
||||
describe('ARP as Java FILIUS runs it', () => {
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'arp-routed.scenario.json'), 'utf8')) as Scenario;
|
||||
|
||||
for (const [name, story] of Object.entries(STORIES)) {
|
||||
it(name, () => {
|
||||
const java = JSON.parse(readFileSync(join(scenarioDirectory, `arp-java-${name}.java.json`), 'utf8')) as JavaRun;
|
||||
const { engine, frames } = watchedEngine(scenario);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 500 });
|
||||
const called = engine.state.currentTimeMilliseconds;
|
||||
const result = story(engine);
|
||||
const returned = engine.state.currentTimeMilliseconds;
|
||||
engine.handle({ type: 'advance', toMilliseconds: 16_000 });
|
||||
|
||||
const expected = gaps(javaFrames(java.frames));
|
||||
const actual = gaps(frames());
|
||||
if (name in NOT_YET_LIKE_JAVA) {
|
||||
expect(JSON.stringify(actual), NOT_YET_LIKE_JAVA[name]).not.toBe(JSON.stringify(expected));
|
||||
return;
|
||||
}
|
||||
const [[javaCalled], [javaReturned, javaOutcome]] = java.events as [[number, string], [number, string]];
|
||||
expect(javaOutcome.replace(/^.* returned /, '').replace(/ from .*$/, '')).toBe(result);
|
||||
expect(Math.abs(returned - called - (javaReturned - javaCalled)), 'duration of the call').toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
|
||||
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
|
||||
expect(actual[index]?.text, `frame #${index + 1}`).toBe(expected[index]?.text);
|
||||
expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `gap before frame #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -53,17 +53,8 @@ function normalized(frames: readonly SeenFrame[]): SeenFrame[] {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stories the engine does not play like Java yet, with the reason; each must
|
||||
* still differ from Java (else it belongs to the others) and return the same.
|
||||
*
|
||||
* noServer: Java's transport thread sends while the resolver already waits,
|
||||
* and asks ARP twice, a round trip apart. The engine's ARP (Swift's) blocks
|
||||
* the call and asks again at once — rebuilt after Java in its own step.
|
||||
*/
|
||||
const NOT_YET_LIKE_JAVA: Record<string, string> = {
|
||||
noServer: "ARP: Java's second request comes a round trip after the first, while the resolver waits",
|
||||
};
|
||||
/** Stories the engine does not play like Java yet, with the reason; each must still differ from Java. */
|
||||
const NOT_YET_LIKE_JAVA: Record<string, string> = {};
|
||||
|
||||
describe('DNS as Java FILIUS runs it', () => {
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'dns-lan.scenario.json'), 'utf8')) as Scenario;
|
||||
|
||||
@@ -1531,3 +1531,60 @@ describe('DNS', () => {
|
||||
expect(() => lan({ '10.30.0.2': [] }, true)).toThrow(NotPortedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ARP', () => {
|
||||
const requestsFor = (engine: NetworkRuntimeEngine, ipAddress: string) =>
|
||||
engine.state.packetTraces.filter((trace) => trace.detail === `ARP request for ${ipAddress}`).map((trace) => trace.timeMilliseconds);
|
||||
|
||||
it('asks twice, a round trip apart, then gives up', () => {
|
||||
const { engine, sourceNodeId } = routedTopology();
|
||||
const called = engine.state.currentTimeMilliseconds;
|
||||
|
||||
expect(engine.resolveMacAddress(sourceNodeId, '10.0.0.9')).toBeUndefined();
|
||||
expect(requestsFor(engine, '10.0.0.9')).toEqual([called, called + 2_750]);
|
||||
expect(engine.state.currentTimeMilliseconds).toBe(called + 2 * 2_750);
|
||||
});
|
||||
|
||||
it('lets a router answer host unreachable for an echo request, not for an echo reply', () => {
|
||||
const { engine, sourceNodeId } = routedTopology();
|
||||
const toNowhere = (kind: 'echoRequest' | 'echoReply', identity: number): IPv4Packet => ({
|
||||
identity,
|
||||
senderIPAddress: '10.0.0.2',
|
||||
receiverIPAddress: '20.0.0.9',
|
||||
timeToLive: 64,
|
||||
protocol: 'icmp',
|
||||
payload: { kind: 'icmp', message: icmpMessage(kind, { identifier: identity, sequenceNumber: 1, data: new Uint8Array() }) },
|
||||
});
|
||||
const observed = () => (engine.state.icmpObservationsByNodeId.get(sourceNodeId) ?? []).map((each) => each.message.kind);
|
||||
|
||||
engine.sendIPv4Packet(sourceNodeId, toNowhere('echoReply', 901));
|
||||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||||
expect(observed()).toEqual([]);
|
||||
|
||||
engine.sendIPv4Packet(sourceNodeId, toNowhere('echoRequest', 902));
|
||||
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
|
||||
expect(observed()).toEqual(['destinationHostUnreachable']);
|
||||
});
|
||||
|
||||
it('forgets the lookups that wait on stop', () => {
|
||||
const [firstId, firstPortId, secondId, secondPortId] = [0x1a01, 0x1a02, 0x1a03, 0x1a04].map(uuid) as [string, string, string, string];
|
||||
const input = (secondIp: string) => ({
|
||||
nodes: [pc(firstId, firstPortId), pc(secondId, secondPortId)],
|
||||
links: [link(firstId, firstPortId, secondId, secondPortId)],
|
||||
deviceConfigurations: new Map([
|
||||
[firstId, device('10.0.0.2')],
|
||||
[secondId, device(secondIp)],
|
||||
]),
|
||||
});
|
||||
const engine = startedEngine(input('10.0.0.8'));
|
||||
const socket = engine.bindUdpSocket(firstId, { remoteIPAddress: '10.0.0.9', remotePort: 9 })!;
|
||||
engine.sendUdp(socket, new TextEncoder().encode('lost'));
|
||||
|
||||
engine.handle({ type: 'stop' });
|
||||
engine.handle({ type: 'start', snapshot: topologySnapshot(input('10.0.0.9')), seed: 14, initialTimeMilliseconds: 0 });
|
||||
expect(engine.resolveMacAddress(firstId, '10.0.0.9')).toBeDefined();
|
||||
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
||||
|
||||
expect(engine.state.packetTraces.filter((trace) => trace.layer === 'network' && trace.operation === 'sent')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,15 @@ function javaText(frame: JavaFrame): string {
|
||||
return `${macs} UDP ${ends} ${frame.payload}`;
|
||||
}
|
||||
|
||||
/** The ICMP type numbers Java logs. */
|
||||
const ICMP_TYPES: Record<string, number> = {
|
||||
echoReply: 0,
|
||||
destinationNetworkUnreachable: 3,
|
||||
destinationHostUnreachable: 3,
|
||||
echoRequest: 8,
|
||||
timeExceeded: 11,
|
||||
};
|
||||
|
||||
const FLAG_LETTERS: [number, string][] = [
|
||||
[TCP_FLAG.synchronize, 'S'],
|
||||
[TCP_FLAG.acknowledgement, 'A'],
|
||||
@@ -77,7 +86,7 @@ function engineText(frame: EthernetFrame): string {
|
||||
return `${macs} ARP ${arp.operation} ${arp.senderIPAddress} > ${arp.targetIPAddress}`;
|
||||
}
|
||||
const packet = frame.payload.packet;
|
||||
if (packet.payload.kind === 'icmp') return `${macs} ICMP`;
|
||||
if (packet.payload.kind === 'icmp') return `${macs} ICMP ${ICMP_TYPES[packet.payload.message.kind]}`;
|
||||
if (packet.payload.kind === 'tcp') {
|
||||
const segment = packet.payload.segment;
|
||||
const flags = FLAG_LETTERS.filter(([bit]) => (segment.flags & bit) !== 0).map(([, letter]) => letter).join('');
|
||||
|
||||
@@ -73,7 +73,7 @@ function observationJson(item: IcmpObservation): Record<string, unknown> {
|
||||
|
||||
function run(scenario: Scenario): Record<string, unknown> {
|
||||
// Swift's cables carry all frames at once; everything else is compared as it is.
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true });
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true, swiftArp: true });
|
||||
if (scenario.speedPercent !== undefined) engine.setSimulationSpeed(scenario.speedPercent);
|
||||
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
|
||||
|
||||
@@ -301,6 +301,16 @@ const JAVA_INSTEAD_OF_SWIFT: Record<string, string> = {
|
||||
'tcp-closing': 'TCP: TIME_WAIT lasts 3 round trips and ends with the next segment; the other end closes by itself',
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenarios that follow Java from a trace on: the traces before it must match
|
||||
* Swift, the rest must differ (the Java comparisons check that behaviour).
|
||||
*/
|
||||
const SWIFT_UNTIL_TRACE: Record<string, { readonly trace: number; readonly reason: string }> = {
|
||||
'firewall-and-loss': { trace: 36, reason: "a ping without an answer waits a round trip (Java's sendProbe), Swift returns at once" },
|
||||
'routed-two-networks': { trace: 156, reason: 'the router answers host unreachable when ARP has given up after two round trips, Swift at once' },
|
||||
'switch-lan': { trace: 155, reason: 'ARP for an address no card has asks again after a round trip, then host unreachable' },
|
||||
};
|
||||
|
||||
describe('same result as the Swift engine', () => {
|
||||
it('corrects Swift only where it errs', () => {
|
||||
for (const [name, correct] of Object.entries(SWIFT_ERRORS)) {
|
||||
@@ -321,7 +331,21 @@ describe('same result as the Swift engine', () => {
|
||||
});
|
||||
}
|
||||
|
||||
for (const name of scenarioNames.filter((each) => !(each in JAVA_INSTEAD_OF_SWIFT))) {
|
||||
for (const [name, { trace, reason }] of Object.entries(SWIFT_UNTIL_TRACE)) {
|
||||
it(`${name} matches Swift until trace #${trace}, then follows Java: ${reason}`, () => {
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario;
|
||||
const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record<string, unknown>;
|
||||
const actual = run(scenario);
|
||||
const expectedTraces = swift.traces as unknown[];
|
||||
const actualTraces = actual.traces as unknown[];
|
||||
for (let index = 0; index < trace - 1; index += 1) {
|
||||
expect(actualTraces[index], `trace event #${index + 1}`).toEqual(expectedTraces[index]);
|
||||
}
|
||||
expect(actualTraces[trace - 1]).not.toEqual(expectedTraces[trace - 1]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const name of scenarioNames.filter((each) => !(each in JAVA_INSTEAD_OF_SWIFT) && !(each in SWIFT_UNTIL_TRACE))) {
|
||||
it(name, () => {
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario;
|
||||
const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user