fix(sync): stop stranding a thread on a timed-out detach, and unblock Drop - #232
Merged
Conversation
5 tasks
lxsaah
force-pushed
the
fix/sync-detach-no-stranded-thread
branch
3 times, most recently
from
August 26, 2026 17:01
e5dc056 to
2fba34d
Compare
… Drop detach_timeout spawned a helper thread whose only job was to block in join(), and a join() cannot be cancelled. When the wait expired the caller returned but that helper stayed parked for the life of the process — one stranded thread per timed-out detach. The helper is not needed. The runtime thread is ours, so it can hold a liveness channel open for exactly as long as it runs: the sender is moved into the thread and never used, so it drops on the way out by every path — normal return, early return, panic. Disconnection is therefore the completion event, and the caller gets a timed join with no second thread to reclaim. It also makes Disconnected mean something real, where before it stood for an unreachable "failed to join helper thread". Drop attempted a 5-second emergency shutdown. A destructor cannot report a failure — it can only log — so blocking there bought nothing a caller could act on, while costing a five-second stall in a place that must not stall: during unwinding, or inside a C++ destructor when the handle is owned across an FFI boundary. Joining also would not have guaranteed delivery, since nothing between the buffer and the socket reports what was written. It now signals and releases; the signal is what causes cleanup either way. The liveness receiver sits behind a Mutex purely to keep AimDbHandle: Sync, which consumer() relies on — a bare Receiver is Send but not Sync. It is never locked; every access goes through &mut self and takes it by value. Also writes down what a timeout leaves behind, which was the half of this finding that needed deciding rather than fixing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `detach` and `Drop` fork guards dropped `shutdown_tx` and `thread_handle` but left `thread_alive` — the liveness channel this PR adds — in place. Harmless today, because dropping a `Receiver<()>` cannot block or panic and the field was dropped a moment later anyway by the struct's own field drops. It is a correctness trap rather than a bug: the guards read as "release what the missing thread owned", and one of the three was not being released. Both guards now call `release_inherited`, so the set lives in one place. A field added to `AimDbHandle` later is released by both guards or by neither, rather than by whichever one its author happened to read. No behavioural change: the same three values are dropped in the child either way, only earlier and in one place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DKAH7JNjPLPmG4mPTvWfth
lxsaah
force-pushed
the
fix/sync-detach-no-stranded-thread
branch
from
August 26, 2026 17:49
2fba34d to
c59e70b
Compare
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.
Description
The remaining half of CR-3, which #226 explicitly left open. Two problems and one decision.
1. A timed-out detach stranded a thread
detach_timeoutspawned a helper thread whose only job was to block injoin():You cannot cancel a
join(). When the wait expired the caller returned, but that helper stayed parked for the life of the process — one stranded thread per timed-out detach.The helper isn't needed. The runtime thread is ours, so it can hold a liveness channel open for exactly as long as it runs. The sender is moved into the thread and never used: it drops on the way out by every path — normal return, early return, panic. So disconnection is the completion event, and the caller gets a timed join with no second thread to reclaim:
recv_timeoutDisconnectedjoin()— now promptTimeoutJoinHandle, reportOk(())It also makes
Disconnectedmean something real. Before it stood for an unreachable "Failed to join helper thread".2.
Dropcould stall a destructor for five secondsIt attempted an emergency
detach_internal(Some(5s)), logging two warnings and possibly an error.A destructor cannot report a failure — it can only log — so blocking bought nothing a caller could act on, while costing a five-second stall in a place that must not stall: during unwinding, or inside a C++ destructor when the handle is owned across an FFI boundary.
Joining also would not have guaranteed delivery. Nothing between the buffer and the socket reports what was written, so a successful join never meant the data got out — which removes the main argument for blocking at all.
Dropnow signals shutdown and releases the thread. The signal is what causes cleanup either way; the thread stops on its own and drops the database with it. The "you forgotdetach()" warning stays.3. The decision: what a timeout leaves behind
This was the part that needed deciding rather than fixing, and it's now written on
detach_timeout:DetachFailedhere means "not finished in time", not "shutdown failed". The thread stops on its own.RuntimeShutdown.detach()when you need to know.One thing worth flagging for review
Adding the liveness receiver broke
AimDbHandle: Sync, becausempsc::ReceiverisSendbut notSync. The crate's ownassert_sync::<AimDbHandle>()caught it immediately — that matters, sinceconsumer()takes&selfand several threads creating consumers concurrently depends on it.It sits behind a
Mutexpurely to restoreSync. It is never locked: every access goes through&mut selfand takes it by value, so the poisoned arm is unreachable and handled without anunwrap.Tests
a_timed_out_detach_strands_nothing— eight timed-out detaches in a row, then a fresh attach that must still publish. Asserts the property rather than counting threads, which is unreliable.dropping_without_detach_does_not_block— assertsdropreturns well inside a second, where the old path could take five.Related Issue
Checklist
make check) — relying on CI for the full matrix;cargo test -p aimdb-sync, both clippy legs and the no_std build are green locally.