Skip to main content

rivetkit_engine_process/
lib.rs

1use std::path::{Path, PathBuf};
2use std::process::Stdio;
3use std::time::{Duration, Instant};
4
5use anyhow::{Context, Result};
6use reqwest::{Client, Url};
7use serde::Deserialize;
8use sha2::{Digest, Sha256};
9use tokio::process::{Child, Command};
10use tokio::task::JoinHandle;
11
12mod error;
13
14pub use error::EngineProcessError;
15
16const ENGINE_RUNTIME: &str = "engine";
17const RIVETKIT_RUNTIME: &str = "rivetkit";
18const ENGINE_VERSION_ENV: &str = "RIVETKIT_ENGINE_VERSION";
19const RELEASES_ENDPOINT_ENV: &str = "RIVETKIT_ENGINE_RELEASES_ENDPOINT";
20const RELEASES_ENDPOINT: &str = "https://releases.rivet.dev";
21const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60);
22
23#[derive(Debug, Deserialize)]
24struct EngineHealthResponse {
25	status: Option<String>,
26	runtime: Option<String>,
27	version: Option<String>,
28}
29
30#[derive(Clone, Debug)]
31pub struct EngineResolverConfig {
32	pub endpoint: String,
33	pub explicit_binary_path: Option<PathBuf>,
34	pub bind_host: Option<String>,
35	pub bind_port: Option<u16>,
36	pub public_url: Option<String>,
37	pub auto_download: bool,
38	pub version: String,
39	pub releases_endpoint: String,
40}
41
42impl EngineResolverConfig {
43	pub fn from_parts(
44		endpoint: &str,
45		explicit_binary_path: Option<PathBuf>,
46		bind_host: Option<String>,
47		bind_port: Option<u16>,
48		auto_download: bool,
49	) -> Self {
50		Self {
51			endpoint: endpoint.to_owned(),
52			explicit_binary_path,
53			bind_host,
54			bind_port,
55			public_url: None,
56			auto_download,
57			version: std::env::var(ENGINE_VERSION_ENV)
58				.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_owned()),
59			releases_endpoint: std::env::var(RELEASES_ENDPOINT_ENV)
60				.unwrap_or_else(|_| RELEASES_ENDPOINT.to_owned()),
61		}
62	}
63}
64
65#[derive(Debug, PartialEq, Eq)]
66pub enum ResolvedEngine {
67	Existing,
68	Binary(PathBuf),
69}
70
71/// Manages the rivet-engine subprocess.
72///
73/// The engine is intentionally orphaned: dropping the manager (or having the
74/// host process exit) must NOT terminate the engine. This lets a dev-server
75/// restart of the rivetkit host reattach to the same long-lived engine and
76/// keep all in-flight actor state. To honor that contract:
77///
78/// - `Command::kill_on_drop` is left at its default (false) so the tokio
79///   `Child` does not send SIGKILL on drop.
80/// - Stdout and stderr are routed to log files at spawn time so the engine's
81///   write fds remain valid after the host's pipes close.
82/// - On startup we probe the configured endpoint and reuse a healthy engine
83///   instead of spawning a duplicate.
84///
85/// When we spawn the engine, `watcher` holds a tokio task that owns the
86/// `Child` and awaits `child.wait()` so we get a log line if the engine dies
87/// while rivetkit is still running. On Drop we abort the watcher; aborting
88/// drops the `Child` without killing it (kill_on_drop=false), so the engine
89/// stays running and gets reparented to init when rivetkit exits.
90///
91/// `watcher` is `None` when we attached to an already-running engine.
92#[derive(Debug)]
93pub struct EngineProcessManager {
94	watcher: Option<JoinHandle<()>>,
95}
96
97impl EngineProcessManager {
98	pub async fn start_or_reuse(config: EngineResolverConfig) -> Result<Self> {
99		let resolved = resolve_engine_binary(&config).await?;
100		Self::start_resolved(resolved, &config).await
101	}
102
103	async fn start_resolved(
104		resolved: ResolvedEngine,
105		config: &EngineResolverConfig,
106	) -> Result<Self> {
107		let endpoint = &config.endpoint;
108		if matches!(resolved, ResolvedEngine::Existing) {
109			tracing::info!(
110				endpoint = %endpoint,
111				"reusing already-running engine process"
112			);
113			return Ok(Self { watcher: None });
114		}
115
116		let ResolvedEngine::Binary(binary_path) = resolved else {
117			unreachable!("existing engine handled above");
118		};
119		if let Some(health) = probe_existing_engine(endpoint).await? {
120			tracing::info!(
121				endpoint = %endpoint,
122				status = ?health.status,
123				runtime = ?health.runtime,
124				version = ?health.version,
125				"reusing already-running engine process"
126			);
127			return Ok(Self { watcher: None });
128		}
129
130		if !binary_path.exists() {
131			return Err(EngineProcessError::BinaryNotFound {
132				path: binary_path.display().to_string(),
133			}
134			.build());
135		}
136
137		let env = engine_env(config)?;
138		let config_path = write_engine_config(config)?;
139		let db_path = engine_db_path()?;
140		let logs_dir = storage_root()?
141			.join("var")
142			.join("logs")
143			.join("rivet-engine");
144		ensure_dir(&db_path).context("create engine db directory")?;
145		ensure_dir(&logs_dir).context("create engine logs directory")?;
146
147		let timestamp = log_timestamp();
148		let stdout_log_path = logs_dir.join(format!("engine-{timestamp}-stdout.log"));
149		let stderr_log_path = logs_dir.join(format!("engine-{timestamp}-stderr.log"));
150		let stdout_file = open_log_file(&stdout_log_path)
151			.with_context(|| format!("open engine stdout log `{}`", stdout_log_path.display()))?;
152		let stderr_file = open_log_file(&stderr_log_path)
153			.with_context(|| format!("open engine stderr log `{}`", stderr_log_path.display()))?;
154
155		let mut command = Command::new(&binary_path);
156		command.arg("start");
157		if let Some(config_path) = &config_path {
158			command.arg("--config").arg(config_path);
159		}
160		for (key, value) in &env {
161			command.env(key, value);
162		}
163		command
164			.stdin(Stdio::null())
165			.stdout(Stdio::from(stdout_file))
166			.stderr(Stdio::from(stderr_file));
167
168		// Put the engine in its own process group so terminal signals
169		// (Ctrl+C, Ctrl+Z, SIGHUP on terminal close) targeting our foreground
170		// process group do not reach the engine. Combined with no-kill-on-drop
171		// and file-fd stdio, this gives the engine a real "intentional orphan"
172		// lifetime that survives the host being killed for any reason.
173		#[cfg(unix)]
174		command.process_group(0);
175
176		let mut child = command
177			.spawn()
178			.with_context(|| format!("spawn engine binary `{}`", binary_path.display()))?;
179		let pid = child
180			.id()
181			.ok_or_else(|| EngineProcessError::MissingPid.build())?;
182
183		tracing::info!(
184			pid,
185			path = %binary_path.display(),
186			endpoint = %endpoint,
187			db_path = %db_path.display(),
188			"spawned engine process (intentionally orphaned, will outlive this process)"
189		);
190		tracing::info!(
191			stdout_log = %stdout_log_path.display(),
192			stderr_log = %stderr_log_path.display(),
193			"engine stdout/stderr piped to log files"
194		);
195
196		let health_url = engine_health_url(endpoint);
197		let health = match wait_for_engine_health(&health_url).await {
198			Ok(health) => health,
199			Err(error) => {
200				let error = match child.try_wait() {
201					Ok(Some(status)) => error.context(format!(
202						"engine process exited before becoming healthy with status {status}"
203					)),
204					Ok(None) => error,
205					Err(wait_error) => error.context(format!(
206						"failed to inspect engine process status: {wait_error:#}"
207					)),
208				};
209				if let Err(cleanup_error) = terminate_failed_spawn(&mut child).await {
210					tracing::warn!(
211						?cleanup_error,
212						"failed to terminate engine process that never became healthy"
213					);
214				}
215				return Err(error);
216			}
217		};
218
219		tracing::info!(
220			pid,
221			status = ?health.status,
222			runtime = ?health.runtime,
223			version = ?health.version,
224			"engine process is healthy"
225		);
226
227		Ok(Self {
228			watcher: Some(spawn_engine_watcher(child, pid)),
229		})
230	}
231}
232
233/// Path to the rivet-engine database directory under the shared storage root.
234pub fn engine_db_path() -> Result<PathBuf> {
235	Ok(storage_root()?.join("var").join("engine").join("db"))
236}
237
238/// Computes the environment variables that configure a rivet-engine process
239/// for the given endpoint.
240///
241/// Shared by the spawn path and by callers that exec the engine binary
242/// directly (for example the CLI `engine` proxy) so both operate on the same
243/// database, guard, api-peer, and metrics ports.
244pub fn engine_env(config: &EngineResolverConfig) -> Result<Vec<(String, String)>> {
245	let endpoint = &config.endpoint;
246	let endpoint_url =
247		Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?;
248	let guard_host = endpoint_url
249		.host_str()
250		.ok_or_else(|| invalid_endpoint(endpoint, "missing host"))?
251		.to_owned();
252	let guard_host = config.bind_host.clone().unwrap_or(guard_host);
253	let guard_port = endpoint_url
254		.port_or_known_default()
255		.ok_or_else(|| invalid_endpoint(endpoint, "missing port"))?;
256	let guard_port = config.bind_port.unwrap_or(guard_port);
257	let api_peer_port = guard_port
258		.checked_add(1)
259		.ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?;
260	let metrics_port = guard_port
261		.checked_add(10)
262		.ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?;
263
264	let db_path = engine_db_path()?;
265
266	Ok(vec![
267		("RIVET__GUARD__HOST".to_owned(), guard_host.clone()),
268		("RIVET__GUARD__PORT".to_owned(), guard_port.to_string()),
269		("RIVET__API_PEER__HOST".to_owned(), guard_host.clone()),
270		(
271			"RIVET__API_PEER__PORT".to_owned(),
272			api_peer_port.to_string(),
273		),
274		("RIVET__METRICS__HOST".to_owned(), guard_host),
275		("RIVET__METRICS__PORT".to_owned(), metrics_port.to_string()),
276		(
277			"RIVET__FILE_SYSTEM__PATH".to_owned(),
278			db_path.to_string_lossy().into_owned(),
279		),
280	])
281}
282
283fn write_engine_config(config: &EngineResolverConfig) -> Result<Option<PathBuf>> {
284	let Some(public_url) = &config.public_url else {
285		return Ok(None);
286	};
287
288	let public_url = Url::parse(public_url)
289		.with_context(|| format!("parse engine public URL `{public_url}`"))?;
290	let peer_url = peer_url_for_public_url(&public_url)?;
291	let dir = storage_root()?.join("var").join("engine");
292	ensure_dir(&dir)?;
293	let path = dir.join("config.json");
294	let config = serde_json::json!({
295		"topology": {
296			"datacenter_label": 1,
297			"datacenters": {
298				"default": {
299					"datacenter_label": 1,
300					"is_leader": true,
301					"public_url": public_url.as_str(),
302					"peer_url": peer_url,
303				}
304			}
305		}
306	});
307	std::fs::write(&path, serde_json::to_string_pretty(&config)?)
308		.with_context(|| format!("write engine config `{}`", path.display()))?;
309	Ok(Some(path))
310}
311
312fn peer_url_for_public_url(public_url: &Url) -> Result<String> {
313	let mut peer_url = public_url.clone();
314	let port = public_url
315		.port_or_known_default()
316		.ok_or_else(|| invalid_endpoint(public_url.as_str(), "missing port"))?
317		.checked_add(1)
318		.ok_or_else(|| invalid_endpoint(public_url.as_str(), "port is too large"))?;
319	if peer_url.set_port(Some(port)).is_err() {
320		return Err(invalid_endpoint(
321			public_url.as_str(),
322			"could not derive peer URL port",
323		));
324	}
325	peer_url.set_path("");
326	peer_url.set_query(None);
327	peer_url.set_fragment(None);
328	Ok(peer_url.to_string().trim_end_matches('/').to_string())
329}
330
331pub async fn resolve_engine_binary(config: &EngineResolverConfig) -> Result<ResolvedEngine> {
332	if let Some(path) = config.explicit_binary_path.as_ref() {
333		return verify_binary_path(path);
334	}
335
336	if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) {
337		return verify_binary_path(&path);
338	}
339
340	if probe_existing_engine(&config.endpoint).await?.is_some() {
341		return Ok(ResolvedEngine::Existing);
342	}
343
344	let local_roots = local_engine_search_roots();
345	let cached = cached_engine_path(&config.version)?;
346	resolve_engine_binary_after_probe(config, false, &local_roots, cached).await
347}
348
349/// Resolves the engine binary path without probing for an already-running
350/// engine.
351///
352/// Used by callers that exec the binary directly (for example the CLI `engine`
353/// proxy), where a running engine on the endpoint should not short-circuit
354/// resolution to `Existing` and leave the caller without a path to run.
355pub async fn resolve_engine_binary_path(config: &EngineResolverConfig) -> Result<PathBuf> {
356	if let Some(path) = config.explicit_binary_path.as_ref() {
357		verify_binary_path(path)?;
358		return Ok(path.clone());
359	}
360
361	if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) {
362		verify_binary_path(&path)?;
363		return Ok(path);
364	}
365
366	let local_roots = local_engine_search_roots();
367	let cached = cached_engine_path(&config.version)?;
368	match resolve_engine_binary_after_probe(config, false, &local_roots, cached).await? {
369		ResolvedEngine::Binary(path) => Ok(path),
370		ResolvedEngine::Existing => {
371			unreachable!("no-probe resolution never returns Existing")
372		}
373	}
374}
375
376async fn resolve_engine_binary_after_probe(
377	config: &EngineResolverConfig,
378	existing_engine: bool,
379	local_roots: &[PathBuf],
380	cached: PathBuf,
381) -> Result<ResolvedEngine> {
382	if existing_engine {
383		return Ok(ResolvedEngine::Existing);
384	}
385
386	if let Some(path) = find_local_engine_binary_in_roots(local_roots) {
387		return Ok(ResolvedEngine::Binary(path));
388	}
389
390	if cached.exists() {
391		return Ok(ResolvedEngine::Binary(cached));
392	}
393
394	if !config.auto_download {
395		return Err(EngineProcessError::BinaryUnavailable {
396			version: config.version.clone(),
397		}
398		.build());
399	}
400
401	download_engine_binary(config, &cached).await?;
402	Ok(ResolvedEngine::Binary(cached))
403}
404
405fn verify_binary_path(path: &Path) -> Result<ResolvedEngine> {
406	if !path.exists() {
407		return Err(EngineProcessError::BinaryNotFound {
408			path: path.display().to_string(),
409		}
410		.build());
411	}
412	Ok(ResolvedEngine::Binary(path.to_path_buf()))
413}
414
415fn local_engine_search_roots() -> Vec<PathBuf> {
416	Path::new(env!("CARGO_MANIFEST_DIR"))
417		.ancestors()
418		.map(Path::to_path_buf)
419		.collect()
420}
421
422fn find_local_engine_binary_in_roots(roots: &[PathBuf]) -> Option<PathBuf> {
423	for root in roots {
424		for profile in ["debug", "release"] {
425			let candidate = root
426				.join("target")
427				.join(profile)
428				.join(exe_name("rivet-engine"));
429			if candidate.exists() {
430				return Some(candidate);
431			}
432		}
433	}
434	None
435}
436
437fn cached_engine_path(version: &str) -> Result<PathBuf> {
438	Ok(storage_root()?
439		.join("engine")
440		.join(version)
441		.join(engine_artifact_name()))
442}
443
444async fn download_engine_binary(config: &EngineResolverConfig, destination: &Path) -> Result<()> {
445	let artifact = engine_artifact_name();
446	let base = config.releases_endpoint.trim_end_matches('/');
447	let artifact_url = format!("{base}/rivet/{}/engine/{artifact}", config.version);
448	let manifest_url = format!("{base}/rivet/{}/engine/SHA256SUMS", config.version);
449	let client = Client::builder()
450		.timeout(DOWNLOAD_TIMEOUT)
451		.build()
452		.context("build reqwest client for engine download")?;
453
454	let manifest = fetch_text(&client, &manifest_url).await?;
455	let expected = checksum_for_artifact(&manifest, &artifact).ok_or_else(|| {
456		EngineProcessError::DownloadFailed {
457			url: manifest_url.clone(),
458			reason: format!("manifest does not contain `{artifact}`"),
459		}
460		.build()
461	})?;
462
463	let bytes = fetch_bytes(&client, &artifact_url).await?;
464	let received = sha256_hex(&bytes);
465	if !received.eq_ignore_ascii_case(&expected) {
466		return Err(EngineProcessError::ChecksumMismatch {
467			artifact,
468			expected,
469			received,
470		}
471		.build());
472	}
473
474	let parent = destination
475		.parent()
476		.context("engine cache destination has no parent")?;
477	ensure_dir(parent)?;
478	std::fs::write(destination, bytes)
479		.with_context(|| format!("write engine binary `{}`", destination.display()))?;
480	make_executable(destination)?;
481	Ok(())
482}
483
484async fn fetch_text(client: &Client, url: &str) -> Result<String> {
485	let response = client.get(url).send().await.map_err(|error| {
486		EngineProcessError::DownloadFailed {
487			url: url.to_owned(),
488			reason: error.to_string(),
489		}
490		.build()
491	})?;
492	if !response.status().is_success() {
493		let status = response.status();
494		return Err(EngineProcessError::DownloadFailed {
495			url: url.to_owned(),
496			reason: format!("unexpected status {status}"),
497		}
498		.build());
499	}
500	response.text().await.map_err(|error| {
501		EngineProcessError::DownloadFailed {
502			url: url.to_owned(),
503			reason: error.to_string(),
504		}
505		.build()
506	})
507}
508
509async fn fetch_bytes(client: &Client, url: &str) -> Result<Vec<u8>> {
510	let response = client.get(url).send().await.map_err(|error| {
511		EngineProcessError::DownloadFailed {
512			url: url.to_owned(),
513			reason: error.to_string(),
514		}
515		.build()
516	})?;
517	if !response.status().is_success() {
518		let status = response.status();
519		return Err(EngineProcessError::DownloadFailed {
520			url: url.to_owned(),
521			reason: format!("unexpected status {status}"),
522		}
523		.build());
524	}
525	response
526		.bytes()
527		.await
528		.map(|bytes| bytes.to_vec())
529		.map_err(|error| {
530			EngineProcessError::DownloadFailed {
531				url: url.to_owned(),
532				reason: error.to_string(),
533			}
534			.build()
535		})
536}
537
538fn checksum_for_artifact(manifest: &str, artifact: &str) -> Option<String> {
539	manifest.lines().find_map(|line| {
540		let mut parts = line.split_whitespace();
541		let checksum = parts.next()?;
542		let name = parts.next()?.trim_start_matches('*');
543		(checksum.len() == 64 && name == artifact).then(|| checksum.to_owned())
544	})
545}
546
547fn sha256_hex(bytes: &[u8]) -> String {
548	let digest = Sha256::digest(bytes);
549	let mut out = String::with_capacity(digest.len() * 2);
550	for byte in digest {
551		use std::fmt::Write;
552		let _ = write!(&mut out, "{byte:02x}");
553	}
554	out
555}
556
557fn engine_artifact_name() -> String {
558	let arch = match std::env::consts::ARCH {
559		"x86_64" => "x86_64",
560		"aarch64" => "aarch64",
561		other => other,
562	};
563	let target = match std::env::consts::OS {
564		"linux" => format!("{arch}-unknown-linux-musl"),
565		"macos" => format!("{arch}-apple-darwin"),
566		"windows" => format!("{arch}-pc-windows-gnu.exe"),
567		other => format!("{arch}-{other}"),
568	};
569	format!("rivet-engine-{target}")
570}
571
572fn exe_name(base: &str) -> String {
573	if cfg!(windows) {
574		format!("{base}.exe")
575	} else {
576		base.to_owned()
577	}
578}
579
580fn make_executable(path: &Path) -> Result<()> {
581	#[cfg(unix)]
582	{
583		use std::os::unix::fs::PermissionsExt;
584		let mut permissions = std::fs::metadata(path)
585			.with_context(|| format!("read metadata for `{}`", path.display()))?
586			.permissions();
587		permissions.set_mode(0o755);
588		std::fs::set_permissions(path, permissions)
589			.with_context(|| format!("mark `{}` executable", path.display()))?;
590	}
591	#[cfg(not(unix))]
592	{
593		let _ = path;
594	}
595	Ok(())
596}
597
598impl Drop for EngineProcessManager {
599	fn drop(&mut self) {
600		if let Some(handle) = self.watcher.take() {
601			// Aborting drops the `Child` owned by the task. With
602			// `kill_on_drop=false`, dropping the `Child` does NOT signal the
603			// engine, so the engine survives and gets reparented to init.
604			// We give up our crash-detection log line here, but if we are
605			// being dropped the rivetkit host is shutting down anyway.
606			handle.abort();
607			tracing::debug!(
608				"aborted engine watcher; engine continues running (intentional orphan)"
609			);
610		}
611	}
612}
613
614/// Spawns a background task that owns the `Child` and awaits `wait()` so we
615/// log a clear message if the engine dies while rivetkit is still up. Taking
616/// the `Child` into the task also reaps it via `waitpid` on exit, so a
617/// crashed engine never lingers as a zombie in our process table.
618fn spawn_engine_watcher(mut child: Child, pid: u32) -> JoinHandle<()> {
619	tokio::spawn(async move {
620		match child.wait().await {
621			Ok(status) if status.success() => {
622				tracing::warn!(
623					pid,
624					?status,
625					"engine process exited cleanly while rivetkit was still running; \
626					 rivetkit expected the engine to outlive it"
627				);
628			}
629			Ok(status) => {
630				tracing::error!(
631					pid,
632					?status,
633					"engine process crashed while rivetkit was still running"
634				);
635			}
636			Err(error) => {
637				tracing::error!(
638					pid,
639					?error,
640					"failed to wait on engine process; cannot detect crashes"
641				);
642			}
643		}
644	})
645}
646
647/// Probes the configured endpoint for an already-running, healthy engine.
648///
649/// Returns `Ok(Some(health))` if the endpoint is serving a `runtime: "engine"`
650/// health response that we can reattach to. Returns `Ok(None)` if the port is
651/// free. Returns `Err(...)` if the port is occupied by a non-engine process
652/// (for example a stale rivetkit) which would conflict with a fresh spawn.
653async fn probe_existing_engine(endpoint: &str) -> Result<Option<EngineHealthResponse>> {
654	let health_url = engine_health_url(endpoint);
655	let client = Client::builder()
656		.build()
657		.context("build reqwest client for engine probe")?;
658
659	let response = match client
660		.get(&health_url)
661		.timeout(Duration::from_secs(1))
662		.send()
663		.await
664	{
665		Ok(response) => response,
666		Err(_) => return Ok(None),
667	};
668
669	if !response.status().is_success() {
670		return Ok(None);
671	}
672
673	let health = response
674		.json::<EngineHealthResponse>()
675		.await
676		.context("decode existing engine health response")?;
677
678	match health.runtime.as_deref() {
679		Some(ENGINE_RUNTIME) => Ok(Some(health)),
680		Some(RIVETKIT_RUNTIME) => Err(EngineProcessError::PortOccupied {
681			endpoint: endpoint.to_owned(),
682			runtime: RIVETKIT_RUNTIME.to_owned(),
683		}
684		.build()),
685		Some(other) => Err(EngineProcessError::PortOccupied {
686			endpoint: endpoint.to_owned(),
687			runtime: other.to_owned(),
688		}
689		.build()),
690		None => Err(EngineProcessError::PortOccupied {
691			endpoint: endpoint.to_owned(),
692			runtime: "unknown".to_owned(),
693		}
694		.build()),
695	}
696}
697
698fn engine_health_url(endpoint: &str) -> String {
699	format!("{}/health", endpoint.trim_end_matches('/'))
700}
701
702fn storage_root() -> Result<PathBuf> {
703	if let Ok(path) = std::env::var("RIVETKIT_STORAGE_PATH") {
704		return Ok(PathBuf::from(path).join(".rivetkit"));
705	}
706	let home = std::env::var("HOME")
707		.map(PathBuf::from)
708		.or_else(|_| std::env::current_dir())
709		.context("locate home directory for engine storage path")?;
710	Ok(home.join(".rivetkit"))
711}
712
713fn ensure_dir(path: &Path) -> Result<()> {
714	std::fs::create_dir_all(path).with_context(|| format!("create directory `{}`", path.display()))
715}
716
717fn open_log_file(path: &Path) -> Result<std::fs::File> {
718	std::fs::OpenOptions::new()
719		.create(true)
720		.append(true)
721		.open(path)
722		.with_context(|| format!("open log file `{}`", path.display()))
723}
724
725fn log_timestamp() -> String {
726	let now = std::time::SystemTime::now()
727		.duration_since(std::time::UNIX_EPOCH)
728		.unwrap_or_default();
729	format!("{}", now.as_secs())
730}
731
732async fn wait_for_engine_health(health_url: &str) -> Result<EngineHealthResponse> {
733	const HEALTH_MAX_WAIT: Duration = Duration::from_secs(10);
734	const HEALTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
735	const HEALTH_INITIAL_BACKOFF: Duration = Duration::from_millis(100);
736	const HEALTH_MAX_BACKOFF: Duration = Duration::from_secs(1);
737
738	let client = Client::builder()
739		.build()
740		.context("build reqwest client for engine health check")?;
741	let deadline = Instant::now() + HEALTH_MAX_WAIT;
742	let mut attempt = 0u32;
743	let mut backoff = HEALTH_INITIAL_BACKOFF;
744
745	loop {
746		attempt += 1;
747
748		let last_error = match client
749			.get(health_url)
750			.timeout(HEALTH_REQUEST_TIMEOUT)
751			.send()
752			.await
753		{
754			Ok(response) if response.status().is_success() => {
755				let health = response
756					.json::<EngineHealthResponse>()
757					.await
758					.context("decode engine health response")?;
759				return Ok(health);
760			}
761			Ok(response) => format!("unexpected status {}", response.status()),
762			Err(error) => error.to_string(),
763		};
764
765		if Instant::now() >= deadline {
766			return Err(EngineProcessError::HealthCheckFailed {
767				attempts: attempt,
768				reason: last_error,
769			}
770			.build());
771		}
772
773		tokio::time::sleep(backoff).await;
774		backoff = std::cmp::min(backoff * 2, HEALTH_MAX_BACKOFF);
775	}
776}
777
778/// Cleanup path for a spawn that never reached `healthy`. We *do* kill here
779/// because the half-started engine has no useful state to preserve and
780/// leaving it running would conflict with a retry. This is the only place
781/// allowed to terminate the engine.
782async fn terminate_failed_spawn(child: &mut Child) -> Result<()> {
783	const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
784
785	if child
786		.try_wait()
787		.context("check engine process status")?
788		.is_some()
789	{
790		return Ok(());
791	}
792
793	child
794		.start_kill()
795		.context("kill half-started engine process")?;
796	match tokio::time::timeout(SHUTDOWN_TIMEOUT, child.wait()).await {
797		Ok(result) => {
798			let status = result.context("wait for half-started engine to exit")?;
799			tracing::info!(?status, "half-started engine process exited");
800			Ok(())
801		}
802		Err(_) => {
803			tracing::warn!("half-started engine process did not exit within timeout");
804			Ok(())
805		}
806	}
807}
808
809fn invalid_endpoint(endpoint: &str, reason: &str) -> anyhow::Error {
810	EngineProcessError::InvalidEndpoint {
811		endpoint: endpoint.to_owned(),
812		reason: reason.to_owned(),
813	}
814	.build()
815}
816
817#[cfg(test)]
818mod tests {
819	use std::collections::HashMap;
820
821	use tokio::io::{AsyncReadExt, AsyncWriteExt};
822	use tokio::net::TcpListener;
823
824	use super::*;
825
826	fn test_config(releases_endpoint: String, auto_download: bool) -> EngineResolverConfig {
827		EngineResolverConfig {
828			endpoint: "http://127.0.0.1:1".to_owned(),
829			explicit_binary_path: None,
830			bind_host: None,
831			bind_port: None,
832			public_url: None,
833			auto_download,
834			version: "test-version".to_owned(),
835			releases_endpoint,
836		}
837	}
838
839	#[tokio::test]
840	async fn resolver_prefers_existing_engine_before_filesystem_paths() {
841		let temp = tempfile::tempdir().expect("create temp dir");
842		let local = temp
843			.path()
844			.join("target")
845			.join("debug")
846			.join(exe_name("rivet-engine"));
847		std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir");
848		std::fs::write(&local, b"local").expect("write local binary");
849		let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
850
851		let resolved = resolve_engine_binary_after_probe(
852			&test_config("http://127.0.0.1:1".to_owned(), false),
853			true,
854			&[temp.path().to_path_buf()],
855			cached,
856		)
857		.await
858		.expect("resolve engine");
859
860		assert_eq!(resolved, ResolvedEngine::Existing);
861	}
862
863	#[tokio::test]
864	async fn resolver_prefers_local_binary_before_cached_binary() {
865		let temp = tempfile::tempdir().expect("create temp dir");
866		let local = temp
867			.path()
868			.join("target")
869			.join("debug")
870			.join(exe_name("rivet-engine"));
871		std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir");
872		std::fs::write(&local, b"local").expect("write local binary");
873		let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
874		std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir");
875		std::fs::write(&cached, b"cached").expect("write cached binary");
876
877		let resolved = resolve_engine_binary_after_probe(
878			&test_config("http://127.0.0.1:1".to_owned(), false),
879			false,
880			&[temp.path().to_path_buf()],
881			cached,
882		)
883		.await
884		.expect("resolve engine");
885
886		assert_eq!(resolved, ResolvedEngine::Binary(local));
887	}
888
889	#[tokio::test]
890	async fn resolver_reuses_cached_binary_without_download() {
891		let temp = tempfile::tempdir().expect("create temp dir");
892		let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
893		std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir");
894		std::fs::write(&cached, b"cached").expect("write cached binary");
895
896		let resolved = resolve_engine_binary_after_probe(
897			&test_config("http://127.0.0.1:1".to_owned(), false),
898			false,
899			&[],
900			cached.clone(),
901		)
902		.await
903		.expect("resolve engine");
904
905		assert_eq!(resolved, ResolvedEngine::Binary(cached));
906	}
907
908	#[tokio::test]
909	async fn resolver_reports_actionable_error_without_binary_or_download() {
910		let temp = tempfile::tempdir().expect("create temp dir");
911		let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
912
913		let error = resolve_engine_binary_after_probe(
914			&test_config("http://127.0.0.1:1".to_owned(), false),
915			false,
916			&[],
917			cached,
918		)
919		.await
920		.expect_err("missing binary should fail");
921		let message = error.to_string();
922
923		assert!(message.contains("No usable engine binary was found"));
924		assert!(message.contains("Build `rivet-engine`"));
925		assert!(message.contains("RIVET_ENGINE_BINARY_PATH"));
926	}
927
928	#[tokio::test]
929	async fn resolver_download_checks_manifest_checksum() {
930		let temp = tempfile::tempdir().expect("create temp dir");
931		let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
932		let artifact = engine_artifact_name();
933		let expected = sha256_hex(b"different bytes");
934		let manifest = format!("{expected}  {artifact}\n");
935		let releases_endpoint = spawn_download_server(HashMap::from([
936			(
937				format!("/rivet/test-version/engine/SHA256SUMS"),
938				manifest.into_bytes(),
939			),
940			(
941				format!("/rivet/test-version/engine/{artifact}"),
942				b"actual bytes".to_vec(),
943			),
944		]))
945		.await;
946
947		let error = resolve_engine_binary_after_probe(
948			&test_config(releases_endpoint, true),
949			false,
950			&[],
951			cached,
952		)
953		.await
954		.expect_err("checksum mismatch should fail");
955
956		assert!(
957			error
958				.to_string()
959				.contains("Engine binary checksum mismatch")
960		);
961	}
962
963	async fn spawn_download_server(routes: HashMap<String, Vec<u8>>) -> String {
964		let listener = TcpListener::bind("127.0.0.1:0")
965			.await
966			.expect("bind download server");
967		let addr = listener.local_addr().expect("download server address");
968		tokio::spawn(async move {
969			for _ in 0..routes.len() {
970				let (mut socket, _) = listener.accept().await.expect("accept download request");
971				let mut buffer = [0_u8; 2048];
972				let n = socket
973					.read(&mut buffer)
974					.await
975					.expect("read download request");
976				let request = String::from_utf8_lossy(&buffer[..n]);
977				let path = request
978					.split_whitespace()
979					.nth(1)
980					.expect("request path")
981					.to_owned();
982				let body = routes.get(&path).expect("route body");
983				let header = format!(
984					"HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
985					body.len()
986				);
987				socket
988					.write_all(header.as_bytes())
989					.await
990					.expect("write response header");
991				socket.write_all(body).await.expect("write response body");
992			}
993		});
994		format!("http://{addr}")
995	}
996}