EventEmitter: offAndWait()/removeAllListenersAndWait() for the destroy-vs-in-flight-handler race#93
Merged
Conversation
…y-vs-in-flight-handler race An owner that removes a listener and immediately frees the state the listener captured (the ListeningRpcCommunicator::destroy() pattern from the phase-C6 crashes) could race a handler invocation still running on another thread — a use-after-free. The phase-C6 attempt to close this made off() itself wait for in-flight invocations and had to be reverted: it hung Layer1ScaleTest.MultipleLayer1Dht and the 64-node ContentDeliveryLayerNode test. Reproducing the hang with thread samples showed two deadlock shapes, so blocking cannot be the default off() semantics: 1. Handler-handler cycle: one thread ran a connection's Data handler (handshake success -> stopHandshaker -> off<Disconnected>) while another ran the same connection's Disconnected handler (close tie-break -> off<Data>); each waited forever for the other. One of the two threads was a Simulator association-executor delivery task, which is why the hang surfaced as Simulator::stop()'s inFlightOperations drain never reaching zero. 2. Caller-held-lock ABBA: ConnectionManager::acceptNewConnection holds the Endpoint state mutex when detachFromPendingConnection removes the Connected listener, while the in-flight Connected handler blocks on that same mutex. The landed design keeps off()/removeAllListeners() non-blocking (their historical semantics) and adds explicit waiting variants: - offAndWait()/removeAllListenersAndWait() remove the listener(s), cancel not-yet-started invocations of an executing emit loop, and wait until no invocation is running on another thread. - The wait is skipped when the caller is itself inside any handler invocation, tracked by a thread-local invocation depth bumped around every handler call: every blocked waiter then runs no handler itself, so no waiter can be part of a cycle of handlers waiting for each other (shape 1). Callers must not hold locks the event's handlers take (shape 2) — documented on the methods. - ListeningRpcCommunicator::destroy() uses the waiting variants. Also hardened the Simulator's delivery lambda: folly::SerialExecutor swallows task exceptions, so an exception thrown by a delivery call-out would silently skip the inFlightOperations decrement and park stop()'s drain forever. The decrement now runs on all paths and the exception is logged. (No such exception was observed at runtime; this closes the hazard found while investigating.) Regression tests: offAndWait/removeAllListenersAndWait complete only after the in-flight handler finishes (fails on the non-waiting semantics), and the shape-1 deadlock replica (two handlers removing each other across threads) completes without deadlock. Verification: the reintroduced blocking-off() build deadlocked 8 seconds into Layer1ScaleTest.MultipleLayer1Dht; with this design the dht scale test and the 64-node test passed 8/8 runs (instrumented and clean) with zero blocked-wait log lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ptesavol
force-pushed
the
claude/distracted-bhaskara-cf2e2a
branch
from
July 13, 2026 22:18
09c3e39 to
ad7ffc4
Compare
Collaborator
Author
CI failure diagnosis: pre-existing hang, not this change — rebased onto mainThe macOS-leg failure (
Rebased onto current main (post-#90) and re-verified locally: emitter suite 22/22, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
An owner that removes a listener and immediately frees the state the listener captured (the
ListeningRpcCommunicator::destroy()pattern behind the phase-C6 SIGSEGVs) can race a handler invocation still running on another thread — a use-after-free. The phase-C6 attempt madeoff()itself wait for in-flight invocations and was reverted before merge: it hungLayer1ScaleTest.MultipleLayer1DhtandContentDeliveryLayerNodeLayer1Test.HappyPathSixtyFourNodes.Root cause of the hang (runtime-proven with thread samples of the hung tests)
Blocking
off()deadlocks in two distinct shapes, so it cannot be the default:stopHandshaker()→off<Disconnected>) while another ran the same connection's Disconnected handler (close tie-break →off<Data>); each waited forever for the other's invocation to end. One of the two workers was a Simulator association-executor delivery task, so itsinFlightOperationsdecrement never ran — that is exactly why the hang surfaced asSimulator::stop()'s drain never completing.ConnectionManager::acceptNewConnectionholds the Endpoint state mutex whiledetachFromPendingConnectionremoves the Connected listener; the in-flight Connected handler blocks on that same mutex.Design
off()/removeAllListeners()keep their historical non-blocking semantics.offAndWait()/removeAllListenersAndWait()remove the listener(s), cancel not-yet-started invocations of an executing emit loop, and wait until no invocation runs on another thread.thread_localinvocation depth bumped around every handler call. A blocked waiter therefore never runs a handler itself, so no cycle of handlers waiting on each other can form (shape 1). Waiting callers must not hold locks the event's handlers take (shape 2) — documented on the methods.ListeningRpcCommunicator::destroy()now uses the waiting variants.folly::SerialExecutorswallows task exceptions, so a throwing delivery call-out would silently skip theinFlightOperationsdecrement and parkstop()'s drain forever. The decrement now runs on all paths (exception logged). No such exception was observed at runtime; this closes the hazard.Tests
OffWaitsForInFlightHandlerBeforeOwnerFreesState/RemoveAllListenersWaitsForInFlightHandler— the destroy-vs-in-flight-handler regression tests; they fail under non-waiting semantics.HandlersRemovingEachOtherAcrossThreadsDoNotDeadlock— replica of deadlock shape 1; completes via the depth guard.off()deadlocked 8 s intoMultipleLayer1Dht(two workers parked forever, drain starved); with this designMultipleLayer1Dhtand the 64-node test passed 8/8 runs (instrumented + clean builds) with zero blocked-wait diagnostics. Full emitter suite 22/22;streamr-eventemitterandstreamr-dhtlint clean.🤖 Generated with Claude Code