Skip to main content

rivetkit_engine_process/
lib.rs

1use std::process::Stdio;
2use std::time::{Duration, Instant};
3use std::{
4	env,
5	path::{Path, PathBuf},
6};
7
8use anyhow::{Context, Result};
9use reqwest::{Client, Url};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use tokio::io::AsyncReadExt;
13use tokio::process::{Child, Command};
14use tokio::task::JoinHandle;
15
16mod error;
17
18pub use error::EngineProcessError;
19
20const ENGINE_RUNTIME: &str = "engine";
21/// Engine architecture this RivetKit requires. v2 engines report the same runtime and
22/// version but no architecture, so the field alone tells them apart.
23const ENGINE_ARCHITECTURE: &str = "v3";
24const RIVETKIT_RUNTIME: &str = "rivetkit";
25const ENGINE_VERSION_ENV: &str = "RIVETKIT_ENGINE_VERSION";
26const RELEASES_ENDPOINT_ENV: &str = "RIVETKIT_ENGINE_RELEASES_ENDPOINT";
27const RELEASES_ENDPOINT: &str = "https://releases.rivet.dev";
28const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60);
29
30#[derive(Debug, Deserialize)]
31struct EngineHealthResponse {
32	status: Option<String>,
33	runtime: Option<String>,
34	version: Option<String>,
35	architecture: Option<String>,
36}
37
38#[derive(Clone, Debug)]
39pub struct EngineResolverConfig {
40	pub endpoint: String,
41	pub explicit_binary_path: Option<PathBuf>,
42	pub bind_host: Option<String>,
43	pub bind_port: Option<u16>,
44	pub public_url: Option<String>,
45	pub auto_download: bool,
46	pub version: String,
47	pub releases_endpoint: String,
48}
49
50impl EngineResolverConfig {
51	pub fn from_parts(
52		endpoint: &str,
53		explicit_binary_path: Option<PathBuf>,
54		bind_host: Option<String>,
55		bind_port: Option<u16>,
56		auto_download: bool,
57	) -> Self {
58		Self {
59			endpoint: endpoint.to_owned(),
60			explicit_binary_path,
61			bind_host,
62			bind_port,
63			public_url: None,
64			auto_download,
65			version: std::env::var(ENGINE_VERSION_ENV)
66				.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_owned()),
67			releases_endpoint: std::env::var(RELEASES_ENDPOINT_ENV)
68				.unwrap_or_else(|_| RELEASES_ENDPOINT.to_owned()),
69		}
70	}
71}
72
73#[derive(Debug, PartialEq, Eq)]
74pub enum ResolvedEngine {
75	Existing,
76	Binary(PathBuf),
77}
78
79/// Effective runtime parameters of a spawned engine, stamped to disk so a later
80/// process that reattaches can tell how the running engine is configured without
81/// probing it over the network.
82#[derive(Clone, Debug, Serialize, Deserialize)]
83pub struct EngineRuntimeStamp {
84	pub pid: u32,
85	pub endpoint: String,
86	pub bind_host: String,
87	pub public_url: Option<String>,
88}
89
90impl EngineRuntimeStamp {
91	/// True if the engine bound a loopback address only, so peers on other hosts
92	/// (for example a container reaching the host) cannot connect to it.
93	pub fn binds_loopback_only(&self) -> bool {
94		if self.bind_host == "localhost" {
95			return true;
96		}
97		self.bind_host
98			.parse::<std::net::IpAddr>()
99			.map(|ip| ip.is_loopback())
100			.unwrap_or(false)
101	}
102}
103
104/// How `start_or_reuse` satisfied the request.
105#[derive(Debug)]
106pub enum EngineStartup {
107	/// A new engine was spawned with the requested configuration.
108	Spawned,
109	/// An already-running engine was reused. `stamp` is its recorded runtime
110	/// parameters, or `None` when no live stamp was found (an engine from an
111	/// older CLI, started by hand, or a stale stamp whose process is gone).
112	Reused { stamp: Option<EngineRuntimeStamp> },
113}
114
115/// Manages the rivet-engine subprocess.
116///
117/// The engine is intentionally orphaned: dropping the manager (or having the
118/// host process exit) must NOT terminate the engine. This lets a dev-server
119/// restart of the rivetkit host reattach to the same long-lived engine and
120/// keep all in-flight actor state. To honor that contract:
121///
122/// - `Command::kill_on_drop` is left at its default (false) so the tokio
123///   `Child` does not send SIGKILL on drop.
124/// - Stdout and stderr are routed to log files at spawn time so the engine's
125///   write fds remain valid after the host's pipes close.
126/// - On startup we probe the configured endpoint and reuse a healthy engine
127///   instead of spawning a duplicate.
128///
129/// When we spawn the engine, `watcher` holds a tokio task that owns the
130/// `Child` and awaits `child.wait()` so we get a log line if the engine dies
131/// while rivetkit is still running. On Drop we abort the watcher; aborting
132/// drops the `Child` without killing it (kill_on_drop=false), so the engine
133/// stays running and gets reparented to init when rivetkit exits.
134///
135/// `watcher` is `None` when we attached to an already-running engine.
136#[derive(Debug)]
137pub struct EngineProcessManager {
138	watcher: Option<JoinHandle<()>>,
139	startup: EngineStartup,
140}
141
142impl EngineProcessManager {
143	pub async fn start_or_reuse(config: EngineResolverConfig) -> Result<Self> {
144		let resolved = resolve_engine_binary(&config).await?;
145		Self::start_resolved(resolved, &config).await
146	}
147
148	/// How the engine was obtained: freshly spawned or reused.
149	pub fn startup(&self) -> &EngineStartup {
150		&self.startup
151	}
152
153	async fn start_resolved(
154		resolved: ResolvedEngine,
155		config: &EngineResolverConfig,
156	) -> Result<Self> {
157		let endpoint = &config.endpoint;
158		if matches!(resolved, ResolvedEngine::Existing) {
159			tracing::info!(
160				endpoint = %endpoint,
161				"reusing already-running engine process"
162			);
163			return Ok(Self {
164				watcher: None,
165				startup: EngineStartup::Reused {
166					stamp: read_engine_stamp(),
167				},
168			});
169		}
170
171		let ResolvedEngine::Binary(binary_path) = resolved else {
172			unreachable!("existing engine handled above");
173		};
174		if let Some(health) = probe_existing_engine(endpoint).await? {
175			tracing::info!(
176				endpoint = %endpoint,
177				status = ?health.status,
178				runtime = ?health.runtime,
179				version = ?health.version,
180				"reusing already-running engine process"
181			);
182			return Ok(Self {
183				watcher: None,
184				startup: EngineStartup::Reused {
185					stamp: read_engine_stamp(),
186				},
187			});
188		}
189
190		if !binary_path.exists() {
191			return Err(EngineProcessError::BinaryNotFound {
192				path: binary_path.display().to_string(),
193			}
194			.build());
195		}
196
197		ensure_compatible_binary(&binary_path).await?;
198
199		let env = engine_env(config)?;
200		let config_path = write_engine_config(config)?;
201		let data_path = engine_data_path()?;
202		let logs_dir = storage_root()?
203			.join("var")
204			.join("logs")
205			.join("rivet-engine");
206		ensure_dir(&logs_dir).context("create engine logs directory")?;
207		ensure_dir(&engine_log_directory()?).context("create engine log shard directory")?;
208
209		let timestamp = log_timestamp();
210		let stdout_log_path = logs_dir.join(format!("engine-{timestamp}-stdout.log"));
211		let stderr_log_path = logs_dir.join(format!("engine-{timestamp}-stderr.log"));
212		let stdout_file = open_log_file(&stdout_log_path)
213			.with_context(|| format!("open engine stdout log `{}`", stdout_log_path.display()))?;
214		let stderr_file = open_log_file(&stderr_log_path)
215			.with_context(|| format!("open engine stderr log `{}`", stderr_log_path.display()))?;
216
217		let mut command = Command::new(&binary_path);
218		command.arg("--config").arg(&config_path);
219		for (key, value) in &env {
220			command.env(key, value);
221		}
222		command
223			.stdin(Stdio::null())
224			.stdout(Stdio::from(stdout_file))
225			.stderr(Stdio::from(stderr_file));
226
227		// Put the engine in its own process group so terminal signals
228		// (Ctrl+C, Ctrl+Z, SIGHUP on terminal close) targeting our foreground
229		// process group do not reach the engine. Combined with no-kill-on-drop
230		// and file-fd stdio, this gives the engine a real "intentional orphan"
231		// lifetime that survives the host being killed for any reason.
232		#[cfg(unix)]
233		command.process_group(0);
234
235		let mut child = command
236			.spawn()
237			.with_context(|| format!("spawn engine binary `{}`", binary_path.display()))?;
238		let pid = child
239			.id()
240			.ok_or_else(|| EngineProcessError::MissingPid.build())?;
241
242		tracing::info!(
243			pid,
244			path = %binary_path.display(),
245			endpoint = %endpoint,
246			data_path = %data_path.display(),
247			"spawned engine process (intentionally orphaned, will outlive this process)"
248		);
249		tracing::info!(
250			stdout_log = %stdout_log_path.display(),
251			stderr_log = %stderr_log_path.display(),
252			"engine stdout/stderr piped to log files"
253		);
254
255		let health_url = engine_health_url(endpoint);
256		let health = match wait_for_engine_health(&health_url).await {
257			Ok(health) => health,
258			Err(error) => {
259				let error = match child.try_wait() {
260					Ok(Some(status)) => error.context(format!(
261						"engine process exited before becoming healthy with status {status}"
262					)),
263					Ok(None) => error,
264					Err(wait_error) => error.context(format!(
265						"failed to inspect engine process status: {wait_error:#}"
266					)),
267				};
268				if let Err(cleanup_error) = terminate_failed_spawn(&mut child).await {
269					tracing::warn!(
270						?cleanup_error,
271						"failed to terminate engine process that never became healthy"
272					);
273				}
274				return Err(error);
275			}
276		};
277
278		tracing::info!(
279			pid,
280			status = ?health.status,
281			runtime = ?health.runtime,
282			version = ?health.version,
283			"engine process is healthy"
284		);
285
286		// An explicit or cached binary may be an older engine. Never leave one running.
287		let ready = match ensure_compatible_engine(endpoint, &health) {
288			Ok(()) => wait_for_engine_ready(endpoint).await,
289			Err(error) => Err(error),
290		};
291		if let Err(error) = ready {
292			if let Err(cleanup_error) = terminate_failed_spawn(&mut child).await {
293				tracing::warn!(
294					?cleanup_error,
295					"failed to terminate engine process that never became ready"
296				);
297			}
298			return Err(error);
299		}
300		tracing::info!(pid, "engine process is ready");
301
302		// Record the effective runtime parameters so a later reattaching process
303		// can tell how this engine is bound. Best-effort: a failed write only
304		// costs that later process its reuse diagnostics.
305		let stamp = EngineRuntimeStamp {
306			pid,
307			endpoint: endpoint.clone(),
308			bind_host: resolve_bind_host(config)?,
309			public_url: config.public_url.clone(),
310		};
311		if let Err(error) = write_engine_stamp(&stamp) {
312			tracing::warn!(?error, "failed to write engine runtime stamp");
313		}
314
315		Ok(Self {
316			watcher: Some(spawn_engine_watcher(child, pid)),
317			startup: EngineStartup::Spawned,
318		})
319	}
320}
321
322/// How long the `--runtime-info` probe may run before the binary is treated as incompatible.
323const RUNTIME_INFO_TIMEOUT: Duration = Duration::from_secs(5);
324/// The largest `--runtime-info` response read from a probed binary.
325const RUNTIME_INFO_MAX_BYTES: usize = 8192;
326
327/// Probe the executable before creating local data or starting services. Package versions
328/// do not distinguish the unreleased v3 architecture from released v2 binaries.
329async fn ensure_compatible_binary(path: &Path) -> Result<()> {
330	let incompatible = |reason: &str| {
331		EngineProcessError::IncompatibleBinary {
332			path: path.display().to_string(),
333			reason: reason.to_owned(),
334			required: ENGINE_ARCHITECTURE.to_owned(),
335		}
336		.build()
337	};
338	let mut child = Command::new(path)
339		.arg("--runtime-info")
340		.stdin(Stdio::null())
341		.stdout(Stdio::piped())
342		.stderr(Stdio::null())
343		.kill_on_drop(true)
344		.spawn()
345		.with_context(|| format!("probe engine binary `{}`", path.display()))?;
346	let stdout = child.stdout.take().context("probe stdout was not piped")?;
347	let probe = async {
348		let read = async {
349			let mut bytes = Vec::new();
350			let limit = RUNTIME_INFO_MAX_BYTES as u64 + 1;
351			stdout.take(limit).read_to_end(&mut bytes).await?;
352			if bytes.len() > RUNTIME_INFO_MAX_BYTES {
353				return Err(std::io::Error::other(
354					"runtime identity response exceeds its size limit",
355				));
356			}
357			Ok(bytes)
358		};
359		tokio::try_join!(child.wait(), read)
360	};
361	let (status, bytes) = match tokio::time::timeout(RUNTIME_INFO_TIMEOUT, probe).await {
362		Ok(Ok(output)) => output,
363		failure => {
364			// Kill and reap before reporting failure; kill_on_drop also covers caller cancellation.
365			child
366				.kill()
367				.await
368				.context("terminate failed runtime identity probe")?;
369			return Err(incompatible(if failure.is_err() {
370				"runtime identity probe timed out"
371			} else {
372				"runtime identity probe output could not be read"
373			}));
374		}
375	};
376	if !status.success() {
377		return Err(incompatible(
378			"runtime identity probe is unsupported or failed",
379		));
380	}
381	let info: EngineHealthResponse = serde_json::from_slice(&bytes)
382		.map_err(|_| incompatible("runtime identity response is invalid"))?;
383	if info.runtime.as_deref() != Some(ENGINE_RUNTIME)
384		|| info.architecture.as_deref() != Some(ENGINE_ARCHITECTURE)
385	{
386		return Err(incompatible(
387			"runtime identity does not advertise the required architecture",
388		));
389	}
390	Ok(())
391}
392
393/// Path to the local engine's Depot data under the shared storage root. It holds the log
394/// shard and the object store, and is kept apart from the `db` directory older engines used.
395pub fn engine_data_path() -> Result<PathBuf> {
396	Ok(storage_root()?.join("var").join("engine").join("depot"))
397}
398
399/// The local engine's log shard directory, which must exist before the engine starts. The
400/// engine creates its object store directory itself.
401pub fn engine_log_directory() -> Result<PathBuf> {
402	Ok(engine_data_path()?.join("log"))
403}
404
405fn admin_token() -> String {
406	env::var("RIVET__AUTH__ADMIN_TOKEN").unwrap_or_else(|_| "default".to_owned())
407}
408
409fn engine_stamp_path() -> Result<PathBuf> {
410	Ok(storage_root()?
411		.join("var")
412		.join("engine")
413		.join("runtime.json"))
414}
415
416/// The address the engine actually binds: the explicit `bind_host` override, or
417/// the endpoint host when unset. Must match `engine_env`'s guard host.
418fn resolve_bind_host(config: &EngineResolverConfig) -> Result<String> {
419	if let Some(bind_host) = &config.bind_host {
420		return Ok(bind_host.clone());
421	}
422	let endpoint = &config.endpoint;
423	let endpoint_url =
424		Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?;
425	Ok(endpoint_url
426		.host_str()
427		.ok_or_else(|| invalid_endpoint(endpoint, "missing host"))?
428		.to_owned())
429}
430
431fn write_engine_stamp(stamp: &EngineRuntimeStamp) -> Result<()> {
432	let path = engine_stamp_path()?;
433	if let Some(dir) = path.parent() {
434		ensure_dir(dir)?;
435	}
436	let contents = serde_json::to_vec_pretty(stamp).context("serialize engine stamp")?;
437	std::fs::write(&path, contents)
438		.with_context(|| format!("write engine stamp `{}`", path.display()))?;
439	Ok(())
440}
441
442/// Reads the runtime stamp of the currently running engine, if its recorded
443/// process is still alive. A stale stamp (process gone) reads as `None` so
444/// callers treat the binding as unknown rather than trusting old data. On
445/// platforms without a liveness check (see `pid_is_alive`) the stamp is always
446/// treated as unknown for the same reason.
447fn read_engine_stamp() -> Option<EngineRuntimeStamp> {
448	let path = engine_stamp_path().ok()?;
449	let contents = std::fs::read(&path).ok()?;
450	let stamp: EngineRuntimeStamp = serde_json::from_slice(&contents).ok()?;
451	pid_is_alive(stamp.pid).then_some(stamp)
452}
453
454#[cfg(unix)]
455fn pid_is_alive(pid: u32) -> bool {
456	// `kill(pid, 0)` sends no signal but performs the existence and permission
457	// checks: success or EPERM means the process exists, ESRCH means it is gone.
458	let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
459	result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
460}
461
462#[cfg(not(unix))]
463fn pid_is_alive(_pid: u32) -> bool {
464	// No portable liveness check here, so fail safe: report the process as gone
465	// so a stale stamp is never trusted. The binding check then falls back to
466	// its "unknown, warn" branch instead of silently trusting old data.
467	false
468}
469
470/// Computes the environment variables that configure a rivet-engine process
471/// for the given endpoint. The engine requires the log directory to exist; see
472/// [`engine_log_directory`].
473///
474/// Shared by the spawn path and by callers that exec the engine binary
475/// directly (for example the CLI `engine` proxy) so both operate on the same
476/// data, guard, api-peer, and metrics ports. These callers run one local
477/// development engine: it is the only Depot storage worker, keeps its cold
478/// data in a local directory, provisions its own tablet on first start, and uses
479/// shorter recovery and shutdown thresholds than the production defaults.
480pub fn engine_env(config: &EngineResolverConfig) -> Result<Vec<(String, String)>> {
481	let ports = EnginePorts::resolve(config)?;
482	let log_directory = engine_log_directory()?;
483	let object_store = engine_data_path()?.join("objects");
484
485	let env = [
486		("RIVET__AUTH__ADMIN_TOKEN", admin_token()),
487		("RIVET__GUARD__HOST", ports.guard_host.clone()),
488		("RIVET__GUARD__PORT", ports.guard.to_string()),
489		// The peer listener only serves this engine's own internal requests.
490		("RIVET__API_PEER__HOST", LOOPBACK.to_owned()),
491		("RIVET__API_PEER__PORT", ports.api_peer.to_string()),
492		("RIVET__METRICS__HOST", ports.guard_host),
493		("RIVET__METRICS__PORT", ports.metrics.to_string()),
494		(
495			"RIVET__STORAGE__LOG_DIRECTORY",
496			log_directory.to_string_lossy().into_owned(),
497		),
498		(
499			"RIVET__OBJECT_STORAGE__PRIMARY__FILE_SYSTEM__PATH",
500			object_store.to_string_lossy().into_owned(),
501		),
502		// One storage worker holds every tablet, so there is nothing to place or split.
503		("RIVET__STORAGE__BALANCING__ENABLED", "false".to_owned()),
504		("RIVET__STORAGE__AUTO_PROVISION_PARTITIONS", "1".to_owned()),
505		// Bound disk use instead of sizing from the host's free space. The cold store is
506		// already on local disk, so a disk cache in front of it only duplicates data.
507		(
508			"RIVET__STORAGE__LOG_SHARD__MAX_DISK_BYTES",
509			LOG_MAX_DISK_BYTES.to_string(),
510		),
511		(
512			"RIVET__STORAGE__COLD_STORE__DISK_CACHE_BYTES",
513			"0".to_owned(),
514		),
515		// Storage shutdown must outlast the cold-write age, and the forced shutdown must
516		// outlast both stop budgets; these keep a local engine's stop within seconds.
517		("RIVET__STORAGE__WRITE_TO_COLD_AFTER_MS", "5000".to_owned()),
518		("RIVET__STORAGE__STOP_TIMEOUT_MS", "6000".to_owned()),
519		("RIVET__STORAGE__STOP_TEARDOWN_MS", "2000".to_owned()),
520		("RIVET__PEGBOARD__NODE_LOST_THRESHOLD", "7000".to_owned()),
521		("RIVET__RUNTIME__WORKER_SHUTDOWN_DURATION", "1".to_owned()),
522		("RIVET__RUNTIME__GUARD_SHUTDOWN_DURATION", "1".to_owned()),
523		("RIVET__RUNTIME__FORCE_SHUTDOWN_DURATION", "10".to_owned()),
524	];
525	Ok(env
526		.into_iter()
527		.map(|(key, value)| (key.to_owned(), value))
528		.collect())
529}
530
531const LOOPBACK: &str = "127.0.0.1";
532
533/// Log shard disk cap of the local engine.
534const LOG_MAX_DISK_BYTES: u64 = 4 * 1024 * 1024 * 1024;
535
536/// Listener addresses derived from the endpoint: guard on its port, the peer listener one
537/// above it and metrics ten above it.
538struct EnginePorts {
539	guard_host: String,
540	guard: u16,
541	api_peer: u16,
542	metrics: u16,
543}
544
545impl EnginePorts {
546	fn resolve(config: &EngineResolverConfig) -> Result<Self> {
547		let endpoint = &config.endpoint;
548		let endpoint_url =
549			Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?;
550		let guard_port = endpoint_url
551			.port_or_known_default()
552			.ok_or_else(|| invalid_endpoint(endpoint, "missing port"))?;
553		let guard = config.bind_port.unwrap_or(guard_port);
554		Ok(Self {
555			guard_host: resolve_bind_host(config)?,
556			guard,
557			api_peer: guard
558				.checked_add(1)
559				.ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?,
560			metrics: guard
561				.checked_add(10)
562				.ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?,
563		})
564	}
565}
566
567/// Writes the topology, which the engine cannot take from environment variables. The public
568/// URL is the explicit override or the endpoint; the peer URL is the loopback peer listener.
569fn write_engine_config(config: &EngineResolverConfig) -> Result<PathBuf> {
570	let public_url = config.public_url.as_deref().unwrap_or(&config.endpoint);
571	let public_url = Url::parse(public_url)
572		.with_context(|| format!("parse engine public URL `{public_url}`"))?;
573	let ports = EnginePorts::resolve(config)?;
574	let peer_url = format!("http://{LOOPBACK}:{}", ports.api_peer);
575	let dir = storage_root()?.join("var").join("engine");
576	ensure_dir(&dir)?;
577	let path = dir.join("config.json");
578	let config = serde_json::json!({
579		"topology": {
580			"datacenter_label": 1,
581			"datacenters": {
582				"default": {
583					"datacenter_label": 1,
584					"is_leader": true,
585					"public_url": public_url.as_str(),
586					"peer_url": peer_url,
587				}
588			}
589		}
590	});
591	std::fs::write(&path, serde_json::to_string_pretty(&config)?)
592		.with_context(|| format!("write engine config `{}`", path.display()))?;
593	Ok(path)
594}
595
596pub async fn resolve_engine_binary(config: &EngineResolverConfig) -> Result<ResolvedEngine> {
597	if let Some(path) = config.explicit_binary_path.as_ref() {
598		return verify_binary_path(path);
599	}
600
601	if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) {
602		return verify_binary_path(&path);
603	}
604
605	if probe_existing_engine(&config.endpoint).await?.is_some() {
606		return Ok(ResolvedEngine::Existing);
607	}
608
609	let local_roots = local_engine_search_roots();
610	let cached = cached_engine_path(&config.version)?;
611	resolve_engine_binary_after_probe(config, false, &local_roots, cached).await
612}
613
614/// Resolves the engine binary path without probing for an already-running
615/// engine.
616///
617/// Used by callers that exec the binary directly (for example the CLI `engine`
618/// proxy), where a running engine on the endpoint should not short-circuit
619/// resolution to `Existing` and leave the caller without a path to run.
620pub async fn resolve_engine_binary_path(config: &EngineResolverConfig) -> Result<PathBuf> {
621	if let Some(path) = config.explicit_binary_path.as_ref() {
622		verify_binary_path(path)?;
623		return Ok(path.clone());
624	}
625
626	if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) {
627		verify_binary_path(&path)?;
628		return Ok(path);
629	}
630
631	let local_roots = local_engine_search_roots();
632	let cached = cached_engine_path(&config.version)?;
633	match resolve_engine_binary_after_probe(config, false, &local_roots, cached).await? {
634		ResolvedEngine::Binary(path) => Ok(path),
635		ResolvedEngine::Existing => {
636			unreachable!("no-probe resolution never returns Existing")
637		}
638	}
639}
640
641async fn resolve_engine_binary_after_probe(
642	config: &EngineResolverConfig,
643	existing_engine: bool,
644	local_roots: &[PathBuf],
645	cached: PathBuf,
646) -> Result<ResolvedEngine> {
647	if existing_engine {
648		return Ok(ResolvedEngine::Existing);
649	}
650
651	if let Some(path) = find_local_engine_binary_in_roots(local_roots) {
652		return Ok(ResolvedEngine::Binary(path));
653	}
654
655	if cached.exists() {
656		return Ok(ResolvedEngine::Binary(cached));
657	}
658
659	if !config.auto_download {
660		return Err(EngineProcessError::BinaryUnavailable {
661			version: config.version.clone(),
662		}
663		.build());
664	}
665
666	download_engine_binary(config, &cached).await?;
667	Ok(ResolvedEngine::Binary(cached))
668}
669
670fn verify_binary_path(path: &Path) -> Result<ResolvedEngine> {
671	if !path.exists() {
672		return Err(EngineProcessError::BinaryNotFound {
673			path: path.display().to_string(),
674		}
675		.build());
676	}
677	Ok(ResolvedEngine::Binary(path.to_path_buf()))
678}
679
680fn local_engine_search_roots() -> Vec<PathBuf> {
681	Path::new(env!("CARGO_MANIFEST_DIR"))
682		.ancestors()
683		.map(Path::to_path_buf)
684		.collect()
685}
686
687fn find_local_engine_binary_in_roots(roots: &[PathBuf]) -> Option<PathBuf> {
688	for root in roots {
689		for profile in ["debug", "release"] {
690			let candidate = root
691				.join("target")
692				.join(profile)
693				.join(exe_name("rivet-engine"));
694			if candidate.exists() {
695				return Some(candidate);
696			}
697		}
698	}
699	None
700}
701
702/// Cached release binaries live under the architecture, so a v2 engine cached by an older
703/// RivetKit under the same version number is never picked up.
704fn cached_engine_path(version: &str) -> Result<PathBuf> {
705	Ok(storage_root()?
706		.join("engine")
707		.join(ENGINE_ARCHITECTURE)
708		.join(version)
709		.join(engine_artifact_name()))
710}
711
712async fn download_engine_binary(config: &EngineResolverConfig, destination: &Path) -> Result<()> {
713	let artifact = engine_artifact_name();
714	let base = config.releases_endpoint.trim_end_matches('/');
715	let artifact_url = format!("{base}/rivet/{}/engine/{artifact}", config.version);
716	let manifest_url = format!("{base}/rivet/{}/engine/SHA256SUMS", config.version);
717	let client = Client::builder()
718		.timeout(DOWNLOAD_TIMEOUT)
719		.build()
720		.context("build reqwest client for engine download")?;
721
722	let manifest = fetch_text(&client, &manifest_url).await?;
723	let expected = checksum_for_artifact(&manifest, &artifact).ok_or_else(|| {
724		EngineProcessError::DownloadFailed {
725			url: manifest_url.clone(),
726			reason: format!("manifest does not contain `{artifact}`"),
727		}
728		.build()
729	})?;
730
731	let bytes = fetch_bytes(&client, &artifact_url).await?;
732	let received = sha256_hex(&bytes);
733	if !received.eq_ignore_ascii_case(&expected) {
734		return Err(EngineProcessError::ChecksumMismatch {
735			artifact,
736			expected,
737			received,
738		}
739		.build());
740	}
741
742	let parent = destination
743		.parent()
744		.context("engine cache destination has no parent")?;
745	ensure_dir(parent)?;
746	std::fs::write(destination, bytes)
747		.with_context(|| format!("write engine binary `{}`", destination.display()))?;
748	make_executable(destination)?;
749	Ok(())
750}
751
752async fn fetch_text(client: &Client, url: &str) -> Result<String> {
753	let response = client.get(url).send().await.map_err(|error| {
754		EngineProcessError::DownloadFailed {
755			url: url.to_owned(),
756			reason: error.to_string(),
757		}
758		.build()
759	})?;
760	if !response.status().is_success() {
761		let status = response.status();
762		return Err(EngineProcessError::DownloadFailed {
763			url: url.to_owned(),
764			reason: format!("unexpected status {status}"),
765		}
766		.build());
767	}
768	response.text().await.map_err(|error| {
769		EngineProcessError::DownloadFailed {
770			url: url.to_owned(),
771			reason: error.to_string(),
772		}
773		.build()
774	})
775}
776
777async fn fetch_bytes(client: &Client, url: &str) -> Result<Vec<u8>> {
778	let response = client.get(url).send().await.map_err(|error| {
779		EngineProcessError::DownloadFailed {
780			url: url.to_owned(),
781			reason: error.to_string(),
782		}
783		.build()
784	})?;
785	if !response.status().is_success() {
786		let status = response.status();
787		return Err(EngineProcessError::DownloadFailed {
788			url: url.to_owned(),
789			reason: format!("unexpected status {status}"),
790		}
791		.build());
792	}
793	response
794		.bytes()
795		.await
796		.map(|bytes| bytes.to_vec())
797		.map_err(|error| {
798			EngineProcessError::DownloadFailed {
799				url: url.to_owned(),
800				reason: error.to_string(),
801			}
802			.build()
803		})
804}
805
806fn checksum_for_artifact(manifest: &str, artifact: &str) -> Option<String> {
807	manifest.lines().find_map(|line| {
808		let mut parts = line.split_whitespace();
809		let checksum = parts.next()?;
810		let name = parts.next()?.trim_start_matches('*');
811		(checksum.len() == 64 && name == artifact).then(|| checksum.to_owned())
812	})
813}
814
815fn sha256_hex(bytes: &[u8]) -> String {
816	let digest = Sha256::digest(bytes);
817	let mut out = String::with_capacity(digest.len() * 2);
818	for byte in digest {
819		use std::fmt::Write;
820		let _ = write!(&mut out, "{byte:02x}");
821	}
822	out
823}
824
825fn engine_artifact_name() -> String {
826	let arch = match std::env::consts::ARCH {
827		"x86_64" => "x86_64",
828		"aarch64" => "aarch64",
829		other => other,
830	};
831	let target = match std::env::consts::OS {
832		"linux" => format!("{arch}-unknown-linux-musl"),
833		"macos" => format!("{arch}-apple-darwin"),
834		"windows" => format!("{arch}-pc-windows-gnu.exe"),
835		other => format!("{arch}-{other}"),
836	};
837	format!("rivet-engine-{target}")
838}
839
840fn exe_name(base: &str) -> String {
841	if cfg!(windows) {
842		format!("{base}.exe")
843	} else {
844		base.to_owned()
845	}
846}
847
848fn make_executable(path: &Path) -> Result<()> {
849	#[cfg(unix)]
850	{
851		use std::os::unix::fs::PermissionsExt;
852		let mut permissions = std::fs::metadata(path)
853			.with_context(|| format!("read metadata for `{}`", path.display()))?
854			.permissions();
855		permissions.set_mode(0o755);
856		std::fs::set_permissions(path, permissions)
857			.with_context(|| format!("mark `{}` executable", path.display()))?;
858	}
859	#[cfg(not(unix))]
860	{
861		let _ = path;
862	}
863	Ok(())
864}
865
866impl Drop for EngineProcessManager {
867	fn drop(&mut self) {
868		if let Some(handle) = self.watcher.take() {
869			// Aborting drops the `Child` owned by the task. With
870			// `kill_on_drop=false`, dropping the `Child` does NOT signal the
871			// engine, so the engine survives and gets reparented to init.
872			// We give up our crash-detection log line here, but if we are
873			// being dropped the rivetkit host is shutting down anyway.
874			handle.abort();
875			tracing::debug!(
876				"aborted engine watcher; engine continues running (intentional orphan)"
877			);
878		}
879	}
880}
881
882/// Spawns a background task that owns the `Child` and awaits `wait()` so we
883/// log a clear message if the engine dies while rivetkit is still up. Taking
884/// the `Child` into the task also reaps it via `waitpid` on exit, so a
885/// crashed engine never lingers as a zombie in our process table.
886fn spawn_engine_watcher(mut child: Child, pid: u32) -> JoinHandle<()> {
887	tokio::spawn(async move {
888		match child.wait().await {
889			Ok(status) if status.success() => {
890				tracing::warn!(
891					pid,
892					?status,
893					"engine process exited cleanly while rivetkit was still running; \
894					 rivetkit expected the engine to outlive it"
895				);
896			}
897			Ok(status) => {
898				tracing::error!(
899					pid,
900					?status,
901					"engine process crashed while rivetkit was still running"
902				);
903			}
904			Err(error) => {
905				tracing::error!(
906					pid,
907					?error,
908					"failed to wait on engine process; cannot detect crashes"
909				);
910			}
911		}
912	})
913}
914
915/// Probes the configured endpoint for an already-running, healthy engine.
916///
917/// Returns `Ok(Some(health))` if the endpoint is serving a `runtime: "engine"`
918/// health response that we can reattach to. Returns `Ok(None)` if the port is
919/// free. Returns `Err(...)` if the port is occupied by a non-engine process
920/// (for example a stale rivetkit) which would conflict with a fresh spawn.
921async fn probe_existing_engine(endpoint: &str) -> Result<Option<EngineHealthResponse>> {
922	let health_url = engine_health_url(endpoint);
923	let client = Client::builder()
924		.build()
925		.context("build reqwest client for engine probe")?;
926
927	let response = match client
928		.get(&health_url)
929		.timeout(Duration::from_secs(1))
930		.send()
931		.await
932	{
933		Ok(response) => response,
934		Err(_) => return Ok(None),
935	};
936
937	if !response.status().is_success() {
938		return Ok(None);
939	}
940
941	let health = response
942		.json::<EngineHealthResponse>()
943		.await
944		.context("decode existing engine health response")?;
945
946	match health.runtime.as_deref() {
947		Some(ENGINE_RUNTIME) => {
948			ensure_compatible_engine(endpoint, &health)?;
949			Ok(Some(health))
950		}
951		Some(RIVETKIT_RUNTIME) => Err(EngineProcessError::PortOccupied {
952			endpoint: endpoint.to_owned(),
953			runtime: RIVETKIT_RUNTIME.to_owned(),
954		}
955		.build()),
956		Some(other) => Err(EngineProcessError::PortOccupied {
957			endpoint: endpoint.to_owned(),
958			runtime: other.to_owned(),
959		}
960		.build()),
961		None => Err(EngineProcessError::PortOccupied {
962			endpoint: endpoint.to_owned(),
963			runtime: "unknown".to_owned(),
964		}
965		.build()),
966	}
967}
968
969fn engine_health_url(endpoint: &str) -> String {
970	format!("{}/health", endpoint.trim_end_matches('/'))
971}
972
973/// Refuses any engine that does not report the required architecture, so a v2 engine
974/// listening on the endpoint is never reused or left serving.
975fn ensure_compatible_engine(endpoint: &str, health: &EngineHealthResponse) -> Result<()> {
976	if health.architecture.as_deref() == Some(ENGINE_ARCHITECTURE) {
977		return Ok(());
978	}
979	Err(EngineProcessError::IncompatibleEngine {
980		endpoint: endpoint.to_owned(),
981		version: health
982			.version
983			.clone()
984			.unwrap_or_else(|| "unknown".to_owned()),
985		architecture: health
986			.architecture
987			.clone()
988			.unwrap_or_else(|| "v2".to_owned()),
989		required: ENGINE_ARCHITECTURE.to_owned(),
990	}
991	.build())
992}
993
994fn storage_root() -> Result<PathBuf> {
995	if let Ok(path) = std::env::var("RIVETKIT_STORAGE_PATH") {
996		return Ok(PathBuf::from(path).join(".rivetkit"));
997	}
998	let home = std::env::var("HOME")
999		.map(PathBuf::from)
1000		.or_else(|_| std::env::current_dir())
1001		.context("locate home directory for engine storage path")?;
1002	Ok(home.join(".rivetkit"))
1003}
1004
1005fn ensure_dir(path: &Path) -> Result<()> {
1006	std::fs::create_dir_all(path).with_context(|| format!("create directory `{}`", path.display()))
1007}
1008
1009fn open_log_file(path: &Path) -> Result<std::fs::File> {
1010	std::fs::OpenOptions::new()
1011		.create(true)
1012		.append(true)
1013		.open(path)
1014		.with_context(|| format!("open log file `{}`", path.display()))
1015}
1016
1017fn log_timestamp() -> String {
1018	let now = std::time::SystemTime::now()
1019		.duration_since(std::time::UNIX_EPOCH)
1020		.unwrap_or_default();
1021	format!("{}", now.as_secs())
1022}
1023
1024async fn wait_for_engine_health(health_url: &str) -> Result<EngineHealthResponse> {
1025	// A first start creates the log shard before the HTTP listener binds.
1026	const HEALTH_MAX_WAIT: Duration = Duration::from_secs(60);
1027	const HEALTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
1028	const HEALTH_INITIAL_BACKOFF: Duration = Duration::from_millis(100);
1029	const HEALTH_MAX_BACKOFF: Duration = Duration::from_secs(1);
1030
1031	let client = Client::builder()
1032		.build()
1033		.context("build reqwest client for engine health check")?;
1034	let deadline = Instant::now() + HEALTH_MAX_WAIT;
1035	let mut attempt = 0u32;
1036	let mut backoff = HEALTH_INITIAL_BACKOFF;
1037
1038	loop {
1039		attempt += 1;
1040
1041		let last_error = match client
1042			.get(health_url)
1043			.timeout(HEALTH_REQUEST_TIMEOUT)
1044			.send()
1045			.await
1046		{
1047			Ok(response) if response.status().is_success() => {
1048				let health = response
1049					.json::<EngineHealthResponse>()
1050					.await
1051					.context("decode engine health response")?;
1052				return Ok(health);
1053			}
1054			Ok(response) => format!("unexpected status {}", response.status()),
1055			Err(error) => error.to_string(),
1056		};
1057
1058		if Instant::now() >= deadline {
1059			return Err(EngineProcessError::HealthCheckFailed {
1060				attempts: attempt,
1061				reason: last_error,
1062			}
1063			.build());
1064		}
1065
1066		tokio::time::sleep(backoff).await;
1067		backoff = std::cmp::min(backoff * 2, HEALTH_MAX_BACKOFF);
1068	}
1069}
1070
1071/// Waits until the engine routes requests to Depot. Guard answers health before the
1072/// first tablet is provisioned and opened, and actor requests fail until then, so this
1073/// reads an actor listing, which routes through Depot, until it succeeds.
1074async fn wait_for_engine_ready(endpoint: &str) -> Result<()> {
1075	const READY_MAX_WAIT: Duration = Duration::from_secs(60);
1076	const READY_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
1077	const READY_BACKOFF: Duration = Duration::from_millis(250);
1078
1079	let url = format!(
1080		"{}/actors?namespace=default&name=rivetkit-readiness-probe&key=probe",
1081		endpoint.trim_end_matches('/')
1082	);
1083	let token = admin_token();
1084	let client = Client::builder()
1085		.build()
1086		.context("build reqwest client for engine readiness check")?;
1087	let deadline = Instant::now() + READY_MAX_WAIT;
1088	let mut attempt = 0u32;
1089
1090	loop {
1091		attempt += 1;
1092
1093		let last_error = match client
1094			.get(&url)
1095			.bearer_auth(&token)
1096			.timeout(READY_REQUEST_TIMEOUT)
1097			.send()
1098			.await
1099		{
1100			Ok(response) if response.status().is_success() => return Ok(()),
1101			Ok(response) => {
1102				let status = response.status();
1103				let body = response.text().await.unwrap_or_default();
1104				format!("unexpected status {status}: {body}")
1105			}
1106			Err(error) => error.to_string(),
1107		};
1108		tracing::debug!(attempt, reason = %last_error, "waiting for engine readiness");
1109
1110		if Instant::now() >= deadline {
1111			return Err(EngineProcessError::NotReady {
1112				attempts: attempt,
1113				reason: last_error,
1114			}
1115			.build());
1116		}
1117
1118		tokio::time::sleep(READY_BACKOFF).await;
1119	}
1120}
1121
1122/// Cleanup path for a spawn that never reached `healthy`. We *do* kill here
1123/// because the half-started engine has no useful state to preserve and
1124/// leaving it running would conflict with a retry. This is the only place
1125/// allowed to terminate the engine.
1126async fn terminate_failed_spawn(child: &mut Child) -> Result<()> {
1127	const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
1128
1129	if child
1130		.try_wait()
1131		.context("check engine process status")?
1132		.is_some()
1133	{
1134		return Ok(());
1135	}
1136
1137	child
1138		.start_kill()
1139		.context("kill half-started engine process")?;
1140	match tokio::time::timeout(SHUTDOWN_TIMEOUT, child.wait()).await {
1141		Ok(result) => {
1142			let status = result.context("wait for half-started engine to exit")?;
1143			tracing::info!(?status, "half-started engine process exited");
1144			Ok(())
1145		}
1146		Err(_) => {
1147			tracing::warn!("half-started engine process did not exit within timeout");
1148			Ok(())
1149		}
1150	}
1151}
1152
1153fn invalid_endpoint(endpoint: &str, reason: &str) -> anyhow::Error {
1154	EngineProcessError::InvalidEndpoint {
1155		endpoint: endpoint.to_owned(),
1156		reason: reason.to_owned(),
1157	}
1158	.build()
1159}
1160
1161