fix(core): NAT entries expire exactly at their age, not at Java's next one-second check

Java's GatewayFirmware.checkNAT and SwitchFirmware.checkSAT look every
second from the start, so an entry goes up to a second late. The engine
expires both exactly at their age: better than Java where Java only
samples, as long as saved files stay compatible (decided 01.10.2026).
The switch did so already; its departure is now named in the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 14:06:55 +00:00
co-authored by Claude Opus 5.5
parent 088b62469f
commit 739deafbf4
4 changed files with 22 additions and 19 deletions
+12 -12
View File
@@ -837,8 +837,6 @@ export class NetworkRuntimeEngine {
private nextArpLookupId = 1;
/** Per RIP router: its random for the beacon's pauses and its sockets (RIPBeacon, RIPServer). */
private ripRoutersByNodeId = new Map<Id, { readonly random: JavaCompatibleRandom; readonly beaconSocketId: Id; readonly serverSocketId: Id }>();
/** When the NAT tables started: Java's expiry timer ticks every second from then. */
private natStartedAtMilliseconds = 0;
/** DNS queries waiting for their answer, by the socket they were sent from. */
private dnsQueriesBySocketId = new Map<Id, (response: DnsMessage | undefined) => void>();
/** Per node and kind (`nodeId/udp`, `nodeId/icmp`, …). */
@@ -3331,13 +3329,11 @@ export class NetworkRuntimeEngine {
/**
* Java's GatewayFirmware.starten: each home router's port forwarding
* becomes its static entries (rows Java's input check rejects are left
* out). Dynamic entries expire as Java's timer finds them, checked every
* second from the start (natEntryExpired).
* out). Dynamic entries expire exactly at their age (natEntryExpired).
*/
private initializeNatState(): void {
const state = this.stateValue;
state.natTablesByNodeId.clear();
this.natStartedAtMilliseconds = state.currentTimeMilliseconds;
for (const node of state.topologySnapshot.nodes) {
if (node.kind !== 'gateway') continue;
const entries: NatEntry[] = [];
@@ -3368,16 +3364,14 @@ export class NetworkRuntimeEngine {
}
/**
* GatewayFirmware.checkNAT: every second from a second after the start,
* dynamic entries whose last update is 300 s (times Java's delay factor)
* old are removed — at the first such check.
* GatewayFirmware.checkNAT: a dynamic entry whose last update is 300 s
* (times Java's delay factor) old is gone. Java finds it at its timer,
* every second from the start, up to a second later; the engine at that
* age (better than Java where it only samples: the user, 01.10.2026).
*/
private natEntryExpired(entry: NatEntry, nowMilliseconds: number): boolean {
if (entry.type === 'static') return false;
const start = this.natStartedAtMilliseconds;
const retention = NAT_RETENTION_MILLISECONDS * this.runtimeSpeed.javaDelayFactor;
const firstCheck = start + Math.max(1, Math.ceil((entry.updatedAtMilliseconds + retention - start) / 1_000)) * 1_000;
return nowMilliseconds >= firstCheck;
return nowMilliseconds - entry.updatedAtMilliseconds >= NAT_RETENTION_MILLISECONDS * this.runtimeSpeed.javaDelayFactor;
}
private expireNatEntries(nodeId: Id): void {
@@ -4026,6 +4020,12 @@ export class NetworkRuntimeEngine {
}
}
/**
* A learned address is gone when it is the retention time old. Java's
* SwitchFirmware.checkSAT finds it at its timer, every second from the
* start, up to a second later; the engine at that age (better than Java
* where it only samples: the user, 01.10.2026).
*/
private ageSwitchSatEntries(nodeId: Id): void {
const state = this.stateValue;
const retention =
+4 -4
View File
@@ -2221,15 +2221,15 @@ describe('Home router NAT', () => {
expect(engine.bindUdpSocket(gatewayId, { localPort: entry.port })).toBeUndefined();
});
it("drops an entry at the first of Java's checks, each second from the start, when it is 300 s times the delay factor old; answers refresh it", () => {
it('drops an entry when it is 300 s times the delay factor old, not at the next of Java\'s checks each second; answers refresh it', () => {
const engine = home({ speed: 100 });
udpOut(engine);
// At 100 %, the factor is 1: 300 s. The entry came at 15 ms (three cables of 5 ms); it is gone at the check at 301 s.
// At 100 %, the factor is 1: 300 s. The entry came at 15 ms (three cables of 5 ms); it is gone at 300.015 s (Java: at its check at 301 s).
const [entry] = engine.natEntries(gatewayId) as [NatEntry];
expect(entry.updatedAtMilliseconds).toBe(15);
engine.handle({ type: 'advance', toMilliseconds: 300_999 });
engine.handle({ type: 'advance', toMilliseconds: 300_014 });
expect(engine.natEntries(gatewayId)).toHaveLength(1);
engine.handle({ type: 'advance', toMilliseconds: 301_000 });
engine.handle({ type: 'advance', toMilliseconds: 300_015 });
expect(engine.natEntries(gatewayId)).toHaveLength(0);
// Another, now refreshed by an answer, and not by what goes out.