Skip to content

fix(sync): stop stranding a thread on a timed-out detach, and unblock Drop - #232

Merged
lxsaah merged 2 commits into
mainfrom
fix/sync-detach-no-stranded-thread
Aug 26, 2026
Merged

fix(sync): stop stranding a thread on a timed-out detach, and unblock Drop#232
lxsaah merged 2 commits into
mainfrom
fix/sync-detach-no-stranded-thread

Conversation

@lxsaah

@lxsaah lxsaah commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Stacked on #230. Base is feat/sync-fork-safety, so the diff here is just this change. Both rewrite detach_internal and Drop.

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_timeout spawned a helper thread whose only job was to block in join():

let (done_tx, done_rx) = std::sync::mpsc::channel::<bool>();
thread::spawn(move || { let _ = done_tx.send(thread_handle.join().is_ok()); });
match done_rx.recv_timeout(duration) { ... }

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_timeout means what happens
Disconnected the thread is on its way out join() — now prompt
Timeout still running release the JoinHandle, report
Ok(()) nothing is ever sent

It also makes Disconnected mean something real. Before it stood for an unreachable "Failed to join helper thread".

2. Drop could stall a destructor for five seconds

It 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.

Drop now 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 forgot detach()" 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:

  • The shutdown signal was delivered — DetachFailed here means "not finished in time", not "shutdown failed". The thread stops on its own.
  • Nothing is stranded.
  • The handle is consumed either way, so there is nothing to retry with.
  • Surviving producers and consumers keep working until the thread stops, then fail with RuntimeShutdown.
  • Resources are released eventually, not by the time this returns. Use detach() when you need to know.

One thing worth flagging for review

Adding the liveness receiver broke AimDbHandle: Sync, because mpsc::Receiver is Send but not Sync. The crate's own assert_sync::<AimDbHandle>() caught it immediately — that matters, since consumer() takes &self and several threads creating consumers concurrently depends on it.

It sits behind a Mutex purely to restore Sync. It is never locked: every access goes through &mut self and takes it by value, so the poisoned arm is unreachable and handled without an unwrap.

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 — asserts drop returns well inside a second, where the old path could take five.

Related Issue

Checklist

  • I have read the CONTRIBUTING.md document.
  • My code follows the project's coding standards.
  • I have added tests to cover my changes.
  • All new and existing tests passed (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.
  • I have updated the documentation accordingly.

@lxsaah
lxsaah force-pushed the fix/sync-detach-no-stranded-thread branch 3 times, most recently from e5dc056 to 2fba34d Compare August 26, 2026 17:01
Base automatically changed from feat/sync-fork-safety to main August 26, 2026 17:49
lxsaah and others added 2 commits August 26, 2026 19:49
… 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
lxsaah force-pushed the fix/sync-detach-no-stranded-thread branch from 2fba34d to c59e70b Compare August 26, 2026 17:49
@lxsaah
lxsaah merged commit a739f3e into main Aug 26, 2026
14 checks passed
@lxsaah
lxsaah deleted the fix/sync-detach-no-stranded-thread branch August 26, 2026 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants