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