fix(coord): wall-clock deadline for a partial Noise frame (#127) - #153
iliabaranov wants to merge 1 commit into
Conversation
coord_recv() bounded a partial length-prefixed read by a retry COUNT (300), but every retry first blocks for the socket's SO_RCVTIMEO (2 s in long-poll, 60 s during the MapResponse fetch), so a stalled-but-open peer could hold the coord task for minutes and starve the 120 s control watchdog that only runs between reads. One frame now has 10 s of wall clock (1/12 of the watchdog, >= 4 socket timeouts; progress does not extend it — a trickle is a stall too). On expiry the read fails with ETIMEDOUT, not EAGAIN, so noise_recv/poll_map_update reconnect instead of resuming a misaligned stream; an orderly EOF mid-frame is ECONNRESET. noise_recv's own 300-count payload loop gets the same budget. Host test (88 checks, ASAN/UBSAN) extracts the real loop bodies and drives them with a fake clock: header stall -> ETIMEDOUT at 10 s; slow-but-progressing payload -> success; EOF; EAGAIN interleave; attribution matrix.
There was a problem hiding this comment.
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review — findings
Replaces coord_recv's 300-retry count with a 10 s wall-clock deadline (COORD_PARTIAL_FRAME_DEADLINE_MS) armed by the first consumed byte, so a stalled-but-open peer can no longer pin the coord task for up to 10 minutes across long SO_RCVTIMEO reads and starve the control watchdog. On deadline expiry mid-frame it fails with errno = ETIMEDOUT (and maps orderly EOF to ECONNRESET) so callers reconnect rather than misreading it as EAGAIN and resuming a misaligned Noise stream. Applies the same wall-clock budget to noise_recv's payload-completion loop, converting a post-header EAGAIN into ETIMEDOUT so a partly-read frame forces a reconnect instead of an unretryable resume.
No blocking issues surfaced. 7 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 27 functions depend on the 22 functions this change touches.
Health — this change adds coupling hotspots:
- new:
noise_send()— 7 callers, 3 callees - new:
do_fetch_peers()— 1 callers, 13 callees - new:
noise_recv()— 4 callers, 3 callees - new:
do_register()— 1 callers, 11 callees - new:
self_heal_rehome()— 1 callers, 11 callees - new:
do_send_endpoint_update()— 1 callers, 7 callees - new:
do_noise_handshake()— 1 callers, 6 callees - new:
do_start_long_poll()— 1 callers, 6 callees - …and 6 more — each is listed as a finding
Verification — 27 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 27 function(s) in the blast radius were not formally verified this run
· 14 more finding(s) on lines outside this diff (see the check run).
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it reworks partial-frame retry/timeout handling on the control-plane socket that also feeds the control watchdog, a human look would still be worthwhile.
What was reviewed:
coord_recv's deadline arm/check ordering (armed after first byte, checked before each subsequent read) and its interaction with per-readSO_RCVTIMEOretries.- errno handling:
n==0(EOF) now forcesECONNRESET, deadline expiry forcesETIMEDOUT, distinguishing both from a retryableEAGAIN— traced intopoll_map_update, which still only treatsEAGAIN/EWOULDBLOCKas "no data yet" and now correctly falls through on the new codes. noise_recv's outer payload retry loop vs.coord_recv's own internal retry — confirmed the outer loop only re-invokescoord_recv(restarting at buffer offset 0) when zero bytes were consumed in the failed call, so no partial payload bytes are silently discarded/overwritten.
Extended reasoning...
Overview
The change is confined to components/microlink/src/ml_coord.c and reworks two related retry loops in the Tailscale-style control-plane client's coordination socket code: coord_recv (raw partial-frame reads) and noise_recv's payload retry loop. It replaces a fixed retry-count backoff with a wall-clock deadline (COORD_PARTIAL_FRAME_DEADLINE_MS = 10000), and tightens errno semantics so an orderly EOF becomes ECONNRESET, deadline expiry becomes ETIMEDOUT, and only a genuine EAGAIN/EWOULDBLOCK from recv() itself is treated as retryable.
Security risks
No injection, auth, or crypto-boundary changes here — the Noise handshake/encrypt/decrypt calls themselves are untouched; this only changes how many bytes are read off the socket and when the read loop gives up. The main risk class is protocol/stream desynchronization (resuming a misaligned Noise frame stream after a bogus retry), which is exactly what this PR targets. I traced the errno propagation into poll_map_update and confirmed the new ETIMEDOUT/ECONNRESET codes are treated as hard errors (triggering reconnect) rather than misread as "retry later," and confirmed noise_recv's outer retry only restarts coord_recv from scratch when zero bytes were actually consumed by the failed call, so there's no silent data loss/corruption path.
Level of scrutiny
This warrants more than a rubber-stamp: it touches timing-sensitive retry/deadline arithmetic and errno-based control flow that feeds a control-plane watchdog, in a repo whose broader context is a robot's protective-stop/safety system. The logic is subtle enough (armed-once deadlines, per-read timeouts nested inside overall deadlines, interaction between two retry loops in two functions) that a second human pass is valuable even though my own trace did not turn up a defect.
Other factors
The PR description is candid about a known residual: worst-case block can be deadline + one SO_RCVTIMEO (up to ~70s during the 60s MapResponse fetch phase), left in deliberately as a large improvement over the prior ~10 minute worst case. That's a reasonable tradeoff but is a judgment call a human maintainer should sign off on. The PR also references a host-side test harness (88 checks) exercising the extracted loop logic under a fake clock/scripted recv(), though that harness isn't part of this diff to independently verify.
|
Superseded by #156 (consolidated remote-connectivity PR, same commits cherry-picked linearly; all review findings from this thread are addressed there). Closing. |
…rd re-establishment, coord deadline (#156) Consolidates #149, #152, #151, #155 and #153 (closed as superseded; their review threads and bot findings were addressed there and the fixes are included here). 12 linear commits, 409+/29−. Every commit was in the build that passed the 3.7 h soak below. ## What is fixed | area | commit(s) | defect | |---|---|---| | USB tether TX | `de335e8` `1b5fe82` | `tinyusb_net_send_sync(…, 0)` reclaimed the packet before the equal-priority TinyUSB task ran the deferred send — ~95 % of device→host frames silently dropped (regression from #135). Now a 20 ms wait; TIMEOUT-after-send duplicate accepted and documented. | | USB tether ring | `3a8a527` | 16-slot ring lost ~1 safety frame/min on a hub-chain host (burst-induced). 64 slots (96 KB PSRAM), 400 ms lifetime. | | Tether link state | `f9ec4cb` | netif never went link-down when the host detached/suspended, so a dead tether stayed the preferred uplink. esp_tinyusb events now drive it; device netif gets its own MAC. | | WiFi failover crash | `9823785` | first time the failover path ever ran it aborted: `dcs_wifi_set_enabled` reaches flash from the supervisor's PSRAM stack. Toggle moved to an internal-stack worker. | | WireGuard re-establishment | `0440ad2` `5b86d21` `e020c86` `cae7e94` `54149eb` `d7a335f` | **root cause of the multi-minute/indefinite bond drops**: on direct regain with no keypair left, the live endpoint stayed 0.0.0.0 (relay-only) and a safety peer fell into the bulk-peer "one-shot, wait for them to initiate" — the machine host's wireguard-go never initiates without data. Safety peers now connect with paced retries; initiations get a second leg to the peer's authenticated direct disco source while DERP-only; disco pings fan out to it. Per-peer WG diagnostics in `/admin/api/peers`. | | Coord partial frame | `8cf5104` | `coord_recv` bounded by a retry count × 2 s socket timeout (≈10 min); now a 10 s wall-clock deadline, `ETIMEDOUT` so callers reconnect (#127). 88-check host test. | ## Evidence - Deterministic WG repro (block direct+DERP, reboot remote mid-outage, unblock direct only, no host help): **v1.3-14 never recovers (>300 s); this branch bonds in 22 s.** - Soak 2026-09-19 12:06–15:47 PDT, 3.7 h, this code + #148/#150/#154, two Ethernet remotes + one USB remote bonded to a ROS 2 machine on the tether host, DUT E-stop cycled every 5 min (42/42, stop ≤0.5 s, re-arm ≤0.75 s): **zero bond drops / rebonds / resets, 100 % bonded samples on all three** (12 638 × 1 Hz each); reply age p99.9 361–391 ms; USB ring 566 916 frames, 0 expired, 0 dropped; machine-side 194 207 requests, 194 170 answered (the rest late DERP-mirror copies, correctly rejected). One 1.07 s receive-side excursion on an Ethernet remote (W5500 RX class, tracked separately). - Baseline for the same class of USB remote on v1.3: 252 ring drops + 55 expiries per 4 h, and 2–7 min bond outages. ## Not in this PR #148 (release guard), #150 (name scrub), #154 (lockstep mismatch diagnostics — SIL comparator, reviewed separately). DERP-mirror latency (1.3 s median) and the W5500 RX stall are follow-ups.
Closes #127.
Problem
coord_recv()bounded a partial length-prefixed read by a retry count (300). Every retry first blocks for the socket'sSO_RCVTIMEO— 2 s in long-poll, 60 s during the MapResponse fetch — so a stalled-but-open peer could hold the coord task for up to 10 min and starve the 120 s control watchdog, which only runs between reads. Abandoning also lefterrno == EAGAIN, whichnoise_recv/poll_map_updateread as "retry later": the misaligned stream was resumed.Fix (
ml_coord.c, 36+/10−)COORD_PARTIAL_FRAME_DEADLINE_MS = 10000: armed by the first consumed byte, not extended by progress (a trickle is a stall). 10 s = the connect-phaseSO_RCVTIMEO, ≥ 4 long-poll socket timeouts, 1/12 of the watchdog.errno = ETIMEDOUT(callers reconnect); orderly EOF mid-frame →ECONNRESET.noise_recv's own 300-count payload loop gets the same budget.Verification
Host test (
gcc -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined) extracts the real loop bodies and drives them with a scriptedrecv()and fake clock: 88 checks, 0 failures — header stall →ETIMEDOUTat 10 s; slow-but-progressing payload → success; trickle past deadline → fail; EOF ×4; EAGAIN interleave; attribution matrix. Negative control on the unpatched loop: 605 s / 302 recvs /errno=EAGAINfor a 1-byte header stall. Cross-compiles for both roles; running on four bench remotes in the current soak.Residual: worst-case block is deadline + one
SO_RCVTIMEO(70 s in the 60 s fetch phase — left alone deliberately).