Skip to main content

rivetkit_core/actor/
sleep.rs

1use parking_lot::Mutex;
2use rivet_envoy_client::async_counter::AsyncCounter;
3use rivet_envoy_client::handle::EnvoyHandle;
4use std::future::Future;
5use std::sync::Arc;
6#[cfg(test)]
7use std::sync::atomic::AtomicUsize as TestAtomicUsize;
8use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9#[cfg(not(feature = "wasm-runtime"))]
10use tokio::runtime::Handle;
11use tokio::sync::Notify;
12use tokio::task::JoinHandle;
13use tracing::Instrument;
14
15use crate::actor::config::ActorConfig;
16use crate::actor::context::ActorContext;
17#[cfg(not(feature = "wasm-runtime"))]
18use crate::actor::context::ActorWorkRegion;
19use crate::actor::task_types::ShutdownKind;
20#[cfg(not(feature = "wasm-runtime"))]
21use crate::actor::work_registry::ActorWorkPolicy;
22#[cfg(feature = "wasm-runtime")]
23use crate::actor::work_registry::LocalShutdownTask;
24use crate::actor::work_registry::{ActorWorkKind, CountGuard, RegionGuard, WorkRegistry};
25#[cfg(feature = "wasm-runtime")]
26use crate::runtime::RuntimeSpawner;
27#[cfg(test)]
28use crate::time::sleep_until;
29use crate::time::{Instant, sleep};
30#[cfg(test)]
31use crate::types::ActorKey;
32#[cfg(feature = "wasm-runtime")]
33use futures::channel::oneshot as futures_oneshot;
34#[cfg(feature = "wasm-runtime")]
35use futures::future::{AbortHandle, Abortable};
36
37/// Per-actor sleep state.
38///
39/// `ActorContext::reset_sleep_timer()` is invoked on every mutation that changes
40/// a sleep predicate input. Production actors wake the owning `ActorTask` via a
41/// single `Notify`; contexts not wired to an `ActorTask` use the detached
42/// compatibility timer below.
43pub(crate) struct SleepState {
44	// Forced-sync: sleep controller config/runtime handles are synchronous
45	// wiring slots cloned before actor I/O.
46	pub(super) config: Mutex<ActorConfig>,
47	pub(super) envoy_handle: Mutex<Option<EnvoyHandle>>,
48	pub(super) generation: Mutex<Option<u32>>,
49	pub(super) http_request_counter: Mutex<Option<Arc<AsyncCounter>>>,
50	#[cfg(test)]
51	sleep_request_count: TestAtomicUsize,
52	#[cfg(test)]
53	destroy_request_count: TestAtomicUsize,
54	pub(super) lifecycle_started: AtomicBool,
55	pub(super) run_handler_active_count: AtomicUsize,
56	// Forced-sync: the compatibility sleep timer is aborted from sync paths.
57	pub(super) sleep_timer: Mutex<Option<JoinHandle<()>>>,
58	pub(super) work: WorkRegistry,
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub(crate) enum CanSleep {
63	Yes,
64	NotReady,
65	NoSleep,
66	ActiveHttpRequests,
67	ActiveKeepAwake,
68	ActiveInternalKeepAwake,
69	ActiveRunHandler,
70	ActiveDisconnectCallbacks,
71	ActiveConnections,
72	ActiveWebSocketCallbacks,
73}
74
75impl SleepState {
76	pub fn new(config: ActorConfig) -> Self {
77		Self {
78			config: Mutex::new(config),
79			envoy_handle: Mutex::new(None),
80			generation: Mutex::new(None),
81			http_request_counter: Mutex::new(None),
82			#[cfg(test)]
83			sleep_request_count: TestAtomicUsize::new(0),
84			#[cfg(test)]
85			destroy_request_count: TestAtomicUsize::new(0),
86			lifecycle_started: AtomicBool::new(false),
87			run_handler_active_count: AtomicUsize::new(0),
88			sleep_timer: Mutex::new(None),
89			work: WorkRegistry::new(),
90		}
91	}
92}
93
94impl Default for SleepState {
95	fn default() -> Self {
96		Self::new(ActorConfig::default())
97	}
98}
99
100impl std::fmt::Debug for SleepState {
101	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102		f.debug_struct("SleepState")
103			.field(
104				"lifecycle_started",
105				&self.lifecycle_started.load(Ordering::SeqCst),
106			)
107			.field(
108				"run_handler_active_count",
109				&self.run_handler_active_count.load(Ordering::SeqCst),
110			)
111			.field("keep_awake_count", &self.work.keep_awake.load())
112			.field(
113				"internal_keep_awake_count",
114				&self.work.internal_keep_awake.load(),
115			)
116			.field(
117				"websocket_callback_count",
118				&self.work.websocket_callback.load(),
119			)
120			.field(
121				"disconnect_callback_count",
122				&self.work.disconnect_callback.load(),
123			)
124			.finish()
125	}
126}
127
128impl ActorContext {
129	#[cfg(test)]
130	pub(crate) fn new_for_sleep_tests(actor_id: impl Into<String>) -> Self {
131		Self::new(actor_id, "sleep-test", ActorKey::default(), "local")
132	}
133
134	pub(crate) fn configure_sleep_state(&self, config: ActorConfig) {
135		*self.0.sleep.config.lock() = config;
136	}
137
138	pub(crate) fn configure_sleep_envoy(&self, envoy_handle: EnvoyHandle, generation: Option<u32>) {
139		*self.0.sleep.envoy_handle.lock() = Some(envoy_handle);
140		*self.0.sleep.generation.lock() = generation;
141		*self.0.sleep.http_request_counter.lock() =
142			self.lookup_http_request_counter(self.actor_id());
143	}
144
145	pub(crate) fn sleep_envoy_handle(&self) -> Option<EnvoyHandle> {
146		self.0.sleep.envoy_handle.lock().clone()
147	}
148
149	pub(crate) fn sleep_generation(&self) -> Option<u32> {
150		*self.0.sleep.generation.lock()
151	}
152
153	pub(crate) fn request_sleep_from_envoy(&self) {
154		#[cfg(test)]
155		self.0
156			.sleep
157			.sleep_request_count
158			.fetch_add(1, Ordering::SeqCst);
159		let envoy_handle = self.0.sleep.envoy_handle.lock().clone();
160		let generation = *self.0.sleep.generation.lock();
161		if let Some(envoy_handle) = envoy_handle {
162			envoy_handle.sleep_actor(self.actor_id().to_owned(), generation);
163		}
164	}
165
166	pub(crate) fn request_destroy_from_envoy(&self) {
167		#[cfg(test)]
168		self.0
169			.sleep
170			.destroy_request_count
171			.fetch_add(1, Ordering::SeqCst);
172		let envoy_handle = self.0.sleep.envoy_handle.lock().clone();
173		let generation = *self.0.sleep.generation.lock();
174		if let Some(envoy_handle) = envoy_handle {
175			envoy_handle.destroy_actor(self.actor_id().to_owned(), generation);
176		}
177	}
178
179	pub(crate) fn set_lifecycle_started(&self, started: bool) {
180		let previous = self
181			.0
182			.sleep
183			.lifecycle_started
184			.swap(started, Ordering::SeqCst);
185		if previous != started {
186			self.reset_sleep_timer();
187		}
188	}
189
190	pub(crate) fn lifecycle_started(&self) -> bool {
191		self.0.sleep.lifecycle_started.load(Ordering::SeqCst)
192	}
193
194	#[doc(hidden)]
195	pub fn begin_run_handler(&self) {
196		let previous = self
197			.0
198			.sleep
199			.run_handler_active_count
200			.fetch_add(1, Ordering::SeqCst);
201		if previous == 0 {
202			self.reset_sleep_timer();
203		}
204	}
205
206	#[doc(hidden)]
207	pub fn end_run_handler(&self) {
208		match self.0.sleep.run_handler_active_count.fetch_update(
209			Ordering::SeqCst,
210			Ordering::SeqCst,
211			|count| count.checked_sub(1),
212		) {
213			Ok(1) => self.reset_sleep_timer(),
214			Ok(_) => {}
215			Err(_) => {
216				tracing::warn!(
217					actor_id = %self.actor_id(),
218					"run handler active counter underflow"
219				);
220			}
221		}
222	}
223
224	pub(crate) fn run_handler_active(&self) -> bool {
225		self.0.sleep.run_handler_active_count.load(Ordering::SeqCst) > 0
226	}
227
228	#[cfg(test)]
229	pub(crate) fn sleep_request_count(&self) -> usize {
230		self.0.sleep.sleep_request_count.load(Ordering::SeqCst)
231	}
232
233	pub(crate) async fn can_arm_sleep_timer(&self) -> CanSleep {
234		let config = self.sleep_state_config();
235		if !self.0.sleep.lifecycle_started.load(Ordering::SeqCst) {
236			return CanSleep::NotReady;
237		}
238		if config.no_sleep {
239			return CanSleep::NoSleep;
240		}
241		if self.active_http_request_count() > 0 {
242			return CanSleep::ActiveHttpRequests;
243		}
244		if self.sleep_keep_awake_count() > 0 {
245			return CanSleep::ActiveKeepAwake;
246		}
247		if self.sleep_internal_keep_awake_count() > 0 {
248			return CanSleep::ActiveInternalKeepAwake;
249		}
250		// Queue receives are sleep-compatible: sleep aborts the wait via the
251		// actor abort token, then the next generation restarts the run loop.
252		if self.run_handler_active() && self.active_queue_wait_count() == 0 {
253			return CanSleep::ActiveRunHandler;
254		}
255		if self.pending_disconnect_count() > 0 {
256			return CanSleep::ActiveDisconnectCallbacks;
257		}
258		if !self.conns().is_empty() {
259			return CanSleep::ActiveConnections;
260		}
261		if self.websocket_callback_count() > 0 {
262			return CanSleep::ActiveWebSocketCallbacks;
263		}
264
265		CanSleep::Yes
266	}
267
268	pub(crate) fn can_finalize_shutdown(&self, reason: ShutdownKind) -> bool {
269		self.0.sleep.work.core_dispatched_hooks.load() == 0
270			// Sleep is a reversible pause, so let the actor run handler finish
271			// naturally during grace. Destroy is terminal and may cut through it.
272			&& (matches!(reason, ShutdownKind::Destroy) || !self.run_handler_active())
273			&& self.shutdown_task_count() == 0
274			&& self.sleep_keep_awake_count() == 0
275			&& self.sleep_internal_keep_awake_count() == 0
276			&& self.active_http_request_count() == 0
277			&& self.websocket_callback_count() == 0
278			&& self.pending_disconnect_count() == 0
279	}
280
281	/// Spawn the fallback sleep timer used by `ActorContext`s that are not
282	/// bound to an `ActorTask`.
283	///
284	/// This path only engages when `configure_lifecycle_events` has not been
285	/// wired, which in practice means test contexts. Production actors built
286	/// through the registry always have an `ActorTask` and never spawn this
287	/// detached timer.
288	pub(crate) fn reset_sleep_timer_state(&self) {
289		self.cancel_sleep_timer();
290
291		#[cfg(not(feature = "wasm-runtime"))]
292		let Ok(runtime) = Handle::try_current() else {
293			tracing::debug!(
294				actor_id = %self.actor_id(),
295				"sleep activity reset skipped without tokio runtime"
296			);
297			return;
298		};
299
300		tracing::debug!(
301			actor_id = %self.actor_id(),
302			sleep_timeout_ms = self.0.sleep.config.lock().sleep_timeout.as_millis() as u64,
303			"sleep activity reset"
304		);
305
306		let ctx = self.clone();
307		let task_body = async move {
308			let can_sleep = ctx.can_sleep().await;
309			if can_sleep != CanSleep::Yes {
310				tracing::debug!(
311					actor_id = %ctx.actor_id(),
312					reason = ?can_sleep,
313					"sleep idle timer skipped"
314				);
315				return;
316			}
317
318			let timeout = ctx.sleep_config().sleep_timeout;
319			sleep(timeout).await;
320
321			let can_sleep = ctx.can_sleep().await;
322			if can_sleep == CanSleep::Yes {
323				tracing::debug!(
324					actor_id = %ctx.actor_id(),
325					sleep_timeout_ms = timeout.as_millis() as u64,
326					"sleep idle timer elapsed"
327				);
328				if let Err(err) = ctx.sleep() {
329					tracing::debug!(
330						actor_id = %ctx.actor_id(),
331						?err,
332						"sleep idle timer request suppressed"
333					);
334				}
335			} else {
336				tracing::warn!(
337					actor_id = %ctx.actor_id(),
338					reason = ?can_sleep,
339					"sleep idle timer elapsed but actor stayed awake"
340				);
341			}
342		};
343
344		#[cfg(not(feature = "wasm-runtime"))]
345		let task = runtime.spawn(task_body);
346
347		#[cfg(feature = "wasm-runtime")]
348		let task = RuntimeSpawner::spawn(task_body);
349
350		*self.0.sleep.sleep_timer.lock() = Some(task);
351	}
352
353	pub(crate) fn cancel_sleep_timer(&self) {
354		let timer = self.0.sleep.sleep_timer.lock().take();
355		if let Some(timer) = timer {
356			timer.abort();
357		}
358	}
359
360	pub(crate) async fn wait_for_internal_keep_awake_idle(&self, deadline: Instant) -> bool {
361		self.0
362			.sleep
363			.work
364			.internal_keep_awake
365			.wait_zero(deadline)
366			.await
367	}
368
369	#[cfg(test)]
370	pub(crate) async fn wait_for_sleep_idle_window(&self, deadline: Instant) -> bool {
371		loop {
372			let activity = self.sleep_activity_notify();
373			let activity_notified = activity.notified();
374			tokio::pin!(activity_notified);
375			activity_notified.as_mut().enable();
376			let idle = self.0.sleep.work.idle_notify.notified();
377			tokio::pin!(idle);
378			idle.as_mut().enable();
379
380			if self.can_finalize_shutdown(ShutdownKind::Sleep) {
381				return true;
382			}
383
384			tokio::select! {
385				_ = &mut activity_notified => {}
386				_ = &mut idle => {}
387				_ = sleep_until(deadline) => return false,
388			}
389		}
390	}
391
392	#[cfg(test)]
393	pub(crate) async fn wait_for_shutdown_tasks(&self, deadline: Instant) -> bool {
394		loop {
395			let activity = self.sleep_activity_notify();
396			let notified = activity.notified();
397			tokio::pin!(notified);
398			notified.as_mut().enable();
399
400			let shutdown_count = self.shutdown_task_count();
401			let websocket_count = self.websocket_callback_count();
402			if shutdown_count == 0 && websocket_count == 0 {
403				return true;
404			}
405
406			tokio::select! {
407				drained = self.0.sleep.work.shutdown_counter.wait_zero(deadline), if shutdown_count > 0 => {
408					if !drained {
409						return false;
410					}
411				}
412				drained = self.0.sleep.work.websocket_callback.wait_zero(deadline), if websocket_count > 0 => {
413					if !drained {
414						return false;
415					}
416				}
417				_ = &mut notified => {}
418				_ = sleep_until(deadline) => return false,
419			}
420		}
421	}
422
423	pub async fn wait_for_tracked_shutdown_work(&self) -> bool {
424		let shutdown_deadline = self.shutdown_deadline_token();
425		tokio::select! {
426			_ = self.wait_for_tracked_shutdown_work_drained() => true,
427			_ = shutdown_deadline.cancelled() => false,
428		}
429	}
430
431	pub async fn wait_for_tracked_shutdown_work_unbounded(&self) {
432		self.wait_for_tracked_shutdown_work_drained().await;
433	}
434
435	async fn wait_for_tracked_shutdown_work_drained(&self) {
436		loop {
437			let shutdown_count = self.shutdown_task_count();
438			let websocket_count = self.websocket_callback_count();
439			if shutdown_count == 0 && websocket_count == 0 {
440				return;
441			}
442
443			tokio::select! {
444				_ = self.0.sleep.work.shutdown_counter.wait_zero_unbounded(), if shutdown_count > 0 => {}
445				_ = self.0.sleep.work.websocket_callback.wait_zero_unbounded(), if websocket_count > 0 => {}
446			}
447		}
448	}
449
450	pub(crate) async fn wait_for_http_requests_drained(&self, deadline: Instant) -> bool {
451		let Some(counter) = self.http_request_counter() else {
452			return true;
453		};
454		counter.wait_zero(deadline).await
455	}
456
457	pub(crate) async fn wait_for_http_requests_idle(&self) {
458		loop {
459			let idle = self.0.sleep.work.idle_notify.notified();
460			tokio::pin!(idle);
461			idle.as_mut().enable();
462
463			if self.active_http_request_count() == 0 {
464				return;
465			}
466
467			idle.await;
468		}
469	}
470
471	pub(crate) fn keep_awake_region_state(&self) -> RegionGuard {
472		self.0.sleep.work.keep_awake_guard()
473	}
474
475	pub(crate) fn sleep_keep_awake_count(&self) -> usize {
476		self.0.sleep.work.keep_awake.load()
477	}
478
479	pub(crate) fn internal_keep_awake_region(&self) -> RegionGuard {
480		self.0.sleep.work.internal_keep_awake_guard()
481	}
482
483	pub(crate) fn sleep_internal_keep_awake_count(&self) -> usize {
484		self.0.sleep.work.internal_keep_awake.load()
485	}
486
487	fn active_queue_wait_count(&self) -> usize {
488		self.0.active_queue_wait_count.load(Ordering::SeqCst) as usize
489	}
490
491	pub(crate) fn websocket_callback_region_state(&self) -> RegionGuard {
492		self.0.sleep.work.websocket_callback_guard()
493	}
494
495	pub(crate) fn websocket_callback_count(&self) -> usize {
496		self.0.sleep.work.websocket_callback.load()
497	}
498
499	pub(crate) fn disconnect_callback_region_state(&self) -> RegionGuard {
500		self.0.sleep.work.disconnect_callback_guard()
501	}
502
503	#[cfg(not(feature = "wasm-runtime"))]
504	pub(crate) fn spawn_work_inner<F>(&self, kind: ActorWorkKind, fut: F) -> bool
505	where
506		F: Future<Output = ()> + Send + 'static,
507	{
508		if Handle::try_current().is_err() {
509			tracing::warn!(
510				kind = kind.label(),
511				"actor work spawned without tokio runtime"
512			);
513			return false;
514		}
515
516		let policy = kind.policy();
517		if policy.aborts_at_shutdown_deadline {
518			let mut shutdown_tasks = self.0.sleep.work.shutdown_tasks.lock();
519			if self.0.sleep.work.teardown_started.load(Ordering::Acquire) {
520				tracing::warn!(
521					kind = kind.label(),
522					"actor work spawned after teardown; aborting immediately"
523				);
524				return false;
525			}
526			// Reap completed tasks so the JoinSet stays bounded to in-flight
527			// work. Without this the set is only drained at shutdown, so a
528			// long-lived actor that keeps spawning work (for example a workflow
529			// loop registering keep-awake tasks each tick) accumulates finished
530			// task handles for its entire lifetime.
531			while shutdown_tasks.try_join_next().is_some() {}
532			let region = self.begin_work_region(kind);
533			shutdown_tasks.spawn(self.build_spawned_work_task(kind, policy, region, fut));
534		} else {
535			let mut unabortable_shutdown_tasks =
536				self.0.sleep.work.unabortable_shutdown_tasks.lock();
537			if self.0.sleep.work.teardown_started.load(Ordering::Acquire) {
538				tracing::warn!(
539					kind = kind.label(),
540					"actor work spawned after teardown; aborting immediately"
541				);
542				return false;
543			}
544			while unabortable_shutdown_tasks.try_join_next().is_some() {}
545			let region = self.begin_work_region(kind);
546			unabortable_shutdown_tasks
547				.spawn(self.build_spawned_work_task(kind, policy, region, fut));
548		}
549		self.reset_sleep_timer();
550		true
551	}
552
553	#[cfg(not(feature = "wasm-runtime"))]
554	fn build_spawned_work_task<F>(
555		&self,
556		kind: ActorWorkKind,
557		policy: ActorWorkPolicy,
558		region: ActorWorkRegion,
559		fut: F,
560	) -> impl Future<Output = ()> + Send + 'static
561	where
562		F: Future<Output = ()> + Send + 'static,
563	{
564		let ctx = self.clone();
565		async move {
566			let _region = region;
567			if policy.aborts_at_shutdown_deadline {
568				let shutdown_deadline = ctx.shutdown_deadline_token();
569				tokio::select! {
570					_ = fut => {}
571					_ = shutdown_deadline.cancelled() => {
572						tracing::warn!(
573							actor_id = %ctx.actor_id(),
574							kind = kind.label(),
575							reason = "shutdown_deadline_elapsed",
576							"actor work cancelled by shutdown deadline"
577						);
578					}
579				}
580			} else {
581				fut.await;
582			}
583			ctx.reset_sleep_timer();
584		}
585		.in_current_span()
586	}
587
588	#[cfg(feature = "wasm-runtime")]
589	pub(crate) fn spawn_work_inner<F>(&self, kind: ActorWorkKind, fut: F) -> bool
590	where
591		F: Future<Output = ()> + 'static,
592	{
593		let mut local_shutdown_tasks = self.0.sleep.work.local_shutdown_tasks.lock();
594		if self.0.sleep.work.teardown_started.load(Ordering::Acquire) {
595			tracing::warn!(
596				kind = kind.label(),
597				"actor work spawned after teardown; aborting immediately"
598			);
599			return false;
600		}
601
602		// Reap finished tasks so the Vec stays bounded to in-flight work
603		// instead of only draining at shutdown.
604		local_shutdown_tasks.retain_mut(|task| matches!(task.complete_rx.try_recv(), Ok(None)));
605
606		let policy = kind.policy();
607		let region = self.begin_work_region(kind);
608		let ctx = self.clone();
609		let (complete_tx, complete_rx) = futures_oneshot::channel();
610		let (abort_handle, abort_registration) = AbortHandle::new_pair();
611		local_shutdown_tasks.push(LocalShutdownTask {
612			abort_handle,
613			complete_rx,
614			aborts_at_shutdown_deadline: policy.aborts_at_shutdown_deadline,
615		});
616		drop(local_shutdown_tasks);
617		let ctx_for_task = ctx.clone();
618		wasm_bindgen_futures::spawn_local(
619			async move {
620				let task = async move {
621					let _region = region;
622					if policy.aborts_at_shutdown_deadline {
623						let shutdown_deadline = ctx_for_task.shutdown_deadline_token();
624						tokio::select! {
625							_ = fut => {}
626							_ = shutdown_deadline.cancelled() => {
627								tracing::warn!(
628									actor_id = %ctx_for_task.actor_id(),
629									kind = kind.label(),
630									reason = "shutdown_deadline_elapsed",
631									"actor work cancelled by shutdown deadline"
632								);
633							}
634						}
635					} else {
636						fut.await;
637					}
638					let _ = complete_tx.send(());
639					ctx_for_task.reset_sleep_timer();
640				};
641				if Abortable::new(task, abort_registration).await.is_err() {
642					ctx.reset_sleep_timer();
643				}
644			}
645			.in_current_span(),
646		);
647		self.reset_sleep_timer();
648		true
649	}
650
651	#[cfg(not(feature = "wasm-runtime"))]
652	pub(crate) fn track_shutdown_task<F>(&self, fut: F) -> bool
653	where
654		F: Future<Output = ()> + Send + 'static,
655	{
656		if Handle::try_current().is_err() {
657			tracing::warn!("shutdown task spawned without tokio runtime; running fallback");
658			return false;
659		}
660
661		let mut shutdown_tasks = self.0.sleep.work.shutdown_tasks.lock();
662		if self.0.sleep.work.teardown_started.load(Ordering::Acquire) {
663			tracing::warn!("shutdown task spawned after teardown; aborting immediately");
664			return false;
665		}
666		let counter = self.0.sleep.work.shutdown_counter.clone();
667		counter.increment();
668		let guard = CountGuard::from_incremented(counter);
669		let ctx = self.clone();
670		// The guard must drop before resetting the sleep timer so the lifecycle
671		// task observes the post-completion counter state on the wakeup.
672		shutdown_tasks.spawn(
673			async move {
674				{
675					let _guard = guard;
676					fut.await;
677				}
678				ctx.reset_sleep_timer();
679			}
680			.in_current_span(),
681		);
682		drop(shutdown_tasks);
683		self.reset_sleep_timer();
684		true
685	}
686
687	#[cfg(feature = "wasm-runtime")]
688	pub(crate) fn track_shutdown_task<F>(&self, fut: F) -> bool
689	where
690		F: Future<Output = ()> + 'static,
691	{
692		let mut local_shutdown_tasks = self.0.sleep.work.local_shutdown_tasks.lock();
693		if self.0.sleep.work.teardown_started.load(Ordering::Acquire) {
694			tracing::warn!("shutdown task spawned after teardown; aborting immediately");
695			return false;
696		}
697		let counter = self.0.sleep.work.shutdown_counter.clone();
698		counter.increment();
699		let guard = CountGuard::from_incremented(counter);
700		let ctx = self.clone();
701		// Wasm tasks cannot be joined through Tokio. Store an explicit
702		// completion channel plus abort handle so teardown can either drain or
703		// cancel them using the same policy as native shutdown.
704		let (complete_tx, complete_rx) = futures_oneshot::channel();
705		let (abort_handle, abort_registration) = AbortHandle::new_pair();
706		local_shutdown_tasks.push(LocalShutdownTask {
707			abort_handle,
708			complete_rx,
709			aborts_at_shutdown_deadline: true,
710		});
711		drop(local_shutdown_tasks);
712		let ctx_for_task = ctx.clone();
713		wasm_bindgen_futures::spawn_local(
714			async move {
715				let task = async move {
716					{
717						let _guard = guard;
718						fut.await;
719					}
720					let _ = complete_tx.send(());
721					ctx_for_task.reset_sleep_timer();
722				};
723				if Abortable::new(task, abort_registration).await.is_err() {
724					ctx.reset_sleep_timer();
725				}
726			}
727			.in_current_span(),
728		);
729		self.reset_sleep_timer();
730		true
731	}
732
733	pub(crate) fn shutdown_task_count(&self) -> usize {
734		self.0.sleep.work.shutdown_counter.load()
735	}
736
737	pub(crate) fn mark_shutdown_deadline_reached(&self) {
738		self.0
739			.sleep
740			.work
741			.shutdown_deadline_reached
742			.store(true, Ordering::Release);
743	}
744
745	pub(crate) fn begin_core_dispatched_hook(&self) {
746		self.0.sleep.work.core_dispatched_hooks.increment();
747		self.reset_sleep_timer();
748	}
749
750	pub fn mark_core_dispatched_hook_completed(&self) {
751		self.0.sleep.work.core_dispatched_hooks.decrement();
752		self.reset_sleep_timer();
753	}
754
755	pub(crate) fn core_dispatched_hook_count(&self) -> usize {
756		self.0.sleep.work.core_dispatched_hooks.load()
757	}
758
759	pub(crate) async fn teardown_sleep_state(&self) {
760		let abort_remaining = self
761			.0
762			.sleep
763			.work
764			.shutdown_deadline_reached
765			.swap(false, Ordering::AcqRel);
766		// Normal shutdown drains tracked work. Once the grace deadline fires,
767		// teardown switches to cancellation so a stuck waitUntil cannot hold the
768		// actor instance forever.
769		if abort_remaining {
770			self.0
771				.sleep
772				.work
773				.teardown_started
774				.store(true, Ordering::Release);
775		}
776
777		#[cfg(feature = "wasm-runtime")]
778		{
779			loop {
780				let local_shutdown_tasks = {
781					let mut guard = self.0.sleep.work.local_shutdown_tasks.lock();
782					let taken = std::mem::take(&mut *guard);
783					if taken.is_empty() {
784						self.0
785							.sleep
786							.work
787							.teardown_started
788							.store(true, Ordering::Release);
789						return;
790					}
791					taken
792				};
793
794				if abort_remaining {
795					for task in local_shutdown_tasks {
796						if task.aborts_at_shutdown_deadline {
797							task.abort_handle.abort();
798						}
799						if task.complete_rx.await.is_err() {
800							tracing::debug!("aborted shutdown task during teardown");
801						}
802					}
803					self.0
804						.sleep
805						.work
806						.teardown_started
807						.store(true, Ordering::Release);
808					return;
809				}
810
811				for task in local_shutdown_tasks {
812					if task.complete_rx.await.is_err() {
813						tracing::debug!("shutdown task completion dropped during teardown");
814					}
815				}
816			}
817		}
818
819		#[cfg(not(feature = "wasm-runtime"))]
820		loop {
821			let mut abortable_shutdown_tasks = {
822				let mut guard = self.0.sleep.work.shutdown_tasks.lock();
823				let taken = std::mem::take(&mut *guard);
824				let mut unabortable_guard = self.0.sleep.work.unabortable_shutdown_tasks.lock();
825				let unabortable_taken = std::mem::take(&mut *unabortable_guard);
826				if taken.is_empty() && unabortable_taken.is_empty() {
827					self.0
828						.sleep
829						.work
830						.teardown_started
831						.store(true, Ordering::Release);
832					return;
833				}
834				(taken, unabortable_taken)
835			};
836
837			abortable_shutdown_tasks.0.shutdown().await;
838			while let Some(result) = abortable_shutdown_tasks.0.join_next().await {
839				if let Err(error) = result
840					&& !error.is_cancelled()
841				{
842					tracing::error!(?error, "shutdown task join failed during teardown");
843				}
844			}
845			while let Some(result) = abortable_shutdown_tasks.1.join_next().await {
846				if let Err(error) = result
847					&& !error.is_cancelled()
848				{
849					tracing::error!(?error, "shutdown task join failed during teardown");
850				}
851			}
852		}
853	}
854
855	pub(crate) fn sleep_state_config(&self) -> ActorConfig {
856		self.0.sleep.config.lock().clone()
857	}
858
859	pub(crate) fn active_http_request_count(&self) -> usize {
860		self.http_request_counter()
861			.map(|counter| counter.load())
862			.unwrap_or(0)
863	}
864
865	pub(crate) fn sleep_activity_notify(&self) -> Arc<Notify> {
866		self.0.sleep.work.activity_notify.clone()
867	}
868
869	fn http_request_counter(&self) -> Option<Arc<AsyncCounter>> {
870		if let Some(counter) = self.0.sleep.http_request_counter.lock().clone() {
871			return Some(counter);
872		}
873
874		let counter = self.lookup_http_request_counter(self.actor_id())?;
875		*self.0.sleep.http_request_counter.lock() = Some(counter.clone());
876		Some(counter)
877	}
878
879	fn lookup_http_request_counter(&self, actor_id: &str) -> Option<Arc<AsyncCounter>> {
880		let envoy_handle = self.0.sleep.envoy_handle.lock().clone();
881		let generation = *self.0.sleep.generation.lock();
882		let envoy_handle = envoy_handle?;
883		let counter = envoy_handle.http_request_counter(actor_id, generation)?;
884		counter.register_zero_notify(&self.0.sleep.work.idle_notify);
885		// The HTTP counter is owned by envoy-client, so neither increment nor
886		// decrement goes through a rivetkit-core guard. Hook every transition
887		// into the sleep activity notify so the sleep deadline gets
888		// re-evaluated when a request starts or completes.
889		let ctx = self.clone();
890		counter.register_change_callback(Arc::new(move || {
891			ctx.0
892				.metrics
893				.set_http_requests_active(ctx.active_http_request_count());
894			ctx.reset_sleep_timer();
895		}));
896		self.0.metrics.set_http_requests_active(counter.load());
897		Some(counter)
898	}
899}
900
901// Test shim keeps moved tests in crate-root tests/ with private-module access.
902#[cfg(test)]
903#[path = "../../tests/sleep.rs"]
904mod tests;