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			not_global: false,
681			debug_latency_ms: None,
682			max_queued_requests_per_actor:
683				rivet_envoy_client::config::DEFAULT_MAX_QUEUED_REQUESTS_PER_ACTOR,
684			stopped_generation_floor_ttl:
685				rivet_envoy_client::config::DEFAULT_STOPPED_GENERATION_FLOOR_TTL,
686			stopped_generation_floor_capacity:
687				rivet_envoy_client::config::DEFAULT_STOPPED_GENERATION_FLOOR_CAPACITY,
688			callbacks,
689			connector: None,
690		});
691		let envoy_ready: Result<bool> = tokio::select! {
692			biased;
693			_ = shutdown.cancelled() => Ok(false),
694			result = timeout(ENVOY_START_TIMEOUT, handle.started()) => {
695				result.context("serverful envoy startup timed out").and_then(|result| result).map(|_| true)
696			}
697		};
698		if !matches!(&envoy_ready, Ok(true)) {
699			handle.shutdown_and_wait(true).await;
700			#[cfg(feature = "native-runtime")]
701			if let Some(development_processes) = development_processes {
702				development_processes.shutdown().await;
703			}
704			return envoy_ready.map(|_| ());
705		}
706		on_handle(CoreEnvoyHandle::new(handle.clone()));
707
708		// Do not install `tokio::signal::ctrl_c()` here. It calls
709		// `sigaction(SIGINT, ...)` at the POSIX level, which overrides the
710		// host's default SIGINT handling when rivetkit-core is embedded in
711		// Node via NAPI and leaves the host process unable to exit. Callers
712		// trip the `shutdown` token instead.
713		shutdown.cancelled().await;
714
715		let shutdown_envoy = async {
716			if manage_engine {
717				// Remove the old host from the engine's active pool before sleeping
718				// actors. Otherwise a request racing the stopped report can start the
719				// next generation on this host just before it exits. Keep the socket
720				// open until the stopped reports have been acknowledged.
721				if !handle.mark_stopping().await {
722					tracing::warn!("could not mark local development envoy as stopping");
723				}
724				// A local dev reload replaces this host while keeping its engine. Ask
725				// each running actor to sleep so the engine can receive its stopped
726				// report and wake it on the new host.
727				let mut actors = Vec::new();
728				dispatcher
729					.actor_instances
730					.iter_async(|actor_id, state| {
731						if let Some(instance) = state.active_instance() {
732							actors.push((actor_id.clone(), instance.generation));
733						}
734						true
735					})
736					.await;
737				let actor_count = actors.len();
738				tracing::info!(
739					actors = actor_count,
740					"sleeping local development actors for shutdown"
741				);
742				for (actor_id, generation) in actors {
743					handle.sleep_actor(actor_id, Some(generation), None);
744				}
745				if actor_count > 0 {
746					// A stopped report sent just before closing the socket may not
747					// reach the engine. Wait for its acknowledgement before exiting.
748					let acknowledged = timeout(Duration::from_secs(3), async {
749						handle.wait_actors_drained().await;
750						handle.wait_for_events_acked().await
751					})
752					.await;
753					if !matches!(acknowledged, Ok(true)) {
754						tracing::warn!(
755							"local development actor stops were not acknowledged before shutdown"
756						);
757					}
758				}
759			}
760
761			// TODO: Move into envoy-client since timing out has to do with protocol compliance
762			// Read threshold from protocol metadata, fall back to 30 min
763			let stop_threshold = handle
764				.get_protocol_metadata()
765				.await
766				.map(|x| x.actor_stop_threshold)
767				.unwrap_or(30 * 60 * 1000);
768			// Bounded drain. If envoy cannot reach the engine (reconnect loop stuck),
769			// we fall back to immediate `Stop` rather than hanging indefinitely.
770			// The outer host (TS signal handler / Rust binary) is the backstop.
771			match timeout(
772				Duration::from_millis(stop_threshold as u64),
773				handle.shutdown_and_wait(false),
774			)
775			.await
776			{
777				Ok(()) => {}
778				Err(_) => {
779					tracing::warn!("envoy shutdown drain exceeded timeout; forcing immediate stop");
780					handle.shutdown(true);
781					handle.wait_stopped().await;
782				}
783			}
784		};
785		#[cfg(feature = "native-runtime")]
786		let shutdown_development_processes = async move {
787			if let Some(development_processes) = development_processes {
788				development_processes.shutdown().await;
789			}
790		};
791		#[cfg(feature = "native-runtime")]
792		{
793			shutdown_envoy.await;
794			shutdown_development_processes.await;
795		}
796		#[cfg(not(feature = "native-runtime"))]
797		shutdown_envoy.await;
798
799		Ok(())
800	}
801
802	fn into_dispatcher(self, config: &ServeConfig) -> Arc<RegistryDispatcher> {
803		Arc::new(RegistryDispatcher::new(
804			self.factories,
805			config.handle_inspector_http_in_runtime,
806		))
807	}
808
809	pub async fn into_serverless_runtime(
810		self,
811		config: ServeConfig,
812	) -> Result<crate::serverless::CoreServerlessRuntime> {
813		crate::serverless::CoreServerlessRuntime::new(self.factories, config).await
814	}
815}
816
817impl RegistryDispatcher {
818	pub(crate) fn new(
819		factories: HashMap<String, Arc<ActorFactory>>,
820		handle_inspector_http_in_runtime: bool,
821	) -> Self {
822		Self {
823			factories,
824			actor_instances: SccHashMap::new(),
825			starting_instances: SccHashMap::new(),
826			pending_stops: SccHashMap::new(),
827			region: env::var("RIVET_REGION").unwrap_or_default(),
828			handle_inspector_http_in_runtime,
829			#[cfg(feature = "sqlite-local")]
830			sqlite_pool: sqlite_embedded::worker::SqlitePool::from_env(Some(Arc::new(
831				crate::actor::sqlite::pool_metrics::PoolMetrics,
832			))),
833		}
834	}
835
836	pub(crate) fn build_actor_metadata_map(&self) -> HashMap<String, JsonValue> {
837		build_actor_metadata_map_from_factories(&self.factories)
838	}
839}
840
841pub(crate) fn serverless_metadata_payload(
842	actor_metadata: HashMap<String, JsonValue>,
843	config: &ServeConfig,
844	envoy_kind: ServerlessMetadataEnvoyKind,
845) -> ServerlessMetadataPayload {
846	let actor_names = actor_metadata
847		.into_iter()
848		.map(|(name, metadata)| {
849			(
850				name,
851				ActorName {
852					metadata: Some(metadata),
853				},
854			)
855		})
856		.collect::<HashMap<_, _>>();
857
858	ServerlessMetadataPayload {
859		runtime: "rivetkit".to_owned(),
860		version: config.serverless_package_version.clone(),
861		envoy_protocol_version: Some(protocol::PROTOCOL_VERSION),
862		actor_names,
863		envoy: Some(ServerlessMetadataEnvoy {
864			kind: Some(envoy_kind),
865			version: Some(config.version),
866		}),
867		runner: None,
868		client_endpoint: config.serverless_client_endpoint.clone(),
869		client_namespace: config.serverless_client_namespace.clone(),
870		client_token: config.serverless_client_token.clone(),
871	}
872}
873
874fn build_actor_metadata_map_from_factories(
875	factories: &HashMap<String, Arc<ActorFactory>>,
876) -> HashMap<String, JsonValue> {
877	factories
878		.iter()
879		.map(|(actor_name, factory)| {
880			let config = factory.config();
881			let mut metadata = serde_json::Map::new();
882			if let Some(icon) = &config.icon {
883				metadata.insert("icon".to_owned(), json!(icon));
884			}
885			if let Some(name) = &config.name {
886				metadata.insert("name".to_owned(), json!(name));
887			}
888			(actor_name.clone(), JsonValue::Object(metadata))
889		})
890		.collect()
891}
892
893impl RegistryDispatcher {
894	async fn start_actor(self: &Arc<Self>, request: StartActorRequest) -> Result<()> {
895		let factory = self
896			.factories
897			.get(&request.actor_name)
898			.cloned()
899			.ok_or_else(|| {
900				ActorRuntime::NotRegistered {
901					actor_name: request.actor_name.clone(),
902				}
903				.build()
904			})?;
905		let _ = self
906			.starting_instances
907			.insert_async(
908				request.actor_id.clone(),
909				StartingInstance {
910					generation: request.generation,
911					pending_stop: None,
912				},
913			)
914			.await;
915		// Test-only seam: lets a test hold a generation in the "starting" window so it
916		// can deterministically deliver a stop for a previous generation that parks
917		// under the actor id and gets consumed by this startup.
918		
919		let (lifecycle_tx, lifecycle_rx) = mpsc::unbounded_channel();
920		let (dispatch_tx, dispatch_rx) = mpsc::unbounded_channel();
921		let (lifecycle_events_tx, lifecycle_events_rx) = mpsc::unbounded_channel();
922		request
923			.ctx
924			.configure_lifecycle_events(Some(lifecycle_events_tx));
925		request.ctx.cancel_sleep_timer();
926		request.ctx.set_local_alarm_callback(Some(Arc::new({
927			let lifecycle_tx = lifecycle_tx.clone();
928			move || {
929				let lifecycle_tx = lifecycle_tx.clone();
930				Box::pin(async move {
931					let (reply_tx, reply_rx) = oneshot::channel();
932					if let Err(error) = try_send_lifecycle_command(
933						&lifecycle_tx,
934						LifecycleCommand::FireAlarm { reply: reply_tx },
935					) {
936						tracing::warn!(?error, "failed to enqueue actor alarm");
937						return;
938					}
939					let _ = reply_rx.await;
940				})
941			}
942		})));
943		let task = ActorTask::new(
944			request.actor_id.clone(),
945			request.generation,
946			lifecycle_rx,
947			dispatch_rx,
948			lifecycle_events_rx,
949			factory.clone(),
950			request.ctx.clone(),
951			request.input,
952		);
953		let join = RuntimeSpawner::spawn(task.run());
954
955		let (start_tx, start_rx) = oneshot::channel();
956		let result: Result<Arc<ActorTaskHandle>> = async {
957			try_send_lifecycle_command(&lifecycle_tx, LifecycleCommand::Start { reply: start_tx })
958				.context("send actor task start command")?;
959			start_rx
960				.await
961				.context("receive actor task start reply")?
962				.context("actor task start")?;
963			let inspector = build_actor_inspector();
964			request.ctx.configure_inspector(Some(inspector.clone()));
965			Ok::<Arc<ActorTaskHandle>, anyhow::Error>(Arc::new(ActorTaskHandle {
966				actor_id: request.actor_id.clone(),
967				actor_name: request.actor_name.clone(),
968				generation: request.generation,
969				ctx: request.ctx.clone(),
970				factory,
971				inspector,
972				lifecycle: lifecycle_tx,
973				dispatch: dispatch_tx,
974				join: Arc::new(TokioMutex::new(Some(join))),
975			}))
976		}
977		.await
978		.with_context(|| format!("start actor `{}`", request.actor_id));
979
980		match result {
981			Ok(instance) => {
982				// Publish the instance before leaving the starting window. A stop that
983				// no longer finds the starting entry then finds this instance instead.
984				self.set_actor_instance_state(
985					request.actor_id.clone(),
986					ActorInstanceState::Active(instance),
987				)
988				.await;
989				let parked_during_start = self
990					.starting_instances
991					.remove_async(&request.actor_id)
992					.await
993					.and_then(|(_, starting)| starting.pending_stop);
994				let parked_before_start = self
995					.pending_stops
996					.remove_async(&request.actor_id)
997					.await
998					.map(|(_, pending_stop)| pending_stop);
999
1000				// Only apply a parked stop if it targets the generation we just started.
1001				// A stop parked for a previous generation is stale: complete its handle
1002				// so teardown finalizes, but leave the new generation running.
1003				let mut current_generation_stop = None;
1004				for pending_stop in [parked_before_start, parked_during_start]
1005					.into_iter()
1006					.flatten()
1007				{
1008					if pending_stop.generation == request.generation
1009						&& current_generation_stop.is_none()
1010					{
1011						current_generation_stop = Some(pending_stop);
1012					} else {
1013						let _ = pending_stop.stop_handle.complete();
1014					}
1015				}
1016
1017				if let Some(pending_stop) = current_generation_stop {
1018					self.apply_stop_queued_during_startup(&request.actor_id, pending_stop)
1019						.await;
1020				}
1021				Ok(())
1022			}
1023			Err(error) => {
1024				// A stop parked while this start was in flight would otherwise leak its
1025				// ActorStopHandle in the map (the map holds the sender alive, hanging the
1026				// caller). Drain and complete it since there is no instance to stop.
1027				if let Some((_, starting)) = self
1028					.starting_instances
1029					.remove_async(&request.actor_id)
1030					.await && let Some(pending_stop) = starting.pending_stop
1031				{
1032					let _ = pending_stop.stop_handle.complete();
1033				}
1034				if let Some((_, pending_stop)) =
1035					self.pending_stops.remove_async(&request.actor_id).await
1036				{
1037					let _ = pending_stop.stop_handle.complete();
1038				}
1039				Err(error)
1040			}
1041		}
1042	}
1043
1044	/// Stops a just-started instance for a stop that targeted its generation
1045	/// before it was published. Shutdown runs on its own task so startup returns.
1046	async fn apply_stop_queued_during_startup(
1047		self: &Arc<Self>,
1048		actor_id: &str,
1049		pending_stop: PendingStop,
1050	) {
1051		let stop_reason = map_envoy_stop_reason(&pending_stop.reason);
1052		let instance = match self
1053			.transition_actor_to_stopping(actor_id, pending_stop.generation, stop_reason)
1054			.await
1055		{
1056			TransitionResult::Transitioned(instance) => instance,
1057			TransitionResult::Stale | TransitionResult::Vacant => {
1058				// The instance was replaced or removed after it was published, so
1059				// there is nothing left for this stop to tear down.
1060				let _ = pending_stop.stop_handle.complete();
1061				return;
1062			}
1063		};
1064
1065		let dispatcher = self.clone();
1066		let actor_id = actor_id.to_owned();
1067		RuntimeSpawner::spawn(async move {
1068			if let Err(error) = dispatcher
1069				.shutdown_started_instance(
1070					&actor_id,
1071					instance.clone(),
1072					pending_stop.reason,
1073					pending_stop.stop_handle,
1074				)
1075				.await
1076			{
1077				tracing::error!(
1078					actor_id,
1079					?error,
1080					"failed to stop actor queued during startup"
1081				);
1082			}
1083			dispatcher
1084				.remove_stopping_actor_instance(&actor_id, &instance)
1085				.await;
1086		});
1087	}
1088
1089	async fn set_actor_instance_state(&self, actor_id: String, state: ActorInstanceState) {
1090		match self.actor_instances.entry_async(actor_id).await {
1091			SccEntry::Occupied(mut entry) => {
1092				entry.insert(state);
1093			}
1094			SccEntry::Vacant(entry) => {
1095				entry.insert_entry(state);
1096			}
1097		}
1098	}
1099
1100	async fn transition_actor_to_stopping(
1101		&self,
1102		actor_id: &str,
1103		generation: u32,
1104		reason: ShutdownKind,
1105	) -> TransitionResult {
1106		match self.actor_instances.entry_async(actor_id.to_owned()).await {
1107			SccEntry::Occupied(mut entry) => {
1108				let instance = entry.get().instance();
1109				// A stop is scoped to the generation it was issued for. If the currently
1110				// registered instance is a different generation (e.g. a lost previous
1111				// generation whose replacement is already running), the stop is stale and
1112				// must not tear down the newer generation.
1113				if instance.generation != generation {
1114					return TransitionResult::Stale;
1115				}
1116				if matches!(entry.get(), ActorInstanceState::Active(_)) {
1117					entry.insert(ActorInstanceState::Stopping {
1118						instance: instance.clone(),
1119						reason,
1120					});
1121				} else {
1122					instance
1123						.ctx
1124						.warn_work_sent_to_stopping_instance("stop_actor");
1125				}
1126				TransitionResult::Transitioned(instance)
1127			}
1128			SccEntry::Vacant(entry) => {
1129				drop(entry);
1130				TransitionResult::Vacant
1131			}
1132		}
1133	}
1134
1135	async fn remove_stopping_actor_instance(&self, actor_id: &str, expected: &ActiveActorInstance) {
1136		match self.actor_instances.entry_async(actor_id.to_owned()).await {
1137			SccEntry::Occupied(entry) => {
1138				let should_remove = match entry.get() {
1139					ActorInstanceState::Stopping { instance, .. } => {
1140						Arc::ptr_eq(instance, expected)
1141					}
1142					ActorInstanceState::Active(_) => false,
1143				};
1144				if should_remove {
1145					let _ = entry.remove_entry();
1146				}
1147			}
1148			SccEntry::Vacant(entry) => {
1149				drop(entry);
1150			}
1151		}
1152	}
1153
1154	async fn active_actor(&self, actor_id: &str) -> Result<Arc<ActorTaskHandle>> {
1155		if let Some(instance) = self.actor_instances.get_async(&actor_id.to_owned()).await {
1156			match instance.get() {
1157				ActorInstanceState::Active(instance) => {
1158					let instance = instance.clone();
1159					// TODO: Share admission policy with ActorTask::dispatch_lifecycle_error.
1160					if instance.ctx.started() {
1161						if instance.ctx.destroy_requested() {
1162							instance
1163								.ctx
1164								.warn_work_sent_to_stopping_instance("active_actor");
1165							return Err(ActorLifecycleError::Destroying.build());
1166						}
1167						return Ok(instance);
1168					}
1169
1170					instance
1171						.ctx
1172						.warn_work_sent_to_stopping_instance("active_actor");
1173					return Err(if instance.ctx.destroy_requested() {
1174						ActorLifecycleError::Destroying.build()
1175					} else if instance.ctx.sleep_requested() {
1176						ActorLifecycleError::Stopping.build()
1177					} else {
1178						ActorLifecycleError::Starting.build()
1179					});
1180				}
1181				ActorInstanceState::Stopping { instance, reason } => {
1182					let instance = instance.clone();
1183					match reason {
1184						ShutdownKind::Sleep if instance.ctx.started() => return Ok(instance),
1185						ShutdownKind::Sleep => {
1186							instance
1187								.ctx
1188								.warn_work_sent_to_stopping_instance("active_actor");
1189							return Err(ActorLifecycleError::Stopping.build());
1190						}
1191						ShutdownKind::Destroy => {
1192							instance
1193								.ctx
1194								.warn_work_sent_to_stopping_instance("active_actor");
1195							return Err(ActorLifecycleError::Destroying.build());
1196						}
1197					}
1198				}
1199			}
1200		}
1201
1202		tracing::warn!(actor_id, "actor instance not found");
1203		Err(ActorRuntime::NotFound {
1204			resource: "instance".to_owned(),
1205			id: actor_id.to_owned(),
1206		}
1207		.build())
1208	}
1209
1210	async fn stop_actor(
1211		&self,
1212		actor_id: &str,
1213		generation: u32,
1214		reason: protocol::StopActorReason,
1215		stop_handle: ActorStopHandle,
1216	) -> Result<()> {
1217		let mut pending_stop = PendingStop {
1218			generation,
1219			reason,
1220			stop_handle,
1221		};
1222		loop {
1223			// While a start is in flight, park the stop in its entry so startup can
1224			// decide whether it belongs to the generation being started.
1225			match self
1226				.starting_instances
1227				.entry_async(actor_id.to_owned())
1228				.await
1229			{
1230				SccEntry::Occupied(mut entry) => {
1231					let starting = entry.get_mut();
1232					let rejected = if starting.generation != pending_stop.generation {
1233						// The stop targets a generation other than the one being
1234						// started, so it is stale. It must not displace a parked stop
1235						// for the starting generation.
1236						Some(pending_stop)
1237					} else if starting.pending_stop.is_some() {
1238						// A stop for this generation is already parked and will be
1239						// applied by startup. Keep the first one.
1240						Some(pending_stop)
1241					} else {
1242						starting.pending_stop = Some(pending_stop);
1243						None
1244					};
1245					drop(entry);
1246
1247					if let Some(rejected) = rejected {
1248						let _ = rejected.stop_handle.complete();
1249					}
1250					return Ok(());
1251				}
1252				SccEntry::Vacant(entry) => {
1253					drop(entry);
1254				}
1255			}
1256
1257			let task_stop_reason = map_envoy_stop_reason(&pending_stop.reason);
1258			match self
1259				.transition_actor_to_stopping(actor_id, pending_stop.generation, task_stop_reason)
1260				.await
1261			{
1262				TransitionResult::Transitioned(instance) => {
1263					let result = self
1264						.shutdown_started_instance(
1265							actor_id,
1266							instance.clone(),
1267							pending_stop.reason,
1268							pending_stop.stop_handle,
1269						)
1270						.await;
1271					self.remove_stopping_actor_instance(actor_id, &instance)
1272						.await;
1273					return result;
1274				}
1275				TransitionResult::Stale => {
1276					// The running instance is a different generation; this stop targets a
1277					// generation that is already gone. Complete the handle so envoy-client
1278					// finalizes teardown cleanly instead of warning about a dropped handle.
1279					let _ = pending_stop.stop_handle.complete();
1280					return Ok(());
1281				}
1282				TransitionResult::Vacant => {}
1283			}
1284
1285			
1286
1287			// No instance is registered, so park the stop for the next start of this
1288			// actor id. A start publishes its instance before it drains this map, so
1289			// if one drained the map before this insert, its instance is visible now
1290			// and the stop is reclaimed and retried against it.
1291			if self
1292				.pending_stops
1293				.insert_async(actor_id.to_owned(), pending_stop)
1294				.await
1295				.is_err()
1296			{
1297				return Ok(());
1298			}
1299			if !self
1300				.actor_instances
1301				.contains_async(&actor_id.to_owned())
1302				.await
1303			{
1304				return Ok(());
1305			}
1306			let Some((_, reclaimed)) = self
1307				.pending_stops
1308				.remove_if_async(&actor_id.to_owned(), |parked| {
1309					parked.generation == generation
1310				})
1311				.await
1312			else {
1313				// A start drained the stop concurrently and now owns it.
1314				return Ok(());
1315			};
1316			pending_stop = reclaimed;
1317		}
1318	}
1319
1320	async fn shutdown_started_instance(
1321		&self,
1322		actor_id: &str,
1323		instance: Arc<ActorTaskHandle>,
1324		reason: protocol::StopActorReason,
1325		stop_handle: ActorStopHandle,
1326	) -> Result<()> {
1327		let task_stop_reason = map_envoy_stop_reason(&reason);
1328
1329		if matches!(task_stop_reason, ShutdownKind::Destroy) {
1330			instance.ctx.mark_destroy_requested();
1331		}
1332
1333		tracing::debug!(
1334			actor_id,
1335			handle_actor_id = %instance.actor_id,
1336			actor_name = %instance.actor_name,
1337			generation = instance.generation,
1338			?reason,
1339			?task_stop_reason,
1340			"stopping actor instance"
1341		);
1342
1343		let (reply_tx, reply_rx) = oneshot::channel();
1344		let shutdown_result = match try_send_lifecycle_command(
1345			&instance.lifecycle,
1346			LifecycleCommand::Stop {
1347				reason: task_stop_reason,
1348				reply: reply_tx,
1349			},
1350		) {
1351			Ok(()) => reply_rx
1352				.await
1353				.context("receive actor task stop reply")
1354				.and_then(|result| result),
1355			Err(error) => Err(error),
1356		};
1357
1358		if matches!(task_stop_reason, ShutdownKind::Destroy) {
1359			let shutdown_deadline =
1360				Instant::now() + instance.factory.config().effective_sleep_grace_period();
1361			if !instance
1362				.ctx
1363				.wait_for_internal_keep_awake_idle(shutdown_deadline)
1364				.await
1365			{
1366				instance.ctx.record_direct_subsystem_shutdown_warning(
1367					"internal_keep_awake",
1368					"destroy_drain",
1369				);
1370				tracing::warn!(
1371					actor_id,
1372					"destroy shutdown timed out waiting for in-flight actions"
1373				);
1374			}
1375			if !instance
1376				.ctx
1377				.wait_for_http_requests_drained(shutdown_deadline)
1378				.await
1379			{
1380				instance
1381					.ctx
1382					.record_direct_subsystem_shutdown_warning("http_requests", "destroy_drain");
1383				tracing::warn!(
1384					actor_id,
1385					"destroy shutdown timed out waiting for in-flight http requests"
1386				);
1387			}
1388		}
1389
1390		let mut join_guard = instance.join.lock().await;
1391		// Fold the join outcome into the result instead of propagating with `?`,
1392		// so the stop handle is always signaled and never dropped unsignaled.
1393		let join_result = if let Some(join) = join_guard.take() {
1394			join.await
1395				.context("join actor task")
1396				.and_then(|result| result.context("actor task failed"))
1397		} else {
1398			Ok(())
1399		};
1400		instance.ctx.configure_lifecycle_events(None);
1401
1402		if let (Err(shutdown_error), Err(join_error)) = (&shutdown_result, &join_result) {
1403			tracing::warn!(
1404				actor_id,
1405				%shutdown_error,
1406				discarded_join_error = %join_error,
1407				"actor stop had both shutdown and join failures; only the shutdown error is returned"
1408			);
1409		}
1410
1411		let final_result = shutdown_result.and(join_result);
1412		match &final_result {
1413			Ok(_) => {
1414				let _ = stop_handle.complete();
1415			}
1416			Err(error) => {
1417				let _ = stop_handle.fail(anyhow::Error::new(RivetError::extract(error)));
1418			}
1419		}
1420
1421		final_result.with_context(|| format!("stop actor `{actor_id}`"))
1422	}
1423}
1424
1425impl RegistryDispatcher {
1426	fn can_hibernate(&self, actor_id: &str, request: &HttpRequest) -> bool {
1427		let Some(instance) = self
1428			.actor_instances
1429			.read_sync(actor_id, |_, state| state.active_instance())
1430			.flatten()
1431		else {
1432			return false;
1433		};
1434
1435		match &instance.factory.config().can_hibernate_websocket {
1436			CanHibernateWebSocket::Bool(value) => *value,
1437			CanHibernateWebSocket::Callback(callback) => callback(request),
1438		}
1439	}
1440
1441	#[allow(clippy::too_many_arguments)]
1442	fn build_actor_context(
1443		&self,
1444		handle: EnvoyHandle,
1445		actor_id: &str,
1446		generation: u32,
1447		actor_name: &str,
1448		key: ActorKey,
1449		factory: &ActorFactory,
1450	) -> Result<ActorContext> {
1451		let formatted_key = format_actor_key(&key);
1452		#[cfg_attr(not(feature = "sqlite-local"), allow(unused_mut))]
1453		let mut sql = SqliteDb::new_with_remote_sqlite(
1454			handle.clone(),
1455			actor_id.to_owned(),
1456			Some(formatted_key),
1457			Some(generation as u64),
1458			factory.config().has_database,
1459			factory.config().remote_sqlite,
1460		)?;
1461		#[cfg(feature = "sqlite-local")]
1462		sql.set_worker_pool(self.sqlite_pool.clone());
1463		let ctx = ActorContext::build(
1464			actor_id.to_owned(),
1465			actor_name.to_owned(),
1466			key,
1467			self.region.clone(),
1468			Some(generation),
1469			handle.get_envoy_key().to_owned(),
1470			factory.config().clone(),
1471			LegacyActorKv::new(handle.clone(), actor_id.to_owned()),
1472			sql,
1473		);
1474		ctx.configure_envoy(handle, Some(generation));
1475		Ok(ctx)
1476	}
1477}
1478
1479/// Maps an envoy-protocol stop reason to the lifecycle `ShutdownKind` used by
1480/// `ActorTask`. Reallocation paths (the actor will resurrect on a new envoy)
1481/// are routed through `Sleep` so user `onSleep` runs and durable state is
1482/// preserved without firing a permanent destroy.
1483fn map_envoy_stop_reason(reason: &protocol::StopActorReason) -> ShutdownKind {
1484	match reason {
1485		// Idle sleep requested by the actor itself.
1486		protocol::StopActorReason::SleepIntent => ShutdownKind::Sleep,
1487		// Runner is being drained; engine will reallocate the actor on a new
1488		// envoy. Treat as sleep so persistent state and onSleep semantics hold.
1489		protocol::StopActorReason::GoingAway => ShutdownKind::Sleep,
1490		// Runner connection lost; once reconnected (or another runner is
1491		// allocated) the actor resurrects with the same id.
1492		protocol::StopActorReason::Lost => ShutdownKind::Sleep,
1493		// User-initiated stop intent (`ctx.destroy()` and equivalents).
1494		protocol::StopActorReason::StopIntent => ShutdownKind::Destroy,
1495		// Engine-initiated permanent destroy.
1496		protocol::StopActorReason::Destroy => ShutdownKind::Destroy,
1497	}
1498}
1499
1500// Test shim keeps moved tests in crate-root tests/ with private-module access.
1501
1502
1503// Test-only hooks used by the moved registry tests to deterministically drive the
1504// generation-stop race. Gated behind `cfg(test)` so there is no production impact.
1505