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