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, Serialize};
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#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct EngineRuntimeStamp {
76 pub pid: u32,
77 pub endpoint: String,
78 pub bind_host: String,
79 pub public_url: Option<String>,
80}
81
82impl EngineRuntimeStamp {
83 pub fn binds_loopback_only(&self) -> bool {
86 if self.bind_host == "localhost" {
87 return true;
88 }
89 self.bind_host
90 .parse::<std::net::IpAddr>()
91 .map(|ip| ip.is_loopback())
92 .unwrap_or(false)
93 }
94}
95
96#[derive(Debug)]
98pub enum EngineStartup {
99 Spawned,
101 Reused { stamp: Option<EngineRuntimeStamp> },
105}
106
107#[derive(Debug)]
129pub struct EngineProcessManager {
130 watcher: Option<JoinHandle<()>>,
131 startup: EngineStartup,
132}
133
134impl EngineProcessManager {
135 pub async fn start_or_reuse(config: EngineResolverConfig) -> Result<Self> {
136 let resolved = resolve_engine_binary(&config).await?;
137 Self::start_resolved(resolved, &config).await
138 }
139
140 pub fn startup(&self) -> &EngineStartup {
142 &self.startup
143 }
144
145 async fn start_resolved(
146 resolved: ResolvedEngine,
147 config: &EngineResolverConfig,
148 ) -> Result<Self> {
149 let endpoint = &config.endpoint;
150 if matches!(resolved, ResolvedEngine::Existing) {
151 tracing::info!(
152 endpoint = %endpoint,
153 "reusing already-running engine process"
154 );
155 return Ok(Self {
156 watcher: None,
157 startup: EngineStartup::Reused {
158 stamp: read_engine_stamp(),
159 },
160 });
161 }
162
163 let ResolvedEngine::Binary(binary_path) = resolved else {
164 unreachable!("existing engine handled above");
165 };
166 if let Some(health) = probe_existing_engine(endpoint).await? {
167 tracing::info!(
168 endpoint = %endpoint,
169 status = ?health.status,
170 runtime = ?health.runtime,
171 version = ?health.version,
172 "reusing already-running engine process"
173 );
174 return Ok(Self {
175 watcher: None,
176 startup: EngineStartup::Reused {
177 stamp: read_engine_stamp(),
178 },
179 });
180 }
181
182 if !binary_path.exists() {
183 return Err(EngineProcessError::BinaryNotFound {
184 path: binary_path.display().to_string(),
185 }
186 .build());
187 }
188
189 let env = engine_env(config)?;
190 let config_path = write_engine_config(config)?;
191 let db_path = engine_db_path()?;
192 let logs_dir = storage_root()?
193 .join("var")
194 .join("logs")
195 .join("rivet-engine");
196 ensure_dir(&db_path).context("create engine db directory")?;
197 ensure_dir(&logs_dir).context("create engine logs directory")?;
198
199 let timestamp = log_timestamp();
200 let stdout_log_path = logs_dir.join(format!("engine-{timestamp}-stdout.log"));
201 let stderr_log_path = logs_dir.join(format!("engine-{timestamp}-stderr.log"));
202 let stdout_file = open_log_file(&stdout_log_path)
203 .with_context(|| format!("open engine stdout log `{}`", stdout_log_path.display()))?;
204 let stderr_file = open_log_file(&stderr_log_path)
205 .with_context(|| format!("open engine stderr log `{}`", stderr_log_path.display()))?;
206
207 let mut command = Command::new(&binary_path);
208 command.arg("start");
209 if let Some(config_path) = &config_path {
210 command.arg("--config").arg(config_path);
211 }
212 for (key, value) in &env {
213 command.env(key, value);
214 }
215 command
216 .stdin(Stdio::null())
217 .stdout(Stdio::from(stdout_file))
218 .stderr(Stdio::from(stderr_file));
219
220 #[cfg(unix)]
226 command.process_group(0);
227
228 let mut child = command
229 .spawn()
230 .with_context(|| format!("spawn engine binary `{}`", binary_path.display()))?;
231 let pid = child
232 .id()
233 .ok_or_else(|| EngineProcessError::MissingPid.build())?;
234
235 tracing::info!(
236 pid,
237 path = %binary_path.display(),
238 endpoint = %endpoint,
239 db_path = %db_path.display(),
240 "spawned engine process (intentionally orphaned, will outlive this process)"
241 );
242 tracing::info!(
243 stdout_log = %stdout_log_path.display(),
244 stderr_log = %stderr_log_path.display(),
245 "engine stdout/stderr piped to log files"
246 );
247
248 let health_url = engine_health_url(endpoint);
249 let health = match wait_for_engine_health(&health_url).await {
250 Ok(health) => health,
251 Err(error) => {
252 let error = match child.try_wait() {
253 Ok(Some(status)) => error.context(format!(
254 "engine process exited before becoming healthy with status {status}"
255 )),
256 Ok(None) => error,
257 Err(wait_error) => error.context(format!(
258 "failed to inspect engine process status: {wait_error:#}"
259 )),
260 };
261 if let Err(cleanup_error) = terminate_failed_spawn(&mut child).await {
262 tracing::warn!(
263 ?cleanup_error,
264 "failed to terminate engine process that never became healthy"
265 );
266 }
267 return Err(error);
268 }
269 };
270
271 tracing::info!(
272 pid,
273 status = ?health.status,
274 runtime = ?health.runtime,
275 version = ?health.version,
276 "engine process is healthy"
277 );
278
279 let stamp = EngineRuntimeStamp {
283 pid,
284 endpoint: endpoint.clone(),
285 bind_host: resolve_bind_host(config)?,
286 public_url: config.public_url.clone(),
287 };
288 if let Err(error) = write_engine_stamp(&stamp) {
289 tracing::warn!(?error, "failed to write engine runtime stamp");
290 }
291
292 Ok(Self {
293 watcher: Some(spawn_engine_watcher(child, pid)),
294 startup: EngineStartup::Spawned,
295 })
296 }
297}
298
299pub fn engine_db_path() -> Result<PathBuf> {
301 Ok(storage_root()?.join("var").join("engine").join("db"))
302}
303
304fn engine_stamp_path() -> Result<PathBuf> {
305 Ok(storage_root()?
306 .join("var")
307 .join("engine")
308 .join("runtime.json"))
309}
310
311fn resolve_bind_host(config: &EngineResolverConfig) -> Result<String> {
314 if let Some(bind_host) = &config.bind_host {
315 return Ok(bind_host.clone());
316 }
317 let endpoint = &config.endpoint;
318 let endpoint_url =
319 Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?;
320 Ok(endpoint_url
321 .host_str()
322 .ok_or_else(|| invalid_endpoint(endpoint, "missing host"))?
323 .to_owned())
324}
325
326fn write_engine_stamp(stamp: &EngineRuntimeStamp) -> Result<()> {
327 let path = engine_stamp_path()?;
328 if let Some(dir) = path.parent() {
329 ensure_dir(dir)?;
330 }
331 let contents = serde_json::to_vec_pretty(stamp).context("serialize engine stamp")?;
332 std::fs::write(&path, contents)
333 .with_context(|| format!("write engine stamp `{}`", path.display()))?;
334 Ok(())
335}
336
337fn read_engine_stamp() -> Option<EngineRuntimeStamp> {
343 let path = engine_stamp_path().ok()?;
344 let contents = std::fs::read(&path).ok()?;
345 let stamp: EngineRuntimeStamp = serde_json::from_slice(&contents).ok()?;
346 pid_is_alive(stamp.pid).then_some(stamp)
347}
348
349#[cfg(unix)]
350fn pid_is_alive(pid: u32) -> bool {
351 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
354 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
355}
356
357#[cfg(not(unix))]
358fn pid_is_alive(_pid: u32) -> bool {
359 false
363}
364
365pub fn engine_env(config: &EngineResolverConfig) -> Result<Vec<(String, String)>> {
372 let endpoint = &config.endpoint;
373 let endpoint_url =
374 Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?;
375 let guard_host = resolve_bind_host(config)?;
376 let guard_port = endpoint_url
377 .port_or_known_default()
378 .ok_or_else(|| invalid_endpoint(endpoint, "missing port"))?;
379 let guard_port = config.bind_port.unwrap_or(guard_port);
380 let api_peer_port = guard_port
381 .checked_add(1)
382 .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?;
383 let metrics_port = guard_port
384 .checked_add(10)
385 .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?;
386
387 let db_path = engine_db_path()?;
388
389 Ok(vec![
390 ("RIVET__GUARD__HOST".to_owned(), guard_host.clone()),
391 ("RIVET__GUARD__PORT".to_owned(), guard_port.to_string()),
392 ("RIVET__API_PEER__HOST".to_owned(), guard_host.clone()),
393 (
394 "RIVET__API_PEER__PORT".to_owned(),
395 api_peer_port.to_string(),
396 ),
397 ("RIVET__METRICS__HOST".to_owned(), guard_host),
398 ("RIVET__METRICS__PORT".to_owned(), metrics_port.to_string()),
399 (
400 "RIVET__FILE_SYSTEM__PATH".to_owned(),
401 db_path.to_string_lossy().into_owned(),
402 ),
403 ])
404}
405
406fn write_engine_config(config: &EngineResolverConfig) -> Result<Option<PathBuf>> {
407 let Some(public_url) = &config.public_url else {
408 return Ok(None);
409 };
410
411 let public_url = Url::parse(public_url)
412 .with_context(|| format!("parse engine public URL `{public_url}`"))?;
413 let peer_url = peer_url_for_public_url(&public_url)?;
414 let dir = storage_root()?.join("var").join("engine");
415 ensure_dir(&dir)?;
416 let path = dir.join("config.json");
417 let config = serde_json::json!({
418 "topology": {
419 "datacenter_label": 1,
420 "datacenters": {
421 "default": {
422 "datacenter_label": 1,
423 "is_leader": true,
424 "public_url": public_url.as_str(),
425 "peer_url": peer_url,
426 }
427 }
428 }
429 });
430 std::fs::write(&path, serde_json::to_string_pretty(&config)?)
431 .with_context(|| format!("write engine config `{}`", path.display()))?;
432 Ok(Some(path))
433}
434
435fn peer_url_for_public_url(public_url: &Url) -> Result<String> {
436 let mut peer_url = public_url.clone();
437 let port = public_url
438 .port_or_known_default()
439 .ok_or_else(|| invalid_endpoint(public_url.as_str(), "missing port"))?
440 .checked_add(1)
441 .ok_or_else(|| invalid_endpoint(public_url.as_str(), "port is too large"))?;
442 if peer_url.set_port(Some(port)).is_err() {
443 return Err(invalid_endpoint(
444 public_url.as_str(),
445 "could not derive peer URL port",
446 ));
447 }
448 peer_url.set_path("");
449 peer_url.set_query(None);
450 peer_url.set_fragment(None);
451 Ok(peer_url.to_string().trim_end_matches('/').to_string())
452}
453
454pub async fn resolve_engine_binary(config: &EngineResolverConfig) -> Result<ResolvedEngine> {
455 if let Some(path) = config.explicit_binary_path.as_ref() {
456 return verify_binary_path(path);
457 }
458
459 if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) {
460 return verify_binary_path(&path);
461 }
462
463 if probe_existing_engine(&config.endpoint).await?.is_some() {
464 return Ok(ResolvedEngine::Existing);
465 }
466
467 let local_roots = local_engine_search_roots();
468 let cached = cached_engine_path(&config.version)?;
469 resolve_engine_binary_after_probe(config, false, &local_roots, cached).await
470}
471
472pub async fn resolve_engine_binary_path(config: &EngineResolverConfig) -> Result<PathBuf> {
479 if let Some(path) = config.explicit_binary_path.as_ref() {
480 verify_binary_path(path)?;
481 return Ok(path.clone());
482 }
483
484 if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) {
485 verify_binary_path(&path)?;
486 return Ok(path);
487 }
488
489 let local_roots = local_engine_search_roots();
490 let cached = cached_engine_path(&config.version)?;
491 match resolve_engine_binary_after_probe(config, false, &local_roots, cached).await? {
492 ResolvedEngine::Binary(path) => Ok(path),
493 ResolvedEngine::Existing => {
494 unreachable!("no-probe resolution never returns Existing")
495 }
496 }
497}
498
499async fn resolve_engine_binary_after_probe(
500 config: &EngineResolverConfig,
501 existing_engine: bool,
502 local_roots: &[PathBuf],
503 cached: PathBuf,
504) -> Result<ResolvedEngine> {
505 if existing_engine {
506 return Ok(ResolvedEngine::Existing);
507 }
508
509 if let Some(path) = find_local_engine_binary_in_roots(local_roots) {
510 return Ok(ResolvedEngine::Binary(path));
511 }
512
513 if cached.exists() {
514 return Ok(ResolvedEngine::Binary(cached));
515 }
516
517 if !config.auto_download {
518 return Err(EngineProcessError::BinaryUnavailable {
519 version: config.version.clone(),
520 }
521 .build());
522 }
523
524 download_engine_binary(config, &cached).await?;
525 Ok(ResolvedEngine::Binary(cached))
526}
527
528fn verify_binary_path(path: &Path) -> Result<ResolvedEngine> {
529 if !path.exists() {
530 return Err(EngineProcessError::BinaryNotFound {
531 path: path.display().to_string(),
532 }
533 .build());
534 }
535 Ok(ResolvedEngine::Binary(path.to_path_buf()))
536}
537
538fn local_engine_search_roots() -> Vec<PathBuf> {
539 Path::new(env!("CARGO_MANIFEST_DIR"))
540 .ancestors()
541 .map(Path::to_path_buf)
542 .collect()
543}
544
545fn find_local_engine_binary_in_roots(roots: &[PathBuf]) -> Option<PathBuf> {
546 for root in roots {
547 for profile in ["debug", "release"] {
548 let candidate = root
549 .join("target")
550 .join(profile)
551 .join(exe_name("rivet-engine"));
552 if candidate.exists() {
553 return Some(candidate);
554 }
555 }
556 }
557 None
558}
559
560fn cached_engine_path(version: &str) -> Result<PathBuf> {
561 Ok(storage_root()?
562 .join("engine")
563 .join(version)
564 .join(engine_artifact_name()))
565}
566
567async fn download_engine_binary(config: &EngineResolverConfig, destination: &Path) -> Result<()> {
568 let artifact = engine_artifact_name();
569 let base = config.releases_endpoint.trim_end_matches('/');
570 let artifact_url = format!("{base}/rivet/{}/engine/{artifact}", config.version);
571 let manifest_url = format!("{base}/rivet/{}/engine/SHA256SUMS", config.version);
572 let client = Client::builder()
573 .timeout(DOWNLOAD_TIMEOUT)
574 .build()
575 .context("build reqwest client for engine download")?;
576
577 let manifest = fetch_text(&client, &manifest_url).await?;
578 let expected = checksum_for_artifact(&manifest, &artifact).ok_or_else(|| {
579 EngineProcessError::DownloadFailed {
580 url: manifest_url.clone(),
581 reason: format!("manifest does not contain `{artifact}`"),
582 }
583 .build()
584 })?;
585
586 let bytes = fetch_bytes(&client, &artifact_url).await?;
587 let received = sha256_hex(&bytes);
588 if !received.eq_ignore_ascii_case(&expected) {
589 return Err(EngineProcessError::ChecksumMismatch {
590 artifact,
591 expected,
592 received,
593 }
594 .build());
595 }
596
597 let parent = destination
598 .parent()
599 .context("engine cache destination has no parent")?;
600 ensure_dir(parent)?;
601 std::fs::write(destination, bytes)
602 .with_context(|| format!("write engine binary `{}`", destination.display()))?;
603 make_executable(destination)?;
604 Ok(())
605}
606
607async fn fetch_text(client: &Client, url: &str) -> Result<String> {
608 let response = client.get(url).send().await.map_err(|error| {
609 EngineProcessError::DownloadFailed {
610 url: url.to_owned(),
611 reason: error.to_string(),
612 }
613 .build()
614 })?;
615 if !response.status().is_success() {
616 let status = response.status();
617 return Err(EngineProcessError::DownloadFailed {
618 url: url.to_owned(),
619 reason: format!("unexpected status {status}"),
620 }
621 .build());
622 }
623 response.text().await.map_err(|error| {
624 EngineProcessError::DownloadFailed {
625 url: url.to_owned(),
626 reason: error.to_string(),
627 }
628 .build()
629 })
630}
631
632async fn fetch_bytes(client: &Client, url: &str) -> Result<Vec<u8>> {
633 let response = client.get(url).send().await.map_err(|error| {
634 EngineProcessError::DownloadFailed {
635 url: url.to_owned(),
636 reason: error.to_string(),
637 }
638 .build()
639 })?;
640 if !response.status().is_success() {
641 let status = response.status();
642 return Err(EngineProcessError::DownloadFailed {
643 url: url.to_owned(),
644 reason: format!("unexpected status {status}"),
645 }
646 .build());
647 }
648 response
649 .bytes()
650 .await
651 .map(|bytes| bytes.to_vec())
652 .map_err(|error| {
653 EngineProcessError::DownloadFailed {
654 url: url.to_owned(),
655 reason: error.to_string(),
656 }
657 .build()
658 })
659}
660
661fn checksum_for_artifact(manifest: &str, artifact: &str) -> Option<String> {
662 manifest.lines().find_map(|line| {
663 let mut parts = line.split_whitespace();
664 let checksum = parts.next()?;
665 let name = parts.next()?.trim_start_matches('*');
666 (checksum.len() == 64 && name == artifact).then(|| checksum.to_owned())
667 })
668}
669
670fn sha256_hex(bytes: &[u8]) -> String {
671 let digest = Sha256::digest(bytes);
672 let mut out = String::with_capacity(digest.len() * 2);
673 for byte in digest {
674 use std::fmt::Write;
675 let _ = write!(&mut out, "{byte:02x}");
676 }
677 out
678}
679
680fn engine_artifact_name() -> String {
681 let arch = match std::env::consts::ARCH {
682 "x86_64" => "x86_64",
683 "aarch64" => "aarch64",
684 other => other,
685 };
686 let target = match std::env::consts::OS {
687 "linux" => format!("{arch}-unknown-linux-musl"),
688 "macos" => format!("{arch}-apple-darwin"),
689 "windows" => format!("{arch}-pc-windows-gnu.exe"),
690 other => format!("{arch}-{other}"),
691 };
692 format!("rivet-engine-{target}")
693}
694
695fn exe_name(base: &str) -> String {
696 if cfg!(windows) {
697 format!("{base}.exe")
698 } else {
699 base.to_owned()
700 }
701}
702
703fn make_executable(path: &Path) -> Result<()> {
704 #[cfg(unix)]
705 {
706 use std::os::unix::fs::PermissionsExt;
707 let mut permissions = std::fs::metadata(path)
708 .with_context(|| format!("read metadata for `{}`", path.display()))?
709 .permissions();
710 permissions.set_mode(0o755);
711 std::fs::set_permissions(path, permissions)
712 .with_context(|| format!("mark `{}` executable", path.display()))?;
713 }
714 #[cfg(not(unix))]
715 {
716 let _ = path;
717 }
718 Ok(())
719}
720
721impl Drop for EngineProcessManager {
722 fn drop(&mut self) {
723 if let Some(handle) = self.watcher.take() {
724 handle.abort();
730 tracing::debug!(
731 "aborted engine watcher; engine continues running (intentional orphan)"
732 );
733 }
734 }
735}
736
737fn spawn_engine_watcher(mut child: Child, pid: u32) -> JoinHandle<()> {
742 tokio::spawn(async move {
743 match child.wait().await {
744 Ok(status) if status.success() => {
745 tracing::warn!(
746 pid,
747 ?status,
748 "engine process exited cleanly while rivetkit was still running; \
749 rivetkit expected the engine to outlive it"
750 );
751 }
752 Ok(status) => {
753 tracing::error!(
754 pid,
755 ?status,
756 "engine process crashed while rivetkit was still running"
757 );
758 }
759 Err(error) => {
760 tracing::error!(
761 pid,
762 ?error,
763 "failed to wait on engine process; cannot detect crashes"
764 );
765 }
766 }
767 })
768}
769
770async fn probe_existing_engine(endpoint: &str) -> Result<Option<EngineHealthResponse>> {
777 let health_url = engine_health_url(endpoint);
778 let client = Client::builder()
779 .build()
780 .context("build reqwest client for engine probe")?;
781
782 let response = match client
783 .get(&health_url)
784 .timeout(Duration::from_secs(1))
785 .send()
786 .await
787 {
788 Ok(response) => response,
789 Err(_) => return Ok(None),
790 };
791
792 if !response.status().is_success() {
793 return Ok(None);
794 }
795
796 let health = response
797 .json::<EngineHealthResponse>()
798 .await
799 .context("decode existing engine health response")?;
800
801 match health.runtime.as_deref() {
802 Some(ENGINE_RUNTIME) => Ok(Some(health)),
803 Some(RIVETKIT_RUNTIME) => Err(EngineProcessError::PortOccupied {
804 endpoint: endpoint.to_owned(),
805 runtime: RIVETKIT_RUNTIME.to_owned(),
806 }
807 .build()),
808 Some(other) => Err(EngineProcessError::PortOccupied {
809 endpoint: endpoint.to_owned(),
810 runtime: other.to_owned(),
811 }
812 .build()),
813 None => Err(EngineProcessError::PortOccupied {
814 endpoint: endpoint.to_owned(),
815 runtime: "unknown".to_owned(),
816 }
817 .build()),
818 }
819}
820
821fn engine_health_url(endpoint: &str) -> String {
822 format!("{}/health", endpoint.trim_end_matches('/'))
823}
824
825fn storage_root() -> Result<PathBuf> {
826 if let Ok(path) = std::env::var("RIVETKIT_STORAGE_PATH") {
827 return Ok(PathBuf::from(path).join(".rivetkit"));
828 }
829 let home = std::env::var("HOME")
830 .map(PathBuf::from)
831 .or_else(|_| std::env::current_dir())
832 .context("locate home directory for engine storage path")?;
833 Ok(home.join(".rivetkit"))
834}
835
836fn ensure_dir(path: &Path) -> Result<()> {
837 std::fs::create_dir_all(path).with_context(|| format!("create directory `{}`", path.display()))
838}
839
840fn open_log_file(path: &Path) -> Result<std::fs::File> {
841 std::fs::OpenOptions::new()
842 .create(true)
843 .append(true)
844 .open(path)
845 .with_context(|| format!("open log file `{}`", path.display()))
846}
847
848fn log_timestamp() -> String {
849 let now = std::time::SystemTime::now()
850 .duration_since(std::time::UNIX_EPOCH)
851 .unwrap_or_default();
852 format!("{}", now.as_secs())
853}
854
855async fn wait_for_engine_health(health_url: &str) -> Result<EngineHealthResponse> {
856 const HEALTH_MAX_WAIT: Duration = Duration::from_secs(10);
857 const HEALTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
858 const HEALTH_INITIAL_BACKOFF: Duration = Duration::from_millis(100);
859 const HEALTH_MAX_BACKOFF: Duration = Duration::from_secs(1);
860
861 let client = Client::builder()
862 .build()
863 .context("build reqwest client for engine health check")?;
864 let deadline = Instant::now() + HEALTH_MAX_WAIT;
865 let mut attempt = 0u32;
866 let mut backoff = HEALTH_INITIAL_BACKOFF;
867
868 loop {
869 attempt += 1;
870
871 let last_error = match client
872 .get(health_url)
873 .timeout(HEALTH_REQUEST_TIMEOUT)
874 .send()
875 .await
876 {
877 Ok(response) if response.status().is_success() => {
878 let health = response
879 .json::<EngineHealthResponse>()
880 .await
881 .context("decode engine health response")?;
882 return Ok(health);
883 }
884 Ok(response) => format!("unexpected status {}", response.status()),
885 Err(error) => error.to_string(),
886 };
887
888 if Instant::now() >= deadline {
889 return Err(EngineProcessError::HealthCheckFailed {
890 attempts: attempt,
891 reason: last_error,
892 }
893 .build());
894 }
895
896 tokio::time::sleep(backoff).await;
897 backoff = std::cmp::min(backoff * 2, HEALTH_MAX_BACKOFF);
898 }
899}
900
901async fn terminate_failed_spawn(child: &mut Child) -> Result<()> {
906 const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
907
908 if child
909 .try_wait()
910 .context("check engine process status")?
911 .is_some()
912 {
913 return Ok(());
914 }
915
916 child
917 .start_kill()
918 .context("kill half-started engine process")?;
919 match tokio::time::timeout(SHUTDOWN_TIMEOUT, child.wait()).await {
920 Ok(result) => {
921 let status = result.context("wait for half-started engine to exit")?;
922 tracing::info!(?status, "half-started engine process exited");
923 Ok(())
924 }
925 Err(_) => {
926 tracing::warn!("half-started engine process did not exit within timeout");
927 Ok(())
928 }
929 }
930}
931
932fn invalid_endpoint(endpoint: &str, reason: &str) -> anyhow::Error {
933 EngineProcessError::InvalidEndpoint {
934 endpoint: endpoint.to_owned(),
935 reason: reason.to_owned(),
936 }
937 .build()
938}
939
940#[cfg(test)]
941mod tests {
942 use std::collections::HashMap;
943
944 use tokio::io::{AsyncReadExt, AsyncWriteExt};
945 use tokio::net::TcpListener;
946
947 use super::*;
948
949 fn test_config(releases_endpoint: String, auto_download: bool) -> EngineResolverConfig {
950 EngineResolverConfig {
951 endpoint: "http://127.0.0.1:1".to_owned(),
952 explicit_binary_path: None,
953 bind_host: None,
954 bind_port: None,
955 public_url: None,
956 auto_download,
957 version: "test-version".to_owned(),
958 releases_endpoint,
959 }
960 }
961
962 fn stamp_with_bind(bind_host: &str) -> EngineRuntimeStamp {
963 EngineRuntimeStamp {
964 pid: 1,
965 endpoint: "http://127.0.0.1:6420".to_owned(),
966 bind_host: bind_host.to_owned(),
967 public_url: None,
968 }
969 }
970
971 #[test]
972 fn binds_loopback_only_detects_loopback_addresses() {
973 assert!(stamp_with_bind("127.0.0.1").binds_loopback_only());
974 assert!(stamp_with_bind("localhost").binds_loopback_only());
975 assert!(stamp_with_bind("::1").binds_loopback_only());
976 }
977
978 #[test]
979 fn binds_loopback_only_false_for_reachable_addresses() {
980 assert!(!stamp_with_bind("0.0.0.0").binds_loopback_only());
981 assert!(!stamp_with_bind("::").binds_loopback_only());
982 assert!(!stamp_with_bind("192.168.1.5").binds_loopback_only());
983 }
984
985 #[test]
986 fn resolve_bind_host_prefers_override_then_endpoint() {
987 let mut config = test_config(String::new(), false);
988 config.endpoint = "http://127.0.0.1:6420".to_owned();
989 assert_eq!(resolve_bind_host(&config).unwrap(), "127.0.0.1");
990 config.bind_host = Some("0.0.0.0".to_owned());
991 assert_eq!(resolve_bind_host(&config).unwrap(), "0.0.0.0");
992 }
993
994 #[cfg(unix)]
995 #[test]
996 fn pid_is_alive_true_for_self_false_for_missing() {
997 assert!(pid_is_alive(std::process::id()));
998 assert!(!pid_is_alive(0x7FFF_FFFF));
1000 }
1001
1002 #[tokio::test]
1003 async fn resolver_prefers_existing_engine_before_filesystem_paths() {
1004 let temp = tempfile::tempdir().expect("create temp dir");
1005 let local = temp
1006 .path()
1007 .join("target")
1008 .join("debug")
1009 .join(exe_name("rivet-engine"));
1010 std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir");
1011 std::fs::write(&local, b"local").expect("write local binary");
1012 let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
1013
1014 let resolved = resolve_engine_binary_after_probe(
1015 &test_config("http://127.0.0.1:1".to_owned(), false),
1016 true,
1017 &[temp.path().to_path_buf()],
1018 cached,
1019 )
1020 .await
1021 .expect("resolve engine");
1022
1023 assert_eq!(resolved, ResolvedEngine::Existing);
1024 }
1025
1026 #[tokio::test]
1027 async fn resolver_prefers_local_binary_before_cached_binary() {
1028 let temp = tempfile::tempdir().expect("create temp dir");
1029 let local = temp
1030 .path()
1031 .join("target")
1032 .join("debug")
1033 .join(exe_name("rivet-engine"));
1034 std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir");
1035 std::fs::write(&local, b"local").expect("write local binary");
1036 let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
1037 std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir");
1038 std::fs::write(&cached, b"cached").expect("write cached binary");
1039
1040 let resolved = resolve_engine_binary_after_probe(
1041 &test_config("http://127.0.0.1:1".to_owned(), false),
1042 false,
1043 &[temp.path().to_path_buf()],
1044 cached,
1045 )
1046 .await
1047 .expect("resolve engine");
1048
1049 assert_eq!(resolved, ResolvedEngine::Binary(local));
1050 }
1051
1052 #[tokio::test]
1053 async fn resolver_reuses_cached_binary_without_download() {
1054 let temp = tempfile::tempdir().expect("create temp dir");
1055 let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
1056 std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir");
1057 std::fs::write(&cached, b"cached").expect("write cached binary");
1058
1059 let resolved = resolve_engine_binary_after_probe(
1060 &test_config("http://127.0.0.1:1".to_owned(), false),
1061 false,
1062 &[],
1063 cached.clone(),
1064 )
1065 .await
1066 .expect("resolve engine");
1067
1068 assert_eq!(resolved, ResolvedEngine::Binary(cached));
1069 }
1070
1071 #[tokio::test]
1072 async fn resolver_reports_actionable_error_without_binary_or_download() {
1073 let temp = tempfile::tempdir().expect("create temp dir");
1074 let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
1075
1076 let error = resolve_engine_binary_after_probe(
1077 &test_config("http://127.0.0.1:1".to_owned(), false),
1078 false,
1079 &[],
1080 cached,
1081 )
1082 .await
1083 .expect_err("missing binary should fail");
1084 let message = error.to_string();
1085
1086 assert!(message.contains("No usable engine binary was found"));
1087 assert!(message.contains("Build `rivet-engine`"));
1088 assert!(message.contains("RIVET_ENGINE_BINARY_PATH"));
1089 }
1090
1091 #[tokio::test]
1092 async fn resolver_download_checks_manifest_checksum() {
1093 let temp = tempfile::tempdir().expect("create temp dir");
1094 let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
1095 let artifact = engine_artifact_name();
1096 let expected = sha256_hex(b"different bytes");
1097 let manifest = format!("{expected} {artifact}\n");
1098 let releases_endpoint = spawn_download_server(HashMap::from([
1099 (
1100 format!("/rivet/test-version/engine/SHA256SUMS"),
1101 manifest.into_bytes(),
1102 ),
1103 (
1104 format!("/rivet/test-version/engine/{artifact}"),
1105 b"actual bytes".to_vec(),
1106 ),
1107 ]))
1108 .await;
1109
1110 let error = resolve_engine_binary_after_probe(
1111 &test_config(releases_endpoint, true),
1112 false,
1113 &[],
1114 cached,
1115 )
1116 .await
1117 .expect_err("checksum mismatch should fail");
1118
1119 assert!(
1120 error
1121 .to_string()
1122 .contains("Engine binary checksum mismatch")
1123 );
1124 }
1125
1126 async fn spawn_download_server(routes: HashMap<String, Vec<u8>>) -> String {
1127 let listener = TcpListener::bind("127.0.0.1:0")
1128 .await
1129 .expect("bind download server");
1130 let addr = listener.local_addr().expect("download server address");
1131 tokio::spawn(async move {
1132 for _ in 0..routes.len() {
1133 let (mut socket, _) = listener.accept().await.expect("accept download request");
1134 let mut buffer = [0_u8; 2048];
1135 let n = socket
1136 .read(&mut buffer)
1137 .await
1138 .expect("read download request");
1139 let request = String::from_utf8_lossy(&buffer[..n]);
1140 let path = request
1141 .split_whitespace()
1142 .nth(1)
1143 .expect("request path")
1144 .to_owned();
1145 let body = routes.get(&path).expect("route body");
1146 let header = format!(
1147 "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
1148 body.len()
1149 );
1150 socket
1151 .write_all(header.as_bytes())
1152 .await
1153 .expect("write response header");
1154 socket.write_all(body).await.expect("write response body");
1155 }
1156 });
1157 format!("http://{addr}")
1158 }
1159}