feat(core): sends return at once and go through Java's transport thread
A send hands its frame to the card and returns, as in Java; the frames travel while the clock advances. Swift's send ran the network until every frame had arrived; the option swiftSends keeps that for the Swift comparison. Waiting calls (TCP connect, acknowledgement) now run event by event until the answer instead of to the timeout. UDP and TCP send through a thread per node and protocol (Java's TransportProtokoll): one segment after the other; while IP waits for ARP, the next send blocks and a segment from an event queues behind it. Senders that wait take their turns in the order they came (a fair lock; Java's leaves the order to chance). All four ArpRun stories now match Java FILIUS, including when each call returns. Tests let frames arrive explicitly with letFramesArrive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,11 @@
|
||||
// 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).
|
||||
// A send hands its frame to the card and returns, as in Java; the frames
|
||||
// travel while the clock advances (Swift: the send returns when every frame
|
||||
// in flight has arrived; the option swiftSends keeps that for the comparison).
|
||||
// Java's transport send thread: per node and protocol, one segment after the
|
||||
// other; while IP waits for ARP, a socket's next send blocks.
|
||||
// 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,
|
||||
@@ -542,6 +547,19 @@ interface ArpLookup {
|
||||
readonly done: (macAddress: string | undefined) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Java's TransportProtokoll send thread, one per node and protocol: it takes
|
||||
* the segments one after the other and hands each to IP, which waits there
|
||||
* for ARP.
|
||||
*/
|
||||
interface TransportSender {
|
||||
busy: boolean;
|
||||
/** How many segments it has taken, and how many are out. */
|
||||
started: number;
|
||||
finished: number;
|
||||
readonly queue: ((finished: () => void) => 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. */
|
||||
@@ -589,10 +607,13 @@ export class NetworkRuntimeEngine {
|
||||
/** ARP lookups waiting for their answer (Java: a thread in holeARPTabellenEintrag). */
|
||||
private arpLookups: ArpLookup[] = [];
|
||||
private nextArpLookupId = 1;
|
||||
/** Per node and transport protocol (`nodeId/udp`, `nodeId/tcp`). */
|
||||
private transportSenders = new Map<string, TransportSender>();
|
||||
/** 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;
|
||||
private readonly swiftSends: boolean;
|
||||
|
||||
/**
|
||||
* `swiftCables`: a cable carries all frames at once, as in the Swift
|
||||
@@ -600,14 +621,18 @@ export class NetworkRuntimeEngine {
|
||||
* frame after the other (SimplexVerbindung sleeps the delay with the port's
|
||||
* buffer locked); a frame waits in the output buffer until the one before
|
||||
* has arrived, and shows in the packet log when it leaves.
|
||||
*
|
||||
* `swiftArp`, `swiftSends`: Swift's ARP and Swift's send that returns
|
||||
* when the network is quiet, likewise only for the comparison.
|
||||
*/
|
||||
constructor(
|
||||
seed: bigint | number = DEFAULT_SEED,
|
||||
packetTraceRetentionPolicy: TraceRetentionPolicy = PRODUCTION_TRACE_RETENTION,
|
||||
options: { readonly swiftCables?: boolean; readonly swiftArp?: boolean } = {},
|
||||
options: { readonly swiftCables?: boolean; readonly swiftArp?: boolean; readonly swiftSends?: boolean } = {},
|
||||
) {
|
||||
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
|
||||
this.swiftArp = options.swiftArp === true;
|
||||
this.swiftSends = options.swiftSends === true;
|
||||
const normalizedSeed = toSeed(seed);
|
||||
this.stateValue = initialState(normalizedSeed);
|
||||
this.random = new JavaCompatibleRandom(normalizedSeed);
|
||||
@@ -669,6 +694,7 @@ export class NetworkRuntimeEngine {
|
||||
state.globalPacketLossEnabled = false;
|
||||
this.isProcessingScheduledEvents = false;
|
||||
this.arpLookups = [];
|
||||
this.transportSenders.clear();
|
||||
state.pendingEvents = [];
|
||||
state.arpCachesByNodeId.clear();
|
||||
state.switchForwardingTablesByNodeId.clear();
|
||||
@@ -1054,20 +1080,25 @@ export class NetworkRuntimeEngine {
|
||||
afterHeaders: udpHeaders(datagram),
|
||||
});
|
||||
|
||||
const handedOn: IPv4DeliveryResult = { type: 'delivered', packetIdentity, nodeId: socket.nodeId };
|
||||
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 };
|
||||
const broadcastInterfaces = boundInterface === undefined ? interfaces : [boundInterface];
|
||||
for (const networkInterface of broadcastInterfaces) traceSent(networkInterface, packetIdentity);
|
||||
return this.handToTransportSender(socket.nodeId, 'udp', handedOn, (finished) => {
|
||||
for (const networkInterface of broadcastInterfaces) {
|
||||
const packet: IPv4Packet = {
|
||||
identity: packetIdentity,
|
||||
senderIPAddress: networkInterface.ipAddress,
|
||||
receiverIPAddress: destinationIPAddress,
|
||||
timeToLive: 1,
|
||||
protocol: 'udp',
|
||||
payload: { kind: 'udp', datagram },
|
||||
};
|
||||
this.sendLimitedBroadcastPacket(socket.nodeId, networkInterface, packet);
|
||||
}
|
||||
finished();
|
||||
return handedOn;
|
||||
});
|
||||
}
|
||||
|
||||
let networkInterface: NetworkInterface;
|
||||
@@ -1086,7 +1117,57 @@ export class NetworkRuntimeEngine {
|
||||
payload: { kind: 'udp', datagram },
|
||||
};
|
||||
traceSent(networkInterface, packet.identity);
|
||||
return this.sendIPv4Packet(socket.nodeId, packet, networkInterface.portId);
|
||||
const portId = networkInterface.portId;
|
||||
return this.handToTransportSender(socket.nodeId, 'udp', handedOn, (finished) =>
|
||||
this.sendIPv4PacketWithOptions(socket.nodeId, packet, portId, true, 'sent', { onFinished: finished }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a segment to the node's send thread for the protocol (Java's
|
||||
* TransportProtokoll.senden). The thread holds its list while IP waits for
|
||||
* ARP: a socket's send then blocks until the segment in hand is out, as the
|
||||
* caller does here; from within an event, the segment queues behind it.
|
||||
* The senders that wait take their turns in the order they came, before
|
||||
* the thread takes the next — as with a fair lock; Java's is not fair and
|
||||
* leaves the order to chance. The result is the send's own when the thread
|
||||
* takes it at once, else that it was handed on.
|
||||
*/
|
||||
private handToTransportSender(
|
||||
nodeId: Id,
|
||||
protocol: 'udp' | 'tcp',
|
||||
handedOn: IPv4DeliveryResult,
|
||||
send: (finished: () => void) => IPv4DeliveryResult,
|
||||
): IPv4DeliveryResult {
|
||||
const sender = getOrCreate(this.transportSenders, `${nodeId}/${protocol}`, () => ({ busy: false, started: 0, finished: 0, queue: [] }));
|
||||
if (sender.busy && !this.isProcessingScheduledEvents) {
|
||||
// The segment in hand is out once ARP has answered or given up.
|
||||
const inHand = sender.started;
|
||||
const deadline = this.stateValue.currentTimeMilliseconds + (ARP_ATTEMPTS + 1) * this.runtimeSpeed.roundTripTimeMilliseconds;
|
||||
this.advanceUntil(() => sender.finished >= inHand, deadline);
|
||||
if (this.stateValue.phase !== 'running' || this.transportSenders.get(`${nodeId}/${protocol}`) !== sender) return handedOn;
|
||||
}
|
||||
let result = handedOn;
|
||||
sender.queue.push((finished) => {
|
||||
result = send(finished);
|
||||
});
|
||||
if (!sender.busy) this.startNextTransportSend(sender);
|
||||
return result;
|
||||
}
|
||||
|
||||
private startNextTransportSend(sender: TransportSender): void {
|
||||
const next = sender.queue.shift();
|
||||
if (next === undefined) return;
|
||||
sender.busy = true;
|
||||
sender.started += 1;
|
||||
let out = false;
|
||||
next(() => {
|
||||
if (out) return;
|
||||
out = true;
|
||||
sender.finished += 1;
|
||||
sender.busy = false;
|
||||
this.startNextTransportSend(sender);
|
||||
});
|
||||
}
|
||||
|
||||
private sendLimitedBroadcastPacket(fromNodeId: Id, networkInterface: NetworkInterface, packet: IPv4Packet): void {
|
||||
@@ -1689,9 +1770,6 @@ export class NetworkRuntimeEngine {
|
||||
if (socket === undefined || socket.remoteIPAddress === undefined || socket.remotePort === undefined || session === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
// Java's socket starts waiting when it hands the segment down, before
|
||||
// ARP has found the next hop; the timeout counts from here.
|
||||
const handedDownAt = state.currentTimeMilliseconds;
|
||||
let segment: TcpSegment;
|
||||
if (retransmission && session.lastSentSegment !== undefined) {
|
||||
segment = session.lastSentSegment;
|
||||
@@ -1738,9 +1816,16 @@ export class NetworkRuntimeEngine {
|
||||
afterHeaders: tcpHeaders(segment),
|
||||
detail: retransmission ? `${detail} retransmission attempt ${session.sendAttempts}` : detail,
|
||||
});
|
||||
const delivery = this.sendIPv4Packet(socket.nodeId, packet, networkInterface.portId);
|
||||
if (state.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined) this.scheduleTcpTimeoutIfNeeded(socketId, handedDownAt);
|
||||
return delivery;
|
||||
const portId = networkInterface.portId;
|
||||
const handedOn: IPv4DeliveryResult = { type: 'delivered', packetIdentity: packet.identity, nodeId: socket.nodeId };
|
||||
return this.handToTransportSender(socket.nodeId, 'tcp', handedOn, (finished) => {
|
||||
// Java's socket starts waiting when it has handed the segment down,
|
||||
// before ARP has found the next hop; the timeout counts from here.
|
||||
const handedDownAt = state.currentTimeMilliseconds;
|
||||
const delivery = this.sendIPv4PacketWithOptions(socket.nodeId, packet, portId, true, 'sent', { onFinished: finished });
|
||||
if (state.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined) this.scheduleTcpTimeoutIfNeeded(socketId, handedDownAt);
|
||||
return delivery;
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleTcpTimeoutIfNeeded(socketId: Id, fromMilliseconds: number): void {
|
||||
@@ -1826,10 +1911,9 @@ export class NetworkRuntimeEngine {
|
||||
socketId: Id,
|
||||
options: { waitingStates?: readonly TcpSocketState[]; waitingForAcknowledgement?: boolean },
|
||||
): boolean {
|
||||
let advancedToTimeout = false;
|
||||
while (this.stateValue.phase === 'running') {
|
||||
const waiting = () => {
|
||||
const socket = this.stateValue.socketsById.get(socketId);
|
||||
if (socket === undefined || socket.tcpState === undefined) return advancedToTimeout;
|
||||
if (socket === undefined || socket.tcpState === undefined) return false;
|
||||
const waitingOnState = options.waitingStates?.includes(socket.tcpState) === true;
|
||||
// Data waits for its acknowledgement only while connected: once the
|
||||
// socket closes (Java: senden gives up and calls schliessen), it returns.
|
||||
@@ -1837,13 +1921,17 @@ export class NetworkRuntimeEngine {
|
||||
options.waitingForAcknowledgement === true &&
|
||||
socket.tcpState === 'ESTABLISHED' &&
|
||||
this.stateValue.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined;
|
||||
if (!waitingOnState && !waitingOnAck) return advancedToTimeout;
|
||||
return waitingOnState || waitingOnAck;
|
||||
};
|
||||
let advancedToTimeout = false;
|
||||
while (this.stateValue.phase === 'running' && waiting()) {
|
||||
const deadlines = this.stateValue.pendingEvents
|
||||
.filter((event) => event.kind.kind === 'tcpTimeout' && event.kind.sessionId === socketId)
|
||||
.map((event) => event.deadlineMilliseconds);
|
||||
if (deadlines.length === 0) return advancedToTimeout;
|
||||
advancedToTimeout = true;
|
||||
this.advance(Math.min(...deadlines));
|
||||
// Until the answer comes, or the attempt's time is up.
|
||||
this.advanceUntil(() => !waiting(), Math.min(...deadlines));
|
||||
}
|
||||
return advancedToTimeout;
|
||||
}
|
||||
@@ -2895,8 +2983,14 @@ export class NetworkRuntimeEngine {
|
||||
this.drainPendingNetworkDeliveryEventsIfNeeded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Swift's send, for the comparison: a frame sent from outside the events
|
||||
* runs the network until every frame in flight has arrived. Java's send
|
||||
* hands the frame to the card and returns; the frames travel while the
|
||||
* clock advances.
|
||||
*/
|
||||
private drainPendingNetworkDeliveryEventsIfNeeded(): void {
|
||||
if (this.isProcessingScheduledEvents) return;
|
||||
if (this.isProcessingScheduledEvents || !this.swiftSends) return;
|
||||
const maximumEvents = 16_384;
|
||||
let processedEventCount = 0;
|
||||
while (processedEventCount < maximumEvents) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 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.
|
||||
// (javaRun.ts); the calls must end at about the same times and return the same.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
@@ -27,28 +27,41 @@ function pingResult(result: IPv4DeliveryResult, answered: boolean): string {
|
||||
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);
|
||||
/** Records a call as ArpRun does: when it started, when it ended and what it returned. */
|
||||
type Call = (label: string, run: () => string) => void;
|
||||
|
||||
function udpSend(engine: NetworkRuntimeEngine, target: string, payload: string): string {
|
||||
const socket = engine.bindUdpSocket(HOST, { remoteIPAddress: target, remotePort: 9 })!;
|
||||
engine.sendUdp(socket, new TextEncoder().encode(payload));
|
||||
return '';
|
||||
}
|
||||
|
||||
const STORIES: Record<string, (engine: NetworkRuntimeEngine, call: Call) => void> = {
|
||||
pingLan: (engine, call) => call('sendProbe 10.0.0.9', () => pingResult(engine.sendIcmpEcho(HOST, '10.0.0.9'), false)),
|
||||
pingRouted: (engine, call) =>
|
||||
call('sendProbe 20.0.0.9', () => {
|
||||
const result = engine.sendIcmpEcho(HOST, '20.0.0.9');
|
||||
return pingResult(result, result.type === 'delivered' && result.nodeId !== HOST);
|
||||
}),
|
||||
udpTwice: (engine, call) => {
|
||||
call('senden eins', () => udpSend(engine, '10.0.0.9', 'eins'));
|
||||
call('senden zwei', () => udpSend(engine, '10.0.0.9', 'zwei'));
|
||||
},
|
||||
udpBehind: (engine, call) => {
|
||||
call('senden verloren', () => udpSend(engine, '10.0.0.9', 'verloren'));
|
||||
call('senden dahinter', () => udpSend(engine, '20.0.0.2', 'dahinter'));
|
||||
},
|
||||
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",
|
||||
};
|
||||
/** The client's free port as "client": Java draws it at random, the engine from its seed. */
|
||||
function withClientPort<T extends { text: string }>(frames: readonly T[]): T[] {
|
||||
return frames.map((frame) => ({ ...frame, text: frame.text.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole)) }));
|
||||
}
|
||||
|
||||
/** Java's events without its answer's sender ("from …"), which the engine's results do not name. */
|
||||
function javaEvent(event: string): string {
|
||||
return event.replace(/ from .*$/, '');
|
||||
}
|
||||
|
||||
describe('ARP as Java FILIUS runs it', () => {
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'arp-routed.scenario.json'), 'utf8')) as Scenario;
|
||||
@@ -58,20 +71,22 @@ describe('ARP as Java FILIUS runs it', () => {
|
||||
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;
|
||||
const events: [number, string][] = [];
|
||||
story(engine, (label, run) => {
|
||||
events.push([engine.state.currentTimeMilliseconds, `${label} called`]);
|
||||
const result = run();
|
||||
events.push([engine.state.currentTimeMilliseconds, `${label} returned ${result}`]);
|
||||
});
|
||||
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 expected = gaps(withClientPort(javaFrames(java.frames)));
|
||||
const actual = gaps(withClientPort(frames()));
|
||||
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,
|
||||
);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
type RuntimeOutput,
|
||||
type ScheduledEventKind,
|
||||
} from '../src/index.js';
|
||||
import { device, link, pc, startedEngine, uuid } from './fixtures.js';
|
||||
import { device, letFramesArrive, link, pc, startedEngine, uuid } from './fixtures.js';
|
||||
|
||||
const marker = (label: string): ScheduledEventKind => ({ kind: 'parityMarker', label });
|
||||
|
||||
@@ -274,6 +274,29 @@ describe('event queue and virtual clock', () => {
|
||||
expect(engine.state.packetTraces.find((trace) => trace.layer === 'physical' && trace.operation === 'sent')?.timeMilliseconds).toBe(0);
|
||||
});
|
||||
|
||||
it('forgets the send threads on stop', () => {
|
||||
const [firstId, firstPortId, secondId, secondPortId] = [0x29, 0x2a, 0x2b, 0x2c].map(uuid) as [string, string, string, string];
|
||||
const input = {
|
||||
nodes: [pc(firstId, firstPortId), pc(secondId, secondPortId)],
|
||||
links: [link(firstId, firstPortId, secondId, secondPortId)],
|
||||
deviceConfigurations: new Map([
|
||||
[firstId, device('10.6.0.1')],
|
||||
[secondId, device('10.6.0.2')],
|
||||
]),
|
||||
};
|
||||
const engine = startedEngine(input);
|
||||
// The UDP thread waits for ARP on .99 when the simulation stops.
|
||||
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.6.0.99', remotePort: 9 })!, new TextEncoder().encode('lost'));
|
||||
|
||||
engine.handle({ type: 'stop' });
|
||||
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed: 14, initialTimeMilliseconds: 0 });
|
||||
const receiver = engine.bindUdpSocket(secondId, { localPort: 9 })!;
|
||||
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.6.0.2', remotePort: 9 })!, new TextEncoder().encode('found'));
|
||||
|
||||
expect(engine.state.currentTimeMilliseconds).toBe(0);
|
||||
expect(new TextDecoder().decode(engine.receiveUdp(receiver, 1_000)?.datagram.payload)).toBe('found');
|
||||
});
|
||||
|
||||
it('forgets TCP sessions and accepted sockets on stop', () => {
|
||||
const [clientId, clientPortId, serverId, serverPortId] = [0x21, 0x22, 0x23, 0x24].map(uuid) as [string, string, string, string];
|
||||
const engine = startedEngine({
|
||||
@@ -319,7 +342,7 @@ describe('UDP sockets', () => {
|
||||
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeUndefined();
|
||||
|
||||
engine.sendUdp(sender!, new TextEncoder().encode('hello'));
|
||||
const received = engine.receiveUdp(receiver!);
|
||||
const received = engine.receiveUdp(receiver!, 1_000);
|
||||
expect(text(received?.datagram.payload)).toBe('hello');
|
||||
expect(received?.senderIPAddress).toBe('192.168.5.10');
|
||||
|
||||
@@ -327,6 +350,36 @@ describe('UDP sockets', () => {
|
||||
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns from a send at once, the datagram arrives as the clock advances (swiftSends: when it has arrived)', () => {
|
||||
const arrival = (swiftSends: boolean) => {
|
||||
const [sourceNodeId, sourcePortId, targetNodeId, targetPortId] = [0x15c1, 0x15c2, 0x15c3, 0x15c4].map(uuid) as [string, string, string, string];
|
||||
const engine = new NetworkRuntimeEngine(14, undefined, { swiftSends });
|
||||
engine.handle({
|
||||
type: 'start',
|
||||
snapshot: topologySnapshot({
|
||||
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
|
||||
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
|
||||
deviceConfigurations: new Map([
|
||||
[sourceNodeId, device('192.168.6.10')],
|
||||
[targetNodeId, device('192.168.6.20')],
|
||||
]),
|
||||
}),
|
||||
seed: 14,
|
||||
initialTimeMilliseconds: 0,
|
||||
});
|
||||
const receiver = engine.bindUdpSocket(targetNodeId, { localPort: 9000 })!;
|
||||
const sender = engine.bindUdpSocket(sourceNodeId, { remoteIPAddress: '192.168.6.20', remotePort: 9000 })!;
|
||||
engine.sendUdp(sender, new TextEncoder().encode('hello'));
|
||||
const returnedAt = engine.state.currentTimeMilliseconds;
|
||||
const waiting = engine.state.udpReceiveQueuesBySocketId.get(receiver)?.length ?? 0;
|
||||
// ARP there and back, then the datagram: three cables of 55 ms.
|
||||
return { returnedAt, waiting, receivedAt: engine.receiveUdp(receiver, 1_000)?.receivedAtMilliseconds };
|
||||
};
|
||||
|
||||
expect(arrival(false)).toEqual({ returnedAt: 0, waiting: 0, receivedAt: 165 });
|
||||
expect(arrival(true)).toEqual({ returnedAt: 165, waiting: 1, receivedAt: 165 });
|
||||
});
|
||||
|
||||
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')]]) };
|
||||
@@ -366,13 +419,13 @@ describe('UDP sockets', () => {
|
||||
|
||||
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.receiveUdp(receiverA, 1_000)?.senderIPAddress).toBe('10.0.0.1');
|
||||
expect(engine.receiveUdp(receiverB, 1_000)?.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');
|
||||
expect(text(engine.receiveUdp(sender, 1_000)?.datagram.payload)).toBe('reply');
|
||||
});
|
||||
|
||||
it('advances the virtual clock for a receive timeout and returns nothing', () => {
|
||||
@@ -621,12 +674,15 @@ describe('switch', () => {
|
||||
engine.state.packetTraces.filter((trace) => trace.nodeId === switchId && trace.detail === detail);
|
||||
|
||||
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, '02:00:00:00:00:FE'));
|
||||
letFramesArrive(engine);
|
||||
expect(switchTraces('switch unknown unicast flooding')).toHaveLength(2);
|
||||
|
||||
engine.sendEthernetFrame(hostBId, hostBPortId, frame(macB, macA));
|
||||
letFramesArrive(engine);
|
||||
expect(switchTraces('switch learned unicast forwarding')).toHaveLength(1);
|
||||
|
||||
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, macA));
|
||||
letFramesArrive(engine);
|
||||
expect(switchTraces('switch filtered destination on incoming port').length).toBeGreaterThan(0);
|
||||
expect(engine.switchSatEntries(switchId)).toHaveLength(2);
|
||||
|
||||
@@ -777,6 +833,7 @@ describe('packet loss and trace retention', () => {
|
||||
|
||||
engine.setGlobalPacketLossEnabled(true);
|
||||
engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
|
||||
letFramesArrive(engine);
|
||||
|
||||
expect(engine.state.globalPacketLossEnabled).toBe(true);
|
||||
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.size ?? 0).toBe(0);
|
||||
@@ -792,6 +849,7 @@ describe('packet loss and trace retention', () => {
|
||||
|
||||
engine.setGlobalPacketLossEnabled(false);
|
||||
engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
|
||||
letFramesArrive(engine);
|
||||
|
||||
expect(engine.state.globalPacketLossEnabled).toBe(false);
|
||||
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.get('10.0.0.1')?.macAddress).toBe(
|
||||
@@ -864,6 +922,9 @@ describe('TCP sockets', () => {
|
||||
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
||||
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
|
||||
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('ESTABLISHED');
|
||||
// connect returns with the SYN+ACK; the client's ACK is still on its way.
|
||||
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('SYN_RCVD');
|
||||
letFramesArrive(engine);
|
||||
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('ESTABLISHED');
|
||||
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_SENT -> ESTABLISHED')).toBe(true);
|
||||
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_RCVD -> ESTABLISHED')).toBe(true);
|
||||
@@ -885,6 +946,42 @@ describe('TCP sockets', () => {
|
||||
expect(payloadLengths).toEqual([1_460, 1_460, 81]);
|
||||
});
|
||||
|
||||
it("sends TCP in a thread of its own: UDP waiting for ARP does not hold a connection up", () => {
|
||||
const { engine, clientId, serverId } = pair(0x1731, 0x1732, '10.24.0');
|
||||
engine.openTcpServerSocket(serverId, 80);
|
||||
engine.sendUdp(engine.bindUdpSocket(clientId, { remoteIPAddress: '10.24.0.99', remotePort: 9 })!, new TextEncoder().encode('nobody'));
|
||||
const clientSocketId = engine.openTcpClientSocket(clientId, '10.24.0.2', 80)!;
|
||||
|
||||
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
||||
// ARP for the server (behind the one for .99 on the cable), SYN and SYN+ACK: four cables of 55 ms.
|
||||
expect(engine.state.currentTimeMilliseconds).toBe(55 + 4 * 55);
|
||||
});
|
||||
|
||||
it("holds the TCP thread while ARP waits: a connection's SYN waits behind a SYN+ACK to no one", () => {
|
||||
const { engine, clientId, serverId } = pair(0x17e1, 0x17e2, '10.25.0');
|
||||
engine.openTcpServerSocket(serverId, 80);
|
||||
engine.openTcpServerSocket(clientId, 81);
|
||||
// A SYN to the client's port 81 from .99, which no card has: the client's answer waits for ARP.
|
||||
engine.sendIPv4Packet(serverId, {
|
||||
identity: engine.allocatePacketIdentity(),
|
||||
senderIPAddress: '10.25.0.99',
|
||||
receiverIPAddress: '10.25.0.1',
|
||||
timeToLive: 64,
|
||||
protocol: 'tcp',
|
||||
payload: {
|
||||
kind: 'tcp',
|
||||
segment: { sourcePort: 5000, destinationPort: 81, sequenceNumber: 1, acknowledgementNumber: 0, flags: TCP_FLAG.synchronize, payload: new Uint8Array() },
|
||||
},
|
||||
});
|
||||
engine.handle({ type: 'advance', toMilliseconds: 200 });
|
||||
const clientSocketId = engine.openTcpClientSocket(clientId, '10.25.0.2', 80)!;
|
||||
|
||||
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
||||
// The SYN arrived after ARP there and back (165 ms); ARP for .99 gives up two
|
||||
// round trips later, then the client's SYN and the server's SYN+ACK.
|
||||
expect(engine.state.currentTimeMilliseconds).toBe(165 + 2 * 2_750 + 2 * 55);
|
||||
});
|
||||
|
||||
it('retransmits a connection attempt at most three times and releases the port', () => {
|
||||
const clientId = uuid(0x1741);
|
||||
const engine = startedEngine({ nodes: [pc(clientId, uuid(0x1751))], links: [], deviceConfigurations: new Map([[clientId, device('10.19.0.1')]]) });
|
||||
@@ -932,6 +1029,7 @@ describe('TCP sockets', () => {
|
||||
const serverSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
|
||||
|
||||
engine.closeTcpSocket(clientSocketId);
|
||||
letFramesArrive(engine);
|
||||
// Java's socket thread leaves listen on the FIN and closes without the application.
|
||||
const serverStates = engine.state.packetTraces
|
||||
.filter((trace) => trace.nodeId === serverId && trace.detail?.startsWith('TCP ') === true && trace.detail.includes(' -> '))
|
||||
@@ -971,8 +1069,10 @@ describe('TCP sockets', () => {
|
||||
engine.openTcpServerSocket(serverId, 80);
|
||||
const client = engine.openTcpClientSocket(clientId, '10.23.0.2', 80)!;
|
||||
expect(engine.connectTcp(client)).toBe(true);
|
||||
letFramesArrive(engine);
|
||||
const closedAt = engine.state.currentTimeMilliseconds;
|
||||
engine.closeTcpSocket(client);
|
||||
letFramesArrive(engine);
|
||||
// The server answers the FIN with its ACK and, closing by itself, its FIN at once.
|
||||
const physical = (nodeId: string, operation: string) =>
|
||||
engine.state.packetTraces
|
||||
@@ -1013,6 +1113,7 @@ describe('TCP sockets', () => {
|
||||
const clientSocketId = engine.openTcpClientSocket(clientId, '10.21.0.2', 9090)!;
|
||||
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
||||
engine.closeTcpSocket(clientSocketId);
|
||||
letFramesArrive(engine);
|
||||
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
|
||||
|
||||
const localPort = engine.state.socketsById.get(clientSocketId)!.localPort;
|
||||
@@ -1059,6 +1160,7 @@ describe('TCP sockets', () => {
|
||||
// the listener's successor 3 (made when the first is accepted), the second client 4.
|
||||
const listenerId = engine.openTcpServerSocket(serverId, 80)!;
|
||||
expect(engine.connectTcp(engine.openTcpClientSocket(firstId, '10.22.0.3', 80)!)).toBe(true);
|
||||
letFramesArrive(engine);
|
||||
expect(engine.connectTcp(engine.openTcpClientSocket(secondId, '10.22.0.3', 80)!)).toBe(true);
|
||||
|
||||
const accepted = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)!;
|
||||
@@ -1423,7 +1525,7 @@ describe('DNS', () => {
|
||||
]),
|
||||
),
|
||||
});
|
||||
return { engine, client: hosts[0]!.id };
|
||||
return { engine, client: hosts[0]!.id, server: hosts[1]!.id };
|
||||
};
|
||||
const queries = (engine: NetworkRuntimeEngine) =>
|
||||
engine.state.packetTraces.filter((trace) => trace.detail?.startsWith('DNS query') === true).map((trace) => trace.detail);
|
||||
@@ -1516,14 +1618,55 @@ describe('DNS', () => {
|
||||
expect(engine.state.currentTimeMilliseconds - called).toBe(4 * 110);
|
||||
});
|
||||
|
||||
it("answers behind a datagram of its own whose ARP fails: Java's UDP thread sends one after the other", () => {
|
||||
const { engine, client, server } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9'] });
|
||||
const socket = engine.bindUdpSocket(server, { remoteIPAddress: '10.30.0.99', remotePort: 9 })!;
|
||||
const called = engine.state.currentTimeMilliseconds;
|
||||
engine.sendUdp(socket, new TextEncoder().encode('nobody'));
|
||||
expect(engine.resolveDnsAddress(client, 'web.filius.de')).toEqual({ type: 'resolved', address: '10.30.0.9' });
|
||||
// The answer leaves when ARP has given up on .99 (two round trips), and takes two cables through the switch.
|
||||
expect(engine.state.currentTimeMilliseconds - called).toBe(2 * 2_750 + 110);
|
||||
});
|
||||
|
||||
it('lets the senders that wait take their turns in the order they came, before the thread takes the next', () => {
|
||||
const { engine, client, server } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9'] });
|
||||
const receiver = engine.bindUdpSocket(client, { localPort: 9 })!;
|
||||
// The server's UDP thread waits for ARP on .99 from 0 to 5500 ms.
|
||||
engine.sendUdp(engine.bindUdpSocket(server, { remoteIPAddress: '10.30.0.99', remotePort: 9 })!, new TextEncoder().encode('nobody'));
|
||||
// A query from .98, which no card has, reaches the server at 330 ms; its answer waits its turn.
|
||||
const query = encodeDnsMessage({
|
||||
id: 1,
|
||||
queryResponse: 0,
|
||||
responseCode: 0,
|
||||
questions: [{ name: 'web.filius.de.', type: 'A', questionClass: 'IN' }],
|
||||
answers: [],
|
||||
authority: [],
|
||||
additional: [],
|
||||
});
|
||||
engine.sendIPv4Packet(client, {
|
||||
identity: engine.allocatePacketIdentity(),
|
||||
senderIPAddress: '10.30.0.98',
|
||||
receiverIPAddress: '10.30.0.2',
|
||||
timeToLive: 64,
|
||||
protocol: 'udp',
|
||||
payload: { kind: 'udp', datagram: { sourcePort: 5000, destinationPort: 53, payload: new TextEncoder().encode(query) } },
|
||||
});
|
||||
engine.handle({ type: 'advance', toMilliseconds: 400 });
|
||||
|
||||
// As with a fair lock (Java's is not): the send returns when the datagram
|
||||
// in hand is out; the answer to .98 goes before it and waits for ARP in turn.
|
||||
engine.sendUdp(engine.bindUdpSocket(server, { remoteIPAddress: '10.30.0.1', remotePort: 9 })!, new TextEncoder().encode('third'));
|
||||
expect(engine.state.currentTimeMilliseconds).toBe(2 * 2_750);
|
||||
expect(engine.receiveUdp(receiver, 10_000)?.receivedAtMilliseconds).toBe(4 * 2_750 + 110);
|
||||
});
|
||||
|
||||
it('gives up after five round trips when no server answers, and closes its socket', () => {
|
||||
const { engine, client } = lan({ '10.30.0.2': [] });
|
||||
const called = engine.state.currentTimeMilliseconds;
|
||||
// 10.30.0.3 is there, but no DNS server runs on it.
|
||||
expect(engine.resolveDns(client, 'web.filius.de', 'A', '10.30.0.3')).toEqual({ type: 'timedOut' });
|
||||
// Java's five round trips; before them the engine's send still blocks for
|
||||
// ARP and the query's way to .3 (Java waits from handing it down — the ARP step).
|
||||
expect(engine.state.currentTimeMilliseconds - called).toBe(3 * 110 + 5 * 2_750);
|
||||
// Java's five round trips, from handing the query down: the send returns at once.
|
||||
expect(engine.state.currentTimeMilliseconds - called).toBe(5 * 2_750);
|
||||
expect([...engine.state.socketsById.values()].filter((socket) => socket.nodeId === client)).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -48,3 +48,17 @@ export function startedEngine(
|
||||
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']);
|
||||
|
||||
/**
|
||||
* Advances until every frame in flight has arrived — what Swift's send did
|
||||
* by itself; the engine's send returns at once, as Java's.
|
||||
*/
|
||||
export function letFramesArrive(engine: NetworkRuntimeEngine): void {
|
||||
const inFlight = () =>
|
||||
engine.state.pendingEvents.filter((event) => FRAME_EVENTS.has(event.kind.kind)).map((event) => event.deadlineMilliseconds);
|
||||
for (let deadlines = inFlight(); deadlines.length > 0; deadlines = inFlight()) {
|
||||
engine.handle({ type: 'advance', toMilliseconds: Math.min(...deadlines) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, swiftArp: true });
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true, swiftArp: true, swiftSends: true });
|
||||
if (scenario.speedPercent !== undefined) engine.setSimulationSpeed(scenario.speedPercent);
|
||||
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user