Phase A0: connection-layer locking discipline + simultaneous-connect convergence#56
Merged
Merged
Conversation
…convergence Fixes the two diagnosed ABBA lock-order inversions in streamr-dht's connection layer, plus four further concurrency defects the same --gtest_repeat stress loop exposed. All are races TypeScript's single-threaded runtime never hits. ABBA inversions: 1. Endpoint::mutex vs. the per-state mutexes. The endpoint state machine now runs under one recursive mutex owned by Endpoint (reached by the states through EndpointStateInterface::getStateMachineMutex(); the per-state mutexes are gone). Every public Endpoint operation is two-phase: transition under the mutex, then run the call-outs (event emits, connection/pending-connection close, container removal, replay-emitter handler registration) after releasing it — the states return their call-outs as deferred closures. Connection and pending-connection event handlers pin the endpoint through a weak reference and re-validate under the mutex that they still own the current connection before acting. 2. endpointsMutex vs. Endpoint::mutex. ConnectionManager holds endpointsMutex only for the container lookups/inserts; createConnection, onNewConnection, setConnecting and the endpoint queries in send()/getConnections() run outside it, and handleDisconnect -> removeSelfFromContainer no longer runs under the endpoint mutex. Also found and fixed via the same stress loop: - EventEmitter::emit std::forward-ed the arguments into every listener in its dispatch loop, so with more than one listener the second and later handlers received moved-from (empty) values. Each listener now gets its own copy. - EventEmitter's replay on() invoked the new listener while holding the handler mutex — an ABBA deadlock against a thread that holds a user lock and calls off(). The replay now runs after the internal mutexes are released; the replay/live decision stays atomic under the handler mutex. - waitForEvent registered a once-listener capturing a stack waiter and never removed it on the timeout/cancel path, so a later emit invoked the dangling listener and called setValue on a destroyed promise (folly Promise.h abort in a timed-out connection's teardown). The waiter is now heap-owned and co-owned by the listener, which is removed on every exit path. - Simultaneous-connect convergence race (the received2 message loss): the tie-break is now direction-aware (onNewConnection carries an isLocalInitiated flag; the offerer's connection wins regardless of which thread registered it first); a monotonic sequence number makes the endpoint adopt pending connections in decision order even when the setConnecting() calls race; and OutgoingHandshaker::onHandshakeResponse routes an errored response through handleFailure, which for DUPLICATE_CONNECTION marks the pending connection replaceAsDuplicate() so the peer's subsequent connection close cannot tear down the endpoint (with its buffered messages) that is about to receive the winning connection. New unit tests in streamr-eventemitter and streamr-utils lock in the EventEmitter and waitForEvent fixes. Opt-in STREAMR_DHT_TSAN CMake option added for the connection-layer concurrency work (the vcpkg dependencies are not TSAN-instrumented, so a clean CI leg remains a follow-up). Acceptance: streamr-dht-test-integration --gtest_filter='Simultaneous*:ConnectionManagerIntegration*' --gtest_repeat=500 with zero failures; full ./test.sh (348 tests) and ./lint.sh green. Refs trackerless-network-completion-plan.md phase A0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Bugbot is not enabled for this team, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
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.
Summary
Phase A0 of the trackerless-network completion plan: the connection-layer locking-discipline pass promised at the end of phase 0.3. It fixes the two diagnosed ABBA lock-order inversions that deadlock
streamr-dht's connection layer under--gtest_repeatstress, plus four further concurrency defects the same stress loop surfaced. Every one of these is a race TypeScript's single-threaded runtime never hits.Reproduction before this PR (deadlock/timeout within a few rounds):
The two ABBA inversions
1.
Endpoint::mutexvs. the per-state mutexes. Asend()on the main thread lockedEndpoint::mutexthen waited on a state mutex, while the simulator dispatcher thread ran aConnectingEndpointStateconnected-handler that locked the state mutex then drove the endpoint back throughEndpoint::mutex.The endpoint state machine now runs under one recursive mutex owned by
Endpoint, reached by the state classes throughEndpointStateInterface::getStateMachineMutex()— the per-state mutexes are gone. Every publicEndpointoperation is two-phase: transition under the mutex, then run the call-outs (event emits, connection/pending-connection close, container removal, replay-emitter handler registration) after releasing it. The states return their call-outs toEndpointas deferred closures. Connection and pending-connection event handlers pin the endpoint through a weak reference and re-validate under the mutex that they still own the current connection before acting (an in-flight emit can outliveoff(), and aReplayEventEmittercan replay duringon()).2.
ConnectionManager::endpointsMutexvs.Endpoint::mutex.acceptNewConnectionheldendpointsMutexand called into an endpoint (setConnecting), whileEndpoint::handleDisconnectheld the endpoint mutex and calledremoveSelfFromContainer()(which takesendpointsMutex).ConnectionManagernow holdsendpointsMutexonly for the container lookups/inserts;createConnection,onNewConnection,setConnectingand the endpoint queries insend()/getConnections()run outside it, andhandleDisconnect → removeSelfFromContainerno longer runs under the endpoint mutex. The exists-check/insert inacceptNewConnectionis done under a single lock hold so two racing accepts can't both insert.Four more defects found by the same stress loop
EventEmitter::emitmoved args into every listener. The dispatch loopstd::forward-ed the arguments into each handler, so with more than one listener the second and later ones received moved-from (empty) values — silent data loss for any multi-listener event carryingstd::vector<std::byte>. Each listener now gets its own copy.EventEmitterreplayon()invoked the listener under the handler mutex — an ABBA deadlock against a thread holding a user lock and callingoff(). The replay now runs after the internal mutexes are released; the replay/live-dispatch decision stays atomic under the handler mutex, so a late listener still sees the stored event exactly once.waitForEventleaked aonce-listener capturing a stack waiter on the timeout/cancel path, so a later emit invoked the dangling listener and calledsetValueon a destroyed promise (a follyPromise.h"Check failed: state_" abort, seen in the teardown of a timed-out connection). The waiter is now heap-owned and co-owned by the listener, which is removed on every exit path.received2message loss, initially ~1 in 30 stress iterations). TheacceptNewConnectiontie-break replaced ongetOfferer == remotealone — correct only under TS's single-threaded ordering. The connector's dispatcher thread can deliver the incoming connection before the main-threadsend()registers the outgoing one, and the two nodes then settle on different physical connections. Fixed with three order-independent changes:onNewConnectioncarries anisLocalInitiatedflag; the winning connection is the offerer's regardless of which thread registered it first;ConnectionManagerassigns a monotonic sequence underendpointsMutexand the endpoint adopts only strictly-newer sequences, so a race between the initial and the replacingsetConnectingcan't re-adopt the loser;OutgoingHandshaker::onHandshakeResponserouted any error response straight topendingConnection->close(), tearing the endpoint (and its buffered messages) down on aDUPLICATE_CONNECTIONrejection. It now routes throughhandleFailure, which marks the pending connectionreplaceAsDuplicate()— silencing it so the peer's subsequent connection close cannot drive or tear down the endpoint that is about to receive the winning connection. Per-association FIFO guarantees the error precedes that close, so the silencing is always in place in time.Tests
streamr-eventemitter(per-listener copy; replay exactly-once; replay holds no handler mutex — a two-thread test) andstreamr-utils(waitForEventremoves its listener after both timeout and delivery).STREAMR_DHT_TSANCMake option for the connection-layer concurrency work. The vcpkg dependencies are not TSAN-instrumented, so a clean CI leg remains a follow-up (noted in the plan).Test plan
--gtest_filter='Simultaneous*:ConnectionManagerIntegration*' --gtest_repeat=500— zero failures (was deadlocking/timing out within a few rounds)./test.sh— 348/348 pass./lint.sh— clean (clangd 22)Stacked on the merged #55 (phase 0.3); branched from updated
main. Refstrackerless-network-completion-plan.mdphase A0.🤖 Generated with Claude Code