1use std::process::Stdio;
2use std::time::{Duration, Instant};
3use std::{
4 env,
5 path::{Path, PathBuf},
6};
7
8use anyhow::{Context, Result};
9use reqwest::{Client, Url};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use tokio::io::AsyncReadExt;
13use tokio::process::{Child, Command};
14use tokio::task::JoinHandle;
15
16mod error;
17
18pub use error::EngineProcessError;
19
20const ENGINE_RUNTIME: &str = "engine";
21const ENGINE_ARCHITECTURE: &str = "v3";
24const RIVETKIT_RUNTIME: &str = "rivetkit";
25const ENGINE_VERSION_ENV: &str = "RIVETKIT_ENGINE_VERSION";
26const RELEASES_ENDPOINT_ENV: &str = "RIVETKIT_ENGINE_RELEASES_ENDPOINT";
27const RELEASES_ENDPOINT: &str = "https://releases.rivet.dev";
28const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60);
29
30#[derive(Debug, Deserialize)]
31struct EngineHealthResponse {
32 status: Option<String>,
33 runtime: Option<String>,
34 version: Option<String>,
35 architecture: Option<String>,
36}
37
38#[derive(Clone, Debug)]
39pub struct EngineResolverConfig {
40 pub endpoint: String,
41 pub explicit_binary_path: Option<PathBuf>,
42 pub bind_host: Option<String>,
43 pub bind_port: Option<u16>,
44 pub public_url: Option<String>,
45 pub auto_download: bool,
46 pub version: String,
47 pub releases_endpoint: String,
48}
49
50impl EngineResolverConfig {
51 pub fn from_parts(
52 endpoint: &str,
53 explicit_binary_path: Option<PathBuf>,
54 bind_host: Option<String>,
55 bind_port: Option<u16>,
56 auto_download: bool,
57 ) -> Self {
58 Self {
59 endpoint: endpoint.to_owned(),
60 explicit_binary_path,
61 bind_host,
62 bind_port,
63 public_url: None,
64 auto_download,
65 version: std::env::var(ENGINE_VERSION_ENV)
66 .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_owned()),
67 releases_endpoint: std::env::var(RELEASES_ENDPOINT_ENV)
68 .unwrap_or_else(|_| RELEASES_ENDPOINT.to_owned()),
69 }
70 }
71}
72
73#[derive(Debug, PartialEq, Eq)]
74pub enum ResolvedEngine {
75 Existing,
76 Binary(PathBuf),
77}
78
79#[derive(Clone, Debug, Serialize, Deserialize)]
83pub struct EngineRuntimeStamp {
84 pub pid: u32,
85 pub endpoint: String,
86 pub bind_host: String,
87 pub public_url: Option<String>,
88}
89
90impl EngineRuntimeStamp {
91 pub fn binds_loopback_only(&self) -> bool {
94 if self.bind_host == "localhost" {
95 return true;
96 }
97 self.bind_host
98 .parse::<std::net::IpAddr>()
99 .map(|ip| ip.is_loopback())
100 .unwrap_or(false)
101 }
102}
103
104#[derive(Debug)]
106pub enum EngineStartup {
107 Spawned,
109 Reused { stamp: Option<EngineRuntimeStamp> },
113}
114
115#[derive(Debug)]
137pub struct EngineProcessManager {
138 watcher: Option<JoinHandle<()>>,
139 startup: EngineStartup,
140}
141
142impl EngineProcessManager {
143 pub async fn start_or_reuse(config: EngineResolverConfig) -> Result<Self> {
144 let resolved = resolve_engine_binary(&config).await?;
145 Self::start_resolved(resolved, &config).await
146 }
147
148 pub fn startup(&self) -> &EngineStartup {
150 &self.startup
151 }
152
153 async fn start_resolved(
154 resolved: ResolvedEngine,
155 config: &EngineResolverConfig,
156 ) -> Result<Self> {
157 let endpoint = &config.endpoint;
158 if matches!(resolved, ResolvedEngine::Existing) {
159 tracing::info!(
160 endpoint = %endpoint,
161 "reusing already-running engine process"
162 );
163 return Ok(Self {
164 watcher: None,
165 startup: EngineStartup::Reused {
166 stamp: read_engine_stamp(),
167 },
168 });
169 }
170
171 let ResolvedEngine::Binary(binary_path) = resolved else {
172 unreachable!("existing engine handled above");
173 };
174 if let Some(health) = probe_existing_engine(endpoint).await? {
175 tracing::info!(
176 endpoint = %endpoint,
177 status = ?health.status,
178 runtime = ?health.runtime,
179 version = ?health.version,
180 "reusing already-running engine process"
181 );
182 return Ok(Self {
183 watcher: None,
184 startup: EngineStartup::Reused {
185 stamp: read_engine_stamp(),
186 },
187 });
188 }
189
190 if !binary_path.exists() {
191 return Err(EngineProcessError::BinaryNotFound {
192 path: binary_path.display().to_string(),
193 }
194 .build());
195 }
196
197 ensure_compatible_binary(&binary_path).await?;
198
199 let env = engine_env(config)?;
200 let config_path = write_engine_config(config)?;
201 let data_path = engine_data_path()?;
202 let logs_dir = storage_root()?
203 .join("var")
204 .join("logs")
205 .join("rivet-engine");
206 ensure_dir(&logs_dir).context("create engine logs directory")?;
207 ensure_dir(&engine_log_directory()?).context("create engine log shard directory")?;
208
209 let timestamp = log_timestamp();
210 let stdout_log_path = logs_dir.join(format!("engine-{timestamp}-stdout.log"));
211 let stderr_log_path = logs_dir.join(format!("engine-{timestamp}-stderr.log"));
212 let stdout_file = open_log_file(&stdout_log_path)
213 .with_context(|| format!("open engine stdout log `{}`", stdout_log_path.display()))?;
214 let stderr_file = open_log_file(&stderr_log_path)
215 .with_context(|| format!("open engine stderr log `{}`", stderr_log_path.display()))?;
216
217 let mut command = Command::new(&binary_path);
218 command.arg("--config").arg(&config_path);
219 for (key, value) in &env {
220 command.env(key, value);
221 }
222 command
223 .stdin(Stdio::null())
224 .stdout(Stdio::from(stdout_file))
225 .stderr(Stdio::from(stderr_file));
226
227 #[cfg(unix)]
233 command.process_group(0);
234
235 let mut child = command
236 .spawn()
237 .with_context(|| format!("spawn engine binary `{}`", binary_path.display()))?;
238 let pid = child
239 .id()
240 .ok_or_else(|| EngineProcessError::MissingPid.build())?;
241
242 tracing::info!(
243 pid,
244 path = %binary_path.display(),
245 endpoint = %endpoint,
246 data_path = %data_path.display(),
247 "spawned engine process (intentionally orphaned, will outlive this process)"
248 );
249 tracing::info!(
250 stdout_log = %stdout_log_path.display(),
251 stderr_log = %stderr_log_path.display(),
252 "engine stdout/stderr piped to log files"
253 );
254
255 let health_url = engine_health_url(endpoint);
256 let health = match wait_for_engine_health(&health_url).await {
257 Ok(health) => health,
258 Err(error) => {
259 let error = match child.try_wait() {
260 Ok(Some(status)) => error.context(format!(
261 "engine process exited before becoming healthy with status {status}"
262 )),
263 Ok(None) => error,
264 Err(wait_error) => error.context(format!(
265 "failed to inspect engine process status: {wait_error:#}"
266 )),
267 };
268 if let Err(cleanup_error) = terminate_failed_spawn(&mut child).await {
269 tracing::warn!(
270 ?cleanup_error,
271 "failed to terminate engine process that never became healthy"
272 );
273 }
274 return Err(error);
275 }
276 };
277
278 tracing::info!(
279 pid,
280 status = ?health.status,
281 runtime = ?health.runtime,
282 version = ?health.version,
283 "engine process is healthy"
284 );
285
286 let ready = match ensure_compatible_engine(endpoint, &health) {
288 Ok(()) => wait_for_engine_ready(endpoint).await,
289 Err(error) => Err(error),
290 };
291 if let Err(error) = ready {
292 if let Err(cleanup_error) = terminate_failed_spawn(&mut child).await {
293 tracing::warn!(
294 ?cleanup_error,
295 "failed to terminate engine process that never became ready"
296 );
297 }
298 return Err(error);
299 }
300 tracing::info!(pid, "engine process is ready");
301
302 let stamp = EngineRuntimeStamp {
306 pid,
307 endpoint: endpoint.clone(),
308 bind_host: resolve_bind_host(config)?,
309 public_url: config.public_url.clone(),
310 };
311 if let Err(error) = write_engine_stamp(&stamp) {
312 tracing::warn!(?error, "failed to write engine runtime stamp");
313 }
314
315 Ok(Self {
316 watcher: Some(spawn_engine_watcher(child, pid)),
317 startup: EngineStartup::Spawned,
318 })
319 }
320}
321
322const RUNTIME_INFO_TIMEOUT: Duration = Duration::from_secs(5);
324const RUNTIME_INFO_MAX_BYTES: usize = 8192;
326
327async fn ensure_compatible_binary(path: &Path) -> Result<()> {
330 let incompatible = |reason: &str| {
331 EngineProcessError::IncompatibleBinary {
332 path: path.display().to_string(),
333 reason: reason.to_owned(),
334 required: ENGINE_ARCHITECTURE.to_owned(),
335 }
336 .build()
337 };
338 let mut child = Command::new(path)
339 .arg("--runtime-info")
340 .stdin(Stdio::null())
341 .stdout(Stdio::piped())
342 .stderr(Stdio::null())
343 .kill_on_drop(true)
344 .spawn()
345 .with_context(|| format!("probe engine binary `{}`", path.display()))?;
346 let stdout = child.stdout.take().context("probe stdout was not piped")?;
347 let probe = async {
348 let read = async {
349 let mut bytes = Vec::new();
350 let limit = RUNTIME_INFO_MAX_BYTES as u64 + 1;
351 stdout.take(limit).read_to_end(&mut bytes).await?;
352 if bytes.len() > RUNTIME_INFO_MAX_BYTES {
353 return Err(std::io::Error::other(
354 "runtime identity response exceeds its size limit",
355 ));
356 }
357 Ok(bytes)
358 };
359 tokio::try_join!(child.wait(), read)
360 };
361 let (status, bytes) = match tokio::time::timeout(RUNTIME_INFO_TIMEOUT, probe).await {
362 Ok(Ok(output)) => output,
363 failure => {
364 child
366 .kill()
367 .await
368 .context("terminate failed runtime identity probe")?;
369 return Err(incompatible(if failure.is_err() {
370 "runtime identity probe timed out"
371 } else {
372 "runtime identity probe output could not be read"
373 }));
374 }
375 };
376 if !status.success() {
377 return Err(incompatible(
378 "runtime identity probe is unsupported or failed",
379 ));
380 }
381 let info: EngineHealthResponse = serde_json::from_slice(&bytes)
382 .map_err(|_| incompatible("runtime identity response is invalid"))?;
383 if info.runtime.as_deref() != Some(ENGINE_RUNTIME)
384 || info.architecture.as_deref() != Some(ENGINE_ARCHITECTURE)
385 {
386 return Err(incompatible(
387 "runtime identity does not advertise the required architecture",
388 ));
389 }
390 Ok(())
391}
392
393pub fn engine_data_path() -> Result<PathBuf> {
396 Ok(storage_root()?.join("var").join("engine").join("depot"))
397}
398
399pub fn engine_log_directory() -> Result<PathBuf> {
402 Ok(engine_data_path()?.join("log"))
403}
404
405fn admin_token() -> String {
406 env::var("RIVET__AUTH__ADMIN_TOKEN").unwrap_or_else(|_| "default".to_owned())
407}
408
409fn engine_stamp_path() -> Result<PathBuf> {
410 Ok(storage_root()?
411 .join("var")
412 .join("engine")
413 .join("runtime.json"))
414}
415
416fn resolve_bind_host(config: &EngineResolverConfig) -> Result<String> {
419 if let Some(bind_host) = &config.bind_host {
420 return Ok(bind_host.clone());
421 }
422 let endpoint = &config.endpoint;
423 let endpoint_url =
424 Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?;
425 Ok(endpoint_url
426 .host_str()
427 .ok_or_else(|| invalid_endpoint(endpoint, "missing host"))?
428 .to_owned())
429}
430
431fn write_engine_stamp(stamp: &EngineRuntimeStamp) -> Result<()> {
432 let path = engine_stamp_path()?;
433 if let Some(dir) = path.parent() {
434 ensure_dir(dir)?;
435 }
436 let contents = serde_json::to_vec_pretty(stamp).context("serialize engine stamp")?;
437 std::fs::write(&path, contents)
438 .with_context(|| format!("write engine stamp `{}`", path.display()))?;
439 Ok(())
440}
441
442fn read_engine_stamp() -> Option<EngineRuntimeStamp> {
448 let path = engine_stamp_path().ok()?;
449 let contents = std::fs::read(&path).ok()?;
450 let stamp: EngineRuntimeStamp = serde_json::from_slice(&contents).ok()?;
451 pid_is_alive(stamp.pid).then_some(stamp)
452}
453
454#[cfg(unix)]
455fn pid_is_alive(pid: u32) -> bool {
456 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
459 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
460}
461
462#[cfg(not(unix))]
463fn pid_is_alive(_pid: u32) -> bool {
464 false
468}
469
470pub fn engine_env(config: &EngineResolverConfig) -> Result<Vec<(String, String)>> {
481 let ports = EnginePorts::resolve(config)?;
482 let log_directory = engine_log_directory()?;
483 let object_store = engine_data_path()?.join("objects");
484
485 let env = [
486 ("RIVET__AUTH__ADMIN_TOKEN", admin_token()),
487 ("RIVET__GUARD__HOST", ports.guard_host.clone()),
488 ("RIVET__GUARD__PORT", ports.guard.to_string()),
489 ("RIVET__API_PEER__HOST", LOOPBACK.to_owned()),
491 ("RIVET__API_PEER__PORT", ports.api_peer.to_string()),
492 ("RIVET__METRICS__HOST", ports.guard_host),
493 ("RIVET__METRICS__PORT", ports.metrics.to_string()),
494 (
495 "RIVET__STORAGE__LOG_DIRECTORY",
496 log_directory.to_string_lossy().into_owned(),
497 ),
498 (
499 "RIVET__OBJECT_STORAGE__PRIMARY__FILE_SYSTEM__PATH",
500 object_store.to_string_lossy().into_owned(),
501 ),
502 ("RIVET__STORAGE__BALANCING__ENABLED", "false".to_owned()),
504 ("RIVET__STORAGE__AUTO_PROVISION_PARTITIONS", "1".to_owned()),
505 (
508 "RIVET__STORAGE__LOG_SHARD__MAX_DISK_BYTES",
509 LOG_MAX_DISK_BYTES.to_string(),
510 ),
511 (
512 "RIVET__STORAGE__COLD_STORE__DISK_CACHE_BYTES",
513 "0".to_owned(),
514 ),
515 ("RIVET__STORAGE__WRITE_TO_COLD_AFTER_MS", "5000".to_owned()),
518 ("RIVET__STORAGE__STOP_TIMEOUT_MS", "6000".to_owned()),
519 ("RIVET__STORAGE__STOP_TEARDOWN_MS", "2000".to_owned()),
520 ("RIVET__PEGBOARD__NODE_LOST_THRESHOLD", "7000".to_owned()),
521 ("RIVET__RUNTIME__WORKER_SHUTDOWN_DURATION", "1".to_owned()),
522 ("RIVET__RUNTIME__GUARD_SHUTDOWN_DURATION", "1".to_owned()),
523 ("RIVET__RUNTIME__FORCE_SHUTDOWN_DURATION", "10".to_owned()),
524 ];
525 Ok(env
526 .into_iter()
527 .map(|(key, value)| (key.to_owned(), value))
528 .collect())
529}
530
531const LOOPBACK: &str = "127.0.0.1";
532
533const LOG_MAX_DISK_BYTES: u64 = 4 * 1024 * 1024 * 1024;
535
536struct EnginePorts {
539 guard_host: String,
540 guard: u16,
541 api_peer: u16,
542 metrics: u16,
543}
544
545impl EnginePorts {
546 fn resolve(config: &EngineResolverConfig) -> Result<Self> {
547 let endpoint = &config.endpoint;
548 let endpoint_url =
549 Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?;
550 let guard_port = endpoint_url
551 .port_or_known_default()
552 .ok_or_else(|| invalid_endpoint(endpoint, "missing port"))?;
553 let guard = config.bind_port.unwrap_or(guard_port);
554 Ok(Self {
555 guard_host: resolve_bind_host(config)?,
556 guard,
557 api_peer: guard
558 .checked_add(1)
559 .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?,
560 metrics: guard
561 .checked_add(10)
562 .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?,
563 })
564 }
565}
566
567fn write_engine_config(config: &EngineResolverConfig) -> Result<PathBuf> {
570 let public_url = config.public_url.as_deref().unwrap_or(&config.endpoint);
571 let public_url = Url::parse(public_url)
572 .with_context(|| format!("parse engine public URL `{public_url}`"))?;
573 let ports = EnginePorts::resolve(config)?;
574 let peer_url = format!("http://{LOOPBACK}:{}", ports.api_peer);
575 let dir = storage_root()?.join("var").join("engine");
576 ensure_dir(&dir)?;
577 let path = dir.join("config.json");
578 let config = serde_json::json!({
579 "topology": {
580 "datacenter_label": 1,
581 "datacenters": {
582 "default": {
583 "datacenter_label": 1,
584 "is_leader": true,
585 "public_url": public_url.as_str(),
586 "peer_url": peer_url,
587 }
588 }
589 }
590 });
591 std::fs::write(&path, serde_json::to_string_pretty(&config)?)
592 .with_context(|| format!("write engine config `{}`", path.display()))?;
593 Ok(path)
594}
595
596pub async fn resolve_engine_binary(config: &EngineResolverConfig) -> Result<ResolvedEngine> {
597 if let Some(path) = config.explicit_binary_path.as_ref() {
598 return verify_binary_path(path);
599 }
600
601 if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) {
602 return verify_binary_path(&path);
603 }
604
605 if probe_existing_engine(&config.endpoint).await?.is_some() {
606 return Ok(ResolvedEngine::Existing);
607 }
608
609 let local_roots = local_engine_search_roots();
610 let cached = cached_engine_path(&config.version)?;
611 resolve_engine_binary_after_probe(config, false, &local_roots, cached).await
612}
613
614pub async fn resolve_engine_binary_path(config: &EngineResolverConfig) -> Result<PathBuf> {
621 if let Some(path) = config.explicit_binary_path.as_ref() {
622 verify_binary_path(path)?;
623 return Ok(path.clone());
624 }
625
626 if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) {
627 verify_binary_path(&path)?;
628 return Ok(path);
629 }
630
631 let local_roots = local_engine_search_roots();
632 let cached = cached_engine_path(&config.version)?;
633 match resolve_engine_binary_after_probe(config, false, &local_roots, cached).await? {
634 ResolvedEngine::Binary(path) => Ok(path),
635 ResolvedEngine::Existing => {
636 unreachable!("no-probe resolution never returns Existing")
637 }
638 }
639}
640
641async fn resolve_engine_binary_after_probe(
642 config: &EngineResolverConfig,
643 existing_engine: bool,
644 local_roots: &[PathBuf],
645 cached: PathBuf,
646) -> Result<ResolvedEngine> {
647 if existing_engine {
648 return Ok(ResolvedEngine::Existing);
649 }
650
651 if let Some(path) = find_local_engine_binary_in_roots(local_roots) {
652 return Ok(ResolvedEngine::Binary(path));
653 }
654
655 if cached.exists() {
656 return Ok(ResolvedEngine::Binary(cached));
657 }
658
659 if !config.auto_download {
660 return Err(EngineProcessError::BinaryUnavailable {
661 version: config.version.clone(),
662 }
663 .build());
664 }
665
666 download_engine_binary(config, &cached).await?;
667 Ok(ResolvedEngine::Binary(cached))
668}
669
670fn verify_binary_path(path: &Path) -> Result<ResolvedEngine> {
671 if !path.exists() {
672 return Err(EngineProcessError::BinaryNotFound {
673 path: path.display().to_string(),
674 }
675 .build());
676 }
677 Ok(ResolvedEngine::Binary(path.to_path_buf()))
678}
679
680fn local_engine_search_roots() -> Vec<PathBuf> {
681 Path::new(env!("CARGO_MANIFEST_DIR"))
682 .ancestors()
683 .map(Path::to_path_buf)
684 .collect()
685}
686
687fn find_local_engine_binary_in_roots(roots: &[PathBuf]) -> Option<PathBuf> {
688 for root in roots {
689 for profile in ["debug", "release"] {
690 let candidate = root
691 .join("target")
692 .join(profile)
693 .join(exe_name("rivet-engine"));
694 if candidate.exists() {
695 return Some(candidate);
696 }
697 }
698 }
699 None
700}
701
702fn cached_engine_path(version: &str) -> Result<PathBuf> {
705 Ok(storage_root()?
706 .join("engine")
707 .join(ENGINE_ARCHITECTURE)
708 .join(version)
709 .join(engine_artifact_name()))
710}
711
712async fn download_engine_binary(config: &EngineResolverConfig, destination: &Path) -> Result<()> {
713 let artifact = engine_artifact_name();
714 let base = config.releases_endpoint.trim_end_matches('/');
715 let artifact_url = format!("{base}/rivet/{}/engine/{artifact}", config.version);
716 let manifest_url = format!("{base}/rivet/{}/engine/SHA256SUMS", config.version);
717 let client = Client::builder()
718 .timeout(DOWNLOAD_TIMEOUT)
719 .build()
720 .context("build reqwest client for engine download")?;
721
722 let manifest = fetch_text(&client, &manifest_url).await?;
723 let expected = checksum_for_artifact(&manifest, &artifact).ok_or_else(|| {
724 EngineProcessError::DownloadFailed {
725 url: manifest_url.clone(),
726 reason: format!("manifest does not contain `{artifact}`"),
727 }
728 .build()
729 })?;
730
731 let bytes = fetch_bytes(&client, &artifact_url).await?;
732 let received = sha256_hex(&bytes);
733 if !received.eq_ignore_ascii_case(&expected) {
734 return Err(EngineProcessError::ChecksumMismatch {
735 artifact,
736 expected,
737 received,
738 }
739 .build());
740 }
741
742 let parent = destination
743 .parent()
744 .context("engine cache destination has no parent")?;
745 ensure_dir(parent)?;
746 std::fs::write(destination, bytes)
747 .with_context(|| format!("write engine binary `{}`", destination.display()))?;
748 make_executable(destination)?;
749 Ok(())
750}
751
752async fn fetch_text(client: &Client, url: &str) -> Result<String> {
753 let response = client.get(url).send().await.map_err(|error| {
754 EngineProcessError::DownloadFailed {
755 url: url.to_owned(),
756 reason: error.to_string(),
757 }
758 .build()
759 })?;
760 if !response.status().is_success() {
761 let status = response.status();
762 return Err(EngineProcessError::DownloadFailed {
763 url: url.to_owned(),
764 reason: format!("unexpected status {status}"),
765 }
766 .build());
767 }
768 response.text().await.map_err(|error| {
769 EngineProcessError::DownloadFailed {
770 url: url.to_owned(),
771 reason: error.to_string(),
772 }
773 .build()
774 })
775}
776
777async fn fetch_bytes(client: &Client, url: &str) -> Result<Vec<u8>> {
778 let response = client.get(url).send().await.map_err(|error| {
779 EngineProcessError::DownloadFailed {
780 url: url.to_owned(),
781 reason: error.to_string(),
782 }
783 .build()
784 })?;
785 if !response.status().is_success() {
786 let status = response.status();
787 return Err(EngineProcessError::DownloadFailed {
788 url: url.to_owned(),
789 reason: format!("unexpected status {status}"),
790 }
791 .build());
792 }
793 response
794 .bytes()
795 .await
796 .map(|bytes| bytes.to_vec())
797 .map_err(|error| {
798 EngineProcessError::DownloadFailed {
799 url: url.to_owned(),
800 reason: error.to_string(),
801 }
802 .build()
803 })
804}
805
806fn checksum_for_artifact(manifest: &str, artifact: &str) -> Option<String> {
807 manifest.lines().find_map(|line| {
808 let mut parts = line.split_whitespace();
809 let checksum = parts.next()?;
810 let name = parts.next()?.trim_start_matches('*');
811 (checksum.len() == 64 && name == artifact).then(|| checksum.to_owned())
812 })
813}
814
815fn sha256_hex(bytes: &[u8]) -> String {
816 let digest = Sha256::digest(bytes);
817 let mut out = String::with_capacity(digest.len() * 2);
818 for byte in digest {
819 use std::fmt::Write;
820 let _ = write!(&mut out, "{byte:02x}");
821 }
822 out
823}
824
825fn engine_artifact_name() -> String {
826 let arch = match std::env::consts::ARCH {
827 "x86_64" => "x86_64",
828 "aarch64" => "aarch64",
829 other => other,
830 };
831 let target = match std::env::consts::OS {
832 "linux" => format!("{arch}-unknown-linux-musl"),
833 "macos" => format!("{arch}-apple-darwin"),
834 "windows" => format!("{arch}-pc-windows-gnu.exe"),
835 other => format!("{arch}-{other}"),
836 };
837 format!("rivet-engine-{target}")
838}
839
840fn exe_name(base: &str) -> String {
841 if cfg!(windows) {
842 format!("{base}.exe")
843 } else {
844 base.to_owned()
845 }
846}
847
848fn make_executable(path: &Path) -> Result<()> {
849 #[cfg(unix)]
850 {
851 use std::os::unix::fs::PermissionsExt;
852 let mut permissions = std::fs::metadata(path)
853 .with_context(|| format!("read metadata for `{}`", path.display()))?
854 .permissions();
855 permissions.set_mode(0o755);
856 std::fs::set_permissions(path, permissions)
857 .with_context(|| format!("mark `{}` executable", path.display()))?;
858 }
859 #[cfg(not(unix))]
860 {
861 let _ = path;
862 }
863 Ok(())
864}
865
866impl Drop for EngineProcessManager {
867 fn drop(&mut self) {
868 if let Some(handle) = self.watcher.take() {
869 handle.abort();
875 tracing::debug!(
876 "aborted engine watcher; engine continues running (intentional orphan)"
877 );
878 }
879 }
880}
881
882fn spawn_engine_watcher(mut child: Child, pid: u32) -> JoinHandle<()> {
887 tokio::spawn(async move {
888 match child.wait().await {
889 Ok(status) if status.success() => {
890 tracing::warn!(
891 pid,
892 ?status,
893 "engine process exited cleanly while rivetkit was still running; \
894 rivetkit expected the engine to outlive it"
895 );
896 }
897 Ok(status) => {
898 tracing::error!(
899 pid,
900 ?status,
901 "engine process crashed while rivetkit was still running"
902 );
903 }
904 Err(error) => {
905 tracing::error!(
906 pid,
907 ?error,
908 "failed to wait on engine process; cannot detect crashes"
909 );
910 }
911 }
912 })
913}
914
915async fn probe_existing_engine(endpoint: &str) -> Result<Option<EngineHealthResponse>> {
922 let health_url = engine_health_url(endpoint);
923 let client = Client::builder()
924 .build()
925 .context("build reqwest client for engine probe")?;
926
927 let response = match client
928 .get(&health_url)
929 .timeout(Duration::from_secs(1))
930 .send()
931 .await
932 {
933 Ok(response) => response,
934 Err(_) => return Ok(None),
935 };
936
937 if !response.status().is_success() {
938 return Ok(None);
939 }
940
941 let health = response
942 .json::<EngineHealthResponse>()
943 .await
944 .context("decode existing engine health response")?;
945
946 match health.runtime.as_deref() {
947 Some(ENGINE_RUNTIME) => {
948 ensure_compatible_engine(endpoint, &health)?;
949 Ok(Some(health))
950 }
951 Some(RIVETKIT_RUNTIME) => Err(EngineProcessError::PortOccupied {
952 endpoint: endpoint.to_owned(),
953 runtime: RIVETKIT_RUNTIME.to_owned(),
954 }
955 .build()),
956 Some(other) => Err(EngineProcessError::PortOccupied {
957 endpoint: endpoint.to_owned(),
958 runtime: other.to_owned(),
959 }
960 .build()),
961 None => Err(EngineProcessError::PortOccupied {
962 endpoint: endpoint.to_owned(),
963 runtime: "unknown".to_owned(),
964 }
965 .build()),
966 }
967}
968
969fn engine_health_url(endpoint: &str) -> String {
970 format!("{}/health", endpoint.trim_end_matches('/'))
971}
972
973fn ensure_compatible_engine(endpoint: &str, health: &EngineHealthResponse) -> Result<()> {
976 if health.architecture.as_deref() == Some(ENGINE_ARCHITECTURE) {
977 return Ok(());
978 }
979 Err(EngineProcessError::IncompatibleEngine {
980 endpoint: endpoint.to_owned(),
981 version: health
982 .version
983 .clone()
984 .unwrap_or_else(|| "unknown".to_owned()),
985 architecture: health
986 .architecture
987 .clone()
988 .unwrap_or_else(|| "v2".to_owned()),
989 required: ENGINE_ARCHITECTURE.to_owned(),
990 }
991 .build())
992}
993
994fn storage_root() -> Result<PathBuf> {
995 if let Ok(path) = std::env::var("RIVETKIT_STORAGE_PATH") {
996 return Ok(PathBuf::from(path).join(".rivetkit"));
997 }
998 let home = std::env::var("HOME")
999 .map(PathBuf::from)
1000 .or_else(|_| std::env::current_dir())
1001 .context("locate home directory for engine storage path")?;
1002 Ok(home.join(".rivetkit"))
1003}
1004
1005fn ensure_dir(path: &Path) -> Result<()> {
1006 std::fs::create_dir_all(path).with_context(|| format!("create directory `{}`", path.display()))
1007}
1008
1009fn open_log_file(path: &Path) -> Result<std::fs::File> {
1010 std::fs::OpenOptions::new()
1011 .create(true)
1012 .append(true)
1013 .open(path)
1014 .with_context(|| format!("open log file `{}`", path.display()))
1015}
1016
1017fn log_timestamp() -> String {
1018 let now = std::time::SystemTime::now()
1019 .duration_since(std::time::UNIX_EPOCH)
1020 .unwrap_or_default();
1021 format!("{}", now.as_secs())
1022}
1023
1024async fn wait_for_engine_health(health_url: &str) -> Result<EngineHealthResponse> {
1025 const HEALTH_MAX_WAIT: Duration = Duration::from_secs(60);
1027 const HEALTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
1028 const HEALTH_INITIAL_BACKOFF: Duration = Duration::from_millis(100);
1029 const HEALTH_MAX_BACKOFF: Duration = Duration::from_secs(1);
1030
1031 let client = Client::builder()
1032 .build()
1033 .context("build reqwest client for engine health check")?;
1034 let deadline = Instant::now() + HEALTH_MAX_WAIT;
1035 let mut attempt = 0u32;
1036 let mut backoff = HEALTH_INITIAL_BACKOFF;
1037
1038 loop {
1039 attempt += 1;
1040
1041 let last_error = match client
1042 .get(health_url)
1043 .timeout(HEALTH_REQUEST_TIMEOUT)
1044 .send()
1045 .await
1046 {
1047 Ok(response) if response.status().is_success() => {
1048 let health = response
1049 .json::<EngineHealthResponse>()
1050 .await
1051 .context("decode engine health response")?;
1052 return Ok(health);
1053 }
1054 Ok(response) => format!("unexpected status {}", response.status()),
1055 Err(error) => error.to_string(),
1056 };
1057
1058 if Instant::now() >= deadline {
1059 return Err(EngineProcessError::HealthCheckFailed {
1060 attempts: attempt,
1061 reason: last_error,
1062 }
1063 .build());
1064 }
1065
1066 tokio::time::sleep(backoff).await;
1067 backoff = std::cmp::min(backoff * 2, HEALTH_MAX_BACKOFF);
1068 }
1069}
1070
1071async fn wait_for_engine_ready(endpoint: &str) -> Result<()> {
1075 const READY_MAX_WAIT: Duration = Duration::from_secs(60);
1076 const READY_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
1077 const READY_BACKOFF: Duration = Duration::from_millis(250);
1078
1079 let url = format!(
1080 "{}/actors?namespace=default&name=rivetkit-readiness-probe&key=probe",
1081 endpoint.trim_end_matches('/')
1082 );
1083 let token = admin_token();
1084 let client = Client::builder()
1085 .build()
1086 .context("build reqwest client for engine readiness check")?;
1087 let deadline = Instant::now() + READY_MAX_WAIT;
1088 let mut attempt = 0u32;
1089
1090 loop {
1091 attempt += 1;
1092
1093 let last_error = match client
1094 .get(&url)
1095 .bearer_auth(&token)
1096 .timeout(READY_REQUEST_TIMEOUT)
1097 .send()
1098 .await
1099 {
1100 Ok(response) if response.status().is_success() => return Ok(()),
1101 Ok(response) => {
1102 let status = response.status();
1103 let body = response.text().await.unwrap_or_default();
1104 format!("unexpected status {status}: {body}")
1105 }
1106 Err(error) => error.to_string(),
1107 };
1108 tracing::debug!(attempt, reason = %last_error, "waiting for engine readiness");
1109
1110 if Instant::now() >= deadline {
1111 return Err(EngineProcessError::NotReady {
1112 attempts: attempt,
1113 reason: last_error,
1114 }
1115 .build());
1116 }
1117
1118 tokio::time::sleep(READY_BACKOFF).await;
1119 }
1120}
1121
1122async fn terminate_failed_spawn(child: &mut Child) -> Result<()> {
1127 const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
1128
1129 if child
1130 .try_wait()
1131 .context("check engine process status")?
1132 .is_some()
1133 {
1134 return Ok(());
1135 }
1136
1137 child
1138 .start_kill()
1139 .context("kill half-started engine process")?;
1140 match tokio::time::timeout(SHUTDOWN_TIMEOUT, child.wait()).await {
1141 Ok(result) => {
1142 let status = result.context("wait for half-started engine to exit")?;
1143 tracing::info!(?status, "half-started engine process exited");
1144 Ok(())
1145 }
1146 Err(_) => {
1147 tracing::warn!("half-started engine process did not exit within timeout");
1148 Ok(())
1149 }
1150 }
1151}
1152
1153fn invalid_endpoint(endpoint: &str, reason: &str) -> anyhow::Error {
1154 EngineProcessError::InvalidEndpoint {
1155 endpoint: endpoint.to_owned(),
1156 reason: reason.to_owned(),
1157 }
1158 .build()
1159}
1160
1161