Skip to content

Make RecursiveOperationManager::sendResponse fully asynchronous (remove the last worker-pool park)#83

Merged
ptesavol merged 1 commit into
mainfrom
claude/async-send-response
Jul 12, 2026
Merged

Make RecursiveOperationManager::sendResponse fully asynchronous (remove the last worker-pool park)#83
ptesavol merged 1 commit into
mainfrom
claude/async-send-response

Conversation

@ptesavol

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #81. That PR fixed the permanent worker-pool deadlock in RecursiveOperationManager::sendResponse (destroying the per-session ListeningRpcCommunicator on a pool thread parked the thread in the destructor's scope join, waiting for a send task that itself needed a pool thread) by retiring communicators into a pruned list. One bounded park remained: RecursiveOperationSessionRpcRemote::sendResponse wrapped the generated client notify in blockingWait(), so under pool saturation an RPC handler's shared worker thread was parked for up to sessionRpcTimeout (10 s). This PR removes that park: the response send never blocks any thread anywhere.

Design

  • RecursiveOperationSessionRpcRemote::sendResponse is now a folly::coro::Task<void> coroutine that co_awaits the generated client notify in-frame (per the repo's lazy-task rule: the request locals live in the awaiting frame) and swallows failures — TS fire-and-forget parity, still bounded by sessionRpcTimeout.
  • RecursiveOperationManager::sendResponse runs the whole send as a detached coroutine on a new manager-owned GuardedAsyncScope (sendResponseScope, on SharedExecutors::worker()). The task constructs the per-session ListeningRpcCommunicator and RpcRemote, co_awaits the notify (suspends instead of parking), then disposes of the communicator.
  • Disposal keeps Fix Layer0 e2e teardown SIGSEGVs: drain RPC scopes before their targets die, roll back started on failed start() #81's deadlock rule: 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 very task, so a non-empty-scope join there 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 also proves the coroutine is not inside one of the communicator's tasks; destroy() detaches the transport listeners first, so the zero observation is stable). Otherwise the communicator goes into the existing retiredSessionCommunicators backstop from Fix Layer0 e2e teardown SIGSEGVs: drain RPC scopes before their targets die, roll back started on failed start() #81, pruned exactly as before.
  • The communicator is constructed inside the detached task, so a sendResponse racing stop() is dropped by the scope's close() gate before anything blocking-to-destroy exists: the dropped lambda releases only vectors and protobuf messages at the add() call site. A dropped response after stop is correct for a fire-and-forget send.
  • stop() closes sendResponseScope (owner's thread; DhtNode::stop() calls it while the session transport is still alive) before clearing the retired list, because draining tasks may still retire communicators into it; member declaration order gives the same ordering in the destructor path.

Verification (macOS arm64, Debug)

Pool size forced with a temporary STREAMR_TEST_WORKER_THREADS override in SharedExecutors::worker() (removed before commit); DhtNodeExternalApiTest.ExternalStoreDataHappyPath with a 90 s watchdog per run:

pool size runs result
4 threads 15 15/15 pass (~0.2 s each)
2 threads 10 10/10 pass (~0.1 s each)
1 thread 5 5/5 pass (~0.1–0.4 s each)

Before this change the 1-thread configuration failed after ~10 s per run (the blockingWait park swallowed the only worker until the notify timed out). No hangs in any configuration.

Full suites: streamr-dht-test-unit 181/181, streamr-proto-rpc-test-unit 24/24, streamr-proto-rpc-test-integration 4/4, streamr-dht-test-end-to-end 7/7 four times (three consecutive runs plus once on the final clean rebuild), streamr-dht-test-integration 42/43 — the one failure was Layer1ScaleTest.SingleLayer1Dht, the known load-sensitive flake (#80): it exercises only joinDht, which never touches the recursive-operation path, and it passed when rerun in isolation.

Test updates: the manager unit test's transport.sendCount assertions moved after manager->stop() (which drains the send scope, making the observation deterministic now that the send is detached), and the session unit test blockingWaits the now-coroutine sendResponse on the test thread.

🤖 Generated with Claude Code

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>
@github-actions github-actions Bot added the dht label Jul 12, 2026
@ptesavol ptesavol merged commit 6546a23 into main Jul 12, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant