Skip to content

Fix Layer0 e2e teardown SIGSEGVs: drain RPC scopes before their targets die, roll back started on failed start()#81

Merged
ptesavol merged 2 commits into
mainfrom
claude/funny-pare-e665c6
Jul 11, 2026
Merged

Fix Layer0 e2e teardown SIGSEGVs: drain RPC scopes before their targets die, roll back started on failed start()#81
ptesavol merged 2 commits into
mainfrom
claude/funny-pare-e665c6

Conversation

@ptesavol

Copy link
Copy Markdown
Collaborator

Problem

The streamr-dht end-to-end suite (streamr-dht-test-end-to-end, 7 tests) had a rare SIGSEGV when running the full suite (observed 2026-07-11 in Layer0MixedConnectionTypesTest.TwoNonServerPeersJoinFirst; passes in isolation and on most full-suite reruns). Runtime-evidence debugging against the two macOS crash reports from that day identified two independent teardown crash mechanisms.

Mechanism 1 — drain-order use-after-free (crash report 16:33)

The crash report captured it live on two threads: the main thread inside fixture destruction draining in-flight RPC coroutines (~DhtNode~RoutingRpcCommunicator~RpcCommunicatorClientApiblockingWait(mScope.cancelAndJoinAsync())), while StreamrWorker1 resumed a drained Ping request that ran makeRpcRequest → outgoing callback → sendFnConnectionManager::sendWebsocketClientConnector::connect and crashed at 0x0 inserting into a freed map.

Root cause: the scope drains ran in the client/server API destructors, i.e. after the owning objects' members were already gone:

  • DhtNode declared rpcCommunicator before ownedConnectionManager, so reverse-order destruction freed the ConnectionManager before the drain. A straggler send into a stopped ConnectionManager is a guarded no-op (deliberate design); into a destroyed one it is a use-after-free.
  • The drain also ran after RoutingRpcCommunicator's own members (sendFn, ownServiceId) were destroyed.
  • ConnectionManager's internal lock-RPC communicator drained after endpoints/endpointsMutex/state died, and sendIfStopped responses bypass the stopped-state guard in send().

Instrumented full-suite runs showed the unsafe window opened on every node teardown (350 ordering violations across 10 runs) with ~170 guarded post-stop straggler sends per run — the crash just needed one task still queued at fixture destruction, which is why it was rare and load-dependent.

Mechanism 2 — failed start() poisons stop() (crash report 07:21)

start() set started = true before creating the owned ConnectionManager. If the websocket server failed to bind, transportPtr stayed null while started stayed true; TearDown's stop() then called off() through the null pointer — SEGV at 0x1a0 (emitter mutex offset from null), byte-for-byte the 07:21 report. Reproduced deterministically by occupying the entry-point port (nc -l 10011) before Layer0Test.

Fix

  • Idempotent drainAsyncTasks() (atomic once-flag; folly forbids a second scope join) on RpcCommunicatorClientApi, RpcCommunicatorServerApi, and RpcCommunicator; the API destructors keep their drains as a backstop.
  • New ~RoutingRpcCommunicator calls drainAsyncTasks() so the drain runs while sendFn, ownServiceId, the owner, and its transport are all still alive (ListeningRpcCommunicator inherits this).
  • ~ConnectionManager drains its own lock-RPC communicator in its body, while endpoints/endpointsMutex/state are still alive.
  • DhtNode declares ownedConnectionManager before rpcCommunicator, so the stopped ConnectionManager outlives the drain and absorbs stragglers as guarded no-ops.
  • started = true moves to the end of start() (no suspension points inside; nothing in start() reads it) — a failed start leaves the node safely stoppable, and the ConnectionManager destructor stops whatever came up.

Verification

Check Before fix After fix
Blocked-port repro (nc -l 10011 + Layer0Test) SIGSEGV at 0x1a0 clean test failure (exit 1)
Drain-order violations (instrumented log analyzer) 350 / 10 runs (every teardown) 0 / 33 runs
Full e2e suite, 15 runs under CPU load window open every run 15/15 pass, 0 findings
ASan loop (18 runs, load, --gtest_repeat=2) 0 heap reports, 3 tooling aborts 0 heap reports, 3 tooling aborts
Regressions e2e 3×7/7 clean, streamr-dht unit 181/181, proto-rpc 24/24 + 4/4

The ASan aborts (same rate pre/post fix) are the known ASan-internal asan_thread.cpp:370 unwind CHECK artifact on arm64 (exception unwinding through WebrtcConnection::doCloseemit<Disconnected> on the AbortableTimers thread), not a heap error — consistent with the previously documented ASan/folly custom-stack false-positive family.

🤖 Generated with Claude Code

ptesavol and others added 2 commits July 11, 2026 21:15
…r targets die, roll back started on failed start()

Two teardown crash mechanisms, both proven with runtime evidence (macOS
crash reports from 2026-07-11 + instrumented log ordering + deterministic
reproductions):

1. Drain-order use-after-free (crash report 16:33, fixture destruction in
   Layer0MixedConnectionTypesTest.TwoNonServerPeersJoinFirst): the
   client/server RPC APIs drain their CancellableAsyncScopes in their own
   destructors, which run AFTER the owning objects' members are destroyed.
   A straggler request task drained that late runs makeRpcRequest -> the
   outgoing callback -> sendFn -> transportPtr->send() against freed
   memory: DhtNode declared rpcCommunicator before ownedConnectionManager,
   so the ConnectionManager was destroyed before the drain, and the
   drained send crashed in WebsocketClientConnector::connect inserting
   into a freed map. Instrumentation showed the unsafe window opened on
   EVERY node teardown (350 ordering violations in 10 full-suite runs)
   with ~170 guarded post-stop straggler sends per run - the crash only
   needed one task still queued at fixture destruction.

   Fix: idempotent drainAsyncTasks() on RpcCommunicatorClientApi /
   RpcCommunicatorServerApi / RpcCommunicator; ~RoutingRpcCommunicator
   calls it so the drain runs while sendFn, ownServiceId, the owner and
   its transport are all still alive (ListeningRpcCommunicator inherits
   this); ~ConnectionManager drains its own lock-RPC communicator in its
   body while endpoints/endpointsMutex/state are alive (sendIfStopped
   responses bypass the stopped-state guard in send()); DhtNode declares
   ownedConnectionManager before rpcCommunicator so the stopped
   ConnectionManager outlives the drain - a send into a STOPPED
   ConnectionManager is a guarded no-op, into a destroyed one a
   use-after-free.

2. Failed start() poisons stop() (crash report 07:21, SEGV at 0x1a0 in
   Layer0Test::TearDown): start() set started=true before creating the
   owned ConnectionManager; if the websocket server failed to bind,
   transportPtr stayed null while started stayed true, and stop() then
   called off() through the null pointer (0x1a0 = emitter mutex offset).
   Reproduced deterministically by occupying the entry-point port.

   Fix: set started=true only at the end of start() (no suspension points
   inside; a failed start leaves the node safely stoppable and the
   ConnectionManager destructor stops whatever came up).

Verification: blocked-port repro SIGSEGV(0x1a0) -> clean test failure;
drain-order violations 350/10 runs -> 0/33 runs; full e2e suite 15/15
under load and 3x7/7 clean; ASan 18-run loop shows zero heap reports
(3 aborts are the known ASan-internal unwind CHECK artifact, same rate
as before the fix); streamr-dht unit 181/181, proto-rpc 24/24 + 4/4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…linux-arm64 CI hang)

The linux-arm64 leg of the PR run hung for the full 1200 s ctest timeout
(twice) in DhtNodeExternalApiTest.ExternalStoreDataHappyPath. Root cause
(pre-existing on main, reproduced deterministically on BOTH main and this
branch by capping the shared worker pool at 1 thread; thread dumps show
the identical deadlock on both):

sendResponse() runs on shared worker-pool threads (it is called from RPC
server handlers) and destroyed its stack-local per-session
ListeningRpcCommunicator there. The destructor joins the communicator's
async scopes, and that join parks the pool thread waiting for the
communicator's own queued fire-and-forget send task - which needs a pool
thread to run. Once every worker is parked in such a join (plausible on
the 3-core CI runner's 4-thread pool under concurrent recursive-operation
responses), the queued sends can never run and the pool deadlocks
permanently; Simulator::stop()'s drain-wait then blocks the test forever.

Fix: keep the per-session communicator alive past the handler frame in a
retired-communicators list owned by the manager. Entries are destroyed
opportunistically once their scopes report zero pending tasks (an
empty-scope join completes without needing a pool thread, so it is safe
anywhere; growth is bounded because every send task settles within
sessionRpcTimeout) and finally in stop(), which runs on the owner's
thread where a blocking join may safely wait for pool tasks.
RpcCommunicator/ClientApi/ServerApi gain a pendingAsyncTaskCount() probe
for this. ListeningRpcCommunicator::destroy() now also detaches the
Disconnected listener - previously it dangled on the transport after
destruction and could also add client errors to a retired communicator.

Verification: with a 1-thread pool the test went from 2/2 permanent hangs
(on both main and this branch) to 0 hangs / bounded 10 s failures, and
with the CI-sized 4-thread pool from flaky to 15/15 passes; full local
regression green (DhtNodeExternalApiTest 4/4, e2e suite 3x7/7, dht unit
181/181, proto-rpc 24/24 + 4/4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ptesavol ptesavol force-pushed the claude/funny-pare-e665c6 branch from c77249b to a875bcf Compare July 11, 2026 19:34
@ptesavol

Copy link
Copy Markdown
Collaborator Author

Update: rebased onto cbec392a and added a second commit fixing the linux-arm64 CI hang.

The failed leg was a single-test hang: DhtNodeExternalApiTest.ExternalStoreDataHappyPath hit its 1200 s ctest timeout twice, with the last log line inside Simulator::stop()'s drain-wait. Investigation (thread dumps from deterministic local reproductions with the shared worker pool capped at 1 thread) showed a pre-existing worker-pool deadlock, present on main (cbec392a) and this branch alike — both hang 2/2 at 1 pool thread with the identical stack:

sendResponse() (running on a pool thread inside an RPC server handler) destroyed its stack-local per-session ListeningRpcCommunicator; the destructor's scope join parked the pool thread waiting for the communicator's own queued fire-and-forget send task, which itself needs a pool thread. Once all workers park in such joins (plausible on the 3-core runner's 4-thread pool under concurrent recursive-operation responses), the queued sends can never run — permanent deadlock, which #80's new Simulator::stop() drain-wait then surfaces as this test's timeout. This PR's first commit did not introduce the mechanism (control experiment on cbec392a reproduces it), but it moves the scope joins one destructor level earlier, which is likely why this run tripped it.

The second commit fixes the root cause: per-session communicators are retired into a manager-owned list instead of being destroyed on the pool thread, destroyed opportunistically once their scopes report zero pending tasks (empty-scope joins need no pool thread; growth bounded by sessionRpcTimeout) and finally in stop() on the owner's thread. ListeningRpcCommunicator::destroy() now also detaches the previously-dangling Disconnected listener.

Verification: 1-thread pool 2/2 permanent hangs → 0 hangs (bounded 10 s failures, below the 4-thread design floor); CI-sized 4-thread pool 15/15 passes; full local regression green (e2e 3×7/7, dht unit 181/181, proto-rpc 24/24 + 4/4).

🤖 Generated with Claude Code

@ptesavol ptesavol merged commit 3a71147 into main Jul 11, 2026
6 checks passed
ptesavol added a commit that referenced this pull request Jul 12, 2026
…PartNetworkSplitAvoidance, StreamPartReconnect) (#82)

* Phase C4: entry points in the DHT (PeerDescriptorStoreManager, StreamPartNetworkSplitAvoidance, StreamPartReconnect)

Ports from the pinned TS 103.8.0-rc.3 (af966cf03):

- DiscoveryLayerNode (modules/discovery-layer/): the control layer's view
  of a stream part's layer-1 DHT node, ported in full (events included)
  so C5/C6 can reuse it; TS RingContacts maps to the dht package's
  ClosestRingPeerDescriptors. MockDiscoveryLayerNode joins the test-utils
  module library (mutex-guarded: C++ tests mutate the k-bucket from the
  test thread while detached loops read it on pool threads).
- PeerDescriptorStoreManager (modules/control-layer/): fetch/store/keep
  stream entry points under a DHT key via callback-injected DHT
  operations; entry points virtual so StreamPartReconnect's test can
  substitute the TS fake (which force-casts an unrelated class).
- StreamPartNetworkSplitAvoidance: exponential-run-off rejoin. The TS
  run-off runs ALL attempts even after the task stops throwing — the unit
  test pins that (expects the neighbor count to exceed, not reach, the
  minimum) — preserved. discoverEntryPoints is a parameterless callback
  (TS declares an optional excluded-nodes parameter it never passes).
- StreamPartReconnect: periodic entry-point refetch + rejoin until a
  neighbor appears.

Design deviation (C++ wins, per plan §2.2): TS detaches the interval
loops (scheduleAtInterval) and relies on GC; a detached loop touching
`this` is a use-after-free once the owner dies (see the Layer0 teardown
SIGSEGV fixes, PR #81). The loops are owned by a per-instance
CancellableAsyncScope drained in destroy() — awaited, never a blocking
join on a pool thread (the sendResponse deadlock lesson) — with a
blocking destructor backstop for owner threads.

The phase's three integration tests need NetworkNode/createNetworkNode
and move to milestone C6/C8 (noted in the plan).
unit/StreamPartReconnect.test.ts exists in TS though the plan did not
list it; ported too. Tests: 6 new, package suite 28/28.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Phase C4 lint fixes: consume dht protos via BMI only in the new tests, named test constants

clangd 22 flags every member call on the generated dht types as ambiguous
when a test TU both textually includes DhtRpc.pb.h and imports
streamr.dht.protos (documented duplicate-decl merge failure) - drop the
textual include. Also: static createFakeData, named constants for the
keep-alive observation window, fake TTL and test reconnect interval.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Phase C4 lint fix: call static createFakeData without this->

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ptesavol added a commit that referenced this pull request Jul 12, 2026
PR #81 fixed the permanent worker-pool deadlock in sendResponse (the
per-session ListeningRpcCommunicator was destroyed on a pool thread; the
destructor's scope join parked the thread waiting for the communicator's
own queued send task, which needed a pool thread). One bounded park
remained: RecursiveOperationSessionRpcRemote::sendResponse wrapped the
generated client notify in blockingWait(), parking the RPC handler's
shared worker-pool thread for up to sessionRpcTimeout (10 s) under pool
saturation. This change removes that park.

Design:
- RecursiveOperationSessionRpcRemote::sendResponse is now a
  folly::coro::Task<void> coroutine that co_awaits the generated client
  notify (which must be co_awaited in-frame per the repo's lazy-task
  rule: the request locals live in the awaiting frame) and swallows
  failures, keeping the TS fire-and-forget semantics.
- RecursiveOperationManager::sendResponse no longer does any of the work
  on the calling thread: the whole send runs as a detached coroutine on
  a new manager-owned GuardedAsyncScope (sendResponseScope), started on
  SharedExecutors::worker(). The task constructs the per-session
  ListeningRpcCommunicator and the RpcRemote, co_awaits the notify
  (suspends instead of parking), then disposes of the communicator.
- Disposal keeps the deadlock rule from PR #81: after the notify
  completes, the communicator's own client scope may still hold the
  send task's unfinished tail, and the coroutine may even have been
  resumed from inside that task, so a non-empty-scope join here could
  self-deadlock. The task destroys the communicator in place only when
  pendingAsyncTaskCount() == 0 (an empty-scope join needs no pool
  thread, and a zero count proves the coroutine is not running inside
  one of the communicator's tasks; after destroy() detaches the
  transport listeners the count cannot grow, so the observation is
  stable). Otherwise it retires the communicator into the existing
  retiredSessionCommunicators backstop, pruned as before.
- The communicator is constructed INSIDE the detached task, so a
  sendResponse racing stop() is dropped by the scope's close() gate
  without ever creating the communicator: the dropped lambda destroys
  only plain values at the add() call site and cannot block. A dropped
  response after stop is correct for a fire-and-forget send.
- stop() closes sendResponseScope (on the owner's thread, while the
  session transport is still alive per DhtNode::stop() ordering) BEFORE
  clearing the retired list, because draining tasks may still retire
  their communicators into it. The scope is declared after the list so
  destructor ordering matches.

Test updates:
- RecursiveOperationManagerTest: the response send is now detached, so
  the transport.sendCount assertions moved after manager->stop(), which
  drains the send scope and makes the observation deterministic.
- RecursiveOperationSessionTest: the RpcRemote sendResponse coroutine is
  blockingWait()ed on the test thread (not a pool worker).

Verification (macOS arm64 Debug; pool size forced via a temporary
STREAMR_TEST_WORKER_THREADS override in SharedExecutors::worker(),
removed before commit):
- DhtNodeExternalApiTest.ExternalStoreDataHappyPath: 15/15 pass with a
  4-thread pool, 10/10 with 2 threads, 5/5 with 1 thread (~0.1-0.4 s
  per run). Before this change the 1-thread runs failed after ~10 s
  (the bounded blockingWait park swallowed the only worker until the
  notify timed out); no hangs anywhere (90 s watchdog per run).
- Full suites green: streamr-dht-test-unit (181), streamr-dht-test-
  integration (42/43; the one failure was the known load-sensitive
  Layer1ScaleTest.SingleLayer1Dht flake, which exercises only joinDht —
  recursive operations never run in it — and passed when rerun in
  isolation), streamr-dht-test-end-to-end 4x (3x + once on the final
  clean build), streamr-proto-rpc-test-unit (24),
  streamr-proto-rpc-test-integration (4).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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