Skip to content

fix(worker): stop nested Tokio runtime crash on Cloudflare worker - #333

Merged
haasonsaas merged 1 commit into
mainfrom
fix/cloudflare-nested-runtime-drop
Aug 6, 2026
Merged

fix(worker): stop nested Tokio runtime crash on Cloudflare worker#333
haasonsaas merged 1 commit into
mainfrom
fix/cloudflare-nested-runtime-drop

Conversation

@haasonsaas

@haasonsaas haasonsaas commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Production sandboxwich-cloudflare-worker crash-loops immediately on start with:

Cannot drop a runtime in a context where blocking is not allowed

Root cause

HttpCloudflareBridge always created a private tokio::runtime::Runtime. During async run, registration builds a temporary Cloudflare provider for the capability report (registration_provider_capability_report). That nested Runtime is dropped while still on the outer #[tokio::main] stack → panic (exit 101).

Introduced when capability registration started constructing the provider at startup (command ledger path).

Fix

  • If already inside a Tokio runtime: do not create a nested Runtime; drive bridge I/O with block_in_place + current Handle.
  • If outside async (sync tests): keep an owned Runtime as before.
  • Regression test: construct + drop provider inside multi-thread runtime.

Test

  • cargo test -p sandboxwich-worker cloudflare_

Deploy follow-up

After merge + containers publish, promote the new sandboxwich-worker digest into deploy for sandboxwich-cloudflare-worker (and cohort) so production recovers.


Open in Devin Review

HttpCloudflareBridge always built a private Runtime. When the worker
constructed a temporary Cloudflare provider during async `run`
registration (capability report), that nested Runtime was dropped while
still on the outer #[tokio::main] stack and panicked:

  Cannot drop a runtime in a context where blocking is not allowed

That crash-looped sandboxwich-cloudflare-worker in production after the
command-capability registration path landed.

When already inside a Tokio runtime, reuse the current handle via
block_in_place instead of owning a nested Runtime. Keep an owned Runtime
only for sync/test callers outside async.

Regression test: construct+drop provider inside multi-thread runtime.
@haasonsaas
haasonsaas merged commit 8bcfb69 into main Aug 6, 2026
3 of 7 checks passed
@haasonsaas
haasonsaas deleted the fix/cloudflare-nested-runtime-drop branch August 6, 2026 16:21

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Open in Devin Review

Comment on lines 163 to +172
&self,
future: impl std::future::Future<Output = anyhow::Result<T>>,
) -> anyhow::Result<T> {
self.runtime.block_on(future)
if let Some(runtime) = &self.owned_runtime {
return runtime.block_on(future);
}
// Already inside the worker's multi-thread runtime: drive the future on
// the current handle. `block_in_place` keeps the outer runtime responsive
// while the sync SandboxProvider trait blocks on bridge I/O.
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Runtime strategy is decided at construction time, not at call time

owned_runtime is decided once in HttpCloudflareBridge::new from Handle::try_current(), but the decision is consumed later in block_on on possibly different threads. Two consequences worth noting:

  1. If a bridge is built inside a Tokio context and any later bridge call happens on a thread with no Tokio context (plain std::thread, a rayon pool, a Drop running after runtime shutdown), tokio::runtime::Handle::current() inside the closure panics, whereas the old owned-runtime design worked from any thread. I audited the worker and found no such path today (crates/sandboxwich-worker/src/main.rs uses only tokio::task::spawn_blocking, which keeps a handle set, and no std::thread::spawn around provider calls), so I did not flag it as a bug — but the contract of this shared provider type has changed for future callers.
  2. Deciding at call time (Handle::try_current() inside block_on, falling back to a lazily-created owned runtime) would be robust against both directions and would remove the construction-context coupling.

I also verified the production hot path: provider methods run under tokio::task::spawn_blocking (crates/sandboxwich-worker/src/main.rs:3189, :2831), where block_in_place sees NotEntered/no scheduler context and simply runs the closure, and Handle::block_on from a blocking-pool thread is legal — so no panic there.

(Refers to lines 144-172)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +169 to +172
// Already inside the worker's multi-thread runtime: drive the future on
// the current handle. `block_in_place` keeps the outer runtime responsive
// while the sync SandboxProvider trait blocks on bridge I/O.
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 block_in_place path is fatal under a current-thread runtime

tokio::task::block_in_place panics with "can call blocking only when running on the multi-threaded runtime" when invoked from inside a current-thread runtime's block_on. The worker binary uses default #[tokio::main] (multi-thread, crates/sandboxwich-worker/src/main.rs:997), so production is safe today, but the failure mode is silent for future callers: a #[tokio::test] (current-thread by default, per the AGENTS.md preference for #[tokio::test] on async-adjacent tests) or any current-thread-flavored entrypoint that constructs CloudflareSandboxProvider and then performs a bridge call would panic instead of doing I/O. Existing tests avoid this only because they use FakeBridge (for_test/for_test_with_replay_ledger) or plain #[test]. Consider falling back to an owned runtime when RuntimeFlavor::CurrentThread is detected, or documenting the multi-thread requirement on CloudflareSandboxProvider::new.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1024 to +1031
#[test]
fn cloudflare_provider_construct_and_drop_inside_async_runtime_does_not_panic() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(2)
.build()
.expect("test runtime");
runtime.block_on(async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Regression test uses a hand-built runtime rather than #[tokio::test]

AGENTS.md asks to prefer #[tokio::test] for new async-adjacent tests. This one uses #[test] plus a manually built multi-thread runtime, which is defensible because the test needs to own and explicitly drop the runtime after the nested work to assert the no-nested-runtime property. #[tokio::test(flavor = "multi_thread")] would cover the provider construct/drop-inside-async part but not the explicit outer-runtime drop assertion, so the deviation looks intentional; noting it so the reviewer can confirm.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

1 participant