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
+1 -1
View File
File diff suppressed because one or more lines are too long
+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.
+5 -2
View File
@@ -415,7 +415,8 @@ Threads zwischen Karte und Ethernet-Schicht sitzen):
LAN-Adresse von außen verschwindet.
* Dynamische Einträge verfallen nach 300 s mal Javas Verzögerungsfaktor
(bei 90 % 55 Minuten), geprüft jede Sekunde, aufgefrischt nur von
eingehenden Paketen (aus dem Code, nicht gemessen).
eingehenden Paketen (aus dem Code, nicht gemessen). Der Kern verfällt sie
seit 01.10.2026 genau in diesem Alter statt beim nächsten Sekundentakt.
## WLAN zur Laufzeit
@@ -468,4 +469,6 @@ nach 5 s gehen dort nur noch Broadcasts.
Javas Switch prüft seine Tabelle einmal je Sekunde ab dem Start
(`SwitchFirmware.checkSAT`), ein Eintrag verfällt also bis zu einer Sekunde
nach der Lernzeit; der Kern genau bei ihr. Noch nicht gemessen.
nach der Lernzeit; der Kern genau bei ihr. Das ist gewollt und wird nicht
gemessen: Wo Java nur in einem Takt nachsieht, macht der Kern es genauer
(Entscheidung 01.10.2026; dasselbe für die NAT-Einträge des Heimrouters).