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