-
Notifications
You must be signed in to change notification settings - Fork 21
fix(shared-runtime): make ForkSafeRuntime teardown structurally TLS/poison-safe #2220
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e5b9a90
ecd1886
1742952
e85cc86
302c31d
0ab4490
0af3e0b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ use libdd_common::MutexExt; | |
| use std::io; | ||
| use std::sync::atomic::{AtomicU64, Ordering}; | ||
| use std::sync::{Arc, Mutex}; | ||
| use std::time::Duration; | ||
| use tokio::runtime::{Builder, Runtime}; | ||
| use tracing::{debug, error}; | ||
|
|
||
|
|
@@ -129,27 +130,92 @@ impl ForkSafeRuntime { | |
|
|
||
| /// Shuts down all workers synchronously. Returns `ShutdownTimedOut` if `timeout` is | ||
| /// exceeded. | ||
| pub fn shutdown(&self, timeout: Option<std::time::Duration>) -> Result<(), SharedRuntimeError> { | ||
| /// | ||
| /// This always drives each worker's shutdown to completion so in-flight work (e.g. a | ||
| /// final trace flush) is not lost. Normally that happens by calling `block_on` on the | ||
| /// current thread. But `block_on` needs a live Tokio context, which requires the | ||
| /// calling thread's CONTEXT thread-local — and during (embedded) interpreter | ||
| /// finalization that thread-local may already be destroyed (see | ||
| /// [`Self::can_block_on`]). The runtime's own worker threads are unaffected by that; | ||
| /// only the *calling* thread's TLS is gone. So in that case we hand the same graceful | ||
| /// shutdown off to a freshly spawned thread, whose TLS was never touched, and just wait | ||
| /// for it to finish (bounded by `timeout`, if given) using plain OS-level | ||
| /// synchronization that doesn't touch Tokio's TLS. | ||
| pub fn shutdown(&self, timeout: Option<Duration>) -> Result<(), SharedRuntimeError> { | ||
| debug!(?timeout, "Shutting down ForkSafeRuntime"); | ||
| match self.runtime.lock_or_panic().take() { | ||
| Some(runtime) => { | ||
| if let Some(timeout) = timeout { | ||
| match runtime.block_on(async { | ||
| tokio::time::timeout(timeout, <Self as SharedRuntime>::shutdown_async(self)) | ||
| .await | ||
| }) { | ||
| Ok(()) => Ok(()), | ||
| Err(_) => Err(SharedRuntimeError::ShutdownTimedOut(timeout)), | ||
| } | ||
| } else { | ||
| runtime.block_on(<Self as SharedRuntime>::shutdown_async(self)); | ||
| Ok(()) | ||
| let Some(runtime) = self.runtime.lock_or_recover().take() else { | ||
| return Ok(()); | ||
| }; | ||
|
|
||
| if Self::can_block_on() { | ||
| return Self::block_on_shutdown(&runtime, &self.workers, timeout); | ||
| } | ||
|
|
||
| debug!("No live Tokio context (finalization); flushing from a helper thread"); | ||
| Self::shutdown_from_helper_thread(runtime, self.workers.clone(), timeout) | ||
| } | ||
|
|
||
| /// Drives `shutdown_workers` to completion via `runtime.block_on` on the calling | ||
| /// thread. Only safe to call when [`Self::can_block_on`] is true. | ||
| fn block_on_shutdown( | ||
| runtime: &Runtime, | ||
| workers: &Arc<Mutex<Vec<WorkerEntry>>>, | ||
| timeout: Option<Duration>, | ||
| ) -> Result<(), SharedRuntimeError> { | ||
| match timeout { | ||
| Some(timeout) => { | ||
| match runtime.block_on(async { | ||
| tokio::time::timeout(timeout, shutdown_workers(workers)).await | ||
| }) { | ||
| Ok(()) => Ok(()), | ||
| Err(_) => Err(SharedRuntimeError::ShutdownTimedOut(timeout)), | ||
| } | ||
| } | ||
| None => Ok(()), | ||
| None => { | ||
| runtime.block_on(shutdown_workers(workers)); | ||
| Ok(()) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Runs [`Self::block_on_shutdown`] on a fresh, detached thread (whose Tokio TLS was | ||
| /// never touched) and blocks the calling thread on its completion via a channel — | ||
| /// plain OS-level synchronization, safe even with the calling thread's Tokio TLS gone. | ||
| /// | ||
| /// A missing `timeout` waits indefinitely for completion, matching the behavior of | ||
| /// [`Self::block_on_shutdown`] itself when called with `None`. | ||
| fn shutdown_from_helper_thread( | ||
| runtime: Arc<Runtime>, | ||
| workers: Arc<Mutex<Vec<WorkerEntry>>>, | ||
| timeout: Option<Duration>, | ||
| ) -> Result<(), SharedRuntimeError> { | ||
| let (done_tx, done_rx) = std::sync::mpsc::channel(); | ||
| std::thread::spawn(move || { | ||
| let result = Self::block_on_shutdown(&runtime, &workers, timeout); | ||
| let _ = done_tx.send(result); | ||
| }); | ||
|
|
||
| match timeout { | ||
| Some(timeout) => done_rx | ||
| .recv_timeout(timeout) | ||
| .unwrap_or(Err(SharedRuntimeError::ShutdownTimedOut(timeout))), | ||
| None => done_rx.recv().unwrap_or(Ok(())), | ||
| } | ||
| } | ||
|
|
||
| /// Whether the current thread can safely enter a Tokio context (i.e. call `block_on`). | ||
| /// | ||
| /// Returns `false` only when the Tokio CONTEXT thread-local has been *destroyed*, | ||
| /// which happens during interpreter/thread finalization. A merely-missing context | ||
| /// (the normal case for a thread that never entered a runtime) still returns `true`, | ||
| /// because `block_on` establishes its own context in that case. | ||
| fn can_block_on() -> bool { | ||
| !matches!( | ||
| tokio::runtime::Handle::try_current(), | ||
| Err(ref e) if e.is_thread_local_destroyed() | ||
| ) | ||
| } | ||
|
|
||
| fn push_worker( | ||
| &self, | ||
| workers_guard: &mut std::sync::MutexGuard<Vec<WorkerEntry>>, | ||
|
|
@@ -169,6 +235,39 @@ impl ForkSafeRuntime { | |
| } | ||
| } | ||
|
|
||
| impl Drop for ForkSafeRuntime { | ||
| /// Terminal teardown for the owned Tokio runtime. | ||
| /// | ||
| /// A normal `Runtime` drop blocks the current thread to join its worker threads, | ||
| /// and that join path touches the Tokio CONTEXT thread-local. During (embedded) | ||
| /// interpreter finalization — e.g. a uWSGI worker exiting — that thread-local may | ||
| /// already be destroyed, so a normal drop panics with "The Tokio context | ||
| /// thread-local variable has been destroyed" (and, via a poisoned mutex, cascades). | ||
| /// | ||
| /// `shutdown_background` instead signals shutdown and returns immediately, detaching | ||
| /// the worker threads; the OS reclaims them on process exit. This is done | ||
| /// *unconditionally* — we do not try to detect whether we are in finalization — | ||
| /// so there is no ordering-dependent branch that a future teardown scenario can | ||
| /// slip past. | ||
| /// | ||
| /// [`Self::shutdown`] always takes the runtime out (even during finalization, via a | ||
| /// helper thread — see its docs), so this only fires when `shutdown` was never called | ||
| /// at all before drop; that caller-error case is the one scenario this crate can't | ||
| /// flush on behalf of the caller, so it just detaches cleanly instead of risking a | ||
| /// panic. | ||
| fn drop(&mut self) { | ||
| if let Some(runtime) = self.runtime.lock_or_recover().take() { | ||
| match Arc::try_unwrap(runtime) { | ||
| Ok(runtime) => runtime.shutdown_background(), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This won't trigger the shutdown logic of the workers and therefore will not flush data before the fork this will cause the same issue as
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drop::shutdown_background() now only fires if shutdown() was never called at all before the runtime is dropped |
||
| // Another Arc clone is still alive (e.g. an in-flight block_on holds | ||
| // one). We cannot take ownership to detach; drop our reference and let | ||
| // the last owner's drop run. This is not the finalization path. | ||
| Err(_runtime) => {} | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl SharedRuntime for ForkSafeRuntime { | ||
| fn new() -> Result<Self, SharedRuntimeError> { | ||
| Self::with_worker_threads(1) | ||
|
|
@@ -197,25 +296,33 @@ impl SharedRuntime for ForkSafeRuntime { | |
| } | ||
|
|
||
| async fn shutdown_async(&self) { | ||
| debug!("Shutting down all workers asynchronously"); | ||
| let workers = { | ||
| let mut workers_lock = self.workers.lock_or_panic(); | ||
| std::mem::take(&mut *workers_lock) | ||
| }; | ||
| shutdown_workers(&self.workers).await; | ||
| } | ||
| } | ||
|
|
||
| let futures: FuturesUnordered<_> = workers | ||
| .into_iter() | ||
| .map(|mut worker_entry| async move { | ||
| if let Err(e) = worker_entry.worker.pause().await { | ||
| error!("Worker failed to shutdown: {:?}", e); | ||
| return; | ||
| } | ||
| worker_entry.worker.shutdown().await; | ||
| }) | ||
| .collect(); | ||
| /// Drains and gracefully shuts down every registered worker. Standalone (rather than a | ||
| /// `&self` method) so it can run identically whether driven from the calling thread | ||
| /// ([`ForkSafeRuntime::block_on_shutdown`]) or from the helper thread spawned by | ||
| /// [`ForkSafeRuntime::shutdown_from_helper_thread`]. | ||
| async fn shutdown_workers(workers: &Mutex<Vec<WorkerEntry>>) { | ||
| debug!("Shutting down all workers asynchronously"); | ||
| let workers = { | ||
| let mut workers_lock = workers.lock_or_recover(); | ||
| std::mem::take(&mut *workers_lock) | ||
| }; | ||
|
|
||
| let futures: FuturesUnordered<_> = workers | ||
| .into_iter() | ||
| .map(|mut worker_entry| async move { | ||
| if let Err(e) = worker_entry.worker.pause().await { | ||
| error!("Worker failed to shutdown: {:?}", e); | ||
| return; | ||
| } | ||
| worker_entry.worker.shutdown().await; | ||
| }) | ||
| .collect(); | ||
|
|
||
| futures.collect::<()>().await; | ||
| } | ||
| futures.collect::<()>().await; | ||
| } | ||
|
|
||
| impl BlockingRuntime for ForkSafeRuntime { | ||
|
|
@@ -421,4 +528,101 @@ 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" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_shutdown_from_helper_thread_flushes_workers() { | ||
| // Exercises the destroyed-TLS fallback mechanism directly (this test's thread has | ||
| // a perfectly fine Tokio context, but shutdown_from_helper_thread is the exact | ||
| // code path shutdown() delegates to when the calling thread's context is gone). | ||
| // Confirms the worker's shutdown() -- the "final flush" -- actually runs to | ||
| // completion on the helper thread, not just that teardown avoids a panic. | ||
| 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"); | ||
|
|
||
| let runtime = shared_runtime.runtime.lock_or_recover().take().unwrap(); | ||
| let result = ForkSafeRuntime::shutdown_from_helper_thread( | ||
| runtime, | ||
| shared_runtime.workers.clone(), | ||
| Some(Duration::from_secs(1)), | ||
| ); | ||
| assert!(result.is_ok()); | ||
|
|
||
| let mut last = receiver | ||
| .recv_timeout(Duration::from_secs(1)) | ||
| .expect("helper thread did not flush worker shutdown"); | ||
| while let Ok(v) = receiver.try_recv() { | ||
| last = v; | ||
| } | ||
| assert_eq!( | ||
| last, -1, | ||
| "worker shutdown (the flush) must actually run on the helper thread" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_drop_without_shutdown_detaches_cleanly() { | ||
| // Dropping a ForkSafeRuntime that still owns a live runtime + worker (i.e. | ||
| // shutdown() was never called) must not panic or hang. Drop routes through | ||
| // shutdown_background(), which detaches worker threads without blocking or | ||
| // entering the Tokio context — the structural terminal-teardown path that | ||
| // makes finalization (destroyed-TLS) drops safe. | ||
| 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"); | ||
|
|
||
| // No explicit shutdown() — just drop. Must return promptly without panicking. | ||
| drop(shared_runtime); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_lock_recovers_from_poison() { | ||
| // A panic while holding one of the runtime's locks must not cascade into | ||
| // subsequent lifecycle calls (the original PoisonError second-panic). After a | ||
| // poisoning panic, shutdown()/Drop must still succeed. | ||
| use std::panic::{catch_unwind, AssertUnwindSafe}; | ||
|
|
||
| let shared_runtime = ForkSafeRuntime::new().unwrap(); | ||
| let (worker, _receiver) = make_test_worker(); | ||
| let _ = shared_runtime.spawn_worker(worker, true).unwrap(); | ||
|
|
||
| let _ = catch_unwind(AssertUnwindSafe(|| { | ||
| let _guard = shared_runtime.workers.lock_or_recover(); | ||
| panic!("poison the workers mutex while holding it"); | ||
| })); | ||
|
|
||
| // The mutex is now poisoned; lock_or_recover must still work rather than panic. | ||
| assert!( | ||
| shared_runtime.shutdown(None).is_ok(), | ||
| "shutdown must recover from a poisoned lock" | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PoisonError should not be used, we have no guarantee over the state in which the value was left.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I narrowed lock_or_recover to only the terminal teardown paths. Let me know if we should remove it all together (I can use parking_lot::Mutex instead)