Skip to content

Dispatch rtc callbacks off the rtc threads; TS-parity full-node test scale#94

Merged
ptesavol merged 3 commits into
claude/fix-full-node-teardown-hangfrom
claude/rtc-dispatch-offload
Jul 18, 2026
Merged

Dispatch rtc callbacks off the rtc threads; TS-parity full-node test scale#94
ptesavol merged 3 commits into
claude/fix-full-node-teardown-hangfrom
claude/rtc-dispatch-offload

Conversation

@ptesavol

Copy link
Copy Markdown
Collaborator

Stacked on #92 (retarget to main after it merges). Adopts and revalidates the accept-path investigation from session branch claude/nostalgic-bell-34f492 (2026-07-13, uncommitted at the time), which diagnosed why the full-node tests were capped at 8 nodes: libdatachannel runs all websocket I/O through one global poll thread, and the emit chains ran inline there — the poll thread measured >95% busy while concurrent connectivity checks timed out at 1 s.

Changes

  • Logger: log() early-outs below the cheapest enabled level before marshalling metadata to JSON (filtered-out trace/debug cost 20–90 ms per delivered message in Debug builds).
  • WebsocketConnection/WebrtcConnection: every rtc callback is now a thin wrapper that enqueues the real body onto a per-connection SharedSerialExecutor (per-connection FIFO keeps the emitter contract: Connected → Data in order → Disconnected). Includes the teardown rules the offload requires (drainDispatchQueue() barrier, WebsocketServer::stop() half-ready-map swap + per-connection drain, late-connection drop after connector abort).
  • WebsocketServerConnection::getRemoteAddress(): strip the :service suffix libdatachannel appends — the unstripped value made every connectivity probe dial ws://ip:port:port, silently downgrading node-to-node links to WebRTC.
  • Full-node tests restored to TS-parity node counts (websocket 12, webrtc 22; were scaled down to 8 pending this fix).

Validation (on top of #92's teardown fixes)

  • Websocket 12-node run: ~3 s (previously failed SetUp beyond 8 nodes).
  • WebRTC 22-node: 17–37 s at normal pool sizes.
  • CI-emulated 4-thread pool at TS-parity counts: 10/12 green, zero hangs; the two failures are the known pre-existing Handshaker-interleave UAF (follow-up task filed).
  • Lint green on streamr-logger and streamr-trackerless-network; streamr-dht lint re-run pending a full-tree rebuild (stale-modmap false positive).

Known open item

One of 11 normal-pool WebRTC runs hung after the test passed, in exit-time static destruction: ~CPUThreadPoolExecutor → joinKeepAlive waiting on an unreleased executor KeepAlive (thread sample attached to the investigation notes). leaks --atExit shows zero unreachable blocks, pointing at a still-reachable abandoned connection (rtc callbacks captured self shared_ptrs before this change too) now pinning a dispatch-executor KeepAlive. Filed as a follow-up; pre-existing object-lifetime issue whose symptom this patch changes from silent memory retention to a rare exit stall.

🤖 Generated with Claude Code

@ptesavol

Copy link
Copy Markdown
Collaborator Author

streamr-dht lint is green after a full-tree rebuild — the earlier red was the documented stale-modmap false positive (test TU modmaps regenerate only when their targets build). No source change needed.

ptesavol and others added 2 commits July 17, 2026 12:29
Filtered-out trace/debug calls still marshalled their metadata to JSON
and scanned the environment per call (FollyLoggerImpl) — 20-90 ms per
network message in Debug builds, dominating the connection delivery
chains under the full-node tests. The constructor snapshots the minimum
enabled level across the logger's own level and every LOG_LEVEL_<Category>
override (env vars cannot meaningfully change mid-process; messages that
pass the bound are still re-checked per category as before), and log()
returns before any marshalling when the message level is below it.

Adopted from the accept-path investigation (session branch
claude/nostalgic-bell-34f492, 2026-07-13).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
libdatachannel funnels ALL websocket TCP I/O through ONE global poll
thread; websocket upgrades and message delivery ran inline there, and
data channels share the fixed-size rtc worker pool with ICE/DTLS
processing. Running the emit chains (handshake, RPC parse/dispatch)
inline starved every other connection in the process: measured the poll
thread >95% busy in Data emit chains while 12 concurrent connectivity
checks timed out at their 1 s limit — the accept-path degradation that
kept the full-node tests at 8 nodes.

- WebsocketConnection/WebrtcConnection own a per-connection
  SharedSerialExecutor; every rtc callback is a thin wrapper that only
  enqueues the real body. Per-connection FIFO preserves the emitter
  contract (Connected before Data, Data in order, Disconnected last) —
  the Simulator's per-association idiom.
- Teardown rules the offload requires: drainDispatchQueue() barrier;
  WebsocketServer::stop() quiesces the accept thread, swaps out the
  half-ready map and drains each connection (their listeners capture the
  server raw); handleIncomingClient gates on mStopped; the half-ready
  Connected handler uses find() (stop() may have swapped the map);
  WebsocketServerConnector's server-Connected handler drops late
  connections once aborted.
- WebsocketServerConnection::getRemoteAddress(): libdatachannel returns
  host:service — strip at the LAST colon. The unstripped value made
  every connectivity probe dial ws://ip:port:port, so node descriptors
  lacked websocket info and links silently fell back to WebRTC.
- Full-node tests restored to TS-parity node counts (websocket 12,
  webrtc 22).

Adopted from the accept-path investigation (session branch
claude/nostalgic-bell-34f492, 2026-07-13) and revalidated on top of the
teardown fixes: websocket 12-node run now 3 s (was: failed SetUp beyond
8 nodes); on a CI-sized 4-thread pool 10/12 runs green with zero hangs,
the two failures being the known pre-existing Handshaker-interleave UAF
(follow-up filed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ptesavol
ptesavol force-pushed the claude/rtc-dispatch-offload branch from e710bf8 to 3e61c5a Compare July 17, 2026 09:29
…map erases

1-in-10 runs of the 22-node WebrtcFullNodeNetworkTest hung AFTER gtest
printed PASSED: exit-time static destruction blocked in
CPUThreadPoolExecutor::~CPUThreadPoolExecutor -> joinKeepAliveOnce on a
KeepAlive held by a leaked WebrtcConnection's dispatch executor. A
WebrtcConnection is immortal until doClose() runs (its rtc callbacks and
signalling listeners capture a strong self), and two attempt-map paths
abandoned one without ever closing it:

- pendingConnection Disconnected erased the attempt (the last owner
  stop() could still destroy) without closing the connection; for an
  answerer that never received a handshake request, IncomingHandshaker
  has no pending->connection close link yet, so nobody else closed it
  either. Triggers observed in lifecycle-registry logs: the pending
  connection's 15 s connect timeout and endpoint close at teardown.
- a stale Disconnected from an older connection to the same nodeId
  erased a freshly inserted attempt (insert and erase within the same
  millisecond in the logs), orphaning the new pair at birth.

All three erase handlers now erase only the pair that fired (raw-pointer
identity comparison, never dereferenced), and the pendDisconnected
handler closes the paired connection outside mMutex.

Verified with repeated isolated runs under CPU load: baseline 4/15
exit-hangs (every hung process held exactly one never-closed answerer
connection; clean runs held zero) -> 0/15 with the fix, plus 0/5 on the
de-instrumented build; streamr-dht unit 181/181 and integration 43/43.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ptesavol
ptesavol merged commit e3814bc into claude/fix-full-node-teardown-hang Jul 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant