Skip to main content

rivetkit_core/registry/
mod.rs

1use std::collections::HashMap;
2use std::env;
3use std::io::Cursor;
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::time::Duration;
8
9use crate::time::{Instant, timeout};
10
11use ::http::StatusCode;
12use anyhow::{Context, Result};
13use parking_lot::Mutex;
14use rivet_envoy_client::config::{
15	ActorStopHandle, BoxFuture as EnvoyBoxFuture, EnvoyCallbacks, HttpRequest, HttpResponse,
16	WebSocketHandler, WebSocketMessage, WebSocketSender,
17};
18use rivet_envoy_client::envoy::start_envoy_sync;
19use rivet_envoy_client::handle::EnvoyHandle;
20use rivet_envoy_client::protocol;
21use rivet_error::{ActorSpecifier, RivetError};
22use rivetkit_client_protocol as client_protocol;
23use rivetkit_shared_types::serverless_metadata::{
24	ActorName, ServerlessMetadataEnvoy, ServerlessMetadataEnvoyKind, ServerlessMetadataPayload,
25};
26use scc::{HashMap as SccHashMap, hash_map::Entry as SccEntry};
27use serde::{Deserialize, Serialize};
28use serde_bytes::ByteBuf;
29use serde_json::{Value as JsonValue, json};
30use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc, oneshot};
31use tokio::task::JoinHandle;
32use tokio_util::sync::CancellationToken;
33use url::Url;
34use vbare::Versioned;
35
36use crate::actor::action::ActionDispatchError;
37use crate::actor::config::CanHibernateWebSocket;
38use crate::actor::connection::{ConnHandle, DisconnectOnDrop, HibernatableConnectionMetadata};
39use crate::actor::context::{ActorContext, InspectorAttachGuard};
40use crate::actor::factory::ActorFactory;
41use crate::actor::kv::LegacyActorKv;
42use crate::actor::lifecycle_hooks::Reply;
43use crate::actor::messages::{ActorEvent, ActorHttpResponse, QueueSendResult, Request, StateDelta};
44use crate::actor::task::{
45	ActorTask, DispatchCommand, LifecycleCommand, try_send_dispatch_command,
46	try_send_lifecycle_command,
47};
48use crate::actor::task_types::ShutdownKind;
49#[cfg(feature = "native-runtime")]
50use crate::development_process::DevelopmentProcessManager;
51use crate::error::{ActorLifecycle as ActorLifecycleError, ActorRuntime};
52use crate::inspector::protocol::{
53	self as inspector_protocol, ServerMessage as InspectorServerMessage,
54};
55use crate::inspector::{Inspector, InspectorAuth, InspectorSignal, InspectorSubscription};
56use crate::runtime::RuntimeSpawner;
57use crate::sqlite::SqliteDb;
58use crate::types::{ActorKey, ActorKeySegment, WsMessage, format_actor_key};
59use crate::websocket::WebSocket;
60
61mod actor_connect;
62mod dispatch;
63mod envoy_callbacks;
64mod http;
65mod inspector;
66mod inspector_ws;
67#[cfg(feature = "native-runtime")]
68mod runner_config;
69mod websocket;
70
71use inspector::build_actor_inspector;
72
73const ENVOY_START_TIMEOUT: Duration = Duration::from_secs(30);
74
75#[derive(Default)]
76pub struct CoreRegistry {
77	factories: HashMap<String, Arc<ActorFactory>>,
78}
79
80#[derive(Clone)]
81pub struct CoreEnvoyHandle {
82	handle: EnvoyHandle,
83}
84
85#[derive(Clone, Debug)]
86pub struct CoreEnvoyStatus {
87	pub active_actor_count: usize,
88	pub ping_healthy: bool,
89}
90
91impl CoreEnvoyHandle {
92	pub(crate) fn new(handle: EnvoyHandle) -> Self {
93		Self { handle }
94	}
95
96	pub fn status(&self) -> CoreEnvoyStatus {
97		CoreEnvoyStatus {
98			active_actor_count: self.handle.active_actor_count(),
99			ping_healthy: self.handle.is_ping_healthy(),
100		}
101	}
102
103	/// Resolves after the Engine sends the envoy initialization message.
104	pub async fn started(&self) -> anyhow::Result<()> {
105		self.handle.started().await
106	}
107
108	/// Resolves once the envoy has no active actors (or has stopped).
109	pub async fn wait_actors_drained(&self) {
110		self.handle.wait_actors_drained().await
111	}
112
113	/// Engine-reported drain threshold in milliseconds. `None` until the
114	/// envoy has completed its first protocol-metadata exchange with the
115	/// engine.
116	pub async fn actor_stop_threshold_ms(&self) -> Option<i64> {
117		self.handle
118			.get_protocol_metadata()
119			.await
120			.map(|metadata| metadata.actor_stop_threshold)
121	}
122}
123
124#[derive(Clone)]
125struct ActorTaskHandle {
126	actor_id: String,
127	actor_name: String,
128	generation: u32,
129	ctx: ActorContext,
130	factory: Arc<ActorFactory>,
131	inspector: Inspector,
132	lifecycle: mpsc::UnboundedSender<LifecycleCommand>,
133	dispatch: mpsc::UnboundedSender<DispatchCommand>,
134	join: Arc<TokioMutex<Option<JoinHandle<Result<()>>>>>,
135}
136
137type ActiveActorInstance = Arc<ActorTaskHandle>;
138
139enum ActorInstanceState {
140	Active(ActiveActorInstance),
141	Stopping {
142		instance: ActiveActorInstance,
143		reason: ShutdownKind,
144	},
145}
146
147impl ActorInstanceState {
148	fn instance(&self) -> ActiveActorInstance {
149		match self {
150			Self::Active(instance) | Self::Stopping { instance, .. } => instance.clone(),
151		}
152	}
153
154	fn active_instance(&self) -> Option<ActiveActorInstance> {
155		match self {
156			Self::Active(instance) => Some(instance.clone()),
157			Self::Stopping { .. } => None,
158		}
159	}
160}
161
162#[derive(Clone)]
163struct PendingStop {
164	generation: u32,
165	reason: protocol::StopActorReason,
166	stop_handle: ActorStopHandle,
167}
168
169/// An actor id whose start is in flight on this process.
170///
171/// A stop that arrives during the start parks in this entry, so checking for the
172/// start and parking the stop are one atomic map operation. Only a stop for
173/// `generation` parks here; a stop for any other generation is stale and is
174/// completed immediately. The start takes the parked stop when it removes the
175/// entry, after it has published the instance.
176struct StartingInstance {
177	generation: u32,
178	pending_stop: Option<PendingStop>,
179}
180
181/// Outcome of attempting to transition the actor instance under an id to stopping
182/// for a specific generation.
183enum TransitionResult {
184	/// The current instance matches the requested generation and was moved to stopping.
185	Transitioned(ActiveActorInstance),
186	/// An instance exists but for a different generation than the stop targets, so it
187	/// was left untouched. The stop must not be applied to it.
188	Stale,
189	/// No instance is registered for the actor id.
190	Vacant,
191}
192
193pub(crate) struct RegistryDispatcher {
194	pub(crate) factories: HashMap<String, Arc<ActorFactory>>,
195	actor_instances: SccHashMap<String, ActorInstanceState>,
196	starting_instances: SccHashMap<String, StartingInstance>,
197	pending_stops: SccHashMap<String, PendingStop>,
198	region: String,
199	handle_inspector_http_in_runtime: bool,
200	/// One bounded pool of OS threads runs every native SQLite database of this runtime.
201	/// It spawns threads only when databases need them.
202	#[cfg(feature = "sqlite-local")]
203	sqlite_pool: sqlite_embedded::worker::SqlitePool,
204}
205
206pub(crate) struct RegistryCallbacks {
207	pub(crate) dispatcher: Arc<RegistryDispatcher>,
208}
209
210#[derive(Clone, Debug)]
211struct StartActorRequest {
212	actor_id: String,
213	generation: u32,
214	actor_name: String,
215	input: Option<Vec<u8>>,
216	ctx: ActorContext,
217}
218
219#[derive(Clone, Debug)]
220struct ServeSettings {
221	version: u32,
222	endpoint: String,
223	token: Option<String>,
224	namespace: String,
225	pool_name: String,
226	engine_binary_path: Option<PathBuf>,
227	start_services: bool,
228	services_binary_path: Option<PathBuf>,
229	engine_host: Option<String>,
230	engine_port: Option<u16>,
231	engine_spawn: EngineSpawnMode,
232	engine_auto_download: bool,
233	handle_inspector_http_in_runtime: bool,
234	serverless_base_path: Option<String>,
235	serverless_package_version: String,
236	serverless_client_endpoint: Option<String>,
237	serverless_client_namespace: Option<String>,
238	serverless_client_token: Option<String>,
239	serverless_validate_endpoint: bool,
240	serverless_max_start_payload_bytes: usize,
241}
242
243#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
244pub enum EngineSpawnMode {
245	#[default]
246	Auto,
247	Always,
248	Never,
249}
250
251impl EngineSpawnMode {
252	pub(crate) fn from_env() -> Self {
253		match env::var("RIVETKIT_ENGINE_SPAWN") {
254			Ok(value) if value.eq_ignore_ascii_case("always") => Self::Always,
255			Ok(value) if value.eq_ignore_ascii_case("never") => Self::Never,
256			_ => Self::Auto,
257		}
258	}
259}
260
261/// Selects how `Registry::start` runs, mirroring the TypeScript
262/// `RIVETKIT_RUNTIME_MODE` env var. `Envoy` holds one long-lived outbound
263/// envoy for the process lifetime; `Serverless` runs an HTTP listener that
264/// lazily starts and caches an envoy on the first request.
265#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
266pub enum RuntimeMode {
267	#[default]
268	Envoy,
269	Serverless,
270}
271
272impl RuntimeMode {
273	pub fn from_env() -> Self {
274		match env::var("RIVETKIT_RUNTIME_MODE") {
275			Ok(value) if value.eq_ignore_ascii_case("serverless") => Self::Serverless,
276			_ => Self::Envoy,
277		}
278	}
279}
280
281#[derive(Clone, Debug, Default)]
282pub struct ServeConfig {
283	pub version: u32,
284	pub endpoint: String,
285	pub token: Option<String>,
286	pub namespace: String,
287	pub pool_name: String,
288	pub engine_binary_path: Option<PathBuf>,
289	pub start_services: bool,
290	pub services_binary_path: Option<PathBuf>,
291	pub engine_host: Option<String>,
292	pub engine_port: Option<u16>,
293	pub engine_spawn: EngineSpawnMode,
294	pub engine_auto_download: bool,
295	pub handle_inspector_http_in_runtime: bool,
296	pub serverless_base_path: Option<String>,
297	pub serverless_package_version: String,
298	pub serverless_client_endpoint: Option<String>,
299	pub serverless_client_namespace: Option<String>,
300	pub serverless_client_token: Option<String>,
301	pub serverless_validate_endpoint: bool,
302	pub serverless_max_start_payload_bytes: usize,
303	pub serverless_cache_envoy: bool,
304}
305
306#[derive(Debug, Default, Deserialize)]
307#[serde(default)]
308struct InspectorPatchStateBody {
309	state: JsonValue,
310}
311
312#[derive(Debug, Default, Deserialize)]
313#[serde(default)]
314struct InspectorActionBody {
315	args: Vec<JsonValue>,
316	properties: Option<JsonValue>,
317}
318
319#[derive(Debug, Default, Deserialize)]
320#[serde(default)]
321struct InspectorDatabaseExecuteBody {
322	sql: String,
323	args: Vec<JsonValue>,
324	properties: Option<JsonValue>,
325}
326
327#[derive(Debug, Default, Deserialize)]
328#[serde(default, rename_all = "camelCase")]
329struct InspectorWorkflowReplayBody {
330	entry_id: Option<String>,
331}
332
333#[derive(Debug, Default, Deserialize)]
334#[serde(default)]
335struct InspectorEnqueueBody {
336	name: String,
337	body: Option<JsonValue>,
338}
339
340#[derive(Debug, Serialize)]
341#[serde(rename_all = "camelCase")]
342struct InspectorQueueMessageJson {
343	id: u64,
344	name: String,
345	created_at_ms: i64,
346}
347
348#[derive(Debug, Serialize)]
349#[serde(rename_all = "camelCase")]
350struct InspectorQueueResponseJson {
351	size: u32,
352	max_size: u32,
353	truncated: bool,
354	messages: Vec<InspectorQueueMessageJson>,
355}
356
357#[derive(Debug, Deserialize)]
358#[serde(default)]
359struct HttpActionRequestJson {
360	args: JsonValue,
361}
362
363impl Default for HttpActionRequestJson {
364	fn default() -> Self {
365		Self {
366			args: JsonValue::Array(Vec::new()),
367		}
368	}
369}
370
371/// Only config resolution can opt into managing a local development engine.
372/// A supplied endpoint is connect-only, including loopback addresses.
373pub(crate) fn should_manage_engine(spawn_mode: EngineSpawnMode) -> bool {
374	match spawn_mode {
375		EngineSpawnMode::Always => true,
376		EngineSpawnMode::Auto | EngineSpawnMode::Never => false,
377	}
378}
379
380/// Resolves the environment's spawn preference against an explicit engine endpoint.
381fn resolve_engine_spawn(
382	has_explicit_endpoint: bool,
383	spawn_mode: EngineSpawnMode,
384) -> Result<EngineSpawnMode> {
385	if has_explicit_endpoint {
386		if spawn_mode == EngineSpawnMode::Always {
387			return Err(EngineSpawnEndpointConflict.build());
388		}
389		return Ok(EngineSpawnMode::Never);
390	}
391	Ok(match spawn_mode {
392		EngineSpawnMode::Auto | EngineSpawnMode::Always => EngineSpawnMode::Always,
393		EngineSpawnMode::Never => EngineSpawnMode::Never,
394	})
395}
396
397#[derive(RivetError)]
398#[error(
399	"engine",
400	"spawn_endpoint_conflict",
401	"Engine spawn conflicts with the configured endpoint",
402	"RIVETKIT_ENGINE_SPAWN=always cannot be used with RIVET_ENDPOINT. Remove one setting."
403)]
404struct EngineSpawnEndpointConflict;
405
406
407
408#[derive(Debug, Deserialize)]
409#[serde(default, rename_all = "camelCase")]
410struct HttpQueueSendRequestJson {
411	body: JsonValue,
412	wait: Option<bool>,
413	timeout: Option<u64>,
414}
415
416impl Default for HttpQueueSendRequestJson {
417	fn default() -> Self {
418		Self {
419			body: JsonValue::Null,
420			wait: None,
421			timeout: None,
422		}
423	}
424}
425
426#[derive(RivetError)]
427#[error("message", "incoming_too_long", "Incoming message too long")]
428struct IncomingMessageTooLong;
429
430#[derive(RivetError)]
431#[error("message", "outgoing_too_long", "Outgoing message too long")]
432struct OutgoingMessageTooLong;
433
434#[derive(RivetError)]
435#[error("actor", "action_timed_out", "Action timed out")]
436struct ActionTimedOut;
437
438#[derive(RivetError, Serialize)]
439#[error("actor", "method_not_allowed", "Method not allowed")]
440struct MethodNotAllowed {
441	method: String,
442	path: String,
443}
444
445#[derive(Debug, Serialize)]
446#[serde(rename_all = "camelCase")]
447struct InspectorConnectionJson {
448	#[serde(rename = "type")]
449	connection_type: Option<String>,
450	id: String,
451	details: InspectorConnectionDetailsJson,
452}
453
454#[derive(Debug, Serialize)]
455#[serde(rename_all = "camelCase")]
456struct InspectorConnectionDetailsJson {
457	#[serde(rename = "type")]
458	connection_type: Option<String>,
459	params: JsonValue,
460	state_enabled: bool,
461	state: JsonValue,
462	subscriptions: usize,
463	is_hibernatable: bool,
464}
465
466#[derive(Debug, Serialize)]
467#[serde(rename_all = "camelCase")]
468struct InspectorSummaryJson {
469	state: JsonValue,
470	is_state_enabled: bool,
471	connections: Vec<InspectorConnectionJson>,
472	rpcs: Vec<String>,
473	queue_size: u32,
474	is_database_enabled: bool,
475	#[serde(rename = "isWorkflowEnabled")]
476	workflow_supported: bool,
477	workflow_history: Option<JsonValue>,
478}
479
480const WS_PROTOCOL_ENCODING: &str = "rivet_encoding.";
481const WS_PROTOCOL_CONN_PARAMS: &str = "rivet_conn_params.";
482
483#[derive(Debug)]
484struct ActorConnectInit {
485	actor_id: String,
486	connection_id: String,
487}
488
489#[derive(Debug)]
490struct ActorConnectError {
491	group: String,
492	code: String,
493	message: String,
494	metadata: Option<ByteBuf>,
495	action_id: Option<u64>,
496	actor: Option<ActorSpecifier>,
497}
498
499#[derive(Debug)]
500struct ActorConnectActionResponse {
501	id: u64,
502	output: ByteBuf,
503}
504
505#[derive(Debug)]
506struct ActorConnectEvent {
507	name: String,
508	args: ByteBuf,
509}
510
511#[derive(Clone, Copy, Debug, PartialEq, Eq)]
512enum ActorConnectEncoding {
513	Json,
514	Cbor,
515	Bare,
516}
517
518#[derive(Debug)]
519enum ActorConnectToClient {
520	Init(ActorConnectInit),
521	Error(ActorConnectError),
522	ActionResponse(ActorConnectActionResponse),
523	Event(ActorConnectEvent),
524}
525
526#[derive(Debug)]
527struct ActorConnectActionRequest {
528	id: u64,
529	name: String,
530	args: ByteBuf,
531}
532
533#[derive(Debug)]
534enum ActorConnectSendError {
535	OutgoingTooLong,
536	Encode(anyhow::Error),
537}
538
539#[derive(Debug, Deserialize)]
540struct ActorConnectSubscriptionRequest {
541	#[serde(rename = "eventName")]
542	event_name: String,
543	subscribe: bool,
544}
545
546#[derive(Debug)]
547enum ActorConnectToServer {
548	ActionRequest(ActorConnectActionRequest),
549	SubscriptionRequest(ActorConnectSubscriptionRequest),
550}
551
552#[derive(Debug, Deserialize)]
553struct ActorConnectActionRequestJson {
554	id: u64,
555	name: String,
556	args: JsonValue,
557}
558
559#[derive(Debug, Deserialize)]
560#[serde(tag = "tag", content = "val")]
561enum ActorConnectToServerJsonBody {
562	ActionRequest(ActorConnectActionRequestJson),
563	SubscriptionRequest(ActorConnectSubscriptionRequest),
564}
565
566#[derive(Debug, Deserialize)]
567struct ActorConnectToServerJsonEnvelope {
568	body: ActorConnectToServerJsonBody,
569}
570
571impl CoreRegistry {
572	pub fn new() -> Self {
573		Self::default()
574	}
575
576	pub fn register(&mut self, name: &str, factory: ActorFactory) {
577		self.factories.insert(name.to_owned(), Arc::new(factory));
578	}
579
580	pub fn register_shared(&mut self, name: &str, factory: Arc<ActorFactory>) {
581		self.factories.insert(name.to_owned(), factory);
582	}
583
584	pub fn normal_metadata_payload(&self, config: &ServeConfig) -> ServerlessMetadataPayload {
585		serverless_metadata_payload(
586			build_actor_metadata_map_from_factories(&self.factories),
587			config,
588			ServerlessMetadataEnvoyKind::Normal {},
589		)
590	}
591
592	pub fn serverless_metadata_payload(&self, config: &ServeConfig) -> ServerlessMetadataPayload {
593		serverless_metadata_payload(
594			build_actor_metadata_map_from_factories(&self.factories),
595			config,
596			ServerlessMetadataEnvoyKind::Serverless {},
597		)
598	}
599
600	pub async fn serve(self, shutdown: CancellationToken) -> Result<()> {
601		self.serve_with_config(ServeConfig::from_env()?, shutdown)
602			.await
603	}
604
605	pub async fn serve_with_config(
606		self,
607		config: ServeConfig,
608		shutdown: CancellationToken,
609	) -> Result<()> {
610		self.serve_with_config_and_handle_observer(config, shutdown, |_| {})
611			.await
612	}
613
614	pub async fn serve_with_config_and_handle_observer(
615		self,
616		config: ServeConfig,
617		shutdown: CancellationToken,
618		on_handle: impl FnOnce(CoreEnvoyHandle) + Send + 'static,
619	) -> Result<()> {
620		#[cfg(feature = "sqlite-local")]
621		sqlite_embedded::vfs::verify_batch_atomic_writes_at_process_startup().await?;
622
623		crate::metrics::record_rivetkit_info(
624			config.serverless_package_version.clone(),
625			config.version,
626			"serverful",
627			config.pool_name.clone(),
628		);
629		#[cfg(not(target_arch = "wasm32"))]
630		crate::tokio_runtime_metrics::ensure_sampler_started();
631
632		let dispatcher = self.into_dispatcher(&config);
633		let manage_engine = should_manage_engine(config.engine_spawn);
634		#[cfg(feature = "native-runtime")]
635		let development_processes = if manage_engine {
636			Some(DevelopmentProcessManager::start(&config).await?)
637		} else {
638			None
639		};
640		#[cfg(not(feature = "native-runtime"))]
641		if manage_engine {
642			anyhow::bail!("engine process spawning requires the `native-runtime` feature");
643		}
644
645		#[cfg(feature = "native-runtime")]
646		{
647			let pool_ready: Result<bool> = tokio::select! {
648				biased;
649				_ = shutdown.cancelled() => Ok(false),
650				result = timeout(ENVOY_START_TIMEOUT, runner_config::ensure_local_normal_runner_config(&config)) => {
651					result.context("local node pool configuration timed out").and_then(|result| result).map(|_| true)
652				}
653			};
654			if !matches!(&pool_ready, Ok(true)) {
655				if let Some(development_processes) = development_processes {
656					development_processes.shutdown().await;
657				}
658				return pool_ready.map(|_| ());
659			}
660		}
661		let callbacks = Arc::new(RegistryCallbacks {
662			dispatcher: dispatcher.clone(),
663		});
664
665		let prepopulate_actor_names = dispatcher
666			.build_actor_metadata_map()
667			.into_iter()
668			.map(|(name, metadata)| (name, rivet_envoy_client::config::ActorName { metadata }))
669			.collect();
670		let handle = start_envoy_sync(rivet_envoy_client::config::EnvoyConfig {
671			version: config.version,
672			endpoint: config.endpoint,
673			token: config.token,
674			namespace: config.namespace,
675			pool_name: config.pool_name,
676			prepopulate_actor_names,
677			metadata: Some(json!({
678				"rivetkit": { "version": config.serverless_package_version },
679			})),
680			// Each registry owns its dispatcher, endpoint, and shutdown lifetime.
681			not_global: true,
682			debug_latency_ms: None,
683			max_queued_requests_per_actor:
684				rivet_envoy_client::config::DEFAULT_MAX_QUEUED_REQUESTS_PER_ACTOR,
685			stopped_generation_floor_ttl:
686				rivet_envoy_client::config::DEFAULT_STOPPED_GENERATION_FLOOR_TTL,
687			stopped_generation_floor_capacity:
688				rivet_envoy_client::config::DEFAULT_STOPPED_GENERATION_FLOOR_CAPACITY,
689			callbacks,
690			connector: None,
691		});
692		let envoy_ready: Result<bool> = tokio::select! {
693			biased;
694			_ = shutdown.cancelled() => Ok(false),
695			result = timeout(ENVOY_START_TIMEOUT, handle.started()) => {
696				result.context("serverful envoy startup timed out").and_then(|result| result).map(|_| true)
697			}
698		};
699		if !matches!(&envoy_ready, Ok(true)) {
700			handle.shutdown_and_wait(true).await;
701			#[cfg(feature = "native-runtime")]
702			if let Some(development_processes) = development_processes {
703				development_processes.shutdown().await;
704			}
705			return envoy_ready.map(|_| ());
706		}
707		on_handle(CoreEnvoyHandle::new(handle.clone()));
708
709		// Do not install `tokio::signal::ctrl_c()` here. It calls
710		// `sigaction(SIGINT, ...)` at the POSIX level, which overrides the
711		// host's default SIGINT handling when rivetkit-core is embedded in
712		// Node via NAPI and leaves the host process unable to exit. Callers
713		// trip the `shutdown` token instead.
714		tokio::select! {
715			_ = shutdown.cancelled() => {},
716			_ = async {
717				handle.wait_stopped().await;
718				if handle.terminal_error().is_none() {
719					// Keep existing host-controlled shutdown behavior for ordinary eviction.
720					shutdown.cancelled().await;
721				}
722			} => {},
723		}
724
725		let shutdown_envoy = async {
726			if handle.terminal_error().is_some() {
727				handle.wait_stopped().await;
728				return;
729			}
730			if manage_engine {
731				// Remove the old host from the engine's active pool before sleeping
732				// actors. Otherwise a request racing the stopped report can start the
733				// next generation on this host just before it exits. Keep the socket
734				// open until the stopped reports have been acknowledged.
735				if !handle.mark_stopping().await {
736					tracing::warn!("could not mark local development envoy as stopping");
737				}
738				// A local dev reload replaces this host while keeping its engine. Ask
739				// each running actor to sleep so the engine can receive its stopped
740				// report and wake it on the new host.
741				let mut actors = Vec::new();
742				dispatcher
743					.actor_instances
744					.iter_async(|actor_id, state| {
745						if let Some(instance) = state.active_instance() {
746							actors.push((actor_id.clone(), instance.generation));
747						}
748						true
749					})
750					.await;
751				let actor_count = actors.len();
752				tracing::info!(
753					actors = actor_count,
754					"sleeping local development actors for shutdown"
755				);
756				for (actor_id, generation) in actors {
757					handle.sleep_actor(actor_id, Some(generation), None);
758				}
759				if actor_count > 0 {
760					// A stopped report sent just before closing the socket may not
761					// reach the engine. Wait for its acknowledgement before exiting.
762					let acknowledged = timeout(Duration::from_secs(3), async {
763						handle.wait_actors_drained().await;
764						handle.wait_for_events_acked().await
765					})
766					.await;
767					if !matches!(acknowledged, Ok(true)) {
768						tracing::warn!(
769							"local development actor stops were not acknowledged before shutdown"
770						);
771					}
772				}
773			}
774
775			// TODO: Move into envoy-client since timing out has to do with protocol compliance
776			// Read threshold from protocol metadata, fall back to 30 min
777			let stop_threshold = handle
778				.get_protocol_metadata()
779				.await
780				.map(|x| x.actor_stop_threshold)
781				.unwrap_or(30 * 60 * 1000);
782			// Bounded drain. If envoy cannot reach the engine (reconnect loop stuck),
783			// we fall back to immediate `Stop` rather than hanging indefinitely.
784			// The outer host (TS signal handler / Rust binary) is the backstop.
785			match timeout(
786				Duration::from_millis(stop_threshold as u64),
787				handle.shutdown_and_wait(false),
788			)
789			.await
790			{
791				Ok(()) => {}
792				Err(_) => {
793					tracing::warn!("envoy shutdown drain exceeded timeout; forcing immediate stop");
794					handle.shutdown(true);
795					handle.wait_stopped().await;
796				}
797			}
798		};
799		#[cfg(feature = "native-runtime")]
800		let shutdown_development_processes = async move {
801			if let Some(development_processes) = development_processes {
802				development_processes.shutdown().await;
803			}
804		};
805		#[cfg(feature = "native-runtime")]
806		{
807			shutdown_envoy.await;
808			shutdown_development_processes.await;
809		}
810		#[cfg(not(feature = "native-runtime"))]
811		shutdown_envoy.await;
812
813		match handle.terminal_error() {
814			Some(error) => Err(anyhow::anyhow!(error)),
815			None => Ok(()),
816		}
817	}
818
819	fn into_dispatcher(self, config: &ServeConfig) -> Arc<RegistryDispatcher> {
820		Arc::new(RegistryDispatcher::new(
821			self.factories,
822			config.handle_inspector_http_in_runtime,
823		))
824	}
825
826	pub async fn into_serverless_runtime(
827		self,
828		config: ServeConfig,
829	) -> Result<crate::serverless::CoreServerlessRuntime> {
830		crate::serverless::CoreServerlessRuntime::new(self.factories, config).await
831	}
832}
833
834impl RegistryDispatcher {
835	pub(crate) fn new(
836		factories: HashMap<String, Arc<ActorFactory>>,
837		handle_inspector_http_in_runtime: bool,
838	) -> Self {
839		Self {
840			factories,
841			actor_instances: SccHashMap::new(),
842			starting_instances: SccHashMap::new(),
843			pending_stops: SccHashMap::new(),
844			region: env::var("RIVET_REGION").unwrap_or_default(),
845			handle_inspector_http_in_runtime,
846			#[cfg(feature = "sqlite-local")]
847			sqlite_pool: sqlite_embedded::worker::SqlitePool::from_env(Some(Arc::new(
848				crate::actor::sqlite::pool_metrics::PoolMetrics,
849			))),
850		}
851	}
852
853	pub(crate) fn build_actor_metadata_map(&self) -> HashMap<String, JsonValue> {
854		build_actor_metadata_map_from_factories(&self.factories)
855	}
856}
857
858pub(crate) fn serverless_metadata_payload(
859	actor_metadata: HashMap<String, JsonValue>,
860	config: &ServeConfig,
861	envoy_kind: ServerlessMetadataEnvoyKind,
862) -> ServerlessMetadataPayload {
863	let actor_names = actor_metadata
864		.into_iter()
865		.map(|(name, metadata)| {
866			(
867				name,
868				ActorName {
869					metadata: Some(metadata),
870				},
871			)
872		})
873		.collect::<HashMap<_, _>>();
874
875	ServerlessMetadataPayload {
876		runtime: "rivetkit".to_owned(),
877		version: config.serverless_package_version.clone(),
878		envoy_protocol_version: Some(protocol::PROTOCOL_VERSION),
879		actor_names,
880		envoy: Some(ServerlessMetadataEnvoy {
881			kind: Some(envoy_kind),
882			version: Some(config.version),
883		}),
884		runner: None,
885		client_endpoint: config.serverless_client_endpoint.clone(),
886		client_namespace: config.serverless_client_namespace.clone(),
887		client_token: config.serverless_client_token.clone(),
888	}
889}
890
891fn build_actor_metadata_map_from_factories(
892	factories: &HashMap<String, Arc<ActorFactory>>,
893) -> HashMap<String, JsonValue> {
894	factories
895		.iter()
896		.map(|(actor_name, factory)| {
897			let config = factory.config();
898			let mut metadata = serde_json::Map::new();
899			if let Some(icon) = &config.icon {
900				metadata.insert("icon".to_owned(), json!(icon));
901			}
902			if let Some(name) = &config.name {
903				metadata.insert("name".to_owned(), json!(name));
904			}
905			(actor_name.clone(), JsonValue::Object(metadata))
906		})
907		.collect()
908}
909
910impl RegistryDispatcher {
911	async fn start_actor(self: &Arc<Self>, request: StartActorRequest) -> Result<()> {
912		let factory = self
913			.factories
914			.get(&request.actor_name)
915			.cloned()
916			.ok_or_else(|| {
917				ActorRuntime::NotRegistered {
918					actor_name: request.actor_name.clone(),
919				}
920				.build()
921			})?;
922		let _ = self
923			.starting_instances
924			.insert_async(
925				request.actor_id.clone(),
926				StartingInstance {
927					generation: request.generation,
928					pending_stop: None,
929				},
930			)
931			.await;
932		// Test-only seam: lets a test hold a generation in the "starting" window so it
933		// can deterministically deliver a stop for a previous generation that parks
934		// under the actor id and gets consumed by this startup.
935		
936		let (lifecycle_tx, lifecycle_rx) = mpsc::unbounded_channel();
937		let (dispatch_tx, dispatch_rx) = mpsc::unbounded_channel();
938		let (lifecycle_events_tx, lifecycle_events_rx) = mpsc::unbounded_channel();
939		request
940			.ctx
941			.configure_lifecycle_events(Some(lifecycle_events_tx));
942		request.ctx.cancel_sleep_timer();
943		request.ctx.set_local_alarm_callback(Some(Arc::new({
944			let lifecycle_tx = lifecycle_tx.clone();
945			move || {
946				let lifecycle_tx = lifecycle_tx.clone();
947				Box::pin(async move {
948					let (reply_tx, reply_rx) = oneshot::channel();
949					if let Err(error) = try_send_lifecycle_command(
950						&lifecycle_tx,
951						LifecycleCommand::FireAlarm { reply: reply_tx },
952					) {
953						tracing::warn!(?error, "failed to enqueue actor alarm");
954						return;
955					}
956					let _ = reply_rx.await;
957				})
958			}
959		})));
960		let task = ActorTask::new(
961			request.actor_id.clone(),
962			request.generation,
963			lifecycle_rx,
964			dispatch_rx,
965			lifecycle_events_rx,
966			factory.clone(),
967			request.ctx.clone(),
968			request.input,
969		);
970		let join = RuntimeSpawner::spawn(task.run());
971
972		let (start_tx, start_rx) = oneshot::channel();
973		let result: Result<Arc<ActorTaskHandle>> = async {
974			try_send_lifecycle_command(&lifecycle_tx, LifecycleCommand::Start { reply: start_tx })
975				.context("send actor task start command")?;
976			start_rx
977				.await
978				.context("receive actor task start reply")?
979				.context("actor task start")?;
980			let inspector = build_actor_inspector();
981			request.ctx.configure_inspector(Some(inspector.clone()));
982			Ok::<Arc<ActorTaskHandle>, anyhow::Error>(Arc::new(ActorTaskHandle {
983				actor_id: request.actor_id.clone(),
984				actor_name: request.actor_name.clone(),
985				generation: request.generation,
986				ctx: request.ctx.clone(),
987				factory,
988				inspector,
989				lifecycle: lifecycle_tx,
990				dispatch: dispatch_tx,
991				join: Arc::new(TokioMutex::new(Some(join))),
992			}))
993		}
994		.await
995		.with_context(|| format!("start actor `{}`", request.actor_id));
996
997		match result {
998			Ok(instance) => {
999				// Publish the instance before leaving the starting window. A stop that
1000				// no longer finds the starting entry then finds this instance instead.
1001				self.set_actor_instance_state(
1002					request.actor_id.clone(),
1003					ActorInstanceState::Active(instance),
1004				)
1005				.await;
1006				let parked_during_start = self
1007					.starting_instances
1008					.remove_async(&request.actor_id)
1009					.await
1010					.and_then(|(_, starting)| starting.pending_stop);
1011				let parked_before_start = self
1012					.pending_stops
1013					.remove_async(&request.actor_id)
1014					.await
1015					.map(|(_, pending_stop)| pending_stop);
1016
1017				// Only apply a parked stop if it targets the generation we just started.
1018				// A stop parked for a previous generation is stale: complete its handle
1019				// so teardown finalizes, but leave the new generation running.
1020				let mut current_generation_stop = None;
1021				for pending_stop in [parked_before_start, parked_during_start]
1022					.into_iter()
1023					.flatten()
1024				{
1025					if pending_stop.generation == request.generation
1026						&& current_generation_stop.is_none()
1027					{
1028						current_generation_stop = Some(pending_stop);
1029					} else {
1030						let _ = pending_stop.stop_handle.complete();
1031					}
1032				}
1033
1034				if let Some(pending_stop) = current_generation_stop {
1035					self.apply_stop_queued_during_startup(&request.actor_id, pending_stop)
1036						.await;
1037				}
1038				Ok(())
1039			}
1040			Err(error) => {
1041				// A stop parked while this start was in flight would otherwise leak its
1042				// ActorStopHandle in the map (the map holds the sender alive, hanging the
1043				// caller). Drain and complete it since there is no instance to stop.
1044				if let Some((_, starting)) = self
1045					.starting_instances
1046					.remove_async(&request.actor_id)
1047					.await && let Some(pending_stop) = starting.pending_stop
1048				{
1049					let _ = pending_stop.stop_handle.complete();
1050				}
1051				if let Some((_, pending_stop)) =
1052					self.pending_stops.remove_async(&request.actor_id).await
1053				{
1054					let _ = pending_stop.stop_handle.complete();
1055				}
1056				Err(error)
1057			}
1058		}
1059	}
1060
1061	/// Stops a just-started instance for a stop that targeted its generation
1062	/// before it was published. Shutdown runs on its own task so startup returns.
1063	async fn apply_stop_queued_during_startup(
1064		self: &Arc<Self>,
1065		actor_id: &str,
1066		pending_stop: PendingStop,
1067	) {
1068		let stop_reason = map_envoy_stop_reason(&pending_stop.reason);
1069		let instance = match self
1070			.transition_actor_to_stopping(actor_id, pending_stop.generation, stop_reason)
1071			.await
1072		{
1073			TransitionResult::Transitioned(instance) => instance,
1074			TransitionResult::Stale | TransitionResult::Vacant => {
1075				// The instance was replaced or removed after it was published, so
1076				// there is nothing left for this stop to tear down.
1077				let _ = pending_stop.stop_handle.complete();
1078				return;
1079			}
1080		};
1081
1082		let dispatcher = self.clone();
1083		let actor_id = actor_id.to_owned();
1084		RuntimeSpawner::spawn(async move {
1085			if let Err(error) = dispatcher
1086				.shutdown_started_instance(
1087					&actor_id,
1088					instance.clone(),
1089					pending_stop.reason,
1090					pending_stop.stop_handle,
1091				)
1092				.await
1093			{
1094				tracing::error!(
1095					actor_id,
1096					?error,
1097					"failed to stop actor queued during startup"
1098				);
1099			}
1100			dispatcher
1101				.remove_stopping_actor_instance(&actor_id, &instance)
1102				.await;
1103		});
1104	}
1105
1106	async fn set_actor_instance_state(&self, actor_id: String, state: ActorInstanceState) {
1107		match self.actor_instances.entry_async(actor_id).await {
1108			SccEntry::Occupied(mut entry) => {
1109				entry.insert(state);
1110			}
1111			SccEntry::Vacant(entry) => {
1112				entry.insert_entry(state);
1113			}
1114		}
1115	}
1116
1117	async fn transition_actor_to_stopping(
1118		&self,
1119		actor_id: &str,
1120		generation: u32,
1121		reason: ShutdownKind,
1122	) -> TransitionResult {
1123		match self.actor_instances.entry_async(actor_id.to_owned()).await {
1124			SccEntry::Occupied(mut entry) => {
1125				let instance = entry.get().instance();
1126				// A stop is scoped to the generation it was issued for. If the currently
1127				// registered instance is a different generation (e.g. a lost previous
1128				// generation whose replacement is already running), the stop is stale and
1129				// must not tear down the newer generation.
1130				if instance.generation != generation {
1131					return TransitionResult::Stale;
1132				}
1133				if matches!(entry.get(), ActorInstanceState::Active(_)) {
1134					entry.insert(ActorInstanceState::Stopping {
1135						instance: instance.clone(),
1136						reason,
1137					});
1138				} else {
1139					instance
1140						.ctx
1141						.warn_work_sent_to_stopping_instance("stop_actor");
1142				}
1143				TransitionResult::Transitioned(instance)
1144			}
1145			SccEntry::Vacant(entry) => {
1146				drop(entry);
1147				TransitionResult::Vacant
1148			}
1149		}
1150	}
1151
1152	async fn remove_stopping_actor_instance(&self, actor_id: &str, expected: &ActiveActorInstance) {
1153		match self.actor_instances.entry_async(actor_id.to_owned()).await {
1154			SccEntry::Occupied(entry) => {
1155				let should_remove = match entry.get() {
1156					ActorInstanceState::Stopping { instance, .. } => {
1157						Arc::ptr_eq(instance, expected)
1158					}
1159					ActorInstanceState::Active(_) => false,
1160				};
1161				if should_remove {
1162					let _ = entry.remove_entry();
1163				}
1164			}
1165			SccEntry::Vacant(entry) => {
1166				drop(entry);
1167			}
1168		}
1169	}
1170
1171	async fn active_actor(&self, actor_id: &str) -> Result<Arc<ActorTaskHandle>> {
1172		if let Some(instance) = self.actor_instances.get_async(&actor_id.to_owned()).await {
1173			match instance.get() {
1174				ActorInstanceState::Active(instance) => {
1175					let instance = instance.clone();
1176					// TODO: Share admission policy with ActorTask::dispatch_lifecycle_error.
1177					if instance.ctx.started() {
1178						if instance.ctx.destroy_requested() {
1179							instance
1180								.ctx
1181								.warn_work_sent_to_stopping_instance("active_actor");
1182							return Err(ActorLifecycleError::Destroying.build());
1183						}
1184						return Ok(instance);
1185					}
1186
1187					instance
1188						.ctx
1189						.warn_work_sent_to_stopping_instance("active_actor");
1190					return Err(if instance.ctx.destroy_requested() {
1191						ActorLifecycleError::Destroying.build()
1192					} else if instance.ctx.sleep_requested() {
1193						ActorLifecycleError::Stopping.build()
1194					} else {
1195						ActorLifecycleError::Starting.build()
1196					});
1197				}
1198				ActorInstanceState::Stopping { instance, reason } => {
1199					let instance = instance.clone();
1200					match reason {
1201						ShutdownKind::Sleep if instance.ctx.started() => return Ok(instance),
1202						ShutdownKind::Sleep => {
1203							instance
1204								.ctx
1205								.warn_work_sent_to_stopping_instance("active_actor");
1206							return Err(ActorLifecycleError::Stopping.build());
1207						}
1208						ShutdownKind::Destroy => {
1209							instance
1210								.ctx
1211								.warn_work_sent_to_stopping_instance("active_actor");
1212							return Err(ActorLifecycleError::Destroying.build());
1213						}
1214					}
1215				}
1216			}
1217		}
1218
1219		tracing::warn!(actor_id, "actor instance not found");
1220		Err(ActorRuntime::NotFound {
1221			resource: "instance".to_owned(),
1222			id: actor_id.to_owned(),
1223		}
1224		.build())
1225	}
1226
1227	async fn stop_actor(
1228		&self,
1229		actor_id: &str,
1230		generation: u32,
1231		reason: protocol::StopActorReason,
1232		stop_handle: ActorStopHandle,
1233	) -> Result<()> {
1234		let mut pending_stop = PendingStop {
1235			generation,
1236			reason,
1237			stop_handle,
1238		};
1239		loop {
1240			// While a start is in flight, park the stop in its entry so startup can
1241			// decide whether it belongs to the generation being started.
1242			match self
1243				.starting_instances
1244				.entry_async(actor_id.to_owned())
1245				.await
1246			{
1247				SccEntry::Occupied(mut entry) => {
1248					let starting = entry.get_mut();
1249					let rejected = if starting.generation != pending_stop.generation {
1250						// The stop targets a generation other than the one being
1251						// started, so it is stale. It must not displace a parked stop
1252						// for the starting generation.
1253						Some(pending_stop)
1254					} else if starting.pending_stop.is_some() {
1255						// A stop for this generation is already parked and will be
1256						// applied by startup. Keep the first one.
1257						Some(pending_stop)
1258					} else {
1259						starting.pending_stop = Some(pending_stop);
1260						None
1261					};
1262					drop(entry);
1263
1264					if let Some(rejected) = rejected {
1265						let _ = rejected.stop_handle.complete();
1266					}
1267					return Ok(());
1268				}
1269				SccEntry::Vacant(entry) => {
1270					drop(entry);
1271				}
1272			}
1273
1274			let task_stop_reason = map_envoy_stop_reason(&pending_stop.reason);
1275			match self
1276				.transition_actor_to_stopping(actor_id, pending_stop.generation, task_stop_reason)
1277				.await
1278			{
1279				TransitionResult::Transitioned(instance) => {
1280					let result = self
1281						.shutdown_started_instance(
1282							actor_id,
1283							instance.clone(),
1284							pending_stop.reason,
1285							pending_stop.stop_handle,
1286						)
1287						.await;
1288					self.remove_stopping_actor_instance(actor_id, &instance)
1289						.await;
1290					return result;
1291				}
1292				TransitionResult::Stale => {
1293					// The running instance is a different generation; this stop targets a
1294					// generation that is already gone. Complete the handle so envoy-client
1295					// finalizes teardown cleanly instead of warning about a dropped handle.
1296					let _ = pending_stop.stop_handle.complete();
1297					return Ok(());
1298				}
1299				TransitionResult::Vacant => {}
1300			}
1301
1302			
1303
1304			// No instance is registered, so park the stop for the next start of this
1305			// actor id. A start publishes its instance before it drains this map, so
1306			// if one drained the map before this insert, its instance is visible now
1307			// and the stop is reclaimed and retried against it.
1308			if self
1309				.pending_stops
1310				.insert_async(actor_id.to_owned(), pending_stop)
1311				.await
1312				.is_err()
1313			{
1314				return Ok(());
1315			}
1316			if !self
1317				.actor_instances
1318				.contains_async(&actor_id.to_owned())
1319				.await
1320			{
1321				return Ok(());
1322			}
1323			let Some((_, reclaimed)) = self
1324				.pending_stops
1325				.remove_if_async(&actor_id.to_owned(), |parked| {
1326					parked.generation == generation
1327				})
1328				.await
1329			else {
1330				// A start drained the stop concurrently and now owns it.
1331				return Ok(());
1332			};
1333			pending_stop = reclaimed;
1334		}
1335	}
1336
1337	async fn shutdown_started_instance(
1338		&self,
1339		actor_id: &str,
1340		instance: Arc<ActorTaskHandle>,
1341		reason: protocol::StopActorReason,
1342		stop_handle: ActorStopHandle,
1343	) -> Result<()> {
1344		let task_stop_reason = map_envoy_stop_reason(&reason);
1345
1346		if matches!(task_stop_reason, ShutdownKind::Destroy) {
1347			instance.ctx.mark_destroy_requested();
1348		}
1349
1350		tracing::debug!(
1351			actor_id,
1352			handle_actor_id = %instance.actor_id,
1353			actor_name = %instance.actor_name,
1354			generation = instance.generation,
1355			?reason,
1356			?task_stop_reason,
1357			"stopping actor instance"
1358		);
1359
1360		let (reply_tx, reply_rx) = oneshot::channel();
1361		let shutdown_result = match try_send_lifecycle_command(
1362			&instance.lifecycle,
1363			LifecycleCommand::Stop {
1364				reason: task_stop_reason,
1365				reply: reply_tx,
1366			},
1367		) {
1368			Ok(()) => reply_rx
1369				.await
1370				.context("receive actor task stop reply")
1371				.and_then(|result| result),
1372			Err(error) => Err(error),
1373		};
1374
1375		if matches!(task_stop_reason, ShutdownKind::Destroy) {
1376			let shutdown_deadline =
1377				Instant::now() + instance.factory.config().effective_sleep_grace_period();
1378			if !instance
1379				.ctx
1380				.wait_for_internal_keep_awake_idle(shutdown_deadline)
1381				.await
1382			{
1383				instance.ctx.record_direct_subsystem_shutdown_warning(
1384					"internal_keep_awake",
1385					"destroy_drain",
1386				);
1387				tracing::warn!(
1388					actor_id,
1389					"destroy shutdown timed out waiting for in-flight actions"
1390				);
1391			}
1392			if !instance
1393				.ctx
1394				.wait_for_http_requests_drained(shutdown_deadline)
1395				.await
1396			{
1397				instance
1398					.ctx
1399					.record_direct_subsystem_shutdown_warning("http_requests", "destroy_drain");
1400				tracing::warn!(
1401					actor_id,
1402					"destroy shutdown timed out waiting for in-flight http requests"
1403				);
1404			}
1405		}
1406
1407		let mut join_guard = instance.join.lock().await;
1408		// Fold the join outcome into the result instead of propagating with `?`,
1409		// so the stop handle is always signaled and never dropped unsignaled.
1410		let join_result = if let Some(join) = join_guard.take() {
1411			join.await
1412				.context("join actor task")
1413				.and_then(|result| result.context("actor task failed"))
1414		} else {
1415			Ok(())
1416		};
1417		instance.ctx.configure_lifecycle_events(None);
1418
1419		if let (Err(shutdown_error), Err(join_error)) = (&shutdown_result, &join_result) {
1420			tracing::warn!(
1421				actor_id,
1422				%shutdown_error,
1423				discarded_join_error = %join_error,
1424				"actor stop had both shutdown and join failures; only the shutdown error is returned"
1425			);
1426		}
1427
1428		let final_result = shutdown_result.and(join_result);
1429		match &final_result {
1430			Ok(_) => {
1431				let _ = stop_handle.complete();
1432			}
1433			Err(error) => {
1434				let _ = stop_handle.fail(anyhow::Error::new(RivetError::extract(error)));
1435			}
1436		}
1437
1438		final_result.with_context(|| format!("stop actor `{actor_id}`"))
1439	}
1440}
1441
1442impl RegistryDispatcher {
1443	fn can_hibernate(&self, actor_id: &str, request: &HttpRequest) -> bool {
1444		let Some(instance) = self
1445			.actor_instances
1446			.read_sync(actor_id, |_, state| state.active_instance())
1447			.flatten()
1448		else {
1449			return false;
1450		};
1451
1452		match &instance.factory.config().can_hibernate_websocket {
1453			CanHibernateWebSocket::Bool(value) => *value,
1454			CanHibernateWebSocket::Callback(callback) => callback(request),
1455		}
1456	}
1457
1458	#[allow(clippy::too_many_arguments)]
1459	fn build_actor_context(
1460		&self,
1461		handle: EnvoyHandle,
1462		actor_id: &str,
1463		generation: u32,
1464		actor_name: &str,
1465		key: ActorKey,
1466		factory: &ActorFactory,
1467	) -> Result<ActorContext> {
1468		let formatted_key = format_actor_key(&key);
1469		#[cfg_attr(not(feature = "sqlite-local"), allow(unused_mut))]
1470		let mut sql = SqliteDb::new_with_remote_sqlite(
1471			handle.clone(),
1472			actor_id.to_owned(),
1473			Some(formatted_key),
1474			Some(generation as u64),
1475			factory.config().has_database,
1476			factory.config().remote_sqlite,
1477		)?;
1478		#[cfg(feature = "sqlite-local")]
1479		sql.set_worker_pool(self.sqlite_pool.clone());
1480		let ctx = ActorContext::build(
1481			actor_id.to_owned(),
1482			actor_name.to_owned(),
1483			key,
1484			self.region.clone(),
1485			Some(generation),
1486			handle.get_envoy_key().to_owned(),
1487			factory.config().clone(),
1488			LegacyActorKv::new(handle.clone(), actor_id.to_owned()),
1489			sql,
1490		);
1491		ctx.configure_envoy(handle, Some(generation));
1492		Ok(ctx)
1493	}
1494}
1495
1496/// Maps an envoy-protocol stop reason to the lifecycle `ShutdownKind` used by
1497/// `ActorTask`. Reallocation paths (the actor will resurrect on a new envoy)
1498/// are routed through `Sleep` so user `onSleep` runs and durable state is
1499/// preserved without firing a permanent destroy.
1500fn map_envoy_stop_reason(reason: &protocol::StopActorReason) -> ShutdownKind {
1501	match reason {
1502		// Idle sleep requested by the actor itself.
1503		protocol::StopActorReason::SleepIntent => ShutdownKind::Sleep,
1504		// Runner is being drained; engine will reallocate the actor on a new
1505		// envoy. Treat as sleep so persistent state and onSleep semantics hold.
1506		protocol::StopActorReason::GoingAway => ShutdownKind::Sleep,
1507		// Runner connection lost; once reconnected (or another runner is
1508		// allocated) the actor resurrects with the same id.
1509		protocol::StopActorReason::Lost => ShutdownKind::Sleep,
1510		// User-initiated stop intent (`ctx.destroy()` and equivalents).
1511		protocol::StopActorReason::StopIntent => ShutdownKind::Destroy,
1512		// Engine-initiated permanent destroy.
1513		protocol::StopActorReason::Destroy => ShutdownKind::Destroy,
1514	}
1515}
1516
1517// Test shim keeps moved tests in crate-root tests/ with private-module access.
1518
1519
1520// Test-only hooks used by the moved registry tests to deterministically drive the
1521// generation-stop race. Gated behind `cfg(test)` so there is no production impact.
1522