Skip to content

fix(coord): wall-clock deadline for a partial Noise frame (#127) - #153

Closed
iliabaranov wants to merge 1 commit into
mainfrom
fix/coord-partial-frame-deadline
Closed

iliabaranov wants to merge 1 commit into
mainfrom
fix/coord-partial-frame-deadline

Conversation

@iliabaranov

Copy link
Copy Markdown
Contributor

Closes #127.

Problem

coord_recv() bounded a partial length-prefixed read by a retry count (300). 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 up to 10 min and starve the 120 s control watchdog, which only runs between reads. Abandoning also left errno == EAGAIN, which noise_recv/poll_map_update read 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-phase SO_RCVTIMEO, ≥ 4 long-poll socket timeouts, 1/12 of the watchdog.
  • Expiry → errno = ETIMEDOUT (callers reconnect); orderly EOF mid-frame → ECONNRESET.
  • noise_recv's own 300-count payload loop gets the same budget.
  • Log lines distinguish a per-read socket timeout from the overall deadline.

Verification

Host test (gcc -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined) extracts the real loop bodies and drives them with a scripted recv() and fake clock: 88 checks, 0 failures — header stall → ETIMEDOUT at 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=EAGAIN for 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).

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.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-read SO_RCVTIMEO retries.
  • errno handling: n==0 (EOF) now forces ECONNRESET, deadline expiry forces ETIMEDOUT, distinguishing both from a retryable EAGAIN — traced into poll_map_update, which still only treats EAGAIN/EWOULDBLOCK as "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-invokes coord_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.

@iliabaranov

Copy link
Copy Markdown
Contributor Author

Superseded by #156 (consolidated remote-connectivity PR, same commits cherry-picked linearly; all review findings from this thread are addressed there). Closing.

iliabaranov added a commit that referenced this pull request Sep 20, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bound partial coordination-frame reads by a wall-clock policy

1 participant