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, 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
73#[derive(Default)]
74pub struct CoreRegistry {
75	factories: HashMap<String, Arc<ActorFactory>>,
76}
77
78#[derive(Clone)]
79pub struct CoreEnvoyHandle {
80	handle: EnvoyHandle,
81}
82
83#[derive(Clone, Debug)]
84pub struct CoreEnvoyStatus {
85	pub active_actor_count: usize,
86	pub ping_healthy: bool,
87}
88
89impl CoreEnvoyHandle {
90	pub(crate) fn new(handle: EnvoyHandle) -> Self {
91		Self { handle }
92	}
93
94	pub fn status(&self) -> CoreEnvoyStatus {
95		CoreEnvoyStatus {
96			active_actor_count: self.handle.active_actor_count(),
97			ping_healthy: self.handle.is_ping_healthy(),
98		}
99	}
100
101	/// Resolves after the Engine sends the envoy initialization message.
102	pub async fn started(&self) -> anyhow::Result<()> {
103		self.handle.started().await
104	}
105
106	/// Resolves once the envoy has no active actors (or has stopped).
107	pub async fn wait_actors_drained(&self) {
108		self.handle.wait_actors_drained().await
109	}
110
111	/// Engine-reported drain threshold in milliseconds. `None` until the
112	/// envoy has completed its first protocol-metadata exchange with the
113	/// engine.
114	pub async fn actor_stop_threshold_ms(&self) -> Option<i64> {
115		self.handle
116			.get_protocol_metadata()
117			.await
118			.map(|metadata| metadata.actor_stop_threshold)
119	}
120}
121
122#[derive(Clone)]
123struct ActorTaskHandle {
124	actor_id: String,
125	actor_name: String,
126	generation: u32,
127	ctx: ActorContext,
128	factory: Arc<ActorFactory>,
129	inspector: Inspector,
130	lifecycle: mpsc::UnboundedSender<LifecycleCommand>,
131	dispatch: mpsc::UnboundedSender<DispatchCommand>,
132	join: Arc<TokioMutex<Option<JoinHandle<Result<()>>>>>,
133}
134
135type ActiveActorInstance = Arc<ActorTaskHandle>;
136
137enum ActorInstanceState {
138	Active(ActiveActorInstance),
139	Stopping {
140		instance: ActiveActorInstance,
141		reason: ShutdownKind,
142	},
143}
144
145impl ActorInstanceState {
146	fn instance(&self) -> ActiveActorInstance {
147		match self {
148			Self::Active(instance) | Self::Stopping { instance, .. } => instance.clone(),
149		}
150	}
151
152	fn active_instance(&self) -> Option<ActiveActorInstance> {
153		match self {
154			Self::Active(instance) => Some(instance.clone()),
155			Self::Stopping { .. } => None,
156		}
157	}
158}
159
160#[derive(Clone)]
161struct PendingStop {
162	generation: u32,
163	reason: protocol::StopActorReason,
164	stop_handle: ActorStopHandle,
165}
166
167/// Outcome of attempting to transition the actor instance under an id to stopping
168/// for a specific generation.
169enum TransitionResult {
170	/// The current instance matches the requested generation and was moved to stopping.
171	Transitioned(ActiveActorInstance),
172	/// An instance exists but for a different generation than the stop targets, so it
173	/// was left untouched. The stop must not be applied to it.
174	Stale,
175	/// No instance is registered for the actor id.
176	Vacant,
177}
178
179pub(crate) struct RegistryDispatcher {
180	pub(crate) factories: HashMap<String, Arc<ActorFactory>>,
181	actor_instances: SccHashMap<String, ActorInstanceState>,
182	starting_instances: SccHashMap<String, Arc<Notify>>,
183	pending_stops: SccHashMap<String, PendingStop>,
184	region: String,
185	handle_inspector_http_in_runtime: bool,
186}
187
188pub(crate) struct RegistryCallbacks {
189	pub(crate) dispatcher: Arc<RegistryDispatcher>,
190}
191
192#[derive(Clone, Debug)]
193struct StartActorRequest {
194	actor_id: String,
195	generation: u32,
196	actor_name: String,
197	input: Option<Vec<u8>>,
198	ctx: ActorContext,
199}
200
201#[derive(Clone, Debug)]
202struct ServeSettings {
203	version: u32,
204	endpoint: String,
205	token: Option<String>,
206	namespace: String,
207	pool_name: String,
208	engine_binary_path: Option<PathBuf>,
209	start_services: bool,
210	services_binary_path: Option<PathBuf>,
211	engine_host: Option<String>,
212	engine_port: Option<u16>,
213	engine_spawn: EngineSpawnMode,
214	engine_auto_download: bool,
215	handle_inspector_http_in_runtime: bool,
216	serverless_base_path: Option<String>,
217	serverless_package_version: String,
218	serverless_client_endpoint: Option<String>,
219	serverless_client_namespace: Option<String>,
220	serverless_client_token: Option<String>,
221	serverless_validate_endpoint: bool,
222	serverless_max_start_payload_bytes: usize,
223}
224
225#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
226pub enum EngineSpawnMode {
227	#[default]
228	Auto,
229	Always,
230	Never,
231}
232
233impl EngineSpawnMode {
234	pub(crate) fn from_env() -> Self {
235		match env::var("RIVETKIT_ENGINE_SPAWN") {
236			Ok(value) if value.eq_ignore_ascii_case("always") => Self::Always,
237			Ok(value) if value.eq_ignore_ascii_case("never") => Self::Never,
238			_ => Self::Auto,
239		}
240	}
241}
242
243/// Selects how `Registry::start` runs, mirroring the TypeScript
244/// `RIVETKIT_RUNTIME_MODE` env var. `Envoy` holds one long-lived outbound
245/// envoy for the process lifetime; `Serverless` runs an HTTP listener that
246/// lazily starts and caches an envoy on the first request.
247#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
248pub enum RuntimeMode {
249	#[default]
250	Envoy,
251	Serverless,
252}
253
254impl RuntimeMode {
255	pub fn from_env() -> Self {
256		match env::var("RIVETKIT_RUNTIME_MODE") {
257			Ok(value) if value.eq_ignore_ascii_case("serverless") => Self::Serverless,
258			_ => Self::Envoy,
259		}
260	}
261}
262
263#[derive(Clone, Debug, Default)]
264pub struct ServeConfig {
265	pub version: u32,
266	pub endpoint: String,
267	pub token: Option<String>,
268	pub namespace: String,
269	pub pool_name: String,
270	pub engine_binary_path: Option<PathBuf>,
271	pub start_services: bool,
272	pub services_binary_path: Option<PathBuf>,
273	pub engine_host: Option<String>,
274	pub engine_port: Option<u16>,
275	pub engine_spawn: EngineSpawnMode,
276	pub engine_auto_download: bool,
277	pub handle_inspector_http_in_runtime: bool,
278	pub serverless_base_path: Option<String>,
279	pub serverless_package_version: String,
280	pub serverless_client_endpoint: Option<String>,
281	pub serverless_client_namespace: Option<String>,
282	pub serverless_client_token: Option<String>,
283	pub serverless_validate_endpoint: bool,
284	pub serverless_max_start_payload_bytes: usize,
285	pub serverless_cache_envoy: bool,
286}
287
288#[derive(Debug, Default, Deserialize)]
289#[serde(default)]
290struct InspectorPatchStateBody {
291	state: JsonValue,
292}
293
294#[derive(Debug, Default, Deserialize)]
295#[serde(default)]
296struct InspectorActionBody {
297	args: Vec<JsonValue>,
298	properties: Option<JsonValue>,
299}
300
301#[derive(Debug, Default, Deserialize)]
302#[serde(default)]
303struct InspectorDatabaseExecuteBody {
304	sql: String,
305	args: Vec<JsonValue>,
306	properties: Option<JsonValue>,
307}
308
309#[derive(Debug, Default, Deserialize)]
310#[serde(default, rename_all = "camelCase")]
311struct InspectorWorkflowReplayBody {
312	entry_id: Option<String>,
313}
314
315#[derive(Debug, Default, Deserialize)]
316#[serde(default)]
317struct InspectorEnqueueBody {
318	name: String,
319	body: Option<JsonValue>,
320}
321
322#[derive(Debug, Serialize)]
323#[serde(rename_all = "camelCase")]
324struct InspectorQueueMessageJson {
325	id: u64,
326	name: String,
327	created_at_ms: i64,
328}
329
330#[derive(Debug, Serialize)]
331#[serde(rename_all = "camelCase")]
332struct InspectorQueueResponseJson {
333	size: u32,
334	max_size: u32,
335	truncated: bool,
336	messages: Vec<InspectorQueueMessageJson>,
337}
338
339#[derive(Debug, Deserialize)]
340#[serde(default)]
341struct HttpActionRequestJson {
342	args: JsonValue,
343}
344
345impl Default for HttpActionRequestJson {
346	fn default() -> Self {
347		Self {
348			args: JsonValue::Array(Vec::new()),
349		}
350	}
351}
352
353pub(crate) fn should_manage_engine(endpoint: &str, spawn_mode: EngineSpawnMode) -> Result<bool> {
354	match spawn_mode {
355		EngineSpawnMode::Always => Ok(true),
356		EngineSpawnMode::Never => Ok(false),
357		EngineSpawnMode::Auto => is_loopback_endpoint(endpoint),
358	}
359}
360
361fn is_loopback_endpoint(endpoint: &str) -> Result<bool> {
362	let url =
363		Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?;
364	let Some(host) = url.host_str() else {
365		anyhow::bail!("engine endpoint `{endpoint}` is invalid: missing host");
366	};
367
368	if host == "localhost" || host.ends_with(".localhost") {
369		return Ok(true);
370	}
371
372	let ip_host = host
373		.strip_prefix('[')
374		.and_then(|value| value.strip_suffix(']'))
375		.unwrap_or(host);
376
377	Ok(ip_host
378		.parse::<std::net::IpAddr>()
379		.map(|ip| ip.is_loopback() || ip.is_unspecified())
380		.unwrap_or(false))
381}
382
383#[cfg(test)]
384mod engine_spawn_tests {
385	use super::{EngineSpawnMode, should_manage_engine};
386
387	#[test]
388	fn auto_manages_loopback_endpoints() {
389		assert!(should_manage_engine("http://127.0.0.1:6420", EngineSpawnMode::Auto).unwrap());
390		assert!(should_manage_engine("http://localhost:6420", EngineSpawnMode::Auto).unwrap());
391		assert!(should_manage_engine("http://dev.localhost:6420", EngineSpawnMode::Auto).unwrap());
392		assert!(should_manage_engine("http://[::1]:6420", EngineSpawnMode::Auto).unwrap());
393	}
394
395	#[test]
396	fn auto_leaves_remote_endpoints_connect_only() {
397		assert!(!should_manage_engine("https://api.rivet.dev", EngineSpawnMode::Auto).unwrap());
398		assert!(!should_manage_engine("http://192.0.2.10:6420", EngineSpawnMode::Auto).unwrap());
399	}
400
401	#[test]
402	fn explicit_spawn_mode_overrides_endpoint_shape() {
403		assert!(should_manage_engine("https://api.rivet.dev", EngineSpawnMode::Always).unwrap());
404		assert!(!should_manage_engine("http://127.0.0.1:6420", EngineSpawnMode::Never).unwrap());
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		crate::metrics::record_rivetkit_info(
621			config.serverless_package_version.clone(),
622			config.version,
623			"serverful",
624			config.pool_name.clone(),
625		);
626		#[cfg(not(target_arch = "wasm32"))]
627		crate::tokio_runtime_metrics::ensure_sampler_started();
628
629		let dispatcher = self.into_dispatcher(&config);
630		let manage_engine = should_manage_engine(&config.endpoint, config.engine_spawn)?;
631		#[cfg(feature = "native-runtime")]
632		let development_processes = if manage_engine {
633			Some(DevelopmentProcessManager::start(&config).await?)
634		} else {
635			None
636		};
637		#[cfg(not(feature = "native-runtime"))]
638		if manage_engine {
639			anyhow::bail!("engine process spawning requires the `native-runtime` feature");
640		}
641
642		#[cfg(feature = "native-runtime")]
643		runner_config::ensure_local_normal_runner_config(&config).await?;
644		let callbacks = Arc::new(RegistryCallbacks {
645			dispatcher: dispatcher.clone(),
646		});
647
648		let prepopulate_actor_names = dispatcher
649			.build_actor_metadata_map()
650			.into_iter()
651			.map(|(name, metadata)| (name, rivet_envoy_client::config::ActorName { metadata }))
652			.collect();
653		let handle = start_envoy(rivet_envoy_client::config::EnvoyConfig {
654			version: config.version,
655			endpoint: config.endpoint,
656			token: config.token,
657			namespace: config.namespace,
658			pool_name: config.pool_name,
659			prepopulate_actor_names,
660			metadata: Some(json!({
661				"rivetkit": { "version": config.serverless_package_version },
662			})),
663			not_global: false,
664			debug_latency_ms: None,
665			callbacks,
666		})
667		.await;
668		on_handle(CoreEnvoyHandle::new(handle.clone()));
669
670		// Do not install `tokio::signal::ctrl_c()` here. It calls
671		// `sigaction(SIGINT, ...)` at the POSIX level, which overrides the
672		// host's default SIGINT handling when rivetkit-core is embedded in
673		// Node via NAPI and leaves the host process unable to exit. Callers
674		// trip the `shutdown` token instead.
675		shutdown.cancelled().await;
676
677		let shutdown_envoy = async {
678			// TODO: Move into envoy-client since timing out has to do with protocol compliance
679			// Read threshold from protocol metadata, fall back to 30 min
680			let stop_threshold = handle
681				.get_protocol_metadata()
682				.await
683				.map(|x| x.actor_stop_threshold)
684				.unwrap_or(30 * 60 * 1000);
685			// Bounded drain. If envoy cannot reach the engine (reconnect loop stuck),
686			// we fall back to immediate `Stop` rather than hanging indefinitely.
687			// The outer host (TS signal handler / Rust binary) is the backstop.
688			match timeout(
689				Duration::from_millis(stop_threshold as u64),
690				handle.shutdown_and_wait(false),
691			)
692			.await
693			{
694				Ok(()) => {}
695				Err(_) => {
696					tracing::warn!("envoy shutdown drain exceeded timeout; forcing immediate stop");
697					handle.shutdown(true);
698					handle.wait_stopped().await;
699				}
700			}
701		};
702		#[cfg(feature = "native-runtime")]
703		let shutdown_development_processes = async move {
704			if let Some(development_processes) = development_processes {
705				development_processes.shutdown().await;
706			}
707		};
708		#[cfg(feature = "native-runtime")]
709		tokio::join!(shutdown_envoy, shutdown_development_processes);
710		#[cfg(not(feature = "native-runtime"))]
711		shutdown_envoy.await;
712
713		Ok(())
714	}
715
716	fn into_dispatcher(self, config: &ServeConfig) -> Arc<RegistryDispatcher> {
717		Arc::new(RegistryDispatcher::new(
718			self.factories,
719			config.handle_inspector_http_in_runtime,
720		))
721	}
722
723	pub async fn into_serverless_runtime(
724		self,
725		config: ServeConfig,
726	) -> Result<crate::serverless::CoreServerlessRuntime> {
727		crate::serverless::CoreServerlessRuntime::new(self.factories, config).await
728	}
729}
730
731impl RegistryDispatcher {
732	pub(crate) fn new(
733		factories: HashMap<String, Arc<ActorFactory>>,
734		handle_inspector_http_in_runtime: bool,
735	) -> Self {
736		Self {
737			factories,
738			actor_instances: SccHashMap::new(),
739			starting_instances: SccHashMap::new(),
740			pending_stops: SccHashMap::new(),
741			region: env::var("RIVET_REGION").unwrap_or_default(),
742			handle_inspector_http_in_runtime,
743		}
744	}
745
746	pub(crate) fn build_actor_metadata_map(&self) -> HashMap<String, JsonValue> {
747		build_actor_metadata_map_from_factories(&self.factories)
748	}
749}
750
751pub(crate) fn serverless_metadata_payload(
752	actor_metadata: HashMap<String, JsonValue>,
753	config: &ServeConfig,
754	envoy_kind: ServerlessMetadataEnvoyKind,
755) -> ServerlessMetadataPayload {
756	let actor_names = actor_metadata
757		.into_iter()
758		.map(|(name, metadata)| {
759			(
760				name,
761				ActorName {
762					metadata: Some(metadata),
763				},
764			)
765		})
766		.collect::<HashMap<_, _>>();
767
768	ServerlessMetadataPayload {
769		runtime: "rivetkit".to_owned(),
770		version: config.serverless_package_version.clone(),
771		envoy_protocol_version: Some(protocol::PROTOCOL_VERSION),
772		actor_names,
773		envoy: Some(ServerlessMetadataEnvoy {
774			kind: Some(envoy_kind),
775			version: Some(config.version),
776		}),
777		runner: None,
778		client_endpoint: config.serverless_client_endpoint.clone(),
779		client_namespace: config.serverless_client_namespace.clone(),
780		client_token: config.serverless_client_token.clone(),
781	}
782}
783
784fn build_actor_metadata_map_from_factories(
785	factories: &HashMap<String, Arc<ActorFactory>>,
786) -> HashMap<String, JsonValue> {
787	factories
788		.iter()
789		.map(|(actor_name, factory)| {
790			let config = factory.config();
791			let mut metadata = serde_json::Map::new();
792			if let Some(icon) = &config.icon {
793				metadata.insert("icon".to_owned(), json!(icon));
794			}
795			if let Some(name) = &config.name {
796				metadata.insert("name".to_owned(), json!(name));
797			}
798			(actor_name.clone(), JsonValue::Object(metadata))
799		})
800		.collect()
801}
802
803impl RegistryDispatcher {
804	async fn start_actor(self: &Arc<Self>, request: StartActorRequest) -> Result<()> {
805		let startup_notify = Arc::new(Notify::new());
806		let _ = self
807			.starting_instances
808			.insert_async(request.actor_id.clone(), startup_notify.clone())
809			.await;
810		// Test-only seam: lets a test hold a generation in the "starting" window so it
811		// can deterministically deliver a stop for a previous generation that parks
812		// under the actor id and gets consumed by this startup.
813		#[cfg(test)]
814		test_hooks::wait_for_startup_gate(&request.actor_id).await;
815		let factory = self
816			.factories
817			.get(&request.actor_name)
818			.cloned()
819			.ok_or_else(|| {
820				ActorRuntime::NotRegistered {
821					actor_name: request.actor_name.clone(),
822				}
823				.build()
824			})?;
825		let (lifecycle_tx, lifecycle_rx) = mpsc::unbounded_channel();
826		let (dispatch_tx, dispatch_rx) = mpsc::unbounded_channel();
827		let (lifecycle_events_tx, lifecycle_events_rx) = mpsc::unbounded_channel();
828		request
829			.ctx
830			.configure_lifecycle_events(Some(lifecycle_events_tx));
831		request.ctx.cancel_sleep_timer();
832		request.ctx.set_local_alarm_callback(Some(Arc::new({
833			let lifecycle_tx = lifecycle_tx.clone();
834			move || {
835				let lifecycle_tx = lifecycle_tx.clone();
836				Box::pin(async move {
837					let (reply_tx, reply_rx) = oneshot::channel();
838					if let Err(error) = try_send_lifecycle_command(
839						&lifecycle_tx,
840						LifecycleCommand::FireAlarm { reply: reply_tx },
841					) {
842						tracing::warn!(?error, "failed to enqueue actor alarm");
843						return;
844					}
845					let _ = reply_rx.await;
846				})
847			}
848		})));
849		let task = ActorTask::new(
850			request.actor_id.clone(),
851			request.generation,
852			lifecycle_rx,
853			dispatch_rx,
854			lifecycle_events_rx,
855			factory.clone(),
856			request.ctx.clone(),
857			request.input,
858		);
859		let join = RuntimeSpawner::spawn(task.run());
860
861		let (start_tx, start_rx) = oneshot::channel();
862		let result: Result<Arc<ActorTaskHandle>> = async {
863			try_send_lifecycle_command(&lifecycle_tx, LifecycleCommand::Start { reply: start_tx })
864				.context("send actor task start command")?;
865			start_rx
866				.await
867				.context("receive actor task start reply")?
868				.context("actor task start")?;
869			let inspector = build_actor_inspector();
870			request.ctx.configure_inspector(Some(inspector.clone()));
871			Ok::<Arc<ActorTaskHandle>, anyhow::Error>(Arc::new(ActorTaskHandle {
872				actor_id: request.actor_id.clone(),
873				actor_name: request.actor_name.clone(),
874				generation: request.generation,
875				ctx: request.ctx.clone(),
876				factory,
877				inspector,
878				lifecycle: lifecycle_tx,
879				dispatch: dispatch_tx,
880				join: Arc::new(TokioMutex::new(Some(join))),
881			}))
882		}
883		.await
884		.with_context(|| format!("start actor `{}`", request.actor_id));
885
886		match result {
887			Ok(instance) => {
888				let pending_stop = self
889					.pending_stops
890					.remove_async(&request.actor_id.clone())
891					.await
892					.map(|(_, pending_stop)| pending_stop);
893				// Only apply a parked stop if it targets the generation we just started.
894				// A stop parked for a previous generation is stale: complete its handle
895				// so teardown finalizes, but leave the new generation running.
896				let pending_stop = match pending_stop {
897					Some(pending_stop) if pending_stop.generation == request.generation => {
898						Some(pending_stop)
899					}
900					Some(stale_stop) => {
901						let _ = stale_stop.stop_handle.complete();
902						None
903					}
904					None => None,
905				};
906				if let Some(pending_stop) = pending_stop {
907					let actor_id = request.actor_id.clone();
908					let stop_reason = map_envoy_stop_reason(&pending_stop.reason);
909					if matches!(stop_reason, ShutdownKind::Destroy) {
910						instance.ctx.mark_destroy_requested();
911					}
912					self.set_actor_instance_state(
913						actor_id.clone(),
914						ActorInstanceState::Stopping {
915							instance: instance.clone(),
916							reason: stop_reason,
917						},
918					)
919					.await;
920					let _ = self
921						.starting_instances
922						.remove_async(&request.actor_id.clone())
923						.await;
924
925					let dispatcher = self.clone();
926					RuntimeSpawner::spawn(async move {
927						if let Err(error) = dispatcher
928							.shutdown_started_instance(
929								&actor_id,
930								instance.clone(),
931								pending_stop.reason,
932								pending_stop.stop_handle,
933							)
934							.await
935						{
936							tracing::error!(
937								actor_id,
938								?error,
939								"failed to stop actor queued during startup"
940							);
941						}
942						dispatcher
943							.remove_stopping_actor_instance(&actor_id, &instance)
944							.await;
945					});
946					startup_notify.notify_waiters();
947
948					Ok(())
949				} else {
950					self.set_actor_instance_state(
951						request.actor_id.clone(),
952						ActorInstanceState::Active(instance),
953					)
954					.await;
955					let _ = self
956						.starting_instances
957						.remove_async(&request.actor_id.clone())
958						.await;
959					startup_notify.notify_waiters();
960					Ok(())
961				}
962			}
963			Err(error) => {
964				let _ = self
965					.starting_instances
966					.remove_async(&request.actor_id.clone())
967					.await;
968				// A stop parked while this start was in flight would otherwise leak its
969				// ActorStopHandle in the map (the map holds the sender alive, hanging the
970				// caller). Drain and complete it since there is no instance to stop.
971				if let Some((_, pending_stop)) = self
972					.pending_stops
973					.remove_async(&request.actor_id.clone())
974					.await
975				{
976					let _ = pending_stop.stop_handle.complete();
977				}
978				startup_notify.notify_waiters();
979				Err(error)
980			}
981		}
982	}
983
984	async fn set_actor_instance_state(&self, actor_id: String, state: ActorInstanceState) {
985		match self.actor_instances.entry_async(actor_id).await {
986			SccEntry::Occupied(mut entry) => {
987				entry.insert(state);
988			}
989			SccEntry::Vacant(entry) => {
990				entry.insert_entry(state);
991			}
992		}
993	}
994
995	async fn transition_actor_to_stopping(
996		&self,
997		actor_id: &str,
998		generation: u32,
999		reason: ShutdownKind,
1000	) -> TransitionResult {
1001		match self.actor_instances.entry_async(actor_id.to_owned()).await {
1002			SccEntry::Occupied(mut entry) => {
1003				let instance = entry.get().instance();
1004				// A stop is scoped to the generation it was issued for. If the currently
1005				// registered instance is a different generation (e.g. a lost previous
1006				// generation whose replacement is already running), the stop is stale and
1007				// must not tear down the newer generation.
1008				if instance.generation != generation {
1009					return TransitionResult::Stale;
1010				}
1011				if matches!(entry.get(), ActorInstanceState::Active(_)) {
1012					entry.insert(ActorInstanceState::Stopping {
1013						instance: instance.clone(),
1014						reason,
1015					});
1016				} else {
1017					instance
1018						.ctx
1019						.warn_work_sent_to_stopping_instance("stop_actor");
1020				}
1021				TransitionResult::Transitioned(instance)
1022			}
1023			SccEntry::Vacant(entry) => {
1024				drop(entry);
1025				TransitionResult::Vacant
1026			}
1027		}
1028	}
1029
1030	async fn remove_stopping_actor_instance(&self, actor_id: &str, expected: &ActiveActorInstance) {
1031		match self.actor_instances.entry_async(actor_id.to_owned()).await {
1032			SccEntry::Occupied(entry) => {
1033				let should_remove = match entry.get() {
1034					ActorInstanceState::Stopping { instance, .. } => {
1035						Arc::ptr_eq(instance, expected)
1036					}
1037					ActorInstanceState::Active(_) => false,
1038				};
1039				if should_remove {
1040					let _ = entry.remove_entry();
1041				}
1042			}
1043			SccEntry::Vacant(entry) => {
1044				drop(entry);
1045			}
1046		}
1047	}
1048
1049	async fn active_actor(&self, actor_id: &str) -> Result<Arc<ActorTaskHandle>> {
1050		if let Some(instance) = self.actor_instances.get_async(&actor_id.to_owned()).await {
1051			match instance.get() {
1052				ActorInstanceState::Active(instance) => {
1053					let instance = instance.clone();
1054					// TODO: Share admission policy with ActorTask::dispatch_lifecycle_error.
1055					if instance.ctx.started() {
1056						if instance.ctx.destroy_requested() {
1057							instance
1058								.ctx
1059								.warn_work_sent_to_stopping_instance("active_actor");
1060							return Err(ActorLifecycleError::Destroying.build());
1061						}
1062						return Ok(instance);
1063					}
1064
1065					instance
1066						.ctx
1067						.warn_work_sent_to_stopping_instance("active_actor");
1068					return Err(if instance.ctx.destroy_requested() {
1069						ActorLifecycleError::Destroying.build()
1070					} else if instance.ctx.sleep_requested() {
1071						ActorLifecycleError::Stopping.build()
1072					} else {
1073						ActorLifecycleError::Starting.build()
1074					});
1075				}
1076				ActorInstanceState::Stopping { instance, reason } => {
1077					let instance = instance.clone();
1078					match reason {
1079						ShutdownKind::Sleep if instance.ctx.started() => return Ok(instance),
1080						ShutdownKind::Sleep => {
1081							instance
1082								.ctx
1083								.warn_work_sent_to_stopping_instance("active_actor");
1084							return Err(ActorLifecycleError::Stopping.build());
1085						}
1086						ShutdownKind::Destroy => {
1087							instance
1088								.ctx
1089								.warn_work_sent_to_stopping_instance("active_actor");
1090							return Err(ActorLifecycleError::Destroying.build());
1091						}
1092					}
1093				}
1094			}
1095		}
1096
1097		tracing::warn!(actor_id, "actor instance not found");
1098		Err(ActorRuntime::NotFound {
1099			resource: "instance".to_owned(),
1100			id: actor_id.to_owned(),
1101		}
1102		.build())
1103	}
1104
1105	async fn stop_actor(
1106		&self,
1107		actor_id: &str,
1108		generation: u32,
1109		reason: protocol::StopActorReason,
1110		stop_handle: ActorStopHandle,
1111	) -> Result<()> {
1112		if self
1113			.starting_instances
1114			.get_async(&actor_id.to_owned())
1115			.await
1116			.is_some()
1117		{
1118			// The target generation is still starting. Park the stop with its generation
1119			// so startup can decide whether it belongs to the generation being started.
1120			let _ = self
1121				.pending_stops
1122				.insert_async(
1123					actor_id.to_owned(),
1124					PendingStop {
1125						generation,
1126						reason,
1127						stop_handle,
1128					},
1129				)
1130				.await;
1131			return Ok(());
1132		}
1133
1134		let task_stop_reason = map_envoy_stop_reason(&reason);
1135		match self
1136			.transition_actor_to_stopping(actor_id, generation, task_stop_reason)
1137			.await
1138		{
1139			TransitionResult::Transitioned(instance) => {
1140				let result = self
1141					.shutdown_started_instance(actor_id, instance.clone(), reason, stop_handle)
1142					.await;
1143				self.remove_stopping_actor_instance(actor_id, &instance)
1144					.await;
1145				result
1146			}
1147			TransitionResult::Stale => {
1148				// The running instance is a different generation; this stop targets a
1149				// generation that is already gone. Complete the handle so envoy-client
1150				// finalizes teardown cleanly instead of warning about a dropped handle.
1151				let _ = stop_handle.complete();
1152				Ok(())
1153			}
1154			TransitionResult::Vacant => {
1155				let _ = self
1156					.pending_stops
1157					.insert_async(
1158						actor_id.to_owned(),
1159						PendingStop {
1160							generation,
1161							reason,
1162							stop_handle,
1163						},
1164					)
1165					.await;
1166				Ok(())
1167			}
1168		}
1169	}
1170
1171	async fn shutdown_started_instance(
1172		&self,
1173		actor_id: &str,
1174		instance: Arc<ActorTaskHandle>,
1175		reason: protocol::StopActorReason,
1176		stop_handle: ActorStopHandle,
1177	) -> Result<()> {
1178		let task_stop_reason = map_envoy_stop_reason(&reason);
1179
1180		if matches!(task_stop_reason, ShutdownKind::Destroy) {
1181			instance.ctx.mark_destroy_requested();
1182		}
1183
1184		tracing::debug!(
1185			actor_id,
1186			handle_actor_id = %instance.actor_id,
1187			actor_name = %instance.actor_name,
1188			generation = instance.generation,
1189			?reason,
1190			?task_stop_reason,
1191			"stopping actor instance"
1192		);
1193
1194		let (reply_tx, reply_rx) = oneshot::channel();
1195		let shutdown_result = match try_send_lifecycle_command(
1196			&instance.lifecycle,
1197			LifecycleCommand::Stop {
1198				reason: task_stop_reason,
1199				reply: reply_tx,
1200			},
1201		) {
1202			Ok(()) => reply_rx
1203				.await
1204				.context("receive actor task stop reply")
1205				.and_then(|result| result),
1206			Err(error) => Err(error),
1207		};
1208
1209		if matches!(task_stop_reason, ShutdownKind::Destroy) {
1210			let shutdown_deadline =
1211				Instant::now() + instance.factory.config().effective_sleep_grace_period();
1212			if !instance
1213				.ctx
1214				.wait_for_internal_keep_awake_idle(shutdown_deadline)
1215				.await
1216			{
1217				instance.ctx.record_direct_subsystem_shutdown_warning(
1218					"internal_keep_awake",
1219					"destroy_drain",
1220				);
1221				tracing::warn!(
1222					actor_id,
1223					"destroy shutdown timed out waiting for in-flight actions"
1224				);
1225			}
1226			if !instance
1227				.ctx
1228				.wait_for_http_requests_drained(shutdown_deadline)
1229				.await
1230			{
1231				instance
1232					.ctx
1233					.record_direct_subsystem_shutdown_warning("http_requests", "destroy_drain");
1234				tracing::warn!(
1235					actor_id,
1236					"destroy shutdown timed out waiting for in-flight http requests"
1237				);
1238			}
1239		}
1240
1241		let mut join_guard = instance.join.lock().await;
1242		// Fold the join outcome into the result instead of propagating with `?`,
1243		// so the stop handle is always signaled and never dropped unsignaled.
1244		let join_result = if let Some(join) = join_guard.take() {
1245			join.await
1246				.context("join actor task")
1247				.and_then(|result| result.context("actor task failed"))
1248		} else {
1249			Ok(())
1250		};
1251		instance.ctx.configure_lifecycle_events(None);
1252
1253		if let (Err(shutdown_error), Err(join_error)) = (&shutdown_result, &join_result) {
1254			tracing::warn!(
1255				actor_id,
1256				%shutdown_error,
1257				discarded_join_error = %join_error,
1258				"actor stop had both shutdown and join failures; only the shutdown error is returned"
1259			);
1260		}
1261
1262		let final_result = shutdown_result.and(join_result);
1263		match &final_result {
1264			Ok(_) => {
1265				let _ = stop_handle.complete();
1266			}
1267			Err(error) => {
1268				let _ = stop_handle.fail(anyhow::Error::new(RivetError::extract(error)));
1269			}
1270		}
1271
1272		final_result.with_context(|| format!("stop actor `{actor_id}`"))
1273	}
1274}
1275
1276impl RegistryDispatcher {
1277	fn can_hibernate(&self, actor_id: &str, request: &HttpRequest) -> bool {
1278		let Some(instance) = self
1279			.actor_instances
1280			.read_sync(actor_id, |_, state| state.active_instance())
1281			.flatten()
1282		else {
1283			return false;
1284		};
1285
1286		match &instance.factory.config().can_hibernate_websocket {
1287			CanHibernateWebSocket::Bool(value) => *value,
1288			CanHibernateWebSocket::Callback(callback) => callback(request),
1289		}
1290	}
1291
1292	#[allow(clippy::too_many_arguments)]
1293	fn build_actor_context(
1294		&self,
1295		handle: EnvoyHandle,
1296		actor_id: &str,
1297		generation: u32,
1298		actor_name: &str,
1299		key: ActorKey,
1300		factory: &ActorFactory,
1301	) -> Result<ActorContext> {
1302		let formatted_key = format_actor_key(&key);
1303		let ctx = ActorContext::build(
1304			actor_id.to_owned(),
1305			actor_name.to_owned(),
1306			key,
1307			self.region.clone(),
1308			Some(generation),
1309			handle.get_envoy_key().to_owned(),
1310			factory.config().clone(),
1311			LegacyActorKv::new(handle.clone(), actor_id.to_owned()),
1312			SqliteDb::new_with_remote_sqlite(
1313				handle.clone(),
1314				actor_id.to_owned(),
1315				Some(formatted_key),
1316				Some(generation as u64),
1317				factory.config().has_database,
1318				factory.config().remote_sqlite,
1319			)?,
1320		);
1321		ctx.configure_envoy(handle, Some(generation));
1322		Ok(ctx)
1323	}
1324}
1325
1326/// Maps an envoy-protocol stop reason to the lifecycle `ShutdownKind` used by
1327/// `ActorTask`. Reallocation paths (the actor will resurrect on a new envoy)
1328/// are routed through `Sleep` so user `onSleep` runs and durable state is
1329/// preserved without firing a permanent destroy.
1330fn map_envoy_stop_reason(reason: &protocol::StopActorReason) -> ShutdownKind {
1331	match reason {
1332		// Idle sleep requested by the actor itself.
1333		protocol::StopActorReason::SleepIntent => ShutdownKind::Sleep,
1334		// Runner is being drained; engine will reallocate the actor on a new
1335		// envoy. Treat as sleep so persistent state and onSleep semantics hold.
1336		protocol::StopActorReason::GoingAway => ShutdownKind::Sleep,
1337		// Runner connection lost; once reconnected (or another runner is
1338		// allocated) the actor resurrects with the same id.
1339		protocol::StopActorReason::Lost => ShutdownKind::Sleep,
1340		// User-initiated stop intent (`ctx.destroy()` and equivalents).
1341		protocol::StopActorReason::StopIntent => ShutdownKind::Destroy,
1342		// Engine-initiated permanent destroy.
1343		protocol::StopActorReason::Destroy => ShutdownKind::Destroy,
1344	}
1345}
1346
1347// Test shim keeps moved tests in crate-root tests/ with private-module access.
1348#[cfg(test)]
1349#[path = "../../tests/registry.rs"]
1350pub(crate) mod tests;
1351
1352// Test-only hooks used by the moved registry tests to deterministically drive the
1353// generation-stop race. Gated behind `cfg(test)` so there is no production impact.
1354#[cfg(test)]
1355pub(crate) mod test_hooks {
1356	use std::sync::{Arc, OnceLock};
1357
1358	use scc::HashMap as SccHashMap;
1359	use tokio::sync::Semaphore;
1360
1361	static STARTUP_GATES: OnceLock<SccHashMap<String, Arc<Semaphore>>> = OnceLock::new();
1362
1363	fn gates() -> &'static SccHashMap<String, Arc<Semaphore>> {
1364		STARTUP_GATES.get_or_init(SccHashMap::new)
1365	}
1366
1367	/// Arms a gate so `start_actor` pauses for `actor_id` after registering as
1368	/// starting, until `release_startup_gate` is called.
1369	pub(crate) fn arm_startup_gate(actor_id: &str) {
1370		let _ = gates().insert_sync(actor_id.to_owned(), Arc::new(Semaphore::new(0)));
1371	}
1372
1373	/// Releases a previously armed gate, letting the paused `start_actor` continue.
1374	pub(crate) fn release_startup_gate(actor_id: &str) {
1375		if let Some(sem) = gates().read_sync(actor_id, |_, sem| sem.clone()) {
1376			sem.add_permits(1);
1377		}
1378	}
1379
1380	/// Called from inside `start_actor`. Blocks only if a gate is armed for the
1381	/// actor. Order-independent: a release before this runs still lets it through.
1382	pub(crate) async fn wait_for_startup_gate(actor_id: &str) {
1383		let sem = gates().read_sync(actor_id, |_, sem| sem.clone());
1384		if let Some(sem) = sem {
1385			let permit = sem.acquire().await.expect("startup gate semaphore closed");
1386			permit.forget();
1387			let _ = gates().remove_sync(actor_id);
1388		}
1389	}
1390}