diff --git a/libdd-shared-runtime/src/shared_runtime/fork_safe.rs b/libdd-shared-runtime/src/shared_runtime/fork_safe.rs index 30d5124e8d..1d7f41dbab 100644 --- a/libdd-shared-runtime/src/shared_runtime/fork_safe.rs +++ b/libdd-shared-runtime/src/shared_runtime/fork_safe.rs @@ -131,6 +131,17 @@ impl ForkSafeRuntime { /// exceeded. pub fn shutdown(&self, timeout: Option) -> Result<(), SharedRuntimeError> { debug!(?timeout, "Shutting down ForkSafeRuntime"); + // block_on calls context::enter() which accesses Tokio's CONTEXT thread-local. + // During CPython interpreter finalization, TLS is destroyed before atexit handlers + // fire, causing a panic. Detect this via try_current() and bail out early — + // the OS will clean up remaining threads on process exit. + if matches!( + tokio::runtime::Handle::try_current(), + Err(ref e) if e.is_thread_local_destroyed() + ) { + debug!("Tokio TLS destroyed during interpreter finalization, skipping shutdown"); + return Ok(()); + } match self.runtime.lock_or_panic().take() { Some(runtime) => { if let Some(timeout) = timeout { @@ -421,4 +432,24 @@ mod tests { "worker should not run or shut down after fork in child when restart_on_fork is false" ); } + + #[test] + fn test_shutdown_is_idempotent() { + // Calling shutdown() twice must not panic or error. The second call hits the + // None-guard (runtime already taken). This covers the same early-return path as + // the TLS-destroyed guard added for CPython atexit finalization ordering. + let shared_runtime = ForkSafeRuntime::new().unwrap(); + let (worker, receiver) = make_test_worker(); + + let _ = shared_runtime.spawn_worker(worker, true).unwrap(); + receiver + .recv_timeout(Duration::from_secs(1)) + .expect("worker did not run"); + + assert!(shared_runtime.shutdown(None).is_ok()); + assert!( + shared_runtime.shutdown(None).is_ok(), + "second shutdown must not panic" + ); + } }