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