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;
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, Notify, broadcast, mpsc, oneshot};
31use tokio::task::JoinHandle;
32use tokio_util::sync::CancellationToken;
33use url::Url;
34use vbare::OwnedVersionedData;
35
36use crate::actor::action::ActionDispatchError;
37use crate::actor::config::CanHibernateWebSocket;
38use crate::actor::connection::{ConnHandle, 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, QueueSendResult, Request, Response, 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::engine_process::{EngineProcessManager, EngineResolverConfig};
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;
72use websocket::is_actor_connect_path;
73
74#[derive(Default)]
75pub struct CoreRegistry {
76	factories: HashMap<String, Arc<ActorFactory>>,
77}
78
79#[derive(Clone)]
80pub struct CoreEnvoyHandle {
81	handle: EnvoyHandle,
82}
83
84#[derive(Clone, Debug)]
85pub struct CoreEnvoyStatus {
86	pub active_actor_count: usize,
87	pub ping_healthy: bool,
88}
89
90impl CoreEnvoyHandle {
91	pub(crate) fn new(handle: EnvoyHandle) -> Self {
92		Self { handle }
93	}
94
95	pub fn status(&self) -> CoreEnvoyStatus {
96		CoreEnvoyStatus {
97			active_actor_count: self.handle.active_actor_count(),
98			ping_healthy: self.handle.is_ping_healthy(),
99		}
100	}
101
102	/// Resolves after the Engine sends the envoy initialization message.
103	pub async fn started(&self) -> anyhow::Result<()> {
104		self.handle.started().await
105	}
106
107	/// Resolves once the envoy has no active actors (or has stopped).
108	pub async fn wait_actors_drained(&self) {
109		self.handle.wait_actors_drained().await
110	}
111
112	/// Engine-reported drain threshold in milliseconds. `None` until the
113	/// envoy has completed its first protocol-metadata exchange with the
114	/// engine.
115	pub async fn actor_stop_threshold_ms(&self) -> Option<i64> {
116		self.handle
117			.get_protocol_metadata()
118			.await
119			.map(|metadata| metadata.actor_stop_threshold)
120	}
121}
122
123#[derive(Clone)]
124struct ActorTaskHandle {
125	actor_id: String,
126	actor_name: String,
127	generation: u32,
128	ctx: ActorContext,
129	factory: Arc<ActorFactory>,
130	inspector: Inspector,
131	lifecycle: mpsc::UnboundedSender<LifecycleCommand>,
132	dispatch: mpsc::UnboundedSender<DispatchCommand>,
133	join: Arc<TokioMutex<Option<JoinHandle<Result<()>>>>>,
134}
135
136type ActiveActorInstance = Arc<ActorTaskHandle>;
137
138enum ActorInstanceState {
139	Active(ActiveActorInstance),
140	Stopping {
141		instance: ActiveActorInstance,
142		reason: ShutdownKind,
143	},
144}
145
146impl ActorInstanceState {
147	fn instance(&self) -> ActiveActorInstance {
148		match self {
149			Self::Active(instance) | Self::Stopping { instance, .. } => instance.clone(),
150		}
151	}
152
153	fn active_instance(&self) -> Option<ActiveActorInstance> {
154		match self {
155			Self::Active(instance) => Some(instance.clone()),
156			Self::Stopping { .. } => None,
157		}
158	}
159}
160
161#[derive(Clone)]
162struct PendingStop {
163	reason: protocol::StopActorReason,
164	stop_handle: ActorStopHandle,
165}
166
167pub(crate) struct RegistryDispatcher {
168	pub(crate) factories: HashMap<String, Arc<ActorFactory>>,
169	actor_instances: SccHashMap<String, ActorInstanceState>,
170	starting_instances: SccHashMap<String, Arc<Notify>>,
171	pending_stops: SccHashMap<String, PendingStop>,
172	region: String,
173	handle_inspector_http_in_runtime: bool,
174}
175
176pub(crate) struct RegistryCallbacks {
177	pub(crate) dispatcher: Arc<RegistryDispatcher>,
178}
179
180#[derive(Clone, Debug)]
181struct StartActorRequest {
182	actor_id: String,
183	generation: u32,
184	actor_name: String,
185	input: Option<Vec<u8>>,
186	ctx: ActorContext,
187}
188
189#[derive(Clone, Debug)]
190struct ServeSettings {
191	version: u32,
192	endpoint: String,
193	token: Option<String>,
194	namespace: String,
195	pool_name: String,
196	engine_binary_path: Option<PathBuf>,
197	engine_host: Option<String>,
198	engine_port: Option<u16>,
199	engine_spawn: EngineSpawnMode,
200	engine_auto_download: bool,
201	handle_inspector_http_in_runtime: bool,
202	serverless_base_path: Option<String>,
203	serverless_package_version: String,
204	serverless_client_endpoint: Option<String>,
205	serverless_client_namespace: Option<String>,
206	serverless_client_token: Option<String>,
207	serverless_validate_endpoint: bool,
208	serverless_max_start_payload_bytes: usize,
209}
210
211#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
212pub enum EngineSpawnMode {
213	#[default]
214	Auto,
215	Always,
216	Never,
217}
218
219impl EngineSpawnMode {
220	pub(crate) fn from_env() -> Self {
221		match env::var("RIVETKIT_ENGINE_SPAWN") {
222			Ok(value) if value.eq_ignore_ascii_case("always") => Self::Always,
223			Ok(value) if value.eq_ignore_ascii_case("never") => Self::Never,
224			_ => Self::Auto,
225		}
226	}
227}
228
229#[derive(Clone, Debug, Default)]
230pub struct ServeConfig {
231	pub version: u32,
232	pub endpoint: String,
233	pub token: Option<String>,
234	pub namespace: String,
235	pub pool_name: String,
236	pub engine_binary_path: Option<PathBuf>,
237	pub engine_host: Option<String>,
238	pub engine_port: Option<u16>,
239	pub engine_spawn: EngineSpawnMode,
240	pub engine_auto_download: bool,
241	pub handle_inspector_http_in_runtime: bool,
242	pub serverless_base_path: Option<String>,
243	pub serverless_package_version: String,
244	pub serverless_client_endpoint: Option<String>,
245	pub serverless_client_namespace: Option<String>,
246	pub serverless_client_token: Option<String>,
247	pub serverless_validate_endpoint: bool,
248	pub serverless_max_start_payload_bytes: usize,
249	pub serverless_cache_envoy: bool,
250}
251
252#[derive(Debug, Default, Deserialize)]
253#[serde(default)]
254struct InspectorPatchStateBody {
255	state: JsonValue,
256}
257
258#[derive(Debug, Default, Deserialize)]
259#[serde(default)]
260struct InspectorActionBody {
261	args: Vec<JsonValue>,
262	properties: Option<JsonValue>,
263}
264
265#[derive(Debug, Default, Deserialize)]
266#[serde(default)]
267struct InspectorDatabaseExecuteBody {
268	sql: String,
269	args: Vec<JsonValue>,
270	properties: Option<JsonValue>,
271}
272
273#[derive(Debug, Default, Deserialize)]
274#[serde(default, rename_all = "camelCase")]
275struct InspectorWorkflowReplayBody {
276	entry_id: Option<String>,
277}
278
279#[derive(Debug, Default, Deserialize)]
280#[serde(default)]
281struct InspectorEnqueueBody {
282	name: String,
283	body: Option<JsonValue>,
284}
285
286#[derive(Debug, Serialize)]
287#[serde(rename_all = "camelCase")]
288struct InspectorQueueMessageJson {
289	id: u64,
290	name: String,
291	created_at_ms: i64,
292}
293
294#[derive(Debug, Serialize)]
295#[serde(rename_all = "camelCase")]
296struct InspectorQueueResponseJson {
297	size: u32,
298	max_size: u32,
299	truncated: bool,
300	messages: Vec<InspectorQueueMessageJson>,
301}
302
303#[derive(Debug, Deserialize)]
304#[serde(default)]
305struct HttpActionRequestJson {
306	args: JsonValue,
307}
308
309impl Default for HttpActionRequestJson {
310	fn default() -> Self {
311		Self {
312			args: JsonValue::Array(Vec::new()),
313		}
314	}
315}
316
317pub(crate) fn should_manage_engine(endpoint: &str, spawn_mode: EngineSpawnMode) -> Result<bool> {
318	match spawn_mode {
319		EngineSpawnMode::Always => Ok(true),
320		EngineSpawnMode::Never => Ok(false),
321		EngineSpawnMode::Auto => is_loopback_endpoint(endpoint),
322	}
323}
324
325fn is_loopback_endpoint(endpoint: &str) -> Result<bool> {
326	let url =
327		Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?;
328	let Some(host) = url.host_str() else {
329		anyhow::bail!("engine endpoint `{endpoint}` is invalid: missing host");
330	};
331
332	if host == "localhost" || host.ends_with(".localhost") {
333		return Ok(true);
334	}
335
336	let ip_host = host
337		.strip_prefix('[')
338		.and_then(|value| value.strip_suffix(']'))
339		.unwrap_or(host);
340
341	Ok(ip_host
342		.parse::<std::net::IpAddr>()
343		.map(|ip| ip.is_loopback() || ip.is_unspecified())
344		.unwrap_or(false))
345}
346
347#[cfg(test)]
348mod engine_spawn_tests {
349	use super::{EngineSpawnMode, should_manage_engine};
350
351	#[test]
352	fn auto_manages_loopback_endpoints() {
353		assert!(should_manage_engine("http://127.0.0.1:6420", EngineSpawnMode::Auto).unwrap());
354		assert!(should_manage_engine("http://localhost:6420", EngineSpawnMode::Auto).unwrap());
355		assert!(should_manage_engine("http://dev.localhost:6420", EngineSpawnMode::Auto).unwrap());
356		assert!(should_manage_engine("http://[::1]:6420", EngineSpawnMode::Auto).unwrap());
357	}
358
359	#[test]
360	fn auto_leaves_remote_endpoints_connect_only() {
361		assert!(!should_manage_engine("https://api.rivet.dev", EngineSpawnMode::Auto).unwrap());
362		assert!(!should_manage_engine("http://192.0.2.10:6420", EngineSpawnMode::Auto).unwrap());
363	}
364
365	#[test]
366	fn explicit_spawn_mode_overrides_endpoint_shape() {
367		assert!(should_manage_engine("https://api.rivet.dev", EngineSpawnMode::Always).unwrap());
368		assert!(!should_manage_engine("http://127.0.0.1:6420", EngineSpawnMode::Never).unwrap());
369	}
370}
371
372#[derive(Debug, Deserialize)]
373#[serde(default, rename_all = "camelCase")]
374struct HttpQueueSendRequestJson {
375	body: JsonValue,
376	wait: Option<bool>,
377	timeout: Option<u64>,
378}
379
380impl Default for HttpQueueSendRequestJson {
381	fn default() -> Self {
382		Self {
383			body: JsonValue::Null,
384			wait: None,
385			timeout: None,
386		}
387	}
388}
389
390#[derive(RivetError)]
391#[error("message", "incoming_too_long", "Incoming message too long")]
392struct IncomingMessageTooLong;
393
394#[derive(RivetError)]
395#[error("message", "outgoing_too_long", "Outgoing message too long")]
396struct OutgoingMessageTooLong;
397
398#[derive(RivetError)]
399#[error("actor", "action_timed_out", "Action timed out")]
400struct ActionTimedOut;
401
402#[derive(RivetError, Serialize)]
403#[error("actor", "method_not_allowed", "Method not allowed")]
404struct MethodNotAllowed {
405	method: String,
406	path: String,
407}
408
409#[derive(Debug, Serialize)]
410#[serde(rename_all = "camelCase")]
411struct InspectorConnectionJson {
412	#[serde(rename = "type")]
413	connection_type: Option<String>,
414	id: String,
415	details: InspectorConnectionDetailsJson,
416}
417
418#[derive(Debug, Serialize)]
419#[serde(rename_all = "camelCase")]
420struct InspectorConnectionDetailsJson {
421	#[serde(rename = "type")]
422	connection_type: Option<String>,
423	params: JsonValue,
424	state_enabled: bool,
425	state: JsonValue,
426	subscriptions: usize,
427	is_hibernatable: bool,
428}
429
430#[derive(Debug, Serialize)]
431#[serde(rename_all = "camelCase")]
432struct InspectorSummaryJson {
433	state: JsonValue,
434	is_state_enabled: bool,
435	connections: Vec<InspectorConnectionJson>,
436	rpcs: Vec<String>,
437	queue_size: u32,
438	is_database_enabled: bool,
439	#[serde(rename = "isWorkflowEnabled")]
440	workflow_supported: bool,
441	workflow_history: Option<JsonValue>,
442}
443
444const WS_PROTOCOL_ENCODING: &str = "rivet_encoding.";
445const WS_PROTOCOL_CONN_PARAMS: &str = "rivet_conn_params.";
446
447#[derive(Debug)]
448struct ActorConnectInit {
449	actor_id: String,
450	connection_id: String,
451}
452
453#[derive(Debug)]
454struct ActorConnectError {
455	group: String,
456	code: String,
457	message: String,
458	metadata: Option<ByteBuf>,
459	action_id: Option<u64>,
460	actor: Option<ActorSpecifier>,
461}
462
463#[derive(Debug)]
464struct ActorConnectActionResponse {
465	id: u64,
466	output: ByteBuf,
467}
468
469#[derive(Debug)]
470struct ActorConnectEvent {
471	name: String,
472	args: ByteBuf,
473}
474
475#[derive(Clone, Copy, Debug, PartialEq, Eq)]
476enum ActorConnectEncoding {
477	Json,
478	Cbor,
479	Bare,
480}
481
482#[derive(Debug)]
483enum ActorConnectToClient {
484	Init(ActorConnectInit),
485	Error(ActorConnectError),
486	ActionResponse(ActorConnectActionResponse),
487	Event(ActorConnectEvent),
488}
489
490#[derive(Debug)]
491struct ActorConnectActionRequest {
492	id: u64,
493	name: String,
494	args: ByteBuf,
495}
496
497#[derive(Debug)]
498enum ActorConnectSendError {
499	OutgoingTooLong,
500	Encode(anyhow::Error),
501}
502
503#[derive(Debug, Deserialize)]
504struct ActorConnectSubscriptionRequest {
505	#[serde(rename = "eventName")]
506	event_name: String,
507	subscribe: bool,
508}
509
510#[derive(Debug)]
511enum ActorConnectToServer {
512	ActionRequest(ActorConnectActionRequest),
513	SubscriptionRequest(ActorConnectSubscriptionRequest),
514}
515
516#[derive(Debug, Deserialize)]
517struct ActorConnectActionRequestJson {
518	id: u64,
519	name: String,
520	args: JsonValue,
521}
522
523#[derive(Debug, Deserialize)]
524#[serde(tag = "tag", content = "val")]
525enum ActorConnectToServerJsonBody {
526	ActionRequest(ActorConnectActionRequestJson),
527	SubscriptionRequest(ActorConnectSubscriptionRequest),
528}
529
530#[derive(Debug, Deserialize)]
531struct ActorConnectToServerJsonEnvelope {
532	body: ActorConnectToServerJsonBody,
533}
534
535impl CoreRegistry {
536	pub fn new() -> Self {
537		Self::default()
538	}
539
540	pub fn register(&mut self, name: &str, factory: ActorFactory) {
541		self.factories.insert(name.to_owned(), Arc::new(factory));
542	}
543
544	pub fn register_shared(&mut self, name: &str, factory: Arc<ActorFactory>) {
545		self.factories.insert(name.to_owned(), factory);
546	}
547
548	pub fn normal_metadata_payload(&self, config: &ServeConfig) -> ServerlessMetadataPayload {
549		serverless_metadata_payload(
550			build_actor_metadata_map_from_factories(&self.factories),
551			config,
552			ServerlessMetadataEnvoyKind::Normal {},
553		)
554	}
555
556	pub fn serverless_metadata_payload(&self, config: &ServeConfig) -> ServerlessMetadataPayload {
557		serverless_metadata_payload(
558			build_actor_metadata_map_from_factories(&self.factories),
559			config,
560			ServerlessMetadataEnvoyKind::Serverless {},
561		)
562	}
563
564	pub async fn serve(self, shutdown: CancellationToken) -> Result<()> {
565		self.serve_with_config(ServeConfig::from_env(), shutdown)
566			.await
567	}
568
569	pub async fn serve_with_config(
570		self,
571		config: ServeConfig,
572		shutdown: CancellationToken,
573	) -> Result<()> {
574		self.serve_with_config_and_handle_observer(config, shutdown, |_| {})
575			.await
576	}
577
578	pub async fn serve_with_config_and_handle_observer(
579		self,
580		config: ServeConfig,
581		shutdown: CancellationToken,
582		on_handle: impl FnOnce(CoreEnvoyHandle) + Send + 'static,
583	) -> Result<()> {
584		crate::metrics_endpoint::record_rivetkit_info(
585			config.serverless_package_version.clone(),
586			config.version,
587			"serverful",
588			config.pool_name.clone(),
589		);
590
591		let dispatcher = self.into_dispatcher(&config);
592		#[cfg(feature = "native-runtime")]
593		let _engine_process = if should_manage_engine(&config.endpoint, config.engine_spawn)? {
594			Some(
595				EngineProcessManager::start_or_reuse(EngineResolverConfig::from_parts(
596					&config.endpoint,
597					config.engine_binary_path.clone(),
598					config.engine_host.clone(),
599					config.engine_port,
600					config.engine_auto_download,
601				))
602				.await?,
603			)
604		} else {
605			None
606		};
607		#[cfg(not(feature = "native-runtime"))]
608		if should_manage_engine(&config.endpoint, config.engine_spawn)? {
609			anyhow::bail!("engine process spawning requires the `native-runtime` feature");
610		}
611
612		#[cfg(feature = "native-runtime")]
613		runner_config::ensure_local_normal_runner_config(&config).await?;
614		let callbacks = Arc::new(RegistryCallbacks {
615			dispatcher: dispatcher.clone(),
616		});
617
618		let prepopulate_actor_names = dispatcher
619			.build_actor_metadata_map()
620			.into_iter()
621			.map(|(name, metadata)| (name, rivet_envoy_client::config::ActorName { metadata }))
622			.collect();
623		let handle = start_envoy(rivet_envoy_client::config::EnvoyConfig {
624			version: config.version,
625			endpoint: config.endpoint,
626			token: config.token,
627			namespace: config.namespace,
628			pool_name: config.pool_name,
629			prepopulate_actor_names,
630			metadata: Some(json!({
631				"rivetkit": { "version": config.serverless_package_version },
632			})),
633			not_global: false,
634			debug_latency_ms: None,
635			callbacks,
636		})
637		.await;
638		on_handle(CoreEnvoyHandle::new(handle.clone()));
639
640		// Do not install `tokio::signal::ctrl_c()` here. It calls
641		// `sigaction(SIGINT, ...)` at the POSIX level, which overrides the
642		// host's default SIGINT handling when rivetkit-core is embedded in
643		// Node via NAPI and leaves the host process unable to exit. Callers
644		// trip the `shutdown` token instead.
645		shutdown.cancelled().await;
646
647		// TODO: Move into envoy-client since timing out has to do with protocol compliance
648		// Read threshold from protocol metadata, fall back to 30 min
649		let stop_threshold = handle
650			.get_protocol_metadata()
651			.await
652			.map(|x| x.actor_stop_threshold)
653			.unwrap_or(30 * 60 * 1000);
654		// Bounded drain. If envoy cannot reach the engine (reconnect loop stuck),
655		// we fall back to immediate `Stop` rather than hanging indefinitely.
656		// The outer host (TS signal handler / Rust binary) is the backstop.
657		match timeout(
658			Duration::from_millis(stop_threshold as u64),
659			handle.shutdown_and_wait(false),
660		)
661		.await
662		{
663			Ok(()) => {}
664			Err(_) => {
665				tracing::warn!("envoy shutdown drain exceeded timeout; forcing immediate stop");
666				handle.shutdown(true);
667				handle.wait_stopped().await;
668			}
669		}
670
671		Ok(())
672	}
673
674	fn into_dispatcher(self, config: &ServeConfig) -> Arc<RegistryDispatcher> {
675		Arc::new(RegistryDispatcher::new(
676			self.factories,
677			config.handle_inspector_http_in_runtime,
678		))
679	}
680
681	pub async fn into_serverless_runtime(
682		self,
683		config: ServeConfig,
684	) -> Result<crate::serverless::CoreServerlessRuntime> {
685		crate::serverless::CoreServerlessRuntime::new(self.factories, config).await
686	}
687}
688
689impl RegistryDispatcher {
690	pub(crate) fn new(
691		factories: HashMap<String, Arc<ActorFactory>>,
692		handle_inspector_http_in_runtime: bool,
693	) -> Self {
694		Self {
695			factories,
696			actor_instances: SccHashMap::new(),
697			starting_instances: SccHashMap::new(),
698			pending_stops: SccHashMap::new(),
699			region: env::var("RIVET_REGION").unwrap_or_default(),
700			handle_inspector_http_in_runtime,
701		}
702	}
703
704	pub(crate) fn build_actor_metadata_map(&self) -> HashMap<String, JsonValue> {
705		build_actor_metadata_map_from_factories(&self.factories)
706	}
707}
708
709pub(crate) fn serverless_metadata_payload(
710	actor_metadata: HashMap<String, JsonValue>,
711	config: &ServeConfig,
712	envoy_kind: ServerlessMetadataEnvoyKind,
713) -> ServerlessMetadataPayload {
714	let actor_names = actor_metadata
715		.into_iter()
716		.map(|(name, metadata)| {
717			(
718				name,
719				ActorName {
720					metadata: Some(metadata),
721				},
722			)
723		})
724		.collect::<HashMap<_, _>>();
725
726	ServerlessMetadataPayload {
727		runtime: "rivetkit".to_owned(),
728		version: config.serverless_package_version.clone(),
729		envoy_protocol_version: Some(protocol::PROTOCOL_VERSION),
730		actor_names,
731		envoy: Some(ServerlessMetadataEnvoy {
732			kind: Some(envoy_kind),
733			version: Some(config.version),
734		}),
735		runner: None,
736		client_endpoint: config.serverless_client_endpoint.clone(),
737		client_namespace: config.serverless_client_namespace.clone(),
738		client_token: config.serverless_client_token.clone(),
739	}
740}
741
742fn build_actor_metadata_map_from_factories(
743	factories: &HashMap<String, Arc<ActorFactory>>,
744) -> HashMap<String, JsonValue> {
745	factories
746		.iter()
747		.map(|(actor_name, factory)| {
748			let config = factory.config();
749			let mut metadata = serde_json::Map::new();
750			if let Some(icon) = &config.icon {
751				metadata.insert("icon".to_owned(), json!(icon));
752			}
753			if let Some(name) = &config.name {
754				metadata.insert("name".to_owned(), json!(name));
755			}
756			(actor_name.clone(), JsonValue::Object(metadata))
757		})
758		.collect()
759}
760
761impl RegistryDispatcher {
762	async fn start_actor(self: &Arc<Self>, request: StartActorRequest) -> Result<()> {
763		let startup_notify = Arc::new(Notify::new());
764		let _ = self
765			.starting_instances
766			.insert_async(request.actor_id.clone(), startup_notify.clone())
767			.await;
768		let factory = self
769			.factories
770			.get(&request.actor_name)
771			.cloned()
772			.ok_or_else(|| {
773				ActorRuntime::NotRegistered {
774					actor_name: request.actor_name.clone(),
775				}
776				.build()
777			})?;
778		let (lifecycle_tx, lifecycle_rx) = mpsc::unbounded_channel();
779		let (dispatch_tx, dispatch_rx) = mpsc::unbounded_channel();
780		let (lifecycle_events_tx, lifecycle_events_rx) = mpsc::unbounded_channel();
781		request
782			.ctx
783			.configure_lifecycle_events(Some(lifecycle_events_tx));
784		request.ctx.cancel_sleep_timer();
785		request.ctx.set_local_alarm_callback(Some(Arc::new({
786			let lifecycle_tx = lifecycle_tx.clone();
787			move || {
788				let lifecycle_tx = lifecycle_tx.clone();
789				Box::pin(async move {
790					let (reply_tx, reply_rx) = oneshot::channel();
791					if let Err(error) = try_send_lifecycle_command(
792						&lifecycle_tx,
793						LifecycleCommand::FireAlarm { reply: reply_tx },
794					) {
795						tracing::warn!(?error, "failed to enqueue actor alarm");
796						return;
797					}
798					let _ = reply_rx.await;
799				})
800			}
801		})));
802		let task = ActorTask::new(
803			request.actor_id.clone(),
804			request.generation,
805			lifecycle_rx,
806			dispatch_rx,
807			lifecycle_events_rx,
808			factory.clone(),
809			request.ctx.clone(),
810			request.input,
811		);
812		let join = RuntimeSpawner::spawn(task.run());
813
814		let (start_tx, start_rx) = oneshot::channel();
815		let result: Result<Arc<ActorTaskHandle>> = async {
816			try_send_lifecycle_command(&lifecycle_tx, LifecycleCommand::Start { reply: start_tx })
817				.context("send actor task start command")?;
818			start_rx
819				.await
820				.context("receive actor task start reply")?
821				.context("actor task start")?;
822			let inspector = build_actor_inspector();
823			request.ctx.configure_inspector(Some(inspector.clone()));
824			Ok::<Arc<ActorTaskHandle>, anyhow::Error>(Arc::new(ActorTaskHandle {
825				actor_id: request.actor_id.clone(),
826				actor_name: request.actor_name.clone(),
827				generation: request.generation,
828				ctx: request.ctx.clone(),
829				factory,
830				inspector,
831				lifecycle: lifecycle_tx,
832				dispatch: dispatch_tx,
833				join: Arc::new(TokioMutex::new(Some(join))),
834			}))
835		}
836		.await
837		.with_context(|| format!("start actor `{}`", request.actor_id));
838
839		match result {
840			Ok(instance) => {
841				let pending_stop = self
842					.pending_stops
843					.remove_async(&request.actor_id.clone())
844					.await
845					.map(|(_, pending_stop)| pending_stop);
846				if let Some(pending_stop) = pending_stop {
847					let actor_id = request.actor_id.clone();
848					let stop_reason = map_envoy_stop_reason(&pending_stop.reason);
849					if matches!(stop_reason, ShutdownKind::Destroy) {
850						instance.ctx.mark_destroy_requested();
851					}
852					self.set_actor_instance_state(
853						actor_id.clone(),
854						ActorInstanceState::Stopping {
855							instance: instance.clone(),
856							reason: stop_reason,
857						},
858					)
859					.await;
860					let _ = self
861						.starting_instances
862						.remove_async(&request.actor_id.clone())
863						.await;
864
865					let dispatcher = self.clone();
866					RuntimeSpawner::spawn(async move {
867						if let Err(error) = dispatcher
868							.shutdown_started_instance(
869								&actor_id,
870								instance.clone(),
871								pending_stop.reason,
872								pending_stop.stop_handle,
873							)
874							.await
875						{
876							tracing::error!(
877								actor_id,
878								?error,
879								"failed to stop actor queued during startup"
880							);
881						}
882						dispatcher
883							.remove_stopping_actor_instance(&actor_id, &instance)
884							.await;
885					});
886					startup_notify.notify_waiters();
887
888					Ok(())
889				} else {
890					self.set_actor_instance_state(
891						request.actor_id.clone(),
892						ActorInstanceState::Active(instance),
893					)
894					.await;
895					let _ = self
896						.starting_instances
897						.remove_async(&request.actor_id.clone())
898						.await;
899					startup_notify.notify_waiters();
900					Ok(())
901				}
902			}
903			Err(error) => {
904				let _ = self
905					.starting_instances
906					.remove_async(&request.actor_id.clone())
907					.await;
908				startup_notify.notify_waiters();
909				Err(error)
910			}
911		}
912	}
913
914	async fn set_actor_instance_state(&self, actor_id: String, state: ActorInstanceState) {
915		match self.actor_instances.entry_async(actor_id).await {
916			SccEntry::Occupied(mut entry) => {
917				entry.insert(state);
918			}
919			SccEntry::Vacant(entry) => {
920				entry.insert_entry(state);
921			}
922		}
923	}
924
925	async fn transition_actor_to_stopping(
926		&self,
927		actor_id: &str,
928		reason: ShutdownKind,
929	) -> Option<ActiveActorInstance> {
930		match self.actor_instances.entry_async(actor_id.to_owned()).await {
931			SccEntry::Occupied(mut entry) => {
932				let instance = entry.get().instance();
933				if matches!(entry.get(), ActorInstanceState::Active(_)) {
934					entry.insert(ActorInstanceState::Stopping {
935						instance: instance.clone(),
936						reason,
937					});
938				} else {
939					instance
940						.ctx
941						.warn_work_sent_to_stopping_instance("stop_actor");
942				}
943				Some(instance)
944			}
945			SccEntry::Vacant(entry) => {
946				drop(entry);
947				None
948			}
949		}
950	}
951
952	async fn remove_stopping_actor_instance(&self, actor_id: &str, expected: &ActiveActorInstance) {
953		match self.actor_instances.entry_async(actor_id.to_owned()).await {
954			SccEntry::Occupied(entry) => {
955				let should_remove = match entry.get() {
956					ActorInstanceState::Stopping { instance, .. } => {
957						Arc::ptr_eq(instance, expected)
958					}
959					ActorInstanceState::Active(_) => false,
960				};
961				if should_remove {
962					let _ = entry.remove_entry();
963				}
964			}
965			SccEntry::Vacant(entry) => {
966				drop(entry);
967			}
968		}
969	}
970
971	async fn active_actor(&self, actor_id: &str) -> Result<Arc<ActorTaskHandle>> {
972		if let Some(instance) = self.actor_instances.get_async(&actor_id.to_owned()).await {
973			match instance.get() {
974				ActorInstanceState::Active(instance) => {
975					let instance = instance.clone();
976					// TODO: Share admission policy with ActorTask::dispatch_lifecycle_error.
977					if instance.ctx.started() {
978						if instance.ctx.destroy_requested() {
979							instance
980								.ctx
981								.warn_work_sent_to_stopping_instance("active_actor");
982							return Err(ActorLifecycleError::Destroying.build());
983						}
984						return Ok(instance);
985					}
986
987					instance
988						.ctx
989						.warn_work_sent_to_stopping_instance("active_actor");
990					return Err(if instance.ctx.destroy_requested() {
991						ActorLifecycleError::Destroying.build()
992					} else if instance.ctx.sleep_requested() {
993						ActorLifecycleError::Stopping.build()
994					} else {
995						ActorLifecycleError::Starting.build()
996					});
997				}
998				ActorInstanceState::Stopping { instance, reason } => {
999					let instance = instance.clone();
1000					match reason {
1001						ShutdownKind::Sleep if instance.ctx.started() => return Ok(instance),
1002						ShutdownKind::Sleep => {
1003							instance
1004								.ctx
1005								.warn_work_sent_to_stopping_instance("active_actor");
1006							return Err(ActorLifecycleError::Stopping.build());
1007						}
1008						ShutdownKind::Destroy => {
1009							instance
1010								.ctx
1011								.warn_work_sent_to_stopping_instance("active_actor");
1012							return Err(ActorLifecycleError::Destroying.build());
1013						}
1014					}
1015				}
1016			}
1017		}
1018
1019		tracing::warn!(actor_id, "actor instance not found");
1020		Err(ActorRuntime::NotFound {
1021			resource: "instance".to_owned(),
1022			id: actor_id.to_owned(),
1023		}
1024		.build())
1025	}
1026
1027	async fn stop_actor(
1028		&self,
1029		actor_id: &str,
1030		reason: protocol::StopActorReason,
1031		stop_handle: ActorStopHandle,
1032	) -> Result<()> {
1033		if self
1034			.starting_instances
1035			.get_async(&actor_id.to_owned())
1036			.await
1037			.is_some()
1038		{
1039			let _ = self
1040				.pending_stops
1041				.insert_async(
1042					actor_id.to_owned(),
1043					PendingStop {
1044						reason,
1045						stop_handle,
1046					},
1047				)
1048				.await;
1049			return Ok(());
1050		}
1051
1052		let task_stop_reason = map_envoy_stop_reason(&reason);
1053		let instance = match self
1054			.transition_actor_to_stopping(actor_id, task_stop_reason)
1055			.await
1056		{
1057			Some(instance) => instance,
1058			None => {
1059				let _ = self
1060					.pending_stops
1061					.insert_async(
1062						actor_id.to_owned(),
1063						PendingStop {
1064							reason,
1065							stop_handle,
1066						},
1067					)
1068					.await;
1069				return Ok(());
1070			}
1071		};
1072		let result = self
1073			.shutdown_started_instance(actor_id, instance.clone(), reason, stop_handle)
1074			.await;
1075		self.remove_stopping_actor_instance(actor_id, &instance)
1076			.await;
1077		result
1078	}
1079
1080	async fn shutdown_started_instance(
1081		&self,
1082		actor_id: &str,
1083		instance: Arc<ActorTaskHandle>,
1084		reason: protocol::StopActorReason,
1085		stop_handle: ActorStopHandle,
1086	) -> Result<()> {
1087		let task_stop_reason = map_envoy_stop_reason(&reason);
1088
1089		if matches!(task_stop_reason, ShutdownKind::Destroy) {
1090			instance.ctx.mark_destroy_requested();
1091		}
1092
1093		tracing::debug!(
1094			actor_id,
1095			handle_actor_id = %instance.actor_id,
1096			actor_name = %instance.actor_name,
1097			generation = instance.generation,
1098			?reason,
1099			?task_stop_reason,
1100			"stopping actor instance"
1101		);
1102
1103		let (reply_tx, reply_rx) = oneshot::channel();
1104		let shutdown_result = match try_send_lifecycle_command(
1105			&instance.lifecycle,
1106			LifecycleCommand::Stop {
1107				reason: task_stop_reason,
1108				reply: reply_tx,
1109			},
1110		) {
1111			Ok(()) => reply_rx
1112				.await
1113				.context("receive actor task stop reply")
1114				.and_then(|result| result),
1115			Err(error) => Err(error),
1116		};
1117
1118		if matches!(task_stop_reason, ShutdownKind::Destroy) {
1119			let shutdown_deadline =
1120				Instant::now() + instance.factory.config().effective_sleep_grace_period();
1121			if !instance
1122				.ctx
1123				.wait_for_internal_keep_awake_idle(shutdown_deadline)
1124				.await
1125			{
1126				instance.ctx.record_direct_subsystem_shutdown_warning(
1127					"internal_keep_awake",
1128					"destroy_drain",
1129				);
1130				tracing::warn!(
1131					actor_id,
1132					"destroy shutdown timed out waiting for in-flight actions"
1133				);
1134			}
1135			if !instance
1136				.ctx
1137				.wait_for_http_requests_drained(shutdown_deadline)
1138				.await
1139			{
1140				instance
1141					.ctx
1142					.record_direct_subsystem_shutdown_warning("http_requests", "destroy_drain");
1143				tracing::warn!(
1144					actor_id,
1145					"destroy shutdown timed out waiting for in-flight http requests"
1146				);
1147			}
1148		}
1149
1150		let mut join_guard = instance.join.lock().await;
1151		// Fold the join outcome into the result instead of propagating with `?`,
1152		// so the stop handle is always signaled and never dropped unsignaled.
1153		let join_result = if let Some(join) = join_guard.take() {
1154			join.await
1155				.context("join actor task")
1156				.and_then(|result| result.context("actor task failed"))
1157		} else {
1158			Ok(())
1159		};
1160		instance.ctx.configure_lifecycle_events(None);
1161
1162		if let (Err(shutdown_error), Err(join_error)) = (&shutdown_result, &join_result) {
1163			tracing::warn!(
1164				actor_id,
1165				%shutdown_error,
1166				discarded_join_error = %join_error,
1167				"actor stop had both shutdown and join failures; only the shutdown error is returned"
1168			);
1169		}
1170
1171		let final_result = shutdown_result.and(join_result);
1172		match &final_result {
1173			Ok(_) => {
1174				let _ = stop_handle.complete();
1175			}
1176			Err(error) => {
1177				let _ = stop_handle.fail(anyhow::Error::new(RivetError::extract(error)));
1178			}
1179		}
1180
1181		final_result.with_context(|| format!("stop actor `{actor_id}`"))
1182	}
1183}
1184
1185impl RegistryDispatcher {
1186	fn can_hibernate(&self, actor_id: &str, request: &HttpRequest) -> bool {
1187		if matches!(is_actor_connect_path(&request.path), Ok(true)) {
1188			return true;
1189		}
1190
1191		let Some(instance) = self
1192			.actor_instances
1193			.read_sync(actor_id, |_, state| state.active_instance())
1194			.flatten()
1195		else {
1196			return false;
1197		};
1198
1199		match &instance.factory.config().can_hibernate_websocket {
1200			CanHibernateWebSocket::Bool(value) => *value,
1201			CanHibernateWebSocket::Callback(callback) => callback(request),
1202		}
1203	}
1204
1205	#[allow(clippy::too_many_arguments)]
1206	fn build_actor_context(
1207		&self,
1208		handle: EnvoyHandle,
1209		actor_id: &str,
1210		generation: u32,
1211		actor_name: &str,
1212		key: ActorKey,
1213		factory: &ActorFactory,
1214	) -> Result<ActorContext> {
1215		let formatted_key = format_actor_key(&key);
1216		let ctx = ActorContext::build(
1217			actor_id.to_owned(),
1218			actor_name.to_owned(),
1219			key,
1220			self.region.clone(),
1221			Some(generation),
1222			handle.get_envoy_key().to_owned(),
1223			factory.config().clone(),
1224			LegacyActorKv::new(handle.clone(), actor_id.to_owned()),
1225			SqliteDb::new_with_remote_sqlite(
1226				handle.clone(),
1227				actor_id.to_owned(),
1228				Some(formatted_key),
1229				Some(generation as u64),
1230				factory.config().has_database,
1231				factory.config().remote_sqlite,
1232			)?,
1233		);
1234		ctx.configure_envoy(handle, Some(generation));
1235		Ok(ctx)
1236	}
1237}
1238
1239/// Maps an envoy-protocol stop reason to the lifecycle `ShutdownKind` used by
1240/// `ActorTask`. Reallocation paths (the actor will resurrect on a new envoy)
1241/// are routed through `Sleep` so user `onSleep` runs and durable state is
1242/// preserved without firing a permanent destroy.
1243fn map_envoy_stop_reason(reason: &protocol::StopActorReason) -> ShutdownKind {
1244	match reason {
1245		// Idle sleep requested by the actor itself.
1246		protocol::StopActorReason::SleepIntent => ShutdownKind::Sleep,
1247		// Runner is being drained; engine will reallocate the actor on a new
1248		// envoy. Treat as sleep so persistent state and onSleep semantics hold.
1249		protocol::StopActorReason::GoingAway => ShutdownKind::Sleep,
1250		// Runner connection lost; once reconnected (or another runner is
1251		// allocated) the actor resurrects with the same id.
1252		protocol::StopActorReason::Lost => ShutdownKind::Sleep,
1253		// User-initiated stop intent (`ctx.destroy()` and equivalents).
1254		protocol::StopActorReason::StopIntent => ShutdownKind::Destroy,
1255		// Engine-initiated permanent destroy.
1256		protocol::StopActorReason::Destroy => ShutdownKind::Destroy,
1257	}
1258}
1259
1260// Test shim keeps moved tests in crate-root tests/ with private-module access.
1261#[cfg(test)]
1262#[path = "../../tests/registry.rs"]
1263pub(crate) mod tests;