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