feat(core): the command line's network commands ping, host, nslookup, arp and netstat after Java, measured against Java

TerminalRun types lines into Java's command line on terminal-net at set
times; the engine types the same lines. New in the engine: an ICMP echo
in a program's thread (the first ICMP message after the send, whatever
it is), Java's route choice, the ARP table with Java's broadcast row, DNS
in a program's thread, ICMP broadcasts of a node's own network, and an
ARP wait that ends after what else happens at that millisecond.

Departures from Java: an echo is answered from the address asked
(RFC 792), network unreachable carries the packet with its sender, ping
names the host and takes only -b before it, arp and netstat list in the
order learned, netstat lists a UDP server's listening socket only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 08:03:10 +00:00
co-authored by Claude Opus 5.5
parent 016f9461df
commit 495c449ef2
22 changed files with 33994 additions and 42 deletions
+184 -28
View File
@@ -348,6 +348,8 @@ export interface SocketRecord {
tcpState: TcpSocketState | undefined;
/** A socket a TCP listener accepted: that listener. */
readonly parentListenerSocketId: Id | undefined;
/** UDP: bound to its port only, a server's (Java's ServerSocket); its remote is the last sender. */
readonly listening?: boolean;
}
/** Swift's TopologyRuntimeTCPSessionRecord; sequence numbers are unsigned 32-bit. */
@@ -379,9 +381,13 @@ export type TcpReceived = { readonly type: 'message'; readonly payload: Uint8Arr
/** Java's DNSQueryAgent waits five round trips for an answer. */
export const DNS_QUERY_TIMEOUT_ROUND_TRIPS = 5;
export type DnsResolution = { readonly type: 'answered'; readonly records: readonly DnsMessageRecord[] } | { readonly type: 'timedOut' };
/** A resolution as it goes on the events: the answer and authority records apart, as Java keeps them. */
type DnsLookup =
| { readonly type: 'answered'; readonly answers: readonly DnsMessageRecord[]; readonly authority: readonly DnsMessageRecord[] }
/**
* A resolution as it goes on the events: the answer and authority records
* apart, as Java keeps them; isLocal for localhost, answered without asking
* (DNSNachricht.isLocal).
*/
export type DnsLookup =
| { readonly type: 'answered'; readonly answers: readonly DnsMessageRecord[]; readonly authority: readonly DnsMessageRecord[]; readonly isLocal: boolean }
| { readonly type: 'timedOut' };
export type DnsAddressResolution = { readonly type: 'resolved'; readonly address: string | undefined } | { readonly type: 'timedOut' };
@@ -610,7 +616,7 @@ function natWithDestination(packet: IPv4Packet, endpoint: NatEndpoint): IPv4Pack
}
/** VermittlungsProtokoll.isBroadcast: the limited broadcast, or the broadcast address of the sender's network. */
function isBroadcastFrom(receiverIPAddress: string, senderIPAddress: string, subnetMask: string): boolean {
export function isBroadcastFrom(receiverIPAddress: string, senderIPAddress: string, subnetMask: string): boolean {
if (receiverIPAddress === LIMITED_BROADCAST_IP_ADDRESS) return true;
const receiver = parseIPv4(receiverIPAddress);
const sender = parseIPv4(senderIPAddress);
@@ -808,6 +814,8 @@ interface ArpLookup {
readonly ipAddress: string;
readonly networkInterface: NetworkInterface;
attemptsLeft: number;
/** Its round trip is over and it waits for what else happens at that time (Java's thread wakes after it). */
woken?: boolean;
/** 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;
@@ -871,6 +879,10 @@ export class NetworkRuntimeEngine {
private tcpPortRandom: JavaCompatibleRandom;
private linkLocalRandom: JavaCompatibleRandom;
private dnsIdRandom: JavaCompatibleRandom;
/** Draws the command line's first ICMP identifier (Terminal.setIdentifier: new Random()). */
private icmpIdentifierRandom: JavaCompatibleRandom;
/** Nodes whose ARP table lost its broadcast row (arp -d 255.255.255.255), until the table is reset. */
private readonly arpBroadcastRowRemoved = new Set<Id>();
private runtimeSpeed: RuntimeSpeed;
private readonly packetTraceRetentionPolicy: TraceRetentionPolicy;
private isProcessingScheduledEvents = false;
@@ -967,6 +979,7 @@ export class NetworkRuntimeEngine {
this.tcpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5443_5050n);
this.linkLocalRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4c4c_4144n);
this.dnsIdRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x444e_5349n);
this.icmpIdentifierRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4943_4d50n);
this.runtimeSpeed = new RuntimeSpeed();
this.packetTraceRetentionPolicy = packetTraceRetentionPolicy;
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
@@ -1020,6 +1033,8 @@ export class NetworkRuntimeEngine {
this.tcpPortRandom = new JavaCompatibleRandom(seed ^ 0x5443_5050n);
this.linkLocalRandom = new JavaCompatibleRandom(seed ^ 0x4c4c_4144n);
this.dnsIdRandom = new JavaCompatibleRandom(seed ^ 0x444e_5349n);
this.icmpIdentifierRandom = new JavaCompatibleRandom(seed ^ 0x4943_4d50n);
this.arpBroadcastRowRemoved.clear();
this.isProcessingScheduledEvents = false;
this.applicationThreads.clear();
this.applicationWakes.clear();
@@ -1054,6 +1069,7 @@ export class NetworkRuntimeEngine {
this.dnsQueriesBySocketId.clear();
state.pendingEvents = [];
state.arpCachesByNodeId.clear();
this.arpBroadcastRowRemoved.clear();
state.switchForwardingTablesByNodeId.clear();
state.switchForwardingUpdatedAtMillisecondsByNodeId.clear();
state.switchSeenFrameIdentitiesByNodeId.clear();
@@ -1530,6 +1546,7 @@ export class NetworkRuntimeEngine {
remotePort: options.remotePort,
tcpState: undefined,
parentListenerSocketId: undefined,
listening: options.remoteIPAddress === undefined,
});
state.udpReceiveQueuesBySocketId.set(id, []);
return id;
@@ -1919,7 +1936,7 @@ export class NetworkRuntimeEngine {
private beginDnsResolution(nodeId: Id, name: string, type: string, server: string | undefined, done: (resolution: DnsLookup) => void): void {
const domainName = name.endsWith('.') ? name : `${name}.`;
if (/^localhost\.?$/i.test(domainName)) {
done({ type: 'answered', answers: [{ name: 'localhost.', type: 'A', ttl: 3600, data: LOCALHOST_IP_ADDRESS }], authority: [] });
done({ type: 'answered', answers: [{ name: 'localhost.', type: 'A', ttl: 3600, data: LOCALHOST_IP_ADDRESS }], authority: [], isLocal: true });
return;
}
const answers: DnsMessageRecord[] = [];
@@ -1927,7 +1944,7 @@ export class NetworkRuntimeEngine {
const queried = new Set<string>();
const ask = (serverToQuery: string | undefined): void => {
if (serverToQuery === undefined || serverToQuery === '') {
done({ type: 'answered', answers, authority });
done({ type: 'answered', answers, authority, isLocal: false });
return;
}
this.queryDnsServer(nodeId, domainName, type, serverToQuery, (response) => {
@@ -1943,7 +1960,7 @@ export class NetworkRuntimeEngine {
answers.push(...findDnsRecords(data[0]!.data, 'A', response.answers));
authority.push(...findDnsRecords(data[0]!.data, 'A', response.authority));
}
done({ type: 'answered', answers, authority });
done({ type: 'answered', answers, authority, isLocal: false });
return;
}
const all = [...response.answers, ...response.authority, ...response.additional];
@@ -1951,7 +1968,7 @@ export class NetworkRuntimeEngine {
const next = nameServer === undefined ? undefined : findDnsRecords(nameServer.data, 'A', all)[0]?.data;
// Java remembers only the servers it was referred to, not the first one.
if (next === undefined || queried.has(next)) {
done({ type: 'answered', answers, authority });
done({ type: 'answered', answers, authority, isLocal: false });
return;
}
queried.add(next);
@@ -1969,17 +1986,22 @@ export class NetworkRuntimeEngine {
return { type: 'resolved', address: findDnsRecords(domainName, 'A', resolution.records)[0]?.data };
}
/** Resolver.holeIPAdresse in a program's thread. */
*resolveDnsAddressSteps(nodeId: Id, name: string): Steps<DnsAddressResolution> {
const domainName = name.endsWith('.') ? name : `${name}.`;
/** Resolver.resolve in a program's thread: asks the node's DNS server, or `serverIPAddress`. */
*resolveDnsSteps(nodeId: Id, name: string, type: string, serverIPAddress?: string): Steps<DnsLookup> {
let lookup: DnsLookup | undefined;
const server = this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer;
this.beginDnsResolution(nodeId, domainName, 'A', server, (result) => {
const server = serverIPAddress ?? this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer;
this.beginDnsResolution(nodeId, name, type, server, (result) => {
lookup = result;
});
yield* waitUntil(() => lookup !== undefined || this.stateValue.phase !== 'running');
const result = lookup as DnsLookup | undefined;
if (result === undefined || result.type === 'timedOut') return { type: 'timedOut' };
return (lookup as DnsLookup | undefined) ?? { type: 'timedOut' };
}
/** Resolver.holeIPAdresse in a program's thread. */
*resolveDnsAddressSteps(nodeId: Id, name: string): Steps<DnsAddressResolution> {
const domainName = name.endsWith('.') ? name : `${name}.`;
const result = yield* this.resolveDnsSteps(nodeId, domainName, 'A');
if (result.type === 'timedOut') return result;
return { type: 'resolved', address: findDnsRecords(domainName, 'A', [...result.answers, ...result.authority])[0]?.data };
}
@@ -2007,14 +2029,8 @@ export class NetworkRuntimeEngine {
/** Resolver.holeIPAdresseMailServer in a program's thread (the e-mail server sending a mail on). */
*resolveDnsMailServerSteps(nodeId: Id, domain: string): Steps<DnsAddressResolution> {
const domainName = domain.endsWith('.') ? domain : `${domain}.`;
let lookup: DnsLookup | undefined;
const server = this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer;
this.beginDnsResolution(nodeId, domainName, 'MX', server, (result) => {
lookup = result;
});
yield* waitUntil(() => lookup !== undefined || this.stateValue.phase !== 'running');
const result = lookup as DnsLookup | undefined;
if (result === undefined || result.type === 'timedOut') return { type: 'timedOut' };
const result = yield* this.resolveDnsSteps(nodeId, domainName, 'MX');
if (result.type === 'timedOut') return result;
const records = [...result.answers, ...result.authority];
const exchange = findDnsRecords(domainName, 'MX', records)[0];
return { type: 'resolved', address: exchange === undefined ? undefined : findDnsRecords(exchange.data, 'A', records)[0]?.data };
@@ -4018,6 +4034,28 @@ export class NetworkRuntimeEngine {
}
}
/**
* The node's ARP table as Java's holds it (ARP.holeARPTabelle): the
* broadcast row Java puts in at each reset, then each learned address, in
* the order learned (Java: a HashMap's order).
*/
arpTable(nodeId: Id): { readonly ipAddress: string; readonly macAddress: string }[] {
const learned = [...(this.stateValue.arpCachesByNodeId.get(nodeId)?.values() ?? [])].map(({ ipAddress, macAddress }) => ({ ipAddress, macAddress }));
return this.arpBroadcastRowRemoved.has(nodeId) ? learned : [{ ipAddress: LIMITED_BROADCAST_IP_ADDRESS, macAddress: ETHERNET_BROADCAST_MAC_ADDRESS }, ...learned];
}
/** ARP.removeARPTableEntry: forgets the address (the broadcast row too, until the table is reset). */
removeArpEntry(nodeId: Id, ipAddress: string): void {
if (ipAddress === LIMITED_BROADCAST_IP_ADDRESS) this.arpBroadcastRowRemoved.add(nodeId);
this.stateValue.arpCachesByNodeId.get(nodeId)?.delete(ipAddress);
}
/** ARP.resetArpTable: every learned address forgotten, the broadcast row back. */
resetArpTable(nodeId: Id): void {
this.clearArpCache(nodeId);
this.arpBroadcastRowRemoved.delete(nodeId);
}
/**
* The MAC address for `targetIPAddress`, asking by ARP broadcast when it is
* not cached. Blocks like Java: each request waits a round trip for its
@@ -4103,10 +4141,19 @@ export class NetworkRuntimeEngine {
for (const lookup of answered) lookup.done(macAddress);
}
/** No answer within the round trip: ask again, or give up. */
/**
* No answer within the round trip: ask again, or give up — after what
* else happens at that time (an answer coming then still counts).
*/
private handleArpTimeout(lookupId: number): void {
const lookup = this.arpLookups.find((each) => each.id === lookupId);
if (lookup === undefined) return;
if (lookup.woken !== true) {
lookup.woken = true;
this.schedule(this.stateValue.currentTimeMilliseconds, { kind: 'arpTimeout', lookupId });
return;
}
lookup.woken = false;
if (lookup.attemptsLeft > 0) {
lookup.attemptsLeft -= 1;
this.schedule(this.stateValue.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds, { kind: 'arpTimeout', lookupId });
@@ -4667,6 +4714,88 @@ export class NetworkRuntimeEngine {
return result;
}
/** The first ICMP identifier of a command line (Terminal.setIdentifier: new Random().nextInt(43) * 23). */
drawIcmpIdentifier(): number {
return this.icmpIdentifierRandom.nextInt(43) * 23;
}
/**
* InternetKnotenBetriebssystem.determineRoute: the row of the forwarding
* table with the longest mask that holds the address, the first of equal
* ones; undefined when none does (RouteNotFoundException).
*/
routeRow(nodeId: Id, ipAddress: string): RouteRow | undefined {
const target = parseIPv4(ipAddress);
if (target === undefined) return undefined;
let best: RouteRow | undefined;
let bestMask = -1;
for (const row of this.forwardingTable(nodeId)) {
const mask = parseIPv4(row.subnetMask);
if (mask === undefined || mask <= bestMask || parseIPv4(row.destinationNetwork) !== maskAddress(target, mask)) continue;
best = row;
bestMask = mask;
}
return best;
}
/**
* ICMP.startSinglePing and sendProbe in a program's thread: the node's
* queue of ICMP answers emptied, an echo request sent (its ARP waits as
* Java's send does; a broadcast of the route's network goes to every card
* with TTL 1, ICMP.sendBroadcast), then a round trip waited for the first
* ICMP message to come (ICMPThread.waitForIcmpResponse), which it returns,
* whatever it answers; undefined when none came.
*/
*icmpEchoSteps(
nodeId: Id,
targetIPAddress: string,
options: { readonly identifier: number; readonly sequenceNumber: number; readonly timeToLive?: number },
): Steps<IcmpObservation | undefined> {
const interfaces = this.networkInterfaces(nodeId);
const route = this.routeForPacket(nodeId, targetIPAddress);
const sourceInterface = route.type === 'local' || route.type === 'egress' ? (route.interface ?? interfaces[0]) : interfaces[0];
if (sourceInterface === undefined) return undefined;
const packet: IPv4Packet = {
identity: this.allocatePacketIdentity(),
senderIPAddress: sourceInterface.ipAddress,
receiverIPAddress: targetIPAddress,
timeToLive: options.timeToLive ?? 64,
protocol: 'icmp',
payload: {
kind: 'icmp',
message: icmpMessage('echoRequest', { identifier: options.identifier, sequenceNumber: options.sequenceNumber, data: new Uint8Array() }),
},
};
this.recordTrace({
packetIdentity: packet.identity,
nodeId,
interfaceId: sourceInterface.portId,
direction: 'outbound',
layer: 'network',
operation: 'created',
afterHeaders: ipv4Headers(packet),
detail: 'ICMP echo request',
});
const observations = () => this.stateValue.icmpObservationsByNodeId.get(nodeId) ?? [];
const queued = observations().length;
let sentAt: number | undefined;
if (route.type === 'egress' && isBroadcastFrom(targetIPAddress, sourceInterface.ipAddress, sourceInterface.subnetMask)) {
this.transmitIPv4Packet(nodeId, { ...packet, timeToLive: 1 }, sourceInterface, ETHERNET_BROADCAST_MAC_ADDRESS, 'sent');
sentAt = this.stateValue.currentTimeMilliseconds;
} else {
this.sendIPv4PacketWithOptions(nodeId, packet, sourceInterface.portId, true, 'sent', {
onFinished: () => {
sentAt = this.stateValue.currentTimeMilliseconds;
},
});
}
const running = () => this.stateValue.phase === 'running';
yield* waitUntil(() => sentAt !== undefined || !running());
if (sentAt === undefined) return undefined;
yield* waitUntil(() => observations().length > queued || !running(), sentAt + this.runtimeSpeed.roundTripTimeMilliseconds);
return observations()[queued];
}
/** `traceroute`: echoes with TTL 1, 2, … until one is answered. */
traceIcmpEcho(
fromNodeId: Id,
@@ -4894,10 +5023,16 @@ export class NetworkRuntimeEngine {
const node = this.nodeSnapshot(nodeId)!;
const packetForProcessing = packet;
const localAddresses = new Set(this.networkInterfaces(nodeId).map((candidate) => candidate.ipAddress));
const interfaces = this.networkInterfaces(nodeId);
const localAddresses = new Set(interfaces.map((candidate) => candidate.ipAddress));
// ICMPThread: a broadcast of a network of the node's, from that network, is the node's; one of another network is dropped.
const isIcmp = packetForProcessing.payload.kind === 'icmp';
const isBroadcastOf = (candidate: NetworkInterface) => isBroadcastFrom(packetForProcessing.receiverIPAddress, candidate.ipAddress, candidate.subnetMask);
if (
localAddresses.has(packetForProcessing.receiverIPAddress) ||
packetForProcessing.receiverIPAddress === LIMITED_BROADCAST_IP_ADDRESS
packetForProcessing.receiverIPAddress === LOCALHOST_IP_ADDRESS ||
packetForProcessing.receiverIPAddress === LIMITED_BROADCAST_IP_ADDRESS ||
(isIcmp && interfaces.some((candidate) => isBroadcastOf(candidate) && sameSubnet(packetForProcessing.senderIPAddress, candidate.ipAddress, candidate.subnetMask)))
) {
if (
isPcClassEndpoint(node.kind) &&
@@ -4912,6 +5047,21 @@ export class NetworkRuntimeEngine {
if (!this.deliverIPv4Locally(packetForProcessing, nodeId, networkInterface, finished)) finished();
return;
}
if (isIcmp && interfaces.some(isBroadcastOf)) {
this.recordTrace({
frameIdentity,
packetIdentity: packetForProcessing.identity,
nodeId,
interfaceId: networkInterface.portId,
direction: 'inbound',
layer: 'network',
operation: 'dropped',
beforeHeaders: ipv4Headers(packetForProcessing),
detail: 'ICMP broadcast of another network',
});
finished();
return;
}
// Java: routers and home routers forward, a host when its IP forwarding is on (ipForwardingEnabled).
if (!(this.stateValue.topologySnapshot.ipForwardingByNodeId.get(nodeId) ?? forwardsIPv4ByDefault(node.kind))) {
this.recordTrace({
@@ -4957,8 +5107,9 @@ export class NetworkRuntimeEngine {
},
onFinished: finished,
});
// As for host unreachable, the packet as the router would have forwarded it (Java: half made, without its sender).
if (result.type === 'dropped' && this.routeForPacket(nodeId, packetForProcessing.receiverIPAddress).type === 'unavailable') {
this.emitIcmpError(nodeId, networkInterface, 'destinationNetworkUnreachable', packet);
this.emitIcmpError(nodeId, networkInterface, 'destinationNetworkUnreachable', forwarded);
}
}
@@ -4971,9 +5122,14 @@ export class NetworkRuntimeEngine {
switch (message.kind) {
case 'echoRequest': {
if (packet.receiverIPAddress === LIMITED_BROADCAST_IP_ADDRESS && packet.timeToLive !== 1) return false;
// From the address asked (RFC 792: the addresses reversed); for a broadcast, the card's. Java answers from the
// address its route back gives: from the near card when a router's far address was asked, from 127.0.0.1 when the
// node's own address was.
const askedOwnAddress =
packet.receiverIPAddress === LOCALHOST_IP_ADDRESS || this.networkInterfaces(nodeId).some((candidate) => candidate.ipAddress === packet.receiverIPAddress);
const reply: IPv4Packet = {
identity: this.allocatePacketIdentity(),
senderIPAddress: networkInterface.ipAddress,
senderIPAddress: askedOwnAddress ? packet.receiverIPAddress : networkInterface.ipAddress,
receiverIPAddress: packet.senderIPAddress,
timeToLive: 64,
protocol: 'icmp',