Conversation
SHUTTLE_CAPTURE_BACKTRACE captured a full backtrace on every call to Task::block, but the field is overwritten on each block and only read for unfinished tasks when the execution deadlocks. On a 4-thread mutex workload that is ~70k stack walks to print at most a handful, and it made the flag cost 79x (27ms -> 2.12s). A task blocked in a synchronous primitive does not need eager capture: it stays suspended inside continuation::switch with its whole blocking call chain still live on its coroutine stack. So defer the walk until we know it matters. Add ContinuationInput::CaptureBacktrace, which resumes a suspended task solely so it can walk its own stack and immediately re-suspend, and drive it from the StepError::Deadlock arm. This is safe to do there: every unfinished task's continuation is still ContinuationState::Ready, the pool is still in scope, panicking() is false, and cleanup() is never reached on that path. The resume is deliberately schedule-neutral. It re-enters the task at its yielder.suspend call, which is after record_tick and maybe_yield, so it makes no scheduling decision and does not extend CurrentSchedule. That last part matters because persist_failure dedups on CurrentSchedule::len, so growing the schedule here would make the panic hook persist a second, mutated schedule that no longer replays the deadlock. Tasks parked on a pending future are unaffected: their poll stack is already unwound by the time they suspend, so those sites still capture eagerly and the deadlock handler leaves their backtrace alone. Measured on the 4-thread mutex workload with the flag set: 2.12s -> 975ms. The remainder is the eager capture on the async path, addressed separately.
This was referenced Sep 18, 2026
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.
Addresses the cost complaint behind #263 (see my comment there for why the fix proposed in that issue — swapping to
Backtrace::new_unresolved— turned out to be a no-op).SHUTTLE_CAPTURE_BACKTRACEcaptured a full backtrace on every call toTask::block. That field is overwritten on each block and only read for unfinished tasks when the execution deadlocks, so on a 4-thread mutex workload it is ~70,000 stack walks to print at most a handful. Measured, the flag cost 87x (29.6ms → 2.57s).A task blocked in a synchronous primitive does not need eager capture: it stays suspended inside
continuation::switchwith its whole blocking call chain still live on its own coroutine stack. So defer the walk until we know it matters.How
A new
ContinuationInput::CaptureBacktraceresumes a suspended task solely so it can walk its own stack and immediately re-suspend, without running any user code. TheStepError::Deadlockarm drives one capture per still-blocked task.The suspend point in
switchbecomes a loop, which is the whole mechanism:Re-suspending with
Yieldedreturns the continuation toContinuationState::Ready— exactly the state it was in — so no newContinuationOutputvariant is needed.Why it's safe at that point
At the deadlock arm, every unfinished task's continuation is still
ContinuationState::Ready, theContinuationPoolis still in scope,std::thread::panicking()is false, andcleanup()is never reached on that path (the arm panics first). Nothing has been dropped, reset, or returned to the pool.The resume is deliberately schedule-neutral: it re-enters the task at its
yielder.suspendcall, which is afterrecord_tickandmaybe_yield, so it makes no scheduling decision, records no annotation tick, and does not extendCurrentSchedule. That last part matters —persist_failurededups onCurrentSchedule::len(), so growing the schedule here would make the panic hook persist a second, mutated schedule that no longer replays the deadlock.A captured backtrace is never clobbered: the handler skips tasks that already have one, and skips tasks whose continuation has not started (resuming those would run the function rather than capture anything).
Scope
Tasks parked on a pending future are unaffected here. Their
pollstack is already unwound by the time they suspend, so those sites still capture eagerly. Making those cheap and accurate is the follow-up PR.Results
4-thread mutex workload, 500 increments each, 20 iterations; 5 samples, same machine and run:
SHUTTLE_CAPTURE_BACKTRACE=1Deadlock reports for synchronous blocking are unchanged in substance. Frame-by-frame diff over both blocked tasks: 78/79 frames before vs 76/77 after, differing only in three hunks of Shuttle-internal plumbing (the capture site at the top; the resume path at the bottom naming the deadlock handler instead of the scheduler loop). Every user frame and the entire blocking call chain are identical, and the user's own frame moves from position #11 to #9.
Testing
cargo fmt --check,cargo clippy --workspace --all-targets,cargo docall cleancargo test --release -p shuttle: 426 passed, 0 faileduser_fn_locks) and the joining sideNote on flaky tests
Before rebasing onto current
mainI saw intermittent full-suite failures on both this branch and unmodifiedmain— different tests each run, withExecutionState: AlreadyBorrowedand tracing-subscriber "cloned a span that already closed" signatures, all passing reliably in isolation. They have not reproduced since rebasing ontomain(which now includes #320 and #346). Mentioning it in case it resurfaces in CI.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.