feat(core): DHCP client after Java, measured with Java FILIUS

The client follows Java's DHCPClient instead of Swift: one DISCOVER or
REQUEST per attempt, an answer that does not fit is read and dropped, the
ARP check waits a round trip (an answer for the address ends it at once,
then DECLINE), a timeout repeats the state's message, and the fifth turns
to a link-local address 169.254.1.x/16 (ARP probe, ARP reply to all); no
old address is given back. DHCP round trip times follow the speed as in
Java (50 cables, 2750 ms at 90 %; Swift: fixed 2500 ms).

Not taken from Java: the client gives up after five occupied link-local
addresses (Java searches on without end), and an ARP packet for another
address does not end the check.

dhcpJava.test.ts compares the four DHCP scenarios with DhcpRun's Java
runs; the Swift comparison only checks that they differ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 10:50:30 +00:00
co-authored by Claude Opus 5.5
parent fd445e8889
commit ed437f9b5c
10 changed files with 1005 additions and 321 deletions
+255 -13
View File
@@ -233,7 +233,7 @@ describe('event queue and virtual clock', () => {
[serverId, dhcpServerConfiguration({ isActive: true, lowerBoundIPAddress: '10.4.0.20', upperBoundIPAddress: '10.4.0.29' })],
]),
});
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
expect(engine.state.dhcpLeasesByIPAddress.size).toBe(1);
engine.handle({ type: 'stop' });
@@ -377,6 +377,20 @@ describe('Java-compatible randomness', () => {
expect([random.nextInt(100), random.nextInt(100), random.nextInt(100)]).toEqual([30, 63, 48]);
expect([random.nextInt(16), random.nextInt(16), random.nextInt(16)]).toEqual([0, 4, 15]);
});
it('matches java.util.Random.nextDouble', () => {
// Measured on OpenJDK 21 (30.09.2026): nextDouble twice, then
// Math.round(nextDouble() * 253f) as the DHCP client draws a link-local address.
const cases: [number, number, number, number][] = [
[42, 0.7275636800328681, 0.6832234717598454, 78],
[0, 0.730967787376657, 0.24053641567148587, 161],
[-7, 0.2691218093260761, 0.6731811902798038, 132],
];
for (const [seed, first, second, rounded] of cases) {
const random = new JavaCompatibleRandom(seed);
expect([random.nextDouble(), random.nextDouble(), Math.round(random.nextDouble() * 253)]).toEqual([first, second, rounded]);
}
});
});
describe('IPv4 packets', () => {
@@ -906,6 +920,9 @@ describe('TCP sockets', () => {
});
describe('DHCP', () => {
// Java's round trip time at the default speed of 90 %: 50 cables of 55 ms.
const RTT = 2_750;
const serverAndClient = (first: number, serverIp: string, client: DeviceConfiguration, server: Partial<DhcpServerConfiguration>) => {
const [serverId, serverPortId, clientId, clientPortId] = [first, first + 1, first + 2, first + 3].map(uuid) as [string, string, string, string];
const engine = startedEngine(
@@ -921,7 +938,58 @@ describe('DHCP', () => {
},
16,
);
return { engine, clientId, clientPortId };
return { engine, serverId, clientId, clientPortId };
};
/** Hosts on one switch, each with its device configuration; the first is the DHCP client. */
const onSwitch = (first: number, devices: DeviceConfiguration[], servers: Map<string, Partial<DhcpServerConfiguration>> = new Map()) => {
const switchId = uuid(first);
const hosts = devices.map((_, index) => ({ id: uuid(first + 1 + 2 * index), portId: uuid(first + 2 + 2 * index), switchPortId: uuid(first + 0x80 + index) }));
const switchNode: NodeSnapshot = {
id: switchId,
kind: 'networkSwitch',
ports: hosts.map((host, index) => portSnapshot(host.switchPortId, `sw${index + 1}`)),
};
const hostIds = hosts.map((host) => host.id);
const engine = startedEngine(
{
nodes: [switchNode, ...hosts.map((host) => pc(host.id, host.portId))],
links: hosts.map((host) => link(host.id, host.portId, switchId, host.switchPortId)),
deviceConfigurations: new Map(devices.map((each, index) => [hostIds[index]!, each])),
dhcpClientConfigurationsByNodeId: new Map([[hostIds[0]!, { isEnabled: true }]]),
dhcpServerConfigurationsByNodeId: new Map(
[...servers].map(([ipAddress, server]) => [
hostIds[devices.findIndex((each) => each.ipAddress === ipAddress)]!,
dhcpServerConfiguration({ isActive: true, ...server }),
]),
),
},
16,
);
return { engine, hostIds };
};
/** When the node sent a DHCP message of the type. */
const sentAt = (engine: NetworkRuntimeEngine, nodeId: string, type: string) =>
engine.state.packetTraces
.filter((trace) => trace.nodeId === nodeId && trace.layer === 'application' && trace.operation === 'sent' && trace.detail === type)
.map((trace) => trace.timeMilliseconds);
/** When the node broadcast an ARP packet with the detail. */
const arpAt = (engine: NetworkRuntimeEngine, nodeId: string, detail: string) =>
engine.state.packetTraces
.filter((trace) => trace.nodeId === nodeId && trace.layer === 'dataLink' && trace.operation === 'created' && trace.detail === detail)
.map((trace) => trace.timeMilliseconds);
/** The link-local addresses the client draws with seed 16, as Java's selectArbitraryLinkLocalAddress skips known ones. */
const linkLocalDraws = (count: number) => {
const random = new JavaCompatibleRandom(16n ^ 0x4c4c_4144n);
const draws: string[] = [];
while (draws.length < count) {
const address = `169.254.1.${Math.round(random.nextDouble() * 253) + 1}`;
if (!draws.includes(address)) draws.push(address);
}
return draws;
};
it('completes DISCOVER, OFFER, REQUEST and ACK and applies the configuration', () => {
@@ -934,17 +1002,23 @@ describe('DHCP', () => {
});
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
// A client that runs goes on waiting; it does not start over.
expect(engine.runDhcpClient(clientId)?.state).toBe('VALIDATE');
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual(
deviceConfiguration('192.168.50.20', '255.255.255.0', '192.168.50.1', '192.168.50.53'),
);
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 0, true]);
expect([status?.state, status?.errorCount, status?.succeeded, status?.linkLocal]).toEqual(['FINISH', 0, true, false]);
const leaseExpiry = [...engine.state.dhcpLeasesByIPAddress.values()][0]?.expiresAtMilliseconds;
expect(leaseExpiry).toBeGreaterThan(engine.state.currentTimeMilliseconds);
expect(leaseExpiry! - engine.state.currentTimeMilliseconds).toBeLessThanOrEqual(DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS);
const details = engine.state.packetTraces.map((trace) => trace.detail);
for (const message of ['DHCPDISCOVER', 'DHCPOFFER', 'DHCPREQUEST', 'DHCPACK']) expect(details).toContain(message);
// One message per attempt.
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]);
// The OFFER comes after two cables' delay; the ARP check waits one round trip before the REQUEST.
expect(arpAt(engine, clientId, 'ARP request for 192.168.50.20')).toEqual([110]);
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([110 + RTT]);
});
it('matches a static assignment regardless of case, with a lease that does not expire', () => {
@@ -955,20 +1029,19 @@ describe('DHCP', () => {
staticAssignments: [{ id: 'static', macAddress: stableMacAddress(clientPortId).toLowerCase(), ipAddress: '10.0.0.99' }],
});
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('10.0.0.99');
expect([...engine.state.dhcpLeasesByIPAddress.values()][0]?.expiresAtMilliseconds).toBeUndefined();
});
it('retries ten timeouts and gives back only the old address', () => {
it('takes a link-local address after the fifth timeout and does not give back the old one', () => {
const clientId = uuid(0x1621);
const oldConfiguration = deviceConfiguration('169.254.5.7', '255.255.0.0', '169.254.0.1', '169.254.0.53');
const engine = startedEngine(
{
nodes: [pc(clientId, uuid(0x1622))],
links: [],
deviceConfigurations: new Map([[clientId, oldConfiguration]]),
deviceConfigurations: new Map([[clientId, deviceConfiguration('169.254.5.7', '255.255.0.0', '169.254.0.1', '169.254.0.53')]]),
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
},
16,
@@ -977,17 +1050,186 @@ describe('DHCP', () => {
engine.handle({ type: 'advance', toMilliseconds: 0 });
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('0.0.0.0');
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('DISCOVER');
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(0);
engine.handle({ type: 'advance', toMilliseconds: 25_000 });
engine.handle({ type: 'advance', toMilliseconds: 5 * RTT - 1 });
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(4);
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
const [address] = linkLocalDraws(1);
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, RTT, 2 * RTT, 3 * RTT, 4 * RTT]);
expect(arpAt(engine, clientId, `ARP request for ${address}`)).toEqual([5 * RTT]);
expect(arpAt(engine, clientId, `ARP reply for ${address}`)).toEqual([6 * RTT]);
// Java: the address, its /16 mask and no gateway; the DNS server stays.
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual(
deviceConfiguration(address!, '255.255.0.0', '', '169.254.0.53'),
);
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 10, false]);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual(oldConfiguration);
expect([status?.state, status?.errorCount, status?.succeeded, status?.linkLocal]).toEqual(['FINISH', 5, true, true]);
expect(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false);
expect(engine.state.socketsById.size).toBe(0);
});
it('sends the REQUEST again to the same server when it does not answer', () => {
const { engine, serverId, clientId } = serverAndClient(0x1631, '10.0.0.1', device('0.0.0.0'), {
lowerBoundIPAddress: '10.0.0.20',
upperBoundIPAddress: '10.0.0.29',
});
engine.handle({ type: 'advance', toMilliseconds: 150 });
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('VALIDATE');
engine.closeSocket(engine.state.dhcpServerSocketIdsByNodeId.get(serverId)!);
engine.handle({ type: 'advance', toMilliseconds: 110 + 5 * RTT });
const requests = [1, 2, 3, 4, 5].map((attempt) => 110 + attempt * RTT);
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual(requests);
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]);
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
expect([status?.state, status?.errorCount, status?.offeredIPAddress]).toEqual(['REQUEST', 4, '10.0.0.20']);
});
it('declines at once when another card answers for the offered address', () => {
const { engine, hostIds } = onSwitch(
0x1641,
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.100')],
new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]),
);
const clientId = hostIds[0]!;
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
// The OFFER takes two cables to the client, the ARP request and the
// holder's reply two each; the DECLINE follows the reply, not the round trip.
const [check] = arpAt(engine, clientId, 'ARP request for 192.168.0.100');
expect(check).toBe(220);
expect(sentAt(engine, clientId, 'DHCPDECLINE')).toEqual([check! + 220]);
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, check! + 220]);
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([]);
});
it('starts over with DISCOVER after a NAK, which is no failure', () => {
const { engine, serverId, clientId } = serverAndClient(0x1671, '10.0.0.1', device('0.0.0.0'), {
lowerBoundIPAddress: '10.0.0.20',
upperBoundIPAddress: '10.0.0.29',
});
engine.handle({ type: 'advance', toMilliseconds: 150 });
// While the client checks 10.0.0.20, the server bars it: the REQUEST gets a NAK.
engine.state.dhcpBlacklistByServerNodeId.set(serverId, new Map([['10.0.0.20', 1_000_000]]));
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
const nak = 110 + RTT + 110;
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([110 + RTT, nak + 110 + RTT]);
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, nak]);
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 0, true]);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('10.0.0.21');
});
it('ignores an answer from another server than the one it asked', () => {
const { engine, hostIds } = onSwitch(
0x1681,
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.66')],
new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]),
);
const [clientId, , rogueId] = hostIds as [string, string, string];
const clientMac = stableMacAddress(uuid(0x1681 + 2));
const request = 220 + RTT;
engine.handle({ type: 'advance', toMilliseconds: request + 10 });
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('REQUEST');
// A NAK for the client, but from 192.168.0.66; it arrives before the server's ACK.
const rogue = engine.bindUdpSocket(rogueId, { localPort: 67 })!;
const nak = encodeDhcpMessage({ type: 'DHCPNAK', yourIPAddress: '192.168.0.100', clientMacAddress: clientMac, serverIdentifier: '192.168.0.66' });
engine.sendUdp(rogue, nak, { ipAddress: '255.255.255.255', port: 68 });
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.100');
});
it("takes the other server's queued OFFER at once after a DECLINE", () => {
// Server A offers the address a third card has, server B another one; B's
// OFFER comes while the client checks A's and waits in its queue.
const { engine, hostIds } = onSwitch(
0x1691,
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.20'), device('192.168.0.100')],
new Map([
['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }],
['192.168.0.20', { lowerBoundIPAddress: '192.168.0.200', upperBoundIPAddress: '192.168.0.200' }],
]),
);
const clientId = hostIds[0]!;
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
const decline = 220 + 220;
expect(sentAt(engine, clientId, 'DHCPDECLINE')).toEqual([decline]);
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, decline]);
expect(arpAt(engine, clientId, 'ARP request for 192.168.0.200')).toEqual([decline]);
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([decline + RTT]);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.200');
});
it('skips a link-local address the ARP cache knows', () => {
const [known, next] = linkLocalDraws(2) as [string, string];
const { engine, hostIds } = onSwitch(0x16a1, [
device('0.0.0.0'),
device(known, { mask: '255.255.0.0' }),
device('169.254.9.9', { mask: '255.255.0.0' }),
]);
const clientId = hostIds[0]!;
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
engine.resolveMacAddress(hostIds[1]!, '169.254.9.9', { maxRetries: 1 });
expect(engine.state.arpCachesByNodeId.get(clientId)?.has(known)).toBe(true);
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
expect(arpAt(engine, clientId, `ARP request for ${known}`)).toEqual([]);
expect(arpAt(engine, clientId, `ARP request for ${next}`)).toEqual([5 * RTT]);
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(5);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe(next);
});
// Java's selectArbitraryLinkLocalAddress loop counts occupied addresses
// but checks its limit only after a timeout, which cannot come there: it
// searches without end. The engine gives up after the fifth, as
// LINK_LOCAL_MAX_ERROR_COUNT intends, and keeps 0.0.0.0.
it('gives up after five occupied link-local addresses (Java searches on without end)', () => {
const occupied = linkLocalDraws(5);
const { engine, hostIds } = onSwitch(0x1651, [device('0.0.0.0'), ...occupied.map((address) => device(address, { mask: '255.255.0.0' }))]);
const clientId = hostIds[0]!;
engine.handle({ type: 'advance', toMilliseconds: 30_000 });
for (const address of occupied) expect(arpAt(engine, clientId, `ARP request for ${address}`)).toHaveLength(1);
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 10, false]);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('0.0.0.0');
expect(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false);
});
// Java's ARP lookup waits on the whole ARP table: any entry that changes
// ends the wait, and the address counts as free. The engine waits for the
// address it checks, or the round trip.
it('waits out the address check when an unrelated ARP packet comes (Java would stop waiting)', () => {
const { engine, hostIds } = onSwitch(
0x1661,
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.30')],
new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]),
);
const clientId = hostIds[0]!;
engine.handle({ type: 'advance', toMilliseconds: 300 });
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('VALIDATE');
engine.resolveMacAddress(hostIds[2]!, '192.168.0.77', { maxRetries: 1 });
expect(engine.state.arpCachesByNodeId.get(clientId)?.has('192.168.0.30')).toBe(true);
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
const [check] = arpAt(engine, clientId, 'ARP request for 192.168.0.100');
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([check! + RTT]);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.100');
});
it("writes and reads a message in Swift's text format", () => {
const message = {
type: 'DHCPOFFER' as const,