fix(net): survive edge connection-table flushes β DERP/disco resilience - #94
fix(net): survive edge connection-table flushes β DERP/disco resilience#94iliabaranov wants to merge 14 commits into
Conversation
run-18 (6h15m, 11 clean region switches) failed only when DUT's direct path spontaneously died in STEADY STATE (NAT/tether rebind) and could not self-recover: the de-gated coord re-fetch FIRES but a re-fetch re-ingests an EXISTING peer with disco state PRESERVED (the !existing guard in ml_wg_apply_peer_update), so we keep re-probing the stale best_ip that no longer reaches a symmetric-NAT peer whose mapping rebound β only a reboot recovered direct. Escalate: after the re-fetch rounds fail (relay_retry_count>=3, rate-limited), do the disco half of a per-peer reboot β clear the stuck safety peer's disco session (best_ip/port, ping/pong/cmm timers, trust) and re-arm the sweep so it sends a fresh CMM + unthrottled ping to the just-refreshed endpoints. The peer then pings the machine from its LIVE mapping and the machine's !has_direct_path learn-from-ping adopts it β re-hole-punch without a reboot. Gated to a relay-bound safety peer (no direct path to disturb), rate-limited; WG data path + DERP heartbeat untouched (best_ip is a disco candidate only). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
β¦design A region switch occasionally leaves a remote on the old region: the machine's region reaches remotes only via a coord PeersChangedPatch that can drop under churn, and there is no recovery (steady-state updates are OmitPeers=true; the coord only re-pushes on an actual change). Green never drops β the safety heartbeat rides the region-independent direct WG path. Documents symptoms, root cause, why it is green-safe, and the mitigation options (recommended: carry the machine's authoritative region in-band over the pstop bond). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
β¦ direct-path teardown Both direct-demote triggers (trust-lease expiry, priority pong watchdog) rest on the DISCO side-channel, whose pings can go silent on a jittery uplink (USB-NCM tether) while the WG DATA flow β the 5 Hz safety heartbeat β still arrives on the direct path. Demoting then tears down a WORKING direct path; for a symmetric-NAT peer that teardown is effectively terminal (only a reboot re-established it, 2026-08-11), and the DERP relay fallback measured too jittery for the 2 s pstop timeout in ANY region (rtt spikes >1.2 s on both sfo and dfw). Fix: authenticated WG data received ON the direct path (real outer source; DERP injections arrive as 0.0.0.0) within ML_DEMOTE_DIRECT_RX_FRESH_MS (1 s = 5 missed 5 Hz heartbeats) outranks a missing disco pong β renew the trust lease and hold the path. A truly dead path stops producing direct rx within the window and demotes exactly as before. Safety (priority/health-tracked) peers only; bulk tailnet peers keep the plain lease behavior. - wireguard_lwip: per-peer last_direct_rx stamp (authenticated data rx with a real source addr) + wireguardif_peer_direct_rx_age() getter, same time base both sides. - ml_demote_verdict.h: pure, host-testable verdict (single source of truth for the veto rule). - ml_wg_mgr: verdict wired into the demotion gate; veto renews the lease; demote_vetoes diag counter in /admin/api/monitor. - host/test_demote_veto.c: 13-check contract test (make test). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
β¦ating safety session run-20 green drop (2026-08-11 18:24): a coord/netmap event hit both remotes simultaneously and invalidated their machn WG peer session β heartbeat sends failed ENOTCONN (errno 128, seen on BOTH remotes) until a fresh handshake, >2 s gap β correct machine fail-safe STOP. No disco demotion fired anywhere (regains flat, both remotes stayed direct): this is the layer ABOVE the demote-verification fix β a control-plane teardown killing a working session. Rule (ml_teardown_veto, same invariant as the demote verdict): authenticated WG data rx within the 2 s heartbeat timeout proves the session/key is live β a re-key retire or coord REMOVE arriving while data authenticates is transient netmap churn, not a genuine event, and is DEFERRED. A genuine re-key/removal stops authenticating within seconds and applies on the next update. Safety peers only. - re-key retire path: veto + skip update when the 'stale' entry still authenticates (a live key cannot be stale). - coord REMOVE path: veto while the safety session authenticates; fail toward keeping a working safety bond, never a tidy peer table. - LRU cap-evict: never evict a safety peer, pinned or not. - wireguardif_peer_rx_age(): any-path authenticated-rx age getter. - Diag counters for all three teardown paths + vetoes in /admin/api/monitor (they were log-only, which is why run-20 forensics could not name the culprit path). - host/test_demote_veto.c: +5 teardown-veto checks (18 total). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
β¦s + WG session instrumentation Root-caused (audit + wire evidence from the DUT-host capture): ENOTCONN green drops (runs 20/21, both ~14-15 min post-switch): WG keypair expiry. A second region transition at negotiator dwell-expiry (T+10min) re-routes the DERP leg while rekey handshakes black-hole (frames stamped with a stale peer-region silently fall back to the HOME conn β a DERP server only delivers to clients connected to IT; the host capture caught tailscaled logging 'derp-2 does not know about peer'). REJECT_AFTER_TIME (180 s) later every send fails ENOTCONN on both remotes at once (synchronized negotiator clocks). Symmetric-NAT never-recovers: MACHINE-side terminal wedge β the 8-slot endpoint table fills with dead NAT candidates (accelerated by peers advertising Docker/libvirt junk), then the append-only learn-from-ping SILENTLY DROPPED the one live candidate a rebooted remote presents. Survives remote reboot/power-cycle; only a machine reboot cleared it. Fixes (each small, audit items 4/5/7/8/9/11): - dual-path mirror for ALL safety peers (was priority-only): the machine's TX leg (data replies AND rekey handshakes) now mirrors to DERP, killing the rekey-pinned-to-dead-endpoint class. - negotiator freeze while a safety rekey is in flight/failing β removes the T+10min second-transition trigger. - learn-from-ping ring-eviction (last 2 slots round-robin) β un-wedges the endpoint table without a machine reboot. - disco-reset v2: per-peer rate limit (global stamp starved multi-peer recovery), KEEP best_ip (was wiping the only proven candidate), clear the flap backoff. - demote-veto streak cap (10 ticks): RX-only evidence can no longer pin a TX-dead direct path indefinitely. Instrumentation (convicts/acquits the mechanisms in one soak): - wireguardif: tx_keypair_expired / tx_no_valid_keys counters, per-peer keypair/init-TX age getter. - ml_derp: route-fallback counter (frames black-holed to the home conn). - ml_wg_mgr: ep_learn_evictions + worst safety keypair/init age; all exported via /admin/api/monitor. - host/test_demote_veto.c: +6 veto-cap checks (24 total). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
β¦nnection-table flushes ROOT CAUSE (2026-08-12, DUT-host wire+journal evidence over the session bridge): the office edge firewall periodically FLUSHES its connection table β RSTing long-lived DERP TCP conns across multiple regions AND rebinding UDP NAT mappings in the same instant (host tailscaled journal: second-exact match to both run-20/21/22 green-drop clusters; multi-WAN public-IP hop). One flush simultaneously (a) kills the machn<->DUT direct path (which is a NAT HAIRPIN off the edge public IP, not LAN-direct) and (b) RSTs the DERP relay β so the heartbeat loses BOTH paths at once. THE BUG: on an RST/EOF-triggered home-DERP reconnect, ml_derp.c did an unconditional vTaskDelay(1000) BEFORE the first reconnect attempt, then a TLS handshake β so relay recovery took >2s, past the pstop timeout, while the direct path was also dead => green drop. Reference: host tailscaled recovers in <1s (connGen++), no pre-delay. FIX: first reconnect attempt is now IMMEDIATE (20ms scheduler yield only, to let the socket close settle); back off (2s) only on CONSECUTIVE failures (a genuinely-down server). A flush RST is not a down server β reconnect now, so the heartbeat rides the relay through the flush and green holds. Telemetry: derp_reconnects / derp_reconnect_last_ms / derp_reconnect_worst_ms in /admin/api/monitor (worst < ~1500 = relay back inside the 2s window). Applies to both remote and machine (shared microlink component); both sides' DERP conns get flushed. Follow-ups (NOT in this commit, direct-path RE-FORMATION not green-safety): re-STUN+re-announce on reconnect, and the OmitPeers=true frozen-peer- endpoints gap (chip never receives the machine's post-flush endpoints). Green holds on relay regardless of those. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
β¦relay stall run-23 edge flush (2026-08-12): f2f883e's fast DERP reconnect works (DUT 936ms) but green STILL dropped β machn disarmed ~4s after each flush wave with derp_reconnects=0, i.e. machn's HOME DERP conn never tore down yet it stopped RECEIVING the DUT's relayed heartbeat for ~2s. Peer wire capture localized the gap to machn's inbound relay leg; the likely mechanism (code-confirmed) is derp_manage_aux's one blocking ~1-2s aux/standby TLS reconnect stalling the loop that polls home-rx (the flush RSTs the derp-2 standby too; home never errored). This adds the metric to PROVE it before any behavior change to the safety-critical DERP loop: per-peer worst inter-frame gap on the RELAY-path rx (symmetric to last_direct_rx), surfaced as relay_rx_worst_gap_ms / relay_rx_age_ms in /admin/api/monitor. On the next flush, worst_gap ~= aux reconnect time confirms the mechanism; then the fix (defer/async the aux connect so it can't starve home-rx). Instrumentation only β no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
β¦ the handover) The relay-only gap accumulated during normal direct operation (no relay frames) and never isolated the flush stall. Measure the worst inter-frame gap across ANY path instead β that's the 'received nothing' window that maps to a disarm. Exposed as rx_worst_gap_ms. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
|
@claude please review this PR for correctness, clarity, and especially any safety concerns in the DERP/disco changes (the safety heartbeat rides these paths). Context: chasing an intermittent protective-stop green-drop root-caused to office-edge firewall connection-table flushes; the last residual fix (defer the blocking standby-DERP reconnect that starves home-rx) is intentionally NOT yet in this PR pending live wire confirmation. Feedback on the committed fixes + instrumentation is welcome. |
β¦elay-bound The edge-flush disarm's residual cause (run-23, wire-localized): derp_manage_aux does one blocking ~2s aux/standby TLS handshake in the DERP I/O loop. The one-per-call cap bounds COUNT but not the single ~2s stall β it delays the next home-rx poll, so machn stops processing a safety peer's relayed heartbeat for the handshake duration and disarms, even though its home conn never dropped (reconnects=0). A flush RSTs the standby, triggering exactly this. Fix: while any safety peer's heartbeat rides a relay (no direct path), defer the blocking aux connect. Home carries the heartbeat; the standby is redundancy, rebuilt once the peer is direct. Trade-off: no warm standby / delayed MBB pre-warm while relay-bound β acceptable (safety > standby readiness). Reaping is unaffected (non-blocking). Diag: aux_connect_deferred in /admin/api/monitor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
|
@claude review always |
|
|
||
| /* Home-rx protection (2026-08-12 edge-flush disarm): the one-connect-per-call | ||
| * cap above bounds N back-to-back connects, but a SINGLE ~2s aux TLS handshake | ||
| * still stalls the loop, delaying the next home-rx poll β so machn stops | ||
| * PROCESSING a safety peer's relayed heartbeat for the handshake duration and | ||
| * disarms (home conn never even dropped). So: while any safety peer's | ||
| * heartbeat is riding a relay (no direct path), DEFER the blocking aux connect | ||
| * entirely. Home carries the heartbeat; the standby is redundancy we rebuild | ||
| * once the peer is direct again. Trade-off: no warm standby / delayed MBB | ||
| * pre-warm while relay-bound β acceptable, safety > standby readiness. Reaping | ||
| * above already ran (non-blocking). */ | ||
| if (ml_wg_any_safety_relay_bound(ml)) { | ||
| s_diag_aux_connect_deferred++; | ||
| return; |
There was a problem hiding this comment.
π΄ In derp_manage_aux() the new home-rx guard defers opening ANY aux DERP connection while ANY safety peer is relay-bound, but the aux want-set (ml_wg_collect_safety_regions) is drawn from that exact same safety-peer set. For a cross-region safety peer, this is self-sustaining: it stays relay-bound because its aux never opens, and the aux never opens because it's relay-bound β with derp_route_conn silently black-holing its heartbeat via the home fallback in the meantime. This reintroduces the safety-heartbeat wedge this PR is meant to fix, but for a cross-region symmetric-NAT peer instead of the home-region one. Scope the deferral to already-connected/serving conns, or allow at least one aux-open attempt for a region a relay-bound safety peer actually needs.
Extended reasoning...
The bug. derp_manage_aux() (components/microlink/src/ml_derp.c:698-711) adds a guard before the 'ensure a conn for each wanted region' loop:
if (ml_wg_any_safety_relay_bound(ml)) {
s_diag_aux_connect_deferred++;
return;
}ml_wg_any_safety_relay_bound() returns true if any active safety peer (pinned/priority/health-tracked) currently has has_direct_path == false. The comment's premise is 'Home carries the heartbeat; the standby is redundancy' β true only when the relay-bound peer is homed on the same DERP region as the chip's home slot.
Why it self-deadlocks for a cross-region peer. The aux want-set that the guarded loop would otherwise service comes from ml_wg_collect_safety_regions(), which draws from the exact same safety-peer set the guard inspects. So a cross-region safety peer that goes relay-bound needs precisely the aux connection for its region to receive anything β derp_route_conn() only reaches a peer homed on a non-home region via a connected aux for that region; otherwise it silently falls back to the home conn, which the code's own comment documents as a server-side black-hole ('DERP server only delivers to clients connected to IT'). But that peer being relay-bound is itself sufficient to make the guard return early and block the very aux connect it needs. The state is self-sustaining: relay-bound β guard blocks aux β aux never opens β relayed heartbeat black-holes via home β peer stays relay-bound forever. Since this is a symmetric-NAT peer (the class this PR is about), there's no autonomous UDP path recovery either.
Why nothing else prevents it. Step 1 (reap of now-unwanted aux slots) runs unconditionally before the guard, so an existing warm-standby aux can be torn down by the reap and never rebuilt once the peer trips into relay-bound. There's also cross-contamination: a same-region priority peer going relay-bound (safe on its own, since home covers it) still trips the global guard and blocks a completely different cross-region peer's aux.
Concrete walkthrough (matches this PR's own target scenario). 1) Steady state: peer B is a cross-region, symmetric-NAT safety peer, direct, with a warm standby aux open on its home region. 2) An edge connection-table flush RSTs the direct hairpin and the aux DERP TCP simultaneously (this PR's whole premise). 3) B goes relay-bound (has_direct_path=false); its aux is now down. 4) derp_manage_aux() runs: ml_wg_any_safety_relay_bound() is true (because of B itself) β guard fires β returns before the aux-open loop. 5) B's relayed heartbeat and DISCO/CallMeMaybe now route via derp_route_conn, which finds no connected aux for B's region and falls back to home β silently eaten server-side since B isn't homed there. 6) B can never regain a direct path (no CMM gets through) and its aux can never reopen (guard still true), so the loop in steps 4-5 repeats indefinitely. Net result: a non-recovering loss of the safety heartbeat for peer B, requiring a reboot β the exact class of failure (a machine STOP that doesn't self-clear) this PR was written to eliminate, just shifted from the home-region case to the cross-region case.
Fix direction. Scope the deferral to conns that are already connected/serving a peer with a direct path (i.e. don't defer opening a NEEDED aux for a region a relay-bound safety peer is homed on), or allow at least one aux-open attempt per pass even while relay-bound, reserving the 'defer' behavior for the home-region case the comment actually describes.
There was a problem hiding this comment.
Resolved β this is a false positive against 6832794. The defer-aux guard this describes was removed in this exact commit; line 711 now anchors to the comment explaining the removal (714β719). The for (wβ¦) loop at 720 attempts aux connects unconditionally (one per call, the pre-existing hard cap), and ml_derp_connect calls derp_pump_home_rx(ml) during each aux TLS handshake (ml_derp.c:1635) so the relay-bound safety peer keeps draining while a standby reconnects. No self-deadlock path remains.
| static uint32_t s_diag_relay_disco_resets; /* per-peer from-scratch disco resets on a relay-stuck safety peer | ||
| * (v2: rate-limited per-peer via p->disco_reset_next_ms) */ |
There was a problem hiding this comment.
π‘ s_diag_relay_disco_resets (declared ml_wg_mgr.c:855, incremented at :3602 in the disco-reset-v2 block) is never read by any getter or surfaced in /admin/api/monitor, unlike every other diagnostic counter this PR adds. A bench run can never observe whether the relay-stuck disco-reset path fired. Either add it to ml_wg_get_reingest_diag (or a new getter) and expose it in handler_monitor's handler, or drop the dead counter.
Extended reasoning...
This PR's own stated design pattern (the reingest-diag comment block just above the affected code) is counters-not-logs, read via /admin/api/monitor, precisely so a log-less bench unit can be diagnosed after the fact. Every other diagnostic counter this PR introduces follows that pattern end-to-end: s_diag_rekey_retires, s_diag_rekey_retire_vetoes, s_diag_peer_removes, s_diag_remove_vetoes, and s_diag_evict_safety_skips are all returned by ml_wg_get_reingest_diag() and surfaced in handler_monitor() (ml_config_httpd.c); s_diag_ep_learn_evictions goes out through ml_wg_get_session_diag(); s_diag_demote_vetoes goes out through ml_wg_get_disco_obs_diag(); and s_diag_aux_connect_deferred/the DERP reconnect counters go out through their own ml_derp_get_* getters.
s_diag_relay_disco_resets is the one counter that breaks this chain. It's declared at ml_wg_mgr.c:855 and incremented at ml_wg_mgr.c:3602, inside the disco-reset-v2 escalation block that clears a relay-stuck safety peer's disco timers/backoff so the next sweep re-runs a from-scratch hole-punch. That escalation path is exactly the kind of rare, safety-relevant event this PR's instrumentation effort is meant to make visible β but there is no getter function anywhere in the tree that reads this static, and it does not appear in handler_monitor()'s JSON output. A grep for the symbol turns up exactly two hits (the declaration and the increment) and nothing else.
The practical effect: on a bench unit with no live serial log, there is no way to tell whether the disco-reset-v2 escalation ever fired, or how many times, during a soak. If a relay-stuck safety peer needed this from-scratch reset to recover (or needed it repeatedly, which would itself be a signal worth noticing), that information is silently discarded. This is the same visibility gap the rest of the PR is explicitly trying to close for the other new recovery paths (rekey-retire vetoes, teardown vetoes, evict-safety-skips, demote-vetoes, DERP reconnects, etc.) β it just wasn't wired up for this one counter.
To confirm this isn't just a naming coincidence or a case where the value is read some other way, I checked whether the increment could be dead code entirely (e.g. behind a condition that never fires) β it isn't; the increment sits inside the normal periodic disco-probe sweep gated only by p->relay_retry_count >= 3u and a per-peer rate limit, so it is very plausibly the kind of counter that would climb slowly during a real edge-flush/relay-stuck episode and matter for exactly the kind of forensics this PR's description talks about (run-20/21 style investigation).
Fix is small and low-risk: add out[5] = s_diag_relay_disco_resets; to ml_wg_get_reingest_diag() (bumping it to a uint32_t[6] and updating the one caller in handler_monitor()), or give it a one-off getter and one more cJSON_AddNumberToObject() line, following the exact pattern already used for every sibling counter. Alternatively, if the value isn't actually expected to be useful, it should be dropped rather than left silently uncollectable. Since this is a pure instrumentation/observability gap β no incorrect behavior, no safety-path change β it doesn't block merge, but it undercuts the PR's own diagnostic goal for the one path that arguably needs it most (a relay-stuck safety peer).
β¦ 5+ collapse) Bisected 2026-08-12: per-remote pstop rtt grows ~300-400ms/remote until the 5Hz heartbeat exceeds the 2s timeout. Build-independent (1874ba1 also broke at 6) -> not the PR #94 DERP changes. Mechanism: DERP TLS/reconnect CPU bursts starve the single I/O task under N-remote load (microlink_internal.h L137). Raising it = future work (non-blocking DERP TLS the top lever). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
Global 0-100% brightness scalar applied at the single WS2812 write (ring_show), dimming every ring state proportionally. Persisted in NVS (dcs_app/led_bri), survives power cycle + OTA; absent/corrupt -> 50%. POST /api/led_brightness?pct=N + a slider on the default webpage between Machine peers and Diagnostics. Safety path untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
β¦king defer-aux Targeted edge-flush fix (staged; full non-blocking connect refactor to follow for the scale ceiling). Replaces the defer-aux guard β which the @claude PR review + a live 274k-deferral/31-per-sec counter both flagged as self-deadlocking a cross-region relay-bound safety peer (the region-2 fleet server): it deferred the very aux that peer needed, wedging it. Now the aux is always allowed to (re)connect, but during its ~1-2s TLS handshake ml_derp_connect drains the HOME conn's rx (derp_pump_home_rx) so the safety heartbeat relay keeps flowing β no home-rx starvation, no green drop across a flush. Aux uses a 100ms read timeout so mbedTLS yields between flights (SSL_TIMEOUT, resumes cleanly on a TLS stream); home keeps the long timeout; overall connect deadline unchanged. Also: surface relay_disco_resets in /admin/api/monitor (@claude review nit #35); diag derp_home_pumps replaces aux_connect_deferred. Transport layer only (pstop_c safety library untouched); a bug here fails safe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
β¦x pre-commit The polymath-python checker copies its ruff.toml to a gitignored .ruff.toml at runtime and relies on ruff ancestor-walk discovery. On CI runners that copy is not found from the hook cwd, so ruff falls back to its built-in default (quote-style=double) and rewrites the repo's single-quoted Python, failing every PR on files it never touched (tools/hil/*.py, tools/usb_relay4.py β all unchanged on this branch). A tracked non-dot ruff.toml is discovered regardless of cwd, pinning the org standard (single quotes) in CI. Verified with the exact CI ruff (0.11.5): all 5 previously-flagged files report already-formatted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht
DERP/disco resilience β survive office-edge connection-table flushes
Status: DRAFT β one fix still pending. Base
main, HEAD674f457.The bug
The bench protective-stop dropped green every few hours. Root cause (nailed via a live wire+journal capture from the DUT-host, over a session-to-session bridge): the office edge firewall periodically flushes its connection table β RSTing DERP TCP across regions and rebinding NAT in the same instant. One flush kills the machineβremote direct path (a NAT hairpin off the edge's public IP, not LAN) and the DERP relay at once, so the 5 Hz heartbeat loses both paths together.
What's fixed and validated
a175361fd95b831874ba138fca7df2f883e7162a9bThe keypair-expiry mechanism that killed earlier soaks is proven fixed β
wg_tx_keypair_expiredstayed 0 across 8 h including two flushes.f2f883e's reconnect works on the wire (936 ms).The one remaining bug (identified, code-confirmed, fix held)
Fast reconnect alone didn't hold green. During a flush the edge also RSTs machn's standby DERP conn, and
derp_manage_auxdoes a blocking ~2 s TLS reconnect inside the same loop that polls home-rx β so machn stops processing the relayed heartbeat for ~2 s (home conn never dropped βreconnects=0) β disarm. The fix (defer/async the standby reconnect) touches the safety-critical DERP loop with a failover trade-off, so it's held for one more flush's independent wire confirmation + review before landing.Do not merge until
run-23 shows green held through a real edge flush on the corrective build.
π€ Generated with Claude Code
https://claude.ai/code/session_01UJz4ZB7WcPErDqKLWDq3ht