Fix Layer0 e2e teardown SIGSEGVs: drain RPC scopes before their targets die, roll back started on failed start()#81
Conversation
…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>
c77249b to
a875bcf
Compare
|
Update: rebased onto The failed leg was a single-test hang:
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 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 |
…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>
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>
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 inLayer0MixedConnectionTypesTest.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→~RpcCommunicatorClientApi→blockingWait(mScope.cancelAndJoinAsync())), whileStreamrWorker1resumed a drained Ping request that ranmakeRpcRequest→ outgoing callback →sendFn→ConnectionManager::send→WebsocketClientConnector::connectand 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:
DhtNodedeclaredrpcCommunicatorbeforeownedConnectionManager, 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.RoutingRpcCommunicator's own members (sendFn,ownServiceId) were destroyed.ConnectionManager's internal lock-RPC communicator drained afterendpoints/endpointsMutex/statedied, andsendIfStoppedresponses bypass the stopped-state guard insend().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()setstarted = truebefore creating the owned ConnectionManager. If the websocket server failed to bind,transportPtrstayed null whilestartedstayed true; TearDown'sstop()then calledoff()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) beforeLayer0Test.Fix
drainAsyncTasks()(atomic once-flag; folly forbids a second scope join) onRpcCommunicatorClientApi,RpcCommunicatorServerApi, andRpcCommunicator; the API destructors keep their drains as a backstop.~RoutingRpcCommunicatorcallsdrainAsyncTasks()so the drain runs whilesendFn,ownServiceId, the owner, and its transport are all still alive (ListeningRpcCommunicatorinherits this).~ConnectionManagerdrains its own lock-RPC communicator in its body, whileendpoints/endpointsMutex/stateare still alive.DhtNodedeclaresownedConnectionManagerbeforerpcCommunicator, so the stopped ConnectionManager outlives the drain and absorbs stragglers as guarded no-ops.started = truemoves to the end ofstart()(no suspension points inside; nothing instart()reads it) — a failed start leaves the node safely stoppable, and the ConnectionManager destructor stops whatever came up.Verification
nc -l 10011+ Layer0Test)--gtest_repeat=2)The ASan aborts (same rate pre/post fix) are the known ASan-internal
asan_thread.cpp:370unwind CHECK artifact on arm64 (exception unwinding throughWebrtcConnection::doClose→emit<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