Skip to main content

query_runtime/runtime/
backend_close.rs

1//! GPU-backend close and drain coordination.
2//!
3//! The coordinator owns the asynchronous close worker and the two-scope
4//! quiescence state. Backend construction and admission stay in
5//! [`super::backend_core`]; this module only consumes the resource generation
6//! once that core has closed its linearization gate.
7//!
8//! Cleanup runs on a dedicated thread, `algeon-gpu-close`, in bounded slices
9//! of `CLEANUP_SLICE`. Each slice re-enters the idempotent drain of whichever
10//! phase is still pending, which gives three properties at once: a caller's
11//! `close_and_wait_until` deadline never has to be known by the worker, a
12//! phase that finishes after some caller timed out is still recorded (as a
13//! late success), and process exit can stop the worker at a slice boundary.
14//! The phase order is fixed by dependency. The service closes admission and
15//! drains its pools while the construction journal still holds every GPU
16//! resource. The journal drains next, and the GPU permit count must reach zero
17//! before the generation, and with it the owner lease, is released. Only then
18//! does the worker wait for observation permits, which may outlive the GPU
19//! resources by design.
20//!
21//! A cleanup that cannot prove completion fails closed. The generation is
22//! forgotten, so its destructors never run and the live-backend lease stays
23//! claimed. No replacement backend can take the devices while GPU work of
24//! unknown state may still exist.
25
26use std::{
27    any::Any,
28    collections::BTreeMap,
29    fmt,
30    panic::{AssertUnwindSafe, catch_unwind},
31    sync::{
32        Arc, Condvar, Mutex, PoisonError,
33        atomic::{AtomicBool, AtomicU64, Ordering},
34        mpsc,
35    },
36    thread,
37    time::{Duration, Instant},
38};
39
40use query_planning::error::{QueryError, QueryErrorCode, Result};
41use serde::Serialize;
42
43use super::{
44    backend_core::{ConstructionResourceJournal, ObservationCore, ResourceCore, ResourceGeneration},
45    backend_lifecycle::BackendLifecycle,
46    process_exit::{
47        ProcessExitPhase, ProcessExitRegistration, ProcessExitShutdown, register_cuda_process_exit_shutdown,
48    },
49    query_service::{QueryService, QueryServiceDrainOutcome, QueryServiceDrainPhaseOutcome},
50};
51use crate::observability::in_runtime_observation_delivery;
52
53const BUILD_OPERATION: &str = "query_engine.gpu_backend_core.build";
54const CLOSE_OPERATION: &str = "query_engine.gpu_backend_core.close";
55const CLEANUP_SLICE: Duration = Duration::from_millis(100);
56
57/// Signals the pre-created cleanup coordinator unless construction reaches
58/// publication. This is intentionally armed before the first GPU mutation.
59pub(crate) struct ConstructionRollback {
60    coordinator: Option<Arc<CloseCoordinator>>,
61}
62
63impl ConstructionRollback {
64    pub(crate) fn new(coordinator: Arc<CloseCoordinator>) -> Self {
65        Self { coordinator: Some(coordinator) }
66    }
67
68    pub(crate) fn disarm(&mut self) {
69        self.coordinator.take();
70    }
71
72    pub(crate) fn attach_cleanup_facts(&mut self, error: QueryError) -> QueryError {
73        let Some(coordinator) = self.coordinator.take() else {
74            return error.with_fact("rollback_cleanup", "not_armed");
75        };
76        let deadline = Instant::now().checked_add(CLEANUP_SLICE).unwrap_or_else(Instant::now);
77        match coordinator.close_and_wait_until(deadline) {
78            Ok(snapshot) => error
79                .with_fact("rollback_cleanup", "quiescent")
80                .with_fact("rollback_gpu_resource_quiescent", snapshot.gpu_resource_quiescent)
81                .with_fact("rollback_observation_quiescent", snapshot.observation_quiescent),
82            Err(cleanup_error) => error
83                .with_fact("rollback_cleanup", "transferred")
84                .with_fact("rollback_cleanup_code", cleanup_error.code().as_str())
85                .with_fact("rollback_cleanup_reason", cleanup_error.fact_value("reason").unwrap_or("unknown")),
86        }
87    }
88}
89
90impl Drop for ConstructionRollback {
91    fn drop(&mut self) {
92        if let Some(coordinator) = self.coordinator.take() {
93            coordinator.signal();
94        }
95    }
96}
97
98/// Owner of the close worker thread and the close signal.
99///
100/// Spawned before the first GPU resource is installed, so it exists as the
101/// rollback owner for every construction failure as well as for ordinary
102/// close. Held by the backend core, by the public-handle tracker's last-drop
103/// callback, and by the process-exit registration.
104#[derive(Debug)]
105pub(crate) struct CloseCoordinator {
106    /// One-shot start signal for the worker. Taken by the first `signal`, so
107    /// the worker can be started at most once; `None` afterwards.
108    sender: Mutex<Option<mpsc::Sender<()>>>,
109    pub(crate) state: Arc<CloseCompletion>,
110    /// Clone of the observation core's lifecycle. `signal` wins or loses the
111    /// close gate here, which is the same gate admissions race against.
112    lifecycle: BackendLifecycle,
113    /// Taken by the first join after quiescence. Left in place when the
114    /// joining thread is the worker itself.
115    join: Mutex<Option<thread::JoinHandle<()>>>,
116    /// Set by the process-exit hook. The worker checks it at every slice
117    /// boundary and returns without releasing the generation.
118    process_exit_requested: Arc<AtomicBool>,
119    process_exit_registration: Mutex<Option<ProcessExitRegistration>>,
120}
121
122impl CloseCoordinator {
123    /// Spawns the worker thread, which parks until the first `signal`.
124    ///
125    /// The worker owns a strong `ResourceCore` reference so the generation
126    /// slot stays reachable even after every public handle and session is
127    /// gone. If the worker panics, that reference is forgotten on purpose: a
128    /// still-installed generation must not be dropped by an unwinding thread,
129    /// because its owner lease would be released with GPU resources live.
130    pub(crate) fn spawn(resource: Arc<ResourceCore>, observation: Arc<ObservationCore>) -> Result<Arc<Self>> {
131        let (sender, receiver) = mpsc::channel();
132        let state = Arc::new(CloseCompletion::new());
133        let process_exit_requested = Arc::new(AtomicBool::new(false));
134        let coordinator = Arc::new(Self {
135            sender: Mutex::new(Some(sender)),
136            state: Arc::clone(&state),
137            lifecycle: observation.lifecycle.clone(),
138            join: Mutex::new(None),
139            process_exit_requested: Arc::clone(&process_exit_requested),
140            process_exit_registration: Mutex::new(None),
141        });
142        let worker_state = Arc::clone(&state);
143        let worker_lifecycle = observation.lifecycle.clone();
144        let resource_for_panic = Arc::clone(&resource);
145        let worker_coordinator = Arc::clone(&coordinator);
146        let join = thread::Builder::new()
147            .name("algeon-gpu-close".to_string())
148            .spawn(move || {
149                let result = catch_unwind(AssertUnwindSafe(|| {
150                    let _ = receiver.recv();
151                    run_cleanup(resource, worker_lifecycle.clone(), Arc::clone(&worker_state), &process_exit_requested);
152                }));
153                if result.is_err() {
154                    worker_state.fail("cleanup_worker", "cleanup worker panicked");
155                    // A panic must not drop a still-installed resource
156                    // generation and release its owner lease.
157                    std::mem::forget(resource_for_panic);
158                }
159                worker_coordinator.unregister_process_exit();
160            })
161            .map_err(|error| {
162                QueryError::new(QueryErrorCode::Internal, BUILD_OPERATION, "failed to create backend cleanup worker")
163                    .with_fact("reason", "cleanup_worker_spawn_failed")
164                    .with_fact("io_error", error.to_string())
165            })?;
166        *coordinator.join.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(join);
167        Ok(coordinator)
168    }
169
170    /// Registers the coordinator with the CUDA process-exit hook. Called by
171    /// construction only after the service install has initialized CUDA, so
172    /// the engine's `atexit` handler is registered after libcudart's and runs
173    /// before it. Registration is `None` when no CUDA runtime is available.
174    pub(crate) fn activate_process_exit(self: &Arc<Self>) {
175        let shutdown: Arc<dyn ProcessExitShutdown> = Arc::clone(self) as _;
176        let registration = register_cuda_process_exit_shutdown(shutdown);
177        *self.process_exit_registration.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = registration;
178    }
179
180    fn unregister_process_exit(&self) {
181        let registration =
182            self.process_exit_registration.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take();
183        if let Some(registration) = registration {
184            registration.unregister();
185        }
186    }
187
188    /// Closes the lifecycle gate and starts the worker. Returns whether this
189    /// call won the gate; only the winner sends the start signal, so the
190    /// worker runs once however many callers (explicit close, last handle
191    /// drop, construction rollback, process exit) signal.
192    ///
193    /// Never blocks: the gate is one atomic and the channel send does not
194    /// wait. A worker that is already gone (spawn failure path) is recorded as
195    /// a cleanup failure so waiters do not hang.
196    pub(crate) fn signal(&self) -> bool {
197        let gate_winner = self.lifecycle.close();
198        if !gate_winner {
199            return false;
200        }
201        self.state.mark_closing();
202        let sender = self.sender.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take();
203        if sender.is_none_or(|sender| sender.send(()).is_err()) {
204            self.state.fail("cleanup_signal", "cleanup worker disconnected before close signal");
205        }
206        true
207    }
208
209    /// Signals close and blocks until observation quiescence or `deadline`.
210    /// The worker thread is joined only after quiescence, so a timeout leaves
211    /// it running and a later call can still observe its completion.
212    pub(crate) fn close_and_wait_until(&self, deadline: Instant) -> Result<GpuBackendCloseSnapshot> {
213        self.signal();
214        let snapshot = self.state.wait_until(deadline, &self.lifecycle)?;
215        self.join_after_quiescence()?;
216        Ok(snapshot)
217    }
218
219    #[cfg(test)]
220    pub(crate) fn worker_join_is_reclaimed_for_test(&self) -> bool {
221        self.join.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_none()
222    }
223
224    fn join_after_quiescence(&self) -> Result<()> {
225        let mut join = self.join.lock().map_err(|_| {
226            QueryError::new(QueryErrorCode::Internal, CLOSE_OPERATION, "backend cleanup worker join mutex was poisoned")
227                .with_fact("reason", "cleanup_worker_join_poisoned")
228        })?;
229        let Some(handle) = join.take() else {
230            return Ok(());
231        };
232        if handle.thread().id() == thread::current().id() {
233            // A reentrant close invoked by the cleanup worker itself cannot
234            // join its own entry point. The worker will unregister only after
235            // it has completed all GPU and observation teardown.
236            *join = Some(handle);
237            return Ok(());
238        }
239        let joined = handle.join();
240        // Keep the join lock to the join itself; unregistering takes the
241        // registration and registry locks.
242        drop(join);
243        self.unregister_process_exit();
244        joined.map_err(|panic| {
245            QueryError::new(QueryErrorCode::Internal, CLOSE_OPERATION, "backend cleanup worker panicked during join")
246                .with_fact("reason", "cleanup_worker_join_panicked")
247                .with_fact("panic_message", panic_message(panic.as_ref()))
248        })
249    }
250
251    fn join_for_process_exit(&self) {
252        // `std::process::exit` does not unwind the caller's stack. An admitted
253        // handle owned by that stack can therefore never participate in the
254        // ordinary quiescence protocol. Tell the cleanup worker to stop at its
255        // next bounded phase boundary and leak the resource generation; the
256        // separately registered execution/stream supervisors then terminate
257        // their workers without allowing CUDA destructors to run after this
258        // process-exit hook returns.
259        self.process_exit_requested.store(true, Ordering::Release);
260        self.signal();
261        let mut join = self.join.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
262        let Some(handle) = join.take() else {
263            return;
264        };
265        if handle.thread().id() == thread::current().id() {
266            *join = Some(handle);
267            return;
268        }
269        let _ = handle.join();
270        // Keep the join lock to the join itself; unregistering takes the
271        // registration and registry locks.
272        drop(join);
273        self.unregister_process_exit();
274    }
275}
276
277impl ProcessExitShutdown for CloseCoordinator {
278    fn phase(&self) -> ProcessExitPhase {
279        ProcessExitPhase::BackendCoordinator
280    }
281
282    fn shutdown_for_process_exit(&self) {
283        self.join_for_process_exit();
284    }
285}
286
287/// The worker body: drains the generation phase by phase and records each
288/// phase's progress in `state`.
289///
290/// Every `return` before `generation.release()` leaves the generation
291/// forgotten by `FailClosedGeneration`, which is the fail-closed outcome for
292/// a permanent failure and the intended outcome for process exit, where the
293/// separately registered pool and stream supervisors stop their workers and
294/// nothing may run CUDA destructors afterwards.
295fn run_cleanup(
296    resource: Arc<ResourceCore>,
297    lifecycle: BackendLifecycle,
298    state: Arc<CloseCompletion>,
299    process_exit_requested: &AtomicBool,
300) {
301    lifecycle.close();
302    // The slot is one `Option` take or set; a guard recovered from a panic
303    // still holds the published generation, which close then drains.
304    let generation = resource.generation.lock().unwrap_or_else(PoisonError::into_inner).take();
305    let Some(generation) = generation else {
306        state.mark_gpu_quiescent();
307        wait_for_observation_forever(&lifecycle, &state, process_exit_requested);
308        return;
309    };
310    let generation = FailClosedGeneration::new(generation);
311    if let Some(service) = generation.service() {
312        // `close` rejects queued waiters and new admissions at once; admitted
313        // attempts keep their grants and finish through their own release.
314        service.close().unwrap_or_else(|error| {
315            eprintln!("[query-engine] backend close signal returned an error: {error}");
316        });
317        if process_exit_requested.load(Ordering::Acquire) {
318            return;
319        }
320        loop {
321            let deadline = Instant::now().checked_add(CLEANUP_SLICE).unwrap_or_else(Instant::now);
322            let outcome: QueryServiceDrainOutcome = service.close_drain_step_until(deadline);
323            let mut all_quiescent = true;
324            let mut permanent_failure = None;
325            for phase_outcome in outcome.phases() {
326                match phase_outcome {
327                    QueryServiceDrainPhaseOutcome::Quiescent { phase } => {
328                        state.record_quiescent(phase.as_str());
329                    },
330                    QueryServiceDrainPhaseOutcome::DeadlineElapsed { phase, .. } => {
331                        all_quiescent = false;
332                        state.record_deadline(phase.as_str());
333                    },
334                    QueryServiceDrainPhaseOutcome::PermanentFailure { phase, error } => {
335                        all_quiescent = false;
336                        if permanent_failure.is_none() {
337                            permanent_failure = Some((phase.as_str(), error));
338                        }
339                    },
340                }
341            }
342            if let Some((phase, error)) = permanent_failure {
343                state.fail(phase, error);
344                return;
345            }
346            if all_quiescent {
347                break;
348            }
349            if process_exit_requested.load(Ordering::Acquire) {
350                return;
351            }
352        }
353    }
354    // The service first closes admission and drains work using the resources
355    // retained by the permanent journal. Only then may the journal close and
356    // verify every runtime/resource handle. Construction rollback has no
357    // published service and therefore reaches this same journal path directly.
358    loop {
359        let deadline = Instant::now().checked_add(CLEANUP_SLICE).unwrap_or_else(Instant::now);
360        match generation.construction_journal().close_and_drain_until(deadline) {
361            Ok(()) => {
362                state.record_quiescent("construction_journal");
363                break;
364            },
365            Err(error) if error.is_cleanup_deadline_elapsed() => {
366                state.record_deadline("construction_journal");
367            },
368            Err(error) => {
369                state.fail("construction_journal", error);
370                return;
371            },
372        }
373        if process_exit_requested.load(Ordering::Acquire) {
374            return;
375        }
376    }
377    loop {
378        let deadline = Instant::now().checked_add(CLEANUP_SLICE).unwrap_or_else(Instant::now);
379        match lifecycle.wait_for_gpu_quiescence_until(deadline) {
380            Ok(()) => {
381                state.record_quiescent("gpu_permit_quiescence");
382                break;
383            },
384            Err(error) if error.is_cleanup_deadline_elapsed() => {
385                state.record_deadline("gpu_permit_quiescence");
386            },
387            Err(error) => {
388                state.fail("gpu_permit_quiescence", error);
389                return;
390            },
391        }
392        if process_exit_requested.load(Ordering::Acquire) {
393            return;
394        }
395    }
396    // Releasing the generation drops the service, then the journal, then the
397    // owner lease; a replacement backend can claim the devices from here on.
398    generation.release();
399    // The core's `Arc` is released before GPU quiescence is published so a
400    // waiter that observes the fact cannot find the slot still retained.
401    drop(resource);
402    state.mark_gpu_quiescent();
403    wait_for_observation_forever(&lifecycle, &state, process_exit_requested);
404}
405
406/// Waits, in slices, for every observation permit to drop. Unbounded because
407/// an observation permit is held by attempt reports that a caller may seal at
408/// any later time; only process exit ends the wait early.
409fn wait_for_observation_forever(
410    lifecycle: &BackendLifecycle,
411    state: &CloseCompletion,
412    process_exit_requested: &AtomicBool,
413) {
414    loop {
415        let deadline = Instant::now().checked_add(CLEANUP_SLICE).unwrap_or_else(Instant::now);
416        match lifecycle.wait_for_observation_quiescence_until(deadline) {
417            Ok(()) => {
418                state.record_quiescent("observation_quiescence");
419                state.mark_observation_quiescent();
420                return;
421            },
422            Err(error) if error.is_cleanup_deadline_elapsed() => {
423                state.record_deadline("observation_quiescence");
424            },
425            Err(error) => {
426                state.fail("observation_quiescence", error);
427                return;
428            },
429        }
430        if process_exit_requested.load(Ordering::Acquire) {
431            return;
432        }
433    }
434}
435
436/// Holds the generation during cleanup. Dropping this value without calling
437/// `release` passes the generation to `std::mem::forget`. Forgetting keeps the
438/// owner lease claimed for the rest of the process, which is the fail-closed
439/// answer to GPU work of unknown state.
440struct FailClosedGeneration {
441    generation: Option<Arc<ResourceGeneration>>,
442}
443
444impl FailClosedGeneration {
445    fn new(generation: Arc<ResourceGeneration>) -> Self {
446        Self { generation: Some(generation) }
447    }
448
449    fn service(&self) -> Option<&QueryService> {
450        self.generation.as_ref().expect("generation retained until release").service()
451    }
452
453    fn construction_journal(&self) -> &ConstructionResourceJournal {
454        self.generation.as_ref().expect("generation retained until release").construction_journal()
455    }
456
457    fn release(mut self) {
458        // Take the generation out before `self` drops. `Drop` passes any generation
459        // still held to `std::mem::forget`.
460        drop(self.generation.take());
461    }
462}
463
464impl Drop for FailClosedGeneration {
465    fn drop(&mut self) {
466        if let Some(generation) = self.generation.take() {
467            std::mem::forget(generation);
468        }
469    }
470}
471
472// Phases advance monotonically in this numeric order. `PHASE_FAILED` is
473// absorbing only before GPU quiescence; see `CloseCompletion::fail`.
474const PHASE_OPEN: u8 = 0;
475const PHASE_CLOSING: u8 = 1;
476const PHASE_GPU_QUIESCENT: u8 = 2;
477const PHASE_OBSERVATION_QUIESCENT: u8 = 3;
478const PHASE_FAILED: u8 = 4;
479
480/// Close progress shared between the worker and every waiter.
481///
482/// The phase and failure live under one mutex with the condition variable;
483/// the counters and the per-phase progress map are separate so a snapshot
484/// taken while a waiter holds the wait lock still reads them.
485#[derive(Debug)]
486pub(crate) struct CloseCompletion {
487    wait: Mutex<CloseWaitState>,
488    changed: Condvar,
489    /// Number of distinct phases that missed at least one cleanup slice.
490    deadline_elapsed: AtomicU64,
491    /// Number of those phases that later reached quiescence anyway.
492    late_successes: AtomicU64,
493    cleanup_phases: Mutex<BTreeMap<&'static str, CleanupPhaseProgress>>,
494}
495
496/// Phase and failure guarded by the same mutex as the [`Condvar`] predicate,
497/// so a state change and its notification are atomic to waiters.
498#[derive(Debug)]
499struct CloseWaitState {
500    phase: u8,
501    failure: Option<CloseFailure>,
502}
503
504#[derive(Debug, Clone)]
505struct CloseFailure {
506    phase: &'static str,
507    error: String,
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
511enum CleanupPhaseProgress {
512    DeadlineElapsed,
513    Quiescent,
514}
515
516impl CloseCompletion {
517    pub(crate) fn new() -> Self {
518        Self {
519            wait: Mutex::new(CloseWaitState { phase: PHASE_OPEN, failure: None }),
520            changed: Condvar::new(),
521            deadline_elapsed: AtomicU64::new(0),
522            late_successes: AtomicU64::new(0),
523            cleanup_phases: Mutex::new(BTreeMap::new()),
524        }
525    }
526
527    fn mark_closing(&self) {
528        self.advance(PHASE_CLOSING);
529    }
530
531    fn mark_gpu_quiescent(&self) {
532        self.advance(PHASE_GPU_QUIESCENT);
533    }
534
535    fn mark_observation_quiescent(&self) {
536        self.advance(PHASE_OBSERVATION_QUIESCENT);
537    }
538
539    fn advance(&self, phase: u8) {
540        {
541            let mut state = self.wait.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
542            if state.phase < phase && state.phase != PHASE_FAILED {
543                state.phase = phase;
544            } else {
545                return;
546            }
547        }
548        self.changed.notify_all();
549    }
550
551    fn record_deadline(&self, phase: &'static str) {
552        let mut phases = self.cleanup_phases.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
553        if !phases.contains_key(phase) {
554            phases.insert(phase, CleanupPhaseProgress::DeadlineElapsed);
555            self.deadline_elapsed.fetch_add(1, Ordering::Relaxed);
556        }
557    }
558
559    fn record_quiescent(&self, phase: &'static str) {
560        let mut phases = self.cleanup_phases.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
561        if phases.insert(phase, CleanupPhaseProgress::Quiescent) == Some(CleanupPhaseProgress::DeadlineElapsed) {
562            self.late_successes.fetch_add(1, Ordering::Relaxed);
563        }
564    }
565
566    fn fail(&self, phase: &'static str, error: impl fmt::Display) {
567        let mut state = self.wait.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
568        if state.failure.is_none() {
569            state.failure = Some(CloseFailure { phase, error: error.to_string() });
570        }
571        // A later observation-cleanup failure must not erase the already
572        // established GPU-resource-quiescent fact. Before GPU quiescence the
573        // failed sentinel also prevents any later success transition.
574        if state.phase < PHASE_GPU_QUIESCENT {
575            state.phase = PHASE_FAILED;
576        }
577        self.changed.notify_all();
578    }
579
580    pub(crate) fn snapshot(&self, lifecycle: &BackendLifecycle) -> GpuBackendCloseSnapshot {
581        let state = self.wait.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
582        self.snapshot_with(state.phase, state.failure.is_some(), lifecycle)
583    }
584
585    fn snapshot_with(&self, phase: u8, cleanup_failed: bool, lifecycle: &BackendLifecycle) -> GpuBackendCloseSnapshot {
586        let lifecycle = lifecycle.snapshot();
587        GpuBackendCloseSnapshot {
588            closing: lifecycle.closing,
589            gpu_resource_quiescent: phase == PHASE_GPU_QUIESCENT || phase == PHASE_OBSERVATION_QUIESCENT,
590            observation_quiescent: phase == PHASE_OBSERVATION_QUIESCENT,
591            active_gpu_permits: lifecycle.active_gpu_permits,
592            active_observation_permits: lifecycle.active_observation_permits,
593            cleanup_failed,
594            cleanup_deadline_elapsed_count: self.deadline_elapsed.load(Ordering::Relaxed),
595            cleanup_late_success_count: self.late_successes.load(Ordering::Relaxed),
596        }
597    }
598
599    /// Blocks until observation quiescence, a recorded cleanup failure, or
600    /// `deadline`. A failure is reported in preference to a timeout whenever
601    /// both are true at the moment the waiter wakes.
602    pub(crate) fn wait_until(
603        &self,
604        deadline: Instant,
605        lifecycle: &BackendLifecycle,
606    ) -> Result<GpuBackendCloseSnapshot> {
607        let mut state = super::deadline::mutex_lock_until(&self.wait, deadline).map_err(|error| match error {
608            super::deadline::DeadlineMutexLockError::Poisoned => close_wait_poisoned_error(),
609            // The phase lives behind the lock this wait could not take, so
610            // the error omits the quiescence facts and does not wait for that lock.
611            super::deadline::DeadlineMutexLockError::TimedOut => self.timeout_error(None, lifecycle),
612        })?;
613        loop {
614            if let Some(failure) = state.failure.clone() {
615                return Err(QueryError::new(
616                    QueryErrorCode::Internal,
617                    CLOSE_OPERATION,
618                    "backend cleanup failed closed",
619                )
620                .with_fact("reason", "cleanup_failed_closed")
621                .with_fact("cleanup_phase", failure.phase)
622                .with_fact("cleanup_error", failure.error));
623            }
624            if state.phase == PHASE_OBSERVATION_QUIESCENT {
625                return Ok(self.snapshot_with(state.phase, false, lifecycle));
626            }
627            if super::deadline::deadline_expired(deadline) {
628                return Err(self.timeout_error(Some(state.phase), lifecycle));
629            }
630            let Some(remaining) = super::deadline::deadline_remaining(deadline) else {
631                return Err(self.timeout_error(Some(state.phase), lifecycle));
632            };
633            // Every wakeup re-reads the full wait state at the top of the
634            // loop, so a failure recorded during the wait is reported as a
635            // cleanup failure. A timeout is returned only when no failure is recorded.
636            let (next, _) =
637                self.changed.wait_timeout(state, remaining).map_err(|_error| close_wait_poisoned_error())?;
638            state = next;
639        }
640    }
641
642    /// Timeout error from state readable without blocking: lifecycle permits
643    /// and cleanup counters always, quiescence facts only when `phase` was
644    /// read under the wait lock.
645    fn timeout_error(&self, phase: Option<u8>, lifecycle: &BackendLifecycle) -> QueryError {
646        let lifecycle = lifecycle.snapshot();
647        let mut error =
648            QueryError::new(QueryErrorCode::ServiceOverloaded, CLOSE_OPERATION, "timed out waiting for backend close")
649                .with_fact("reason", "backend_close_timeout");
650        if let Some(phase) = phase {
651            error = error
652                .with_fact(
653                    "gpu_resource_quiescent",
654                    phase == PHASE_GPU_QUIESCENT || phase == PHASE_OBSERVATION_QUIESCENT,
655                )
656                .with_fact("observation_quiescent", phase == PHASE_OBSERVATION_QUIESCENT);
657        }
658        error
659            .with_fact("active_gpu_permits", lifecycle.active_gpu_permits)
660            .with_fact("active_observation_permits", lifecycle.active_observation_permits)
661            .with_fact("cleanup_deadline_elapsed_count", self.deadline_elapsed.load(Ordering::Relaxed))
662            .with_fact("cleanup_late_success_count", self.late_successes.load(Ordering::Relaxed))
663    }
664}
665
666/// Two-scope close state. GPU quiescence can become true while observation
667/// permits still exist; at that point the device-owner lease has been released.
668#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
669pub struct GpuBackendCloseSnapshot {
670    /// Whether backend close has begun and new admission is closed.
671    pub closing: bool,
672    /// Whether GPU resources and their generation ownership have been released.
673    pub gpu_resource_quiescent: bool,
674    /// Whether pending report production and observation delivery have completed.
675    pub observation_quiescent: bool,
676    /// GPU-resource users still retained at the sample.
677    pub active_gpu_permits: usize,
678    /// Report or observation users still retained at the sample.
679    pub active_observation_permits: usize,
680    /// Whether cleanup recorded a failure that prevents proven resource release.
681    pub cleanup_failed: bool,
682    /// Cleanup phases that reached a wait deadline before proving completion.
683    pub cleanup_deadline_elapsed_count: u64,
684    /// Previously timed-out cleanup phases that subsequently completed.
685    pub cleanup_late_success_count: u64,
686}
687
688fn close_wait_poisoned_error() -> QueryError {
689    QueryError::new(QueryErrorCode::Internal, CLOSE_OPERATION, "backend close completion mutex was poisoned")
690        .with_fact("reason", "close_wait_poisoned")
691}
692
693fn panic_message(panic: &(dyn Any + Send)) -> String {
694    panic
695        .downcast_ref::<&'static str>()
696        .map(|message| (*message).to_string())
697        .or_else(|| panic.downcast_ref::<String>().cloned())
698        .unwrap_or_else(|| "non-string panic payload".to_string())
699}
700
701/// Refuses a blocking close from inside runtime observation delivery. The
702/// observer runs under an observation permit, so a wait for observation
703/// quiescence from there could only end at the deadline.
704pub(crate) fn reject_reentrant_observation_close() -> Result<()> {
705    if in_runtime_observation_delivery() {
706        return Err(QueryError::new(
707            QueryErrorCode::Internal,
708            CLOSE_OPERATION,
709            "backend close-and-wait cannot run from runtime observation delivery",
710        )
711        .with_fact("reason", "reentrant_observation_close"));
712    }
713    Ok(())
714}
715
716#[cfg(test)]
717mod tests {
718    use std::{
719        sync::{Arc, mpsc},
720        thread,
721        time::{Duration, Instant},
722    };
723
724    use super::{CleanupPhaseProgress, CloseCompletion};
725    use crate::runtime::{backend_lifecycle::BackendLifecycle, query_service::QueryServiceDrainPhase};
726
727    #[test]
728    fn cleanup_deadlines_become_late_success_only_for_the_same_phase() {
729        let state = CloseCompletion::new();
730        let lifecycle = BackendLifecycle::new();
731        let phases = [
732            QueryServiceDrainPhase::StreamPoolClose.as_str(),
733            QueryServiceDrainPhase::AdmissionQuiescence.as_str(),
734            QueryServiceDrainPhase::ExecutionPoolQuiescence.as_str(),
735            QueryServiceDrainPhase::StreamPoolQuiescence.as_str(),
736            QueryServiceDrainPhase::ResourceEpoch.as_str(),
737            QueryServiceDrainPhase::FinalCompletion.as_str(),
738            "construction_journal",
739            "gpu_permit_quiescence",
740            "observation_quiescence",
741        ];
742        for phase in phases {
743            state.record_deadline(phase);
744            state.record_deadline(phase);
745        }
746        assert_eq!(state.snapshot(&lifecycle).cleanup_deadline_elapsed_count, phases.len() as u64);
747
748        state.record_quiescent(phases[0]);
749        let snapshot = state.snapshot(&lifecycle);
750        assert_eq!(snapshot.cleanup_late_success_count, 1);
751        let progress = state.cleanup_phases.lock().unwrap();
752        assert_eq!(progress.get(phases[0]), Some(&CleanupPhaseProgress::Quiescent));
753        assert!(phases[1..].iter().all(|phase| progress.get(phase) == Some(&CleanupPhaseProgress::DeadlineElapsed)));
754        drop(progress);
755
756        for phase in &phases[1..] {
757            state.record_quiescent(phase);
758            state.record_quiescent(phase);
759        }
760        state.mark_closing();
761        state.mark_gpu_quiescent();
762        state.mark_observation_quiescent();
763        let snapshot = state.snapshot(&lifecycle);
764
765        assert_eq!(snapshot.cleanup_deadline_elapsed_count, phases.len() as u64);
766        assert_eq!(snapshot.cleanup_late_success_count, phases.len() as u64);
767        assert!(!snapshot.cleanup_failed);
768    }
769
770    #[test]
771    fn observation_failure_does_not_erase_gpu_quiescence() {
772        let state = CloseCompletion::new();
773        let lifecycle = BackendLifecycle::new();
774        state.mark_closing();
775        state.mark_gpu_quiescent();
776        state.fail("observation_quiescence", "injected observation cleanup failure");
777
778        let snapshot = state.snapshot(&lifecycle);
779        assert!(snapshot.gpu_resource_quiescent);
780        assert!(!snapshot.observation_quiescent);
781        assert!(snapshot.cleanup_failed);
782    }
783
784    #[test]
785    fn waiting_at_a_deadline_returns_current_snapshot() {
786        let state = CloseCompletion::new();
787        let lifecycle = BackendLifecycle::new();
788        let error = state.wait_until(Instant::now(), &lifecycle).unwrap_err();
789        assert_eq!(error.fact_value("reason"), Some("backend_close_timeout"));
790        assert_eq!(error.fact_value("gpu_resource_quiescent"), Some("false"));
791    }
792
793    #[test]
794    fn waiting_while_the_wait_lock_is_held_times_out_before_the_holder_releases_it() {
795        let state = Arc::new(CloseCompletion::new());
796        let lifecycle = BackendLifecycle::new();
797        let (locked_tx, locked_rx) = mpsc::channel();
798        let (release_tx, release_rx) = mpsc::channel::<()>();
799        let holder = {
800            let state = Arc::clone(&state);
801            thread::spawn(move || {
802                let _guard = state.wait.lock().unwrap();
803                locked_tx.send(()).unwrap();
804                release_rx.recv().unwrap();
805            })
806        };
807        locked_rx.recv().unwrap();
808
809        let (result_tx, result_rx) = mpsc::channel();
810        let waiter = {
811            let state = Arc::clone(&state);
812            thread::spawn(move || {
813                result_tx.send(state.wait_until(Instant::now() + Duration::from_millis(20), &lifecycle)).unwrap();
814            })
815        };
816        let result = result_rx.recv_timeout(Duration::from_secs(10));
817        release_tx.send(()).unwrap();
818        holder.join().unwrap();
819        waiter.join().unwrap();
820
821        let error = result.expect("wait_until must return while the wait lock is still held").unwrap_err();
822        assert_eq!(error.fact_value("reason"), Some("backend_close_timeout"));
823        assert_eq!(error.fact_value("gpu_resource_quiescent"), None);
824        assert_eq!(error.fact_value("active_gpu_permits"), Some("0"));
825    }
826}