1use 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
57pub(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#[derive(Debug)]
105pub(crate) struct CloseCoordinator {
106 sender: Mutex<Option<mpsc::Sender<()>>>,
109 pub(crate) state: Arc<CloseCompletion>,
110 lifecycle: BackendLifecycle,
113 join: Mutex<Option<thread::JoinHandle<()>>>,
116 process_exit_requested: Arc<AtomicBool>,
119 process_exit_registration: Mutex<Option<ProcessExitRegistration>>,
120}
121
122impl CloseCoordinator {
123 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 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 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 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 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 *join = Some(handle);
237 return Ok(());
238 }
239 let joined = handle.join();
240 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 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 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
287fn run_cleanup(
296 resource: Arc<ResourceCore>,
297 lifecycle: BackendLifecycle,
298 state: Arc<CloseCompletion>,
299 process_exit_requested: &AtomicBool,
300) {
301 lifecycle.close();
302 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 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 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 generation.release();
399 drop(resource);
402 state.mark_gpu_quiescent();
403 wait_for_observation_forever(&lifecycle, &state, process_exit_requested);
404}
405
406fn 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
436struct 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 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
472const 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#[derive(Debug)]
486pub(crate) struct CloseCompletion {
487 wait: Mutex<CloseWaitState>,
488 changed: Condvar,
489 deadline_elapsed: AtomicU64,
491 late_successes: AtomicU64,
493 cleanup_phases: Mutex<BTreeMap<&'static str, CleanupPhaseProgress>>,
494}
495
496#[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 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 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 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 let (next, _) =
637 self.changed.wait_timeout(state, remaining).map_err(|_error| close_wait_poisoned_error())?;
638 state = next;
639 }
640 }
641
642 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
669pub struct GpuBackendCloseSnapshot {
670 pub closing: bool,
672 pub gpu_resource_quiescent: bool,
674 pub observation_quiescent: bool,
676 pub active_gpu_permits: usize,
678 pub active_observation_permits: usize,
680 pub cleanup_failed: bool,
682 pub cleanup_deadline_elapsed_count: u64,
684 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
701pub(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}