Skip to content

fix: remote connectivity — USB tether TX, bus-state failover, WireGuard re-establishment, coord deadline - #156

Merged
iliabaranov merged 16 commits into
mainfrom
fix/remote-connectivity
Sep 20, 2026
Merged

iliabaranov merged 16 commits into
mainfrom
fix/remote-connectivity

Conversation

@iliabaranov

Copy link
Copy Markdown
Contributor

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 + chore(release): guard against credentials in published firmware #148/chore: drop the management product's name from the public tree #150/diag(safety): attribute lockstep mismatches; time NVS flushes #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.

…transmits

tinyusb_net_send_sync() defers can_xmit+xmit onto the TinyUSB task and
waits on an event group. With timeout 0 the caller reclaims its own
packet before the equal-priority TinyUSB task can run it, so the frame is
silently not sent unless that task wins a microsecond race from the other
core. Bench (fresh ESP32-S3, v1.3): 0/20 pings over the tether, 17 frames
sent vs 411 expired vs 979 busy retries in 150 s. With a 20 ms wait:
591/600 pings at 4 ms, +1003 frames / 0 expired in 35 s, HTTP 17 ms.
Regression from #135.
A 16-slot ring lost ~1 safety frame per minute on a remote behind a dock
hub chain (bench 2026-09-19: 252 ring-full drops + 55 expiries in 4 h,
each one a pstop frame that then only arrived via the DERP mirror >1 s
late and was rejected as out-of-order). The disco probe engine emits
bursts of >16 frames toward the tailnet peers; a pstop frame caught in
the burst was dropped while the endpoint drained. 64 slots (96 KB PSRAM)
absorb the burst; 400 ms lifetime rides through host scheduling hiccups
and is still well inside the 1.6 s stop-on-silence budget.
… MAC

- esp_tinyusb device events: attached/resumed -> netif link up,
  detached/suspended -> link down. Until now the synthetic 'connected' at
  start was never undone, so a host that de-enumerated us or suspended the
  bus left a dead tether ranked as the best uplink and the supervisor never
  failed over (bench 2026-09-18: port de-authorised, device kept its lease,
  no WiFi for minutes). Enables CONFIG_TINYUSB_SUSPEND/RESUME_CALLBACK.
- The MAC given to tinyusb_net_init() is the HOST's (CDC-NCM iMACAddress);
  the lwIP netif now uses a distinct locally-administered address instead
  of the same one (spec violation that worked on Linux by accident).
dcs_wifi_set_enabled() reaches flash (wifi_list NVS blob, PHY calibration
NVS) and the supervisor's stack lives in PSRAM, so the first time the
failover path ever ran — tether link-down from the previous commit — it
died in spi_flash_disable_interrupts_caches_and_other_cpu's
esp_task_stack_is_sane_cache_disabled assert (coredump: net_sup ->
dcs_wifi_set_enabled -> ml_config_get_wifi_list -> nvs_get_blob). The
toggle now runs on a transient 4 KB internal-stack worker; the supervisor
waits on a dedicated semaphore so its link-down task notification is not
consumed. Any Ethernet unit losing Eth+USB would have hit the same abort.
… source while DERP-only

Bench 2026-09-19: a USB-tethered remote sat DERP-only for 7+ minutes with
no valid keypair for the machine host — its handshake initiations went
via the relay only and never completed — while the host pinged it
DIRECTLY every few seconds (disco both ways on the tether). One
host-initiated handshake healed it instantly; an idle wireguard-go never
initiates on its own, so the remote must be able to re-establish the
session by itself.

Now the source of the most recent direct disco ping/pong from a safety
peer is kept as a handshake candidate (60 s freshness). While the peer
has no direct WG endpoint, each initiation is sent both via DERP and to
that candidate: one handshake, two legs, either response completes it,
and a direct response re-adopts the direct endpoint (update_peer_addr).
Data frames are unchanged. Diag: wg_hs_cand_sends in the monitor.
…ery diagnostics

A bare WireGuard initiation never re-adds a peer that the far side's
wireguard-go has trimmed for inactivity — only an inbound disco PING does
(Tailscale lazy peer configuration; already noted in ml_wg_mgr.c). So the
second handshake leg alone cannot recover a remote that stopped pinging
its machine host: disco_send_ping_to_peer() now also fans the ping out to
the handshake candidate (the peer's last direct disco source) for safety
peers, which keeps the host's peer configured and yields the pong the
normal direct regain needs even when the candidate table lacks it.

/admin/api/peers gains wg_up, eps, best, hs_cand, ping_age_ms,
backoff_ms per peer so the next stuck-DERP-only event is attributable
from a snapshot; monitor gains disco_hs_cand_pings.
…nects with retries

The pong-promotion path stores a regained/first direct endpoint with
wireguardif_update_endpoint() (connect_ip only) and calls
wireguardif_connect() — which re-points the LIVE endpoint peer->ip and
retries the handshake — only when some keypair is still valid. Without
one it fell to a deliberate one-shot (then active=false, 'wait for them
to initiate'), or to nothing at all for an already-known peer. Right for
bulk peers that may have trimmed us; a deadlock for the machine host,
whose wireguard-go initiates only when it has data — and it has nothing
to send while we are silent. The demote's connect_derp() had left
peer->ip at 0.0.0.0, so the remote kept initiating relay-only while disco
reported direct: indefinitely with the relay dead or slow. A stale-but-
valid keypair took the other branch and recovered in seconds — a lottery
(bench 2026-09-19: 10 s, 21 s, 169 s, never).

Safety peers now connect() with retries in that branch. Diag:
wg_safety_reconnects.
Pongs arrive several times a second and wireguardif_connect() resets the
pending handshake state; without pacing a slow path could keep answering
an already-superseded initiation. One connect per 5 s per peer.
…rite

Review: microlink_get_peer_info() left wg_active/wg_send_hs/wg_hs_pending/
wg_init_age_ms unset when there was no WG peer to read; the candidate
tuple is written from the disco task and read on the TCPIP thread — write
port last (0 first) so a reader seeing a non-zero port sees the matching
ip. Lock-free scalar reads for telemetry documented as deliberate.
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.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds a WireGuard handshake "second leg": each peer now records the source of its most recent direct disco packet (hs_cand_ip/hs_cand_port/hs_cand_ms, set via wireguardif_set_hs_candidate), and while a peer is DERP-only wireguardif_peer_output mirrors handshake initiations to that direct candidate as well as the relay, counting them in wireguardif_hs_cand_sends and treating a successful direct send as success even if the relay leg fails — so an idle DERP-only peer that never initiates can still be healed and re-adopt its direct endpoint. Exposes per-peer path-recovery diagnostics through microlink_get_peer_info (endpoint count, chosen endpoint, handshake candidate, ping/init ages, backoff, and WG session/handshake flags) and surfaces them in the /peers HTTP response. The WG-owned scalars are read without the core lock on purpose, accepting a stale value in a single telemetry sample to avoid stalling the safety loop.

Worth a look

  • Handshake initiation mirrored to unvalidated disco-source address (spoofable off-path leg)components/microlink/components/wireguard_lwip/src/wireguardif.c:181 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Direct disco ping can steer WireGuard handshake candidate endpointcomponents/microlink/src/ml_wg_mgr.c:2934 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Handshake candidate check/use is not atomiccomponents/microlink/components/wireguard_lwip/src/wireguardif.c:184 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Payload deadline is not enforced while coord_recv blockscomponents/microlink/src/ml_coord.c:258 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Partial-frame deadline does not bound blocking recvcomponents/microlink/src/ml_coord.c:184 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 464 functions depend on the 411 functions this change touches.

Health — this change adds coupling hotspots:

  • new: disco_send_ping_to_peer() — 8 callers, 8 callees
  • new: dcs_support_init() — 2 callers, 30 callees
  • new: add_peer() — 2 callers, 22 callees
  • new: wireguardif_network_rx() — 3 callers, 11 callees
  • new: disco_send_call_me_maybe() — 6 callers, 4 callees
  • new: noise_send() — 7 callers, 3 callees
  • new: neg_apply_target() — 2 callers, 10 callees
  • new: microlink_init() — 1 callers, 17 callees
  • …and 43 more — each is listed as a finding

Verification — 464 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: 463 function(s) in the blast radius were not formally verified this run

· 51 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's a large, safety-critical change touching WireGuard handshake re-establishment, USB tether link-state failover, and coordination-socket timeout handling, a human look would still be worthwhile.

  • Checked the coord_recv wall-clock deadline replacing the retry-count guard, including errno normalization (EAGAIN vs ECONNRESET/ETIMEDOUT) for misaligned-stream detection.
  • Checked the new handshake-candidate mirroring in wireguardif.c/ml_wg_mgr.c and the rate-limited safety-peer reconnect path.
  • Checked the USB tether TinyUSB attach/detach/suspend/resume handler driving netif link state, and the TX ring size/TTL increase in ml_usb_tx.c.
  • Reviewed the 3 ruled-out candidates (safety-peer reconnect pong race, coord deadline sizing vs. frame size, USB suspend debounce) and found their reasoning consistent with the code.
Extended reasoning...

Overview

This PR spans 14 files across the MicroLink WireGuard-over-lwIP stack, the USB device-tether TinyUSB integration, and firmware/machn sdkconfig defaults. It changes: (1) ml_coord.c's partial-frame receive logic from a retry-count bound to a wall-clock deadline with errno normalization; (2) wireguardif.c/.h and wireguard.h to add a per-peer handshake-candidate address so INITIATIONS can be mirrored off DERP; (3) ml_wg_mgr.c to wire up candidate tracking, extra disco pings, and a rate-limited safety-peer WireGuard reconnect; (4) microlink.c/.h and ml_config_httpd.c to expose new peer/link diagnostics via a lock-free read of wireguard_device fields; (5) ml_dev_tether.c to mirror TinyUSB bus events onto tether netif link state and change the device MAC; (6) ml_usb_tx.c to enlarge the TX ring and TTL.

Security risks

No injection, auth-bypass, or credential-exposure concerns were evident. The lock-free diagnostic read of wireguard peer state in microlink.c is explicitly reasoned about in the code/PR as safe-enough for diagnostics-only use (stale/torn reads only affect an HTTP monitor endpoint, not the data path), which is a reasonable tradeoff for a read-only admin API.

Level of scrutiny

This warrants a high level of scrutiny: it touches the safety-relevant tether/mesh link used for protective-stop signaling (failover behavior, WireGuard re-establishment, USB TX reliability for "pstop" frames), and several of the changes are timing-sensitive (deadlines, debounce-free bus-event handling, rate-limited reconnect vs. pong-driven state resets). The PR description cites a 3.7h soak and a deterministic repro, which is good evidence, but the changes are complex enough — and safety-adjacent enough — that independent human review of the timing interactions (e.g., the ruled-out pong-vs-reconnect race, the stacked coord/payload deadlines, and the no-debounce USB suspend/resume) is warranted before merge.

Other factors

The automated bug-hunting run completed all budgeted rounds and reported no bugs; three related candidate issues were investigated and explicitly ruled out (each is a plausible-sounding but not-actually-broken interaction). No CLAUDE.md conventions apply to the changed paths. Given the size, safety-criticality, and the fact that this consolidates five prior PRs' worth of changes into 12 commits, deferring to a human is the appropriate outcome even with zero automated findings.

@rajasimman-madhivanan rajasimman-madhivanan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And no test updates following these changes?

errno = ETIMEDOUT;
return -1;
}
int n = ml_recv(ml->coord_sock, buf + recvd, len - recvd, 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This can block for 60s and so coord_recv can block for more than 60s even though the timeout expected is 10s. Is this okay?

@iliabaranov iliabaranov Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct as written — and the same held one call deeper: after the 3-byte Noise header the payload read's first byte still waited a full SO_RCVTIMEO. Fixed in 0625bc7: once a frame is partial, every wait is bounded by the remaining 10 s budget via select() (the file's existing ml_select_fds idiom), never by SO_RCVTIMEO; noise_recv enters the payload read mid-frame so it is bounded from the first byte. Worst-case hold of the coord task drops from budget + 60 s (≈70 s) to the 10 s budget. The policy is extracted into ml_coord_frame_budget.h with a host test (host/test_coord_frame_budget.c, 18 checks) that includes the #127 regression: 300 stalled reads cost 10 s total, not 300 × SO_RCVTIMEO.

// Written from the disco task, read on the TCPIP thread (peer_output).
// Order the writes so a reader that sees a non-zero port also sees the
// matching ip/ms; a reader racing the update at worst skips one leg.
peer->hs_cand_port = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ordinary field assignments do not guarantee cross-core memory ordering in C. The compiler or CPU may expose those writes to the other core in a different order. May be a todo here just in case this var is accessed from another thread concurrently?

@iliabaranov iliabaranov Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — plain stores give no cross-core ordering guarantee, and the comment was promising one. Rather than a TODO, 0625bc7 uses the file's existing idiom for exactly this case (wireguardif_core_lock_needed() + LOCK_TCPIP_CORE(), as at netif_set_link_up/down): the setter and getter update/read the whole hs_cand_{ip,port,ms} record under the core lock, and the reader in peer_output runs on the TCPIP thread which already holds it. The lock query is holder-aware, so a caller already holding the lock does not re-take it.

@iliabaranov

iliabaranov commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

Re "no test updates?": the repo's unit-test surface is the host harness (make -C host test, CI host-check.yml) for pure logic; none of the touched files were covered by it, same as the previously merged connectivity PRs (#123/#134/#135/#145). The behavioural evidence for this PR is hardware: soak run 3 (3.7 h, zero drops/rebonds on three remotes), a deterministic re-establishment reproduction (block direct UDP + DERP, reboot mid-outage, unblock direct only: v1.3-14 stuck > 300 s, this branch bonded in 22 s), and the tether TX fix (566 916 frames, 0 expired). That is not a substitute for unit coverage of the decisions, so 0625bc77 adds two, in the pattern of ml_demote_verdict.h:

  • ml_wg_regain_policy.h + host/test_wg_regain_policy.c (11 checks) — the re-establishment decision on a direct regain. Writing it found a gap in this PR: the safety branch never set tried_initial_handshake, so the next pong inside the 5 s pace window fell into the bulk one-shot — a second connect() ~200 ms later (the superseded-initiation hazard the pacing exists to prevent) and active = false, cancelling the retries just armed. Fixed in the same commit; the test pins that case.
  • ml_coord_frame_budget.h + host/test_coord_frame_budget.c (18 checks) — the partial-frame budget, including the Bound partial coordination-frame reads by a wall-clock policy #127 300-retry regression.

Both roles build; make -C host test passes (6 binaries). Also in this round: core-locked hs_cand record and budget-bounded coord waits (see the inline threads).

…oord waits, regain policy + host tests

- wireguardif: hs_cand_{ip,port,ms} is updated and read under LOCK_TCPIP_CORE
  (the file's existing idiom) instead of relying on store order, which plain
  C assignments do not guarantee across cores.
- coord_recv: once a frame is partial, each wait is bounded by the REMAINING
  10 s budget via select(), never by SO_RCVTIMEO (2 s / 60 s); the Noise
  payload read enters mid-frame so its first byte is bounded too. Worst-case
  hold of the coord task drops from budget + 60 s to the budget.
- ml_wg_mgr: the regain decision goes through ml_wg_regain_action(); the
  safety branch now marks tried_initial_handshake, so a pong inside the 5 s
  pace window no longer falls into the bulk one-shot (second connect() 200 ms
  later + active=false cancelling the retries just armed).
- host tests: test_wg_regain_policy (11 checks), test_coord_frame_budget
  (18 checks, incl. the #127 300-retry regression); wired into make -C host test.

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

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds a WireGuard handshake "second leg": wireguardif_peer_output mirrors handshake initiations to the most recent direct disco source of a DERP-only peer (wireguardif_set_hs_candidate/wireguardif_get_hs_candidate, stored per-peer and read under the TCPIP core lock), so an idle peer that never initiates can still be healed direct while the relay leg is slow or dead, counted in wireguardif_hs_cand_sends. Bounds one partial coordination frame to a 10 s wall-clock budget via the new ml_coord_frame_budget helper so a trickling-but-open peer can no longer pin the coord task for up to 10 minutes past the control watchdog, arming on the first byte and capping each blocking wait to the remaining budget. Expands microlink_peer_info_t with path-recovery diagnostics (endpoint count, chosen and handshake-candidate endpoints, ping/init ages, backoff, and WG session/handshake state) and paces keyless-regain wireguardif_connect() calls to REKEY_TIMEOUT via last_safety_connect_ms.

Worth a look

  • HTTP telemetry reads WireGuard peer state without TCPIP/core synchronizationcomponents/microlink/src/microlink.c:880 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • WireGuard peer index is bounds-checked only after being usedcomponents/microlink/src/microlink.c:866 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • TCPIP core lock does not cover peer lookup in cross-thread candidate settercomponents/microlink/components/wireguard_lwip/src/wireguardif.c:1050 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Public peer info struct size/layout changedcomponents/microlink/include/microlink.h:62 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • USB detach/suspend events are dropped when netif is not yet availablecomponents/ml_dev_tether/src/ml_dev_tether.c:145 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 497 functions depend on the 444 functions this change touches.

Health — this change adds coupling hotspots:

  • new: disco_send_ping_to_peer() — 8 callers, 8 callees
  • new: dcs_support_init() — 2 callers, 30 callees
  • new: add_peer() — 2 callers, 22 callees
  • new: wireguardif_network_rx() — 3 callers, 11 callees
  • new: disco_send_call_me_maybe() — 6 callers, 4 callees
  • new: noise_send() — 7 callers, 3 callees
  • new: neg_apply_target() — 2 callers, 10 callees
  • new: microlink_init() — 1 callers, 17 callees
  • …and 43 more — each is listed as a finding

Verification — 497 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: 496 function(s) in the blast radius were not formally verified this run

· 51 more finding(s) on lines outside this diff (see the check run).

Comment thread components/microlink/include/ml_coord_frame_budget.h Outdated
…ookup, bus-seeded tether link

- coord frame budget: a slow-but-live frame is no longer cut at 10 s. Two
  limits from the first byte: a 10 s no-progress window restarted by every
  byte (the #127 stall still costs 10 s) and a hard cap scaled by frame size,
  max(10 s, len / 1 KiB/s) — a 64 KB MapResponse frame gets ~64 s, so a link
  sustaining ~8 kbit/s completes instead of reconnecting. Host test: 27 checks
  incl. 64 KB at 1 KiB/s completes, 512 B/s trickle cut at the cap, mid-frame
  stall abandoned 10 s after the last byte.
- wireguardif hs-candidate set/get: the core lock now also covers the peer
  slot lookup; HS_CAND_FRESH_MS exported.
- microlink_get_peer_info: reads the candidate lock-free like its neighbours
  (documented telemetry snapshot; the locked getter is for the disco task).
- tether: the netif's initial link state is seeded from the bus
  (tud_mounted && !tud_suspended) under s_rx_lock, so a mount/suspend event
  that fired before the netif existed is not lost.
@iliabaranov

Copy link
Copy Markdown
Contributor Author

Graphify advisories on 0625bc77, addressed in dc2c6cc9 where warranted:

  • hs-candidate setter/getter: core lock does not cover the peer lookup — fixed; the lock now encloses wireguardif_lookup_peer as well.
  • HTTP telemetry reads WG peer state without core synchronization (microlink.c:880) — that block is a deliberate, documented lock-free snapshot (a torn read can only yield one stale bool/age in a JSON sample; taking the core lock from the HTTP task for telemetry is what stalled the safety loop before). My round-3 change had made the candidate read the one locked call in that block, contradicting the rationale; it now reads lock-free like its neighbours, and the locked getter is used only by the disco task, where the decision depends on it.
  • peer index bounds-checked only after use (microlink.c:866)wireguardif_get_hs_candidatepeer_lookup_by_peer_index bounds-checks (peer_index < WIREGUARD_MAX_PEERS, wireguard.c:118) and returns ERR_ARG; moot now that the telemetry path reads inside the existing bounds-checked block.
  • ml_peer_info_t layout changed — internal firmware struct, no ABI consumers outside this tree; all users are rebuilt together.
  • USB detach/suspend dropped when the netif does not exist yet — real, narrow boot-time window (event before esp_netif_new): fixed by seeding the netif's initial link state from the bus (tud_mounted() && !tud_suspended()) under s_rx_lock at start, instead of assuming "connected"; the next mounted/resumed event then brings it up.

Both roles build; make -C host test passes (6 binaries, 27 checks in the frame-budget test).

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

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds a handshake "second leg" so DERP-only safety peers can heal: wireguardif_set_hs_candidate/wireguardif_get_hs_candidate record the source of the most recent direct disco packet (under the TCPIP core lock, stale after HS_CAND_FRESH_MS), and wireguardif_peer_output mirrors handshake initiations to that direct candidate alongside the relay, treating the send as OK if either leg succeeds while counting mirrored sends in wireguardif_hs_cand_sends. Replaces the retry-count bound on partial coordination-frame reads with a wall-clock budget (ml_coord_frame_budget): a 10 s no-progress window restarted on every byte plus a frame-size-scaled hard cap, so a stalled-but-open peer is abandoned in ~10 s instead of holding the coord task for up to 10 minutes and starving the control watchdog. Expands microlink_peer_info_t with path-recovery diagnostics (chosen endpoint, handshake candidate, ping/init ages, WG session flags) and paces keyless-regain wireguardif_connect calls via last_safety_connect_ms.

Worth a look

  • s_netif read/write across on_usb_event and ml_dev_tether_stop only partly lock-protectedcomponents/ml_dev_tether/src/ml_dev_tether.c · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Direct disco packet fields are stored as a handshake candidate without endpoint validationcomponents/microlink/src/ml_wg_mgr.c:2936 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • WiFi toggle helper uses shared request/semaphore while supervisor can be started multiple timesfirmware/components/dcs_support/src/dcs_net_supervisor.c:290 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Lock-free reads of WireGuard peer state race with TCPIP thread updatescomponents/microlink/src/microlink.c:879 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Public peer info struct layout was expandedcomponents/microlink/include/microlink.h:65 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 499 functions depend on the 446 functions this change touches.

Health — this change adds coupling hotspots:

  • new: disco_send_ping_to_peer() — 8 callers, 8 callees
  • new: dcs_support_init() — 2 callers, 30 callees
  • new: add_peer() — 2 callers, 22 callees
  • new: wireguardif_network_rx() — 3 callers, 11 callees
  • new: disco_send_call_me_maybe() — 6 callers, 4 callees
  • new: noise_send() — 7 callers, 3 callees
  • new: neg_apply_target() — 2 callers, 10 callees
  • new: microlink_init() — 1 callers, 17 callees
  • …and 45 more — each is listed as a finding

Verification — 499 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: 498 function(s) in the blast radius were not formally verified this run

· 2 grounded finding(s) anchored inline below; 51 more finding(s) on lines outside this diff (see the check run).

Comment thread components/microlink/src/ml_coord.c Outdated
* MapResponse). A stalled peer holds the coord task — and delays the
* ML_CTRL_WATCHDOG_MS check that runs between reads — for at most 10 s; a slow
* but live MapResponse frame keeps going as long as bytes arrive. */
static int coord_recv_ex(microlink_t * ml, uint8_t * buf, size_t len, bool mid_frame)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressioncoord_recv_ex()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.


#define S(x) ((int64_t)(x) * 1000000LL) /* seconds -> us */

int main(void)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionmain()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 components/microlink/src/ml_wg_mgr.c — A safety peer that lands in a reused peer-table slot can have its first direct-regain wireguardif_connect() delayed up to 5s by a stale timestamp left over from that slot's previous occupant. The !existing init block resets sibling stale-stamp fields (direct_backoff_until, relay_refetch_next_ms, last_cmm_sent_ms) precisely to avoid an evicted slot's leftover stamp suppressing the new occupant, but the new last_safety_connect_ms field (ml_wg_mgr.c:617, set at ml_wg_mgr.c:3183) was never added to that list. Fix: also zero p->last_safety_connect_ms in the !existing block (ml_wg_mgr.c:2320-2345) so a fresh peer's regain pacing in ml_wg_regain_action() never reads a predecessor's connect time.

    Extended reasoning...

    Peer table slots are reused without a memset: eviction only sets ml->peers[evict_idx].active=false (ml_wg_mgr.c ~2176), and the re-key retire path does the same (~2095). The !existing block at 2320 resets many per-peer timers explicitly BECAUSE stale values from the evicted occupant would misbehave (see its own comments for direct_backoff_until and relay_refetch_next_ms), but last_safety_connect_ms is absent from that list. Sequence: peer A occupies slot X, becomes health-tracked (is_safety_peer true), gets a direct pong, and process_disco_pong sets p->last_safety_connect_ms=T0 and calls wireguardif_connect (ml_wg_mgr.c:3181-3187). A then unbonds (is_health_tracked false) and, before T0+5000ms, is evicted from slot X to make room for a new pinned peer B. B is newly added via the !existing path, inheriting slot X's last_safety_connect_ms=T0 untouched. If B is itself a safety peer and gets its own first direct pong before T0+5000ms, ml_wg_regain_action(true, ..., now, T0) computes now-T0 < ML_WG_SAFETY_CONNECT_PACE_MS and returns ML_WG_REGAIN_NONE instead of…

    Verification: normal (bounded). The new field p->last_safety_connect_ms (microlink_internal.h:617, set only at ml_wg_mgr.c:3183 for safety peers) is a stale-stamp field just like its siblings, but it is NOT reset in the !existing slot-reuse init block. That block at ml_wg_mgr.c:2320-2346 deliberately zeroes direct_backoff_until (2335), relay_refetch_next_ms (2342) and last_cmm_sent_ms (2345), each with a…

…ce started, idempotent supervisor start

- ml_wg_mgr: the !existing slot-reuse block also resets last_safety_connect_ms
  and tried_initial_handshake — a predecessor's stamp paced a fresh safety
  peer's first connect() by up to 5 s, and its one-shot flag would deny a
  fresh bulk peer its single initiation.
- tether: the netif is created, started and published under s_rx_lock, then
  its link state is seeded from the bus; on_usb_event()/on_usb_rx() can no
  longer observe an un-started netif, and the seed is ordered with events.
- dcs_net_supervisor_start() is idempotent (single instance by construction).
@iliabaranov

Copy link
Copy Markdown
Contributor Author

Round on dc2c6cc9 → addressed in 07e5d058 (both roles build, make -C host test 6/6):

claude[bot] 🟡 (outside diff) — last_safety_connect_ms not reset on slot reuse. Correct, and tried_initial_handshake (pre-existing) had the same gap — a reused slot would have denied a fresh bulk peer its single initiation. Both are now reset in the !existing block next to their sibling stale-stamp fields.

Graphify advisories:

  • s_netif only partly lock-protected across on_usb_event/stop (high) — the remaining gap was on the start side: s_netif was published at creation, a few ms before esp_netif_action_start, so a bus event in that window could act on an un-started netif. Now the netif is created, started and published under s_rx_lock, then its link state is seeded from the bus in the same critical section; the TCPIP thread never takes s_rx_lock, so holding it across the esp_netif IPC calls cannot deadlock. stop() already NULLs under the lock and destroys outside it.
  • Handshake candidate stored from a direct disco PING without endpoint validation — by design, with a bounded blast radius: the candidate is used only as a second leg for handshake initiations (a message that is public on the wire and reveals nothing; the responder's identity is still cryptographically verified), never for data — data endpoints are still promoted only through tx-id-validated pongs. A replayed disco frame from a spoofed source could at most attract one extra initiation copy per REKEY_TIMEOUT (5 s) for 60 s (HS_CAND_FRESH_MS). The PING path is kept because it is the asymmetric-NAT healing case: their ping reaching us proves where they are reachable even when our pings don't get through.
  • WiFi toggle helper vs. multiple supervisor starts — the supervisor is started exactly once (dcs_support.c) and the helper is called only from that single task, synchronously; dcs_net_supervisor_start() is now idempotent anyway.
  • Lock-free reads of WG peer state (microlink.c:879) / ml_peer_info_t layout — as answered on the previous head: a documented telemetry snapshot (torn read = one stale bool in a JSON sample; taking the core lock from the HTTP task is what stalled the safety loop before), and an internal struct with no external ABI consumers.
  • "Health regression: coord_recv_ex() / test main() fan out to 6 callees" — deterministic coupling metric; the callees are the small inline budget helpers and the test's checks. Acknowledged, not acted on.

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

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 4 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds a WireGuard handshake "second leg" for DERP-only safety peers: wireguardif_peer_output mirrors handshake initiations to the most recent direct disco source (hs_cand_ip/port/ms, fresh within HS_CAND_FRESH_MS), so an idle remote that never initiates can be healed by a host-driven handshake, and a direct response re-adopts the direct endpoint; the candidate is set/read under the TCPIP core lock via wireguardif_set_hs_candidate/wireguardif_get_hs_candidate and mirrored sends counted in wireguardif_hs_cand_sends. Replaces the retry-count frame timeout in coord_recv_ex with a wall-clock budget (ml_coord_frame_budget_init): a 10 s no-progress window that resets on each byte plus a frame-size-scaled hard cap, so a stalled-but-open peer is abandoned in ~10 s instead of holding the coord task for up to 10 min and starving the control watchdog. Expands microlink_peer_info_t and microlink_get_peer_info with path-recovery diagnostics (endpoint count, chosen/candidate endpoints, ping/init ages, backoff, WG session/handshake flags) and paces keyless-regain wireguardif_connect calls via last_safety_connect_ms.

Worth a look

  • Timeout retry can duplicate USB framescomponents/ml_dev_tether/src/ml_usb_tx.c:103 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • tinyusb_net_send_sync timeout is converted from ms to tickscomponents/ml_dev_tether/src/ml_usb_tx.c:102 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • coord_sock is re-read across select/recv without synchronizationcomponents/microlink/src/ml_coord.c:190 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Lock-free reads of TCPIP-owned WireGuard peer state introduce data racescomponents/microlink/src/microlink.c:876 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 499 functions depend on the 446 functions this change touches.

Health — this change adds coupling hotspots:

  • new: disco_send_ping_to_peer() — 8 callers, 8 callees
  • new: dcs_support_init() — 2 callers, 30 callees
  • new: add_peer() — 2 callers, 22 callees
  • new: wireguardif_network_rx() — 3 callers, 11 callees
  • new: disco_send_call_me_maybe() — 6 callers, 4 callees
  • new: noise_send() — 7 callers, 3 callees
  • new: neg_apply_target() — 2 callers, 10 callees
  • new: microlink_init() — 1 callers, 17 callees
  • …and 45 more — each is listed as a finding

Verification — 499 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: 498 function(s) in the blast radius were not formally verified this run

· 2 grounded finding(s) anchored inline below; 51 more finding(s) on lines outside this diff (see the check run).

Comment thread components/microlink/src/ml_coord.c Outdated
* MapResponse). A stalled peer holds the coord task — and delays the
* ML_CTRL_WATCHDOG_MS check that runs between reads — for at most 10 s; a slow
* but live MapResponse frame keeps going as long as bytes arrive. */
static int coord_recv_ex(microlink_t * ml, uint8_t * buf, size_t len, bool mid_frame)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressioncoord_recv_ex()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.


#define S(x) ((int64_t)(x) * 1000000LL) /* seconds -> us */

int main(void)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionmain()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@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 this round touches WireGuard peer-state re-establishment, USB tether netif/link-state ordering, and the coordination frame read path — all safety- and security-sensitive — a human look would still be worthwhile.

  • Confirmed the coord frame hard cap (ml_coord_frame_budget.h) now scales with frame length via ml_coord_frame_budget_total_ms(len) rather than a flat 10 s, so a large MapResponse read gets a proportional budget instead of forcing a reconnect.
  • Checked round 5's slot-reuse resets in ml_wg_mgr.c add_peer(), the tether netif create/start/publish-under-s_rx_lock reordering in ml_dev_tether.c, and the dcs_net_supervisor_start() idempotency guard — each is small and consistent with the file's existing patterns.
Extended reasoning...

Overview

This run's diff (9d3b0e435..HEAD) covers a 15-commit series hardening the microlink coordination/WireGuard/USB-tether stack: a wall-clock frame-read budget replacing a fixed retry-count timeout in ml_coord.c (issue #127), a WireGuard direct-path regain policy for keyless peers in ml_wg_mgr.c, USB bus-state-to-netif-link mirroring in ml_dev_tether.c/ml_usb_tx.c, and a WiFi-failover flash-access fix plus idempotent start in dcs_net_supervisor.c. Two new host-testable headers (ml_coord_frame_budget.h, ml_wg_regain_policy.h) ship with unit tests. This is round 5 of a PR I've reviewed on 3 prior pushed versions.

Security risks

No injection, auth-bypass, or data-exposure concerns. The security-relevant surface is WireGuard handshake/keypair state management (ml_wg_mgr.c, wireguardif.c) and the Noise-frame read path (ml_coord.c) — a bug here could either weaken the peer-liveness/handshake logic or (as my own prior review on this PR flagged) cause an availability regression by tearing down connections that were merely slow rather than dead. I independently re-verified in this turn that the specific concern I raised earlier — a flat 10 s hard cap applied uniformly to up to 64 KB Noise frames — has been fixed: coord_recv_ex now calls ml_coord_frame_budget_init(&budget, len) with the caller's frame length, and the cap is max(10s, len/1KiB/s), backed by host/test_coord_frame_budget.c assertions for the 64 KB case. I did not rely on the thread's "resolved" marker alone (per the self-resolution caveat) — I read the current code to confirm the fix.

Level of scrutiny

High. This PR touches crypto-adjacent peer/session state, network failover ordering, and a control-plane read loop whose timing directly affects whether a live connection is torn down — exactly the kind of change where a subtle timing or state-reset bug could cause either indefinite hangs or spurious reconnects in production, and where a human reviewer with domain knowledge of WireGuard/lwIP internals is warranted regardless of automated review outcome.

Other factors

The round-5 commit (07e5d0586a06) is small and self-contained: three files, ~33 lines net, each fix (peer-slot-reuse state reset, netif publish-after-start ordering, supervisor idempotency) is a narrow, well-commented correction consistent with the surrounding code's existing patterns (e.g., the same slot-reuse reset idiom already used for direct_backoff_until, relay_refetch_interval_ms, etc. in add_peer()). The PR carries substantial test coverage (host-testable budget/policy headers with dedicated unit tests) and a described 3.7 h soak test. Since round 4/5 commits substantively addressed the concern from my last review, I'm posting an updated review rather than staying silent, but per the approval guidelines I should not approve a change of this scope and sensitivity — a human should still look, particularly at any open third-party review threads on the coordination/frame-budget code I could not independently inspect for content (only metadata was visible to me).

@iliabaranov

Copy link
Copy Markdown
Contributor Author

8 h steady-state soak (run 4) — evidence for this PR

Setup. 2026-09-19 18:50 → 09-20 03:05 PDT, 8 h 15 min, no E-stop cycling. Image = main + this PR (as of 0625bc77) + #154 + #148 + #150 + the W5500 polling branch, v1.3-25-g4df6332c. Three remotes bonded to one machine node: DUT (Ethernet, HQ site), 7a60 (Ethernet, office), eed0 (USB tether, office — the topology this PR's tether fixes target). 1 Hz state.json telemetry on all units, 60 s monitor snapshots, machine-side and tether-side captures.

Whole-run table

metric DUT 7a60 eed0
status OK OK OK
W5500 mode poll10 IRQ tether
link / path to machine eth / direct eth / direct usb / direct
bond (Δ rebonds) BONDED (Δ1) BONDED (Δ0) BONDED (Δ1)
uptime / resets in window 8h15 / 0 8h16 / 0 8h17 / 0
traffic Δ sent / replies / send-fail 148239 / 148225 / 4 147853 / 147850 / 0 145367 / 145294 / 72
INT gauge max / eth recoveries 360 ms / 0 1000 ms / 0 — (tether)
lockstep mismatch Δ timeout/content (total) 0/4 (0/6) 0/0 (2/0) 0/0 (0/0)
NVS flush max in window 152 ms 122 ms 136 ms
internal heap free / LFB / boot-min 59 / 16 / 11 KB (window min 20 KB) 59 / 16 / 12 KB (window min 23 KB) 11 / 4 / 3 KB (window min 6 KB)
machine-side pcap ≥600 ms (window) 3 gaps, 2 unans, lat≤8 ms 0 gaps, 1 unans, lat≤2 ms n/a
unreachable samples / stale crash record 0 / 1 0 / 1 0 / 1

(reply age 1 Hz p99/max: DUT 263/432 ms, 7a60 310/555 ms, eed0 243/378 ms outside its outage. send-fail on eed0 = 72 × ENOTCONN during the outage below.)

Result. Zero resets, zero Ethernet recoveries, zero timeout-class lockstep mismatches after boot, ~148 000 heartbeats per unit all answered; internal heap flat for 8 h. Two machine STOPs, both external to the firmware under test and both recovered autonomously:

  1. 19:18 — eed0 (tether) 9-min bond loss. The tether host rekeyed; the device responded correctly but the new session carried no traffic, the device's keys expired at 19:21 and its ~65 initiations over 6 min were silently ignored by wireguard-go until the host reset its peer state. Root-cause candidate is a pre-existing defect outside this PR's scope — the handshake TAI64N is built from uptime, so post-reboot initiations look like replays: WireGuard handshake TAI64N is derived from uptime: device-initiated handshakes rejected as replays after a reboot #157. This PR's re-establishment path did what it should (paced connect() retries, second leg to the direct disco source — wg_hs_cand_sends incremented); the host simply would not answer.
  2. 01:07 — DUT 7 s outage. HQ-site WAN/NAT blip: the machine host lost direct contact with the DUT and with an unrelated HQ host at the same second, the DUT's DERP TCP also dropped. Rebond in 7 s, direct path back within 2 min. Not bridgeable device-side; the fail-safe STOP is correct.

Tether TX path (this PR's #149/#152/#151 fixes): eed0 usb_tx 0 expired / 0 ring-full drops over 8 h (previous firmware: ~95 % TX loss on this topology). WG re-establishment (#155): wg_safety_reconnects fired on DUT at the 01:07 regain; recovered without intervention. Coord deadline (#153): no coord-task stall; control-plane reconnects 3.1/h/unit (runs 1/3: 1.3–2.4), dominated by a 2-min office-side burst at 21:10 and the WAN event — data plane unaffected throughout.

Watch item, not a regression: eed0 internal heap runs at ~11 KB free / 4 KB largest block (min 6.7 KB this run), wg_pbuf_fails 0.

Current head 07e5d058 differs from the soaked image only by the review-round changes above (all host-tested); CI green.

Comment thread components/microlink/src/ml_coord.c Outdated
* alignment is permanently lost, so it runs mid_frame: the wall-clock budget
* is armed before the first payload byte and bounds every wait. Past it,
* ETIMEDOUT makes the caller reconnect instead of resuming misaligned. */
if (coord_recv_ex(ml, ciphertext, ct_len, true) < 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor Issues:

  1. Payload uses coord_recv_ex while header uses coord_recv
  2. Header and payload now get two different timeouts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both points fixed in 99f57085: the budget is now caller-owned and noise_recv uses one for the whole frame — it arms on the header's first byte, and once the header says how long the payload is, ml_coord_frame_budget_set_frame_len() re-sizes the hard cap for header + payload measured from that same first byte (never shrinking); the no-progress window carries across the two reads. coord_recv() keeps a private budget for single-read frames, so there is a single code path (coord_recv_budget) and no mid_frame flag any more. Host test extended to 34 checks, including 'cap runs from the header's first byte, not from the re-size'.

noise_recv read the 3-byte header and the payload under two independent
budgets (header: 10 s floor; payload: its own size-scaled cap armed
separately). The budget is now caller-owned: it arms on the header's first
byte, is re-sized for header + payload once the header says how long the
payload is (ml_coord_frame_budget_set_frame_len, measured from that same
first byte, never shrinking), and the same no-progress window carries across
the two reads. coord_recv() keeps a private budget for single-read frames.
Host test: 34 checks.

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

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds a WireGuard handshake "second leg" that mirrors handshake initiations to a peer's most recent direct disco source while the peer is DERP-only, letting an idle-but-directly-reachable peer complete a handshake when the relay leg is dead; the candidate endpoint is stored per-peer with a 60 s freshness window and read/written under the TCPIP core lock, and a direct response re-adopts the direct endpoint. Replaces the coordination frame's retry-count bound with a wall-clock budget (ml_coord_frame_budget): a 10 s no-progress window that every received byte restarts plus a frame-size-scaled hard cap (max(10 s, frame_len/1 KiB/s)), so a stalled-but-open peer is abandoned in ~10 s instead of holding the coord task for up to 10 min and starving the control watchdog. Surfaces path-recovery diagnostics on microlink_peer_info_t (chosen endpoint, handshake candidate, ping/init ages, backoff, WG session/handshake flags) and paces keyless-regain wireguardif_connect calls to REKEY_TIMEOUT, and wires up new host tests for the frame budget and WG regain policy.

Worth a look

  • wg_peer_index can race to -1 and index before peers arraycomponents/microlink/src/microlink.c:875 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Disco ping can steer WireGuard candidate traffic to arbitrary IP/portcomponents/microlink/src/ml_wg_mgr.c:2941 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Handshake packets can be reflected to unvalidated candidate endpointscomponents/microlink/components/wireguard_lwip/src/wireguardif.c:184 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Noise frame ciphertext length not validated before allocation/readcomponents/microlink/src/ml_coord.c:284 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Safety-peer connect uses stale endpoint (candidate stored but peer->ip not updated to pong source)components/microlink/src/ml_wg_mgr.c:3186 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 500 functions depend on the 447 functions this change touches.

Health — this change adds coupling hotspots:

  • new: disco_send_ping_to_peer() — 8 callers, 8 callees
  • new: dcs_support_init() — 2 callers, 30 callees
  • new: add_peer() — 2 callers, 22 callees
  • new: wireguardif_network_rx() — 3 callers, 11 callees
  • new: disco_send_call_me_maybe() — 6 callers, 4 callees
  • new: noise_send() — 7 callers, 3 callees
  • new: neg_apply_target() — 2 callers, 10 callees
  • new: noise_recv() — 4 callers, 5 callees
  • …and 44 more — each is listed as a finding

Verification — 500 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: 499 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify wireguardif\_peer\_output.

The verifier did not have enough to check wireguardif\_peer\_output, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: err_t

Could not verify: Could not verify microlink\_get\_peer\_info.

The verifier did not have enough to check microlink\_get\_peer\_info, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: esp_err_t

Could not verify: Could not verify handler\_get\_peers.

The verifier did not have enough to check handler\_get\_peers, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: esp_err_t

Could not verify: Could not verify handler\_monitor.

The verifier did not have enough to check handler\_monitor, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: esp_err_t

Could not verify: Could not verify coord\_recv.

The verifier did not have enough to check coord\_recv, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported parameter type(s)

Could not verify: Could not verify noise\_recv.

The verifier did not have enough to check noise\_recv, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported parameter type(s)

Could not verify: Could not verify add\_peer.

The verifier did not have enough to check add\_peer, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported parameter type(s)

Could not verify: Could not verify disco\_send\_ping\_to\_peer.

The verifier did not have enough to check disco\_send\_ping\_to\_peer, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: void

Could not verify: Could not verify process\_disco\_ping.

The verifier did not have enough to check process\_disco\_ping, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: void

Could not verify: Could not verify process\_disco\_pong.

The verifier did not have enough to check process\_disco\_pong, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: void

Could not verify: Could not verify ml\_dev\_tether\_try\_start.

The verifier did not have enough to check ml\_dev\_tether\_try\_start, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: esp_err_t

Could not verify: Could not verify usb\_drain.

The verifier did not have enough to check usb\_drain, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported parameter type(s)

Could not verify: Could not verify dcs\_net\_supervisor\_start.

The verifier did not have enough to check dcs\_net\_supervisor\_start, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: void

Could not verify: Could not verify supervisor\_task.

The verifier did not have enough to check supervisor\_task, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: void

· 2 grounded finding(s) anchored inline below; 50 more finding(s) on lines outside this diff (see the check run).

@@ -209,9 +259,14 @@ static int noise_send(microlink_t * ml, ml_noise_state_t * noise, const uint8_t
/* Receive and decrypt a Noise transport frame, returns plaintext length */
static int noise_recv(microlink_t * ml, ml_noise_state_t * noise, uint8_t * plaintext, size_t max_len)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionnoise_recv()

high coupling complexity (Ca·Ce = 20).

Grounded coupling-delta finding (deterministic), not an LLM guess.


#define S(x) ((int64_t)(x) * 1000000LL) /* seconds -> us */

int main(void)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionmain()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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

This pull request has been reviewed before and this review found new issues. Where they share a root cause, one fix may close them together.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment on lines +180 to +189
if (device->udp_output_fn && !ip_addr_isany(&peer->hs_cand_ip) && peer->hs_cand_port != 0 &&
!wireguard_expired(peer->hs_cand_ms, HS_CAND_FRESH_MS / 1000) &&
wireguard_get_message_type(data, q->tot_len) == MESSAGE_HANDSHAKE_INITIATION) {
uint32_t cand_ip = ip4_addr_get_u32(ip_2_ip4(&peer->hs_cand_ip));
err_t leg2 = device->udp_output_fn(cand_ip, peer->hs_cand_port, data, q->tot_len, device->udp_output_ctx);
wireguardif_hs_cand_sends++;
if (result != ERR_OK && leg2 == ERR_OK) {
result = ERR_OK;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 An attacker who replays one authenticated disco ping with a spoofed source IP makes the device repeatedly send real WireGuard handshake-init packets to that address for 60s, not just the single PONG the base branch reflects. ml_wg_mgr.c:2935-2942 sets peer->hs_cand from pkt->src_ip/src_port on an unvalidated inbound ping, before the pong that would confirm it. wireguardif.c:180-189 then mirrors every paced (5s, ML_WG_SAFETY_CONNECT_PACE_MS) safety-peer handshake initiation there while HS_CAND_FRESH_MS=60s holds. Fix: gate mirroring on a validated pong / has_direct_path, not the raw inbound ping.

Extended reasoning...

Disco pings are nacl_box_open-authenticated (ml_wg_mgr.c:3430) so only the true peer's disco key can produce valid content, but the UDP source IP/port used as pkt->src_ip/src_port is not part of that authenticated payload. An attacker who observes one genuine ping (network tap/MITM) can resend the identical ciphertext with a forged source IP=victim; nacl_box_open still succeeds. Handler at ml_wg_mgr.c:2935 sees !pkt->via_derp, is_safety_peer true, calls wireguardif_set_hs_candidate(peer_index, victim_ip, victim_port) immediately - the very next comment says reachability is only proven later by a pong that flips has_direct_path, which never comes for a spoofed address. Safety-peer regain policy (ml_wg_regain_policy.h) paces handshake retries every ML_WG_SAFETY_CONNECT_PACE_MS=5000ms while DERP-only. Each retry goes through wireguardif_peer_output; since hs_cand is still fresh (HS_CAND_FRESH_MS=60000), wireguardif.c:180-189 sends a second copy via udp_output_fn straight to victim_ip:victim_port. Over the 60s window that is up to ~12 reflected packets from one injected ping, versus the…

Verification: Severity: normal (security-relevant; newly introduced by this change, modest amplification, gated by an outage window + on-path capture). The mechanism is real and reachable. Disco pings are authenticated only over the nacl box ciphertext (ml_wg_mgr.c:3430 nacl_box_open uses sender_disco_key + ml->disco_private_key); the UDP transport 5-tuple (pkt->src_ip/src_port) is NOT covered by that box,…

Comment on lines +866 to +892
if (ml->wg_netif != NULL && p->wg_peer_index >= 0) {
struct netif * netif = (struct netif *)ml->wg_netif;
ip_addr_t cur_ip;
u16_t cur_port;
info->wg_up = (wireguardif_peer_is_up(netif, (u8_t)p->wg_peer_index, &cur_ip, &cur_port) == ERR_OK);
/* Diagnostic snapshot of plain scalars owned by the TCPIP thread, read
* without the core lock on purpose: a torn read can only yield a stale
* bool or age for one JSON sample, never a fault, and taking the lock from
* the HTTP task for telemetry is what stalled the safety loop before. */
struct wireguard_device * dev = (struct wireguard_device *)netif->state;
if (dev != NULL && p->wg_peer_index < WIREGUARD_MAX_PEERS) {
const struct wireguard_peer * wp = &dev->peers[p->wg_peer_index];
info->wg_active = wp->active;
info->wg_send_hs = wp->send_handshake;
info->wg_hs_pending = wp->handshake.valid;
/* Handshake second-leg candidate: same lock-free telemetry snapshot (the
* locked wireguardif_get_hs_candidate() is for the disco task's decisions,
* not for the HTTP task — see the comment above). */
if (
!ip_addr_isany(&wp->hs_cand_ip) && wp->hs_cand_port != 0 &&
!wireguard_expired(wp->hs_cand_ms, HS_CAND_FRESH_MS / 1000))
{
info->hs_cand_ip = lwip_ntohl(ip4_addr_get_u32(ip_2_ip4(&wp->hs_cand_ip)));
info->hs_cand_port = wp->hs_cand_port;
}
uint32_t wg_now = sys_now(); /* wireguardif stamps with lwIP sys_now(), not esp_timer */
info->wg_init_age_ms = (wp->last_initiation_tx != 0) ? (wg_now - wp->last_initiation_tx) : 0xFFFFFFFFu;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 (optional) After merging, an admin can see /admin/api/peers show a retired or LRU-evicted peer row (active:false) carrying a completely different peer's live WireGuard diagnostics (wg_up, wg_active, hs_cand, wg_init_age_ms) - forensics data this diff adds and that is now wrong for exactly the kind of investigation it exists for. ml_wg_mgr.c's re-key retire (ml_wg_mgr.c:2105-2115) and LRU evict (ml_wg_mgr.c:2172-2176) paths call wireguardif_remove_peer() but clear only p->active, never p->wg_peer_index or p->vpn_ip. wireguardif_add_peer()'s peer_alloc() can hand that freed WG slot to an unrelated peer. microlink_get_peer_info() (microlink.c:866-892) then reads dev->peers[p->wg_peer_index] whenever the stale index is >=0, without checking p->active or matching the slot's key/vpn_ip to this peer. …

Extended reasoning...

…Fix: clear wg_peer_index (and vpn_ip) at retire/evict time, or verify the WG slot still belongs to this peer (matching public_key) before exposing its state.

ml_wg_mgr.c:2105-2115 retires a re-keyed peer: wireguardif_remove_peer(netif, p->wg_peer_index) then only ml->peers[idx].active=false; wg_peer_index and vpn_ip are left as-is. Same gap at the LRU-evict path, ml_wg_mgr.c:2172-2176. wireguardif_remove_peer (wireguardif.c:1219-1228) sets peer->valid=false on that WG slot. A later wireguardif_add_peer() for a DIFFERENT peer calls peer_alloc(), which scans for the first invalid slot and can return this same just-freed index. If ml_wg_mgr.c's own free-slot scan (2120-2125) picks an earlier already-inactive ml->peers[] slot for the new peer instead of idx, the retired entry at idx stays indefinitely with active=false, vpn_ip nonzero, wg_peer_index pointing at the now-reused slot. handler_get_peers (ml_config_httpd.c:701-704) only skips vpn_ip==0, so it emits this row. microlink_get_peer_info tests p->wg_peer_index>=0 (true) and reads dev->peers[index] (microlink.c:875-892):…

Verification: nit. Real but low-severity diagnostic-accuracy defect newly introduced by this diff (the WG diagnostics block at microlink.c:866-893 is added here; the base get_peer_info never touched wg_peer_index). Mechanism (retire path only): ml_wg_mgr.c:2105-2115 removes the WG slot (wireguardif_remove_peer, 2106; the slot is zeroed and valid=false per wireguardif.c:1223-1224) but clears only… | nit. Real…

Comment on lines +184 to 213
if (ml_coord_frame_budget_armed(budget)) {
/* Mid-frame: wait for readability at most the remaining budget. A 0 result
* loops back to the expiry check above; data/EOF/error fall through to
* recv(), which reports them. */
int64_t wait_us = ml_coord_frame_budget_wait_us(budget, now_us);
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(ml->coord_sock, &rfds);
struct timeval tv = {.tv_sec = (time_t)(wait_us / 1000000LL), .tv_usec = (suseconds_t)(wait_us % 1000000LL)};
int sel = ml_select_fds(ml->coord_sock + 1, &rfds, NULL, NULL, &tv);
if (sel == 0) {
timeouts++;
continue;
}
if (sel < 0 && errno == EINTR) continue;
}
int n = ml_recv(ml->coord_sock, buf + recvd, len - recvd, 0);
if (n <= 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
if (recvd == 0) {
/* No data consumed yet — timeout is fine, caller can retry */
return -1;
}
/* Partial data consumed — we MUST finish this read or the
* Noise frame stream will be misaligned. Retry with backoff. */
if (++retries > 300) { /* ~3 seconds */
ESP_LOGE(TAG, "coord_recv partial timeout: %d/%d bytes", (int)recvd, (int)len);
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
if (recvd == 0 && !ml_coord_frame_budget_armed(budget)) {
/* Idle stream, nothing of this frame consumed — caller can retry */
return -1;
}
/* Partial data consumed — we MUST finish this read or the Noise frame
* stream will be misaligned. select() said readable but recv() had
* nothing (spurious wakeup / socket timeout): retry until the budget. */
timeouts++; /* outside ESP_LOGW: its args are compiled out below LOG_LOCAL_LEVEL */
ESP_LOGW(TAG, "coord_recv socket timeout #%d mid-frame: %d/%d bytes", timeouts, (int)recvd, (int)len);
vTaskDelay(pdMS_TO_TICKS(10));
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟣 coord_recv_budget's select()-based deadline can still be defeated by a blocking recv(), letting a stalled coord read cost up to 60s instead of the documented 10s bound. When select() reports the socket readable but recv() at ml_coord.c:200 finds nothing (the 'spurious wakeup / socket timeout' case the comment at ml_coord.c:207-209 itself calls out), recv() is a plain blocking call (coord_sock has no O_NONBLOCK, unlike ml_derp.c/ml_stun.c which use O_NONBLOCK+select for exactly this reason) so it can only return once data arrives or the socket's own SO_RCVTIMEO elapses -- 60s during MapResponse fetch (ml_coord.c:1636, 1753) or the 60s 'normal' timeout restored at ml_coord.c:581-582. …

Extended reasoning...

…Fix: make coord_sock non-blocking (or pass MSG_DONTWAIT) so recv() after select() never itself blocks past the remaining frame budget, for every coord_recv_budget call site.

coord_sock is created blocking at ml_coord.c:331-341 (SO_RCVTIMEO set, no O_NONBLOCK). Elsewhere the socket's SO_RCVTIMEO is set to 60s for MapResponse fetch (ml_coord.c:1636/1753) and restored to 60s as the steady-state timeout (ml_coord.c:581-582). coord_recv_budget arms a frame budget on the first byte, then each loop iteration bounds the wait with select() to the min of the 10s no-progress window and the size-scaled hard cap (ml_coord.c:184-198). If select() marks the fd readable but the byte isn't actually consumable when recv() runs -- exactly the case ml_coord.c:207-209 names 'spurious wakeup / socket timeout' -- recv() at line 200 blocks using the socket's own SO_RCVTIMEO because the socket was never switched to non-blocking. One such event during a mid-frame MapResponse read stalls the coord task up to 60s, not the <=10s ml_coord_frame_budget.h promises, undermining the ML_CTRL_WATCHDOG_MS protection…

Verification: pre-existing. The mechanism is real and reachable: coord_sock is blocking (ml_coord.c:340 sets SO_RCVTIMEO only, no O_NONBLOCK; steady-state 60s at :582, MapResponse 60s at :1636/:1753). In coord_recv_budget the armed-frame wait is bounded by select() (:188-:197), but control then falls through to the blocking ml_recv() at :200. The n < 0 && (errno==EAGAIN||EWOULDBLOCK) branch at :202 on a…

@iliabaranov
iliabaranov merged commit c4938eb into main Sep 20, 2026
11 checks passed
@iliabaranov
iliabaranov deleted the fix/remote-connectivity branch September 20, 2026 17:34
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.

2 participants