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)>> {
374 let endpoint = &config.endpoint;
375 let endpoint_url =
376 Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?;
377 let guard_host = resolve_bind_host(config)?;
378 let guard_port = endpoint_url
379 .port_or_known_default()
380 .ok_or_else(|| invalid_endpoint(endpoint, "missing port"))?;
381 let guard_port = config.bind_port.unwrap_or(guard_port);
382 let api_peer_port = guard_port
383 .checked_add(1)
384 .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?;
385 let metrics_port = guard_port
386 .checked_add(10)
387 .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?;
388
389 let db_path = engine_db_path()?;
390
391 Ok(vec![
392 ("RIVET__GUARD__HOST".to_owned(), guard_host.clone()),
393 ("RIVET__GUARD__PORT".to_owned(), guard_port.to_string()),
394 ("RIVET__API_PEER__HOST".to_owned(), guard_host.clone()),
395 (
396 "RIVET__API_PEER__PORT".to_owned(),
397 api_peer_port.to_string(),
398 ),
399 ("RIVET__METRICS__HOST".to_owned(), guard_host),
400 ("RIVET__METRICS__PORT".to_owned(), metrics_port.to_string()),
401 (
402 "RIVET__FILE_SYSTEM__PATH".to_owned(),
403 db_path.to_string_lossy().into_owned(),
404 ),
405 (
406 "RIVET__PEGBOARD__RETRY_RESET_DURATION".to_owned(),
407 "100".to_owned(),
408 ),
409 (
410 "RIVET__PEGBOARD__BASE_RETRY_TIMEOUT".to_owned(),
411 "100".to_owned(),
412 ),
413 (
414 "RIVET__PEGBOARD__RESCHEDULE_BACKOFF_MAX_EXPONENT".to_owned(),
415 "1".to_owned(),
416 ),
417 (
418 "RIVET__PEGBOARD__RUNNER_ELIGIBLE_THRESHOLD".to_owned(),
419 "5000".to_owned(),
420 ),
421 (
422 "RIVET__PEGBOARD__RUNNER_LOST_THRESHOLD".to_owned(),
423 "7000".to_owned(),
424 ),
425 (
426 "RIVET__PEGBOARD__ENVOY_ELIGIBLE_THRESHOLD".to_owned(),
427 "5000".to_owned(),
428 ),
429 (
430 "RIVET__PEGBOARD__ENVOY_LOST_THRESHOLD".to_owned(),
431 "7000".to_owned(),
432 ),
433 (
434 "RIVET__PEGBOARD__MIN_METADATA_POLL_INTERVAL".to_owned(),
435 "1000".to_owned(),
436 ),
437 (
438 "RIVET__RUNTIME__WORKER_SHUTDOWN_DURATION".to_owned(),
439 "1".to_owned(),
440 ),
441 (
442 "RIVET__RUNTIME__GUARD_SHUTDOWN_DURATION".to_owned(),
443 "1".to_owned(),
444 ),
445 (
446 "RIVET__RUNTIME__FORCE_SHUTDOWN_DURATION".to_owned(),
447 "2".to_owned(),
448 ),
449 ])
450}
451
452fn write_engine_config(config: &EngineResolverConfig) -> Result<Option<PathBuf>> {
453 let Some(public_url) = &config.public_url else {
454 return Ok(None);
455 };
456
457 let public_url = Url::parse(public_url)
458 .with_context(|| format!("parse engine public URL `{public_url}`"))?;
459 let peer_url = peer_url_for_public_url(&public_url)?;
460 let dir = storage_root()?.join("var").join("engine");
461 ensure_dir(&dir)?;
462 let path = dir.join("config.json");
463 let config = serde_json::json!({
464 "topology": {
465 "datacenter_label": 1,
466 "datacenters": {
467 "default": {
468 "datacenter_label": 1,
469 "is_leader": true,
470 "public_url": public_url.as_str(),
471 "peer_url": peer_url,
472 }
473 }
474 }
475 });
476 std::fs::write(&path, serde_json::to_string_pretty(&config)?)
477 .with_context(|| format!("write engine config `{}`", path.display()))?;
478 Ok(Some(path))
479}
480
481fn peer_url_for_public_url(public_url: &Url) -> Result<String> {
482 let mut peer_url = public_url.clone();
483 let port = public_url
484 .port_or_known_default()
485 .ok_or_else(|| invalid_endpoint(public_url.as_str(), "missing port"))?
486 .checked_add(1)
487 .ok_or_else(|| invalid_endpoint(public_url.as_str(), "port is too large"))?;
488 if peer_url.set_port(Some(port)).is_err() {
489 return Err(invalid_endpoint(
490 public_url.as_str(),
491 "could not derive peer URL port",
492 ));
493 }
494 peer_url.set_path("");
495 peer_url.set_query(None);
496 peer_url.set_fragment(None);
497 Ok(peer_url.to_string().trim_end_matches('/').to_string())
498}
499
500pub async fn resolve_engine_binary(config: &EngineResolverConfig) -> Result<ResolvedEngine> {
501 if let Some(path) = config.explicit_binary_path.as_ref() {
502 return verify_binary_path(path);
503 }
504
505 if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) {
506 return verify_binary_path(&path);
507 }
508
509 if probe_existing_engine(&config.endpoint).await?.is_some() {
510 return Ok(ResolvedEngine::Existing);
511 }
512
513 let local_roots = local_engine_search_roots();
514 let cached = cached_engine_path(&config.version)?;
515 resolve_engine_binary_after_probe(config, false, &local_roots, cached).await
516}
517
518pub async fn resolve_engine_binary_path(config: &EngineResolverConfig) -> Result<PathBuf> {
525 if let Some(path) = config.explicit_binary_path.as_ref() {
526 verify_binary_path(path)?;
527 return Ok(path.clone());
528 }
529
530 if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) {
531 verify_binary_path(&path)?;
532 return Ok(path);
533 }
534
535 let local_roots = local_engine_search_roots();
536 let cached = cached_engine_path(&config.version)?;
537 match resolve_engine_binary_after_probe(config, false, &local_roots, cached).await? {
538 ResolvedEngine::Binary(path) => Ok(path),
539 ResolvedEngine::Existing => {
540 unreachable!("no-probe resolution never returns Existing")
541 }
542 }
543}
544
545async fn resolve_engine_binary_after_probe(
546 config: &EngineResolverConfig,
547 existing_engine: bool,
548 local_roots: &[PathBuf],
549 cached: PathBuf,
550) -> Result<ResolvedEngine> {
551 if existing_engine {
552 return Ok(ResolvedEngine::Existing);
553 }
554
555 if let Some(path) = find_local_engine_binary_in_roots(local_roots) {
556 return Ok(ResolvedEngine::Binary(path));
557 }
558
559 if cached.exists() {
560 return Ok(ResolvedEngine::Binary(cached));
561 }
562
563 if !config.auto_download {
564 return Err(EngineProcessError::BinaryUnavailable {
565 version: config.version.clone(),
566 }
567 .build());
568 }
569
570 download_engine_binary(config, &cached).await?;
571 Ok(ResolvedEngine::Binary(cached))
572}
573
574fn verify_binary_path(path: &Path) -> Result<ResolvedEngine> {
575 if !path.exists() {
576 return Err(EngineProcessError::BinaryNotFound {
577 path: path.display().to_string(),
578 }
579 .build());
580 }
581 Ok(ResolvedEngine::Binary(path.to_path_buf()))
582}
583
584fn local_engine_search_roots() -> Vec<PathBuf> {
585 Path::new(env!("CARGO_MANIFEST_DIR"))
586 .ancestors()
587 .map(Path::to_path_buf)
588 .collect()
589}
590
591fn find_local_engine_binary_in_roots(roots: &[PathBuf]) -> Option<PathBuf> {
592 for root in roots {
593 for profile in ["debug", "release"] {
594 let candidate = root
595 .join("target")
596 .join(profile)
597 .join(exe_name("rivet-engine"));
598 if candidate.exists() {
599 return Some(candidate);
600 }
601 }
602 }
603 None
604}
605
606fn cached_engine_path(version: &str) -> Result<PathBuf> {
607 Ok(storage_root()?
608 .join("engine")
609 .join(version)
610 .join(engine_artifact_name()))
611}
612
613async fn download_engine_binary(config: &EngineResolverConfig, destination: &Path) -> Result<()> {
614 let artifact = engine_artifact_name();
615 let base = config.releases_endpoint.trim_end_matches('/');
616 let artifact_url = format!("{base}/rivet/{}/engine/{artifact}", config.version);
617 let manifest_url = format!("{base}/rivet/{}/engine/SHA256SUMS", config.version);
618 let client = Client::builder()
619 .timeout(DOWNLOAD_TIMEOUT)
620 .build()
621 .context("build reqwest client for engine download")?;
622
623 let manifest = fetch_text(&client, &manifest_url).await?;
624 let expected = checksum_for_artifact(&manifest, &artifact).ok_or_else(|| {
625 EngineProcessError::DownloadFailed {
626 url: manifest_url.clone(),
627 reason: format!("manifest does not contain `{artifact}`"),
628 }
629 .build()
630 })?;
631
632 let bytes = fetch_bytes(&client, &artifact_url).await?;
633 let received = sha256_hex(&bytes);
634 if !received.eq_ignore_ascii_case(&expected) {
635 return Err(EngineProcessError::ChecksumMismatch {
636 artifact,
637 expected,
638 received,
639 }
640 .build());
641 }
642
643 let parent = destination
644 .parent()
645 .context("engine cache destination has no parent")?;
646 ensure_dir(parent)?;
647 std::fs::write(destination, bytes)
648 .with_context(|| format!("write engine binary `{}`", destination.display()))?;
649 make_executable(destination)?;
650 Ok(())
651}
652
653async fn fetch_text(client: &Client, url: &str) -> Result<String> {
654 let response = client.get(url).send().await.map_err(|error| {
655 EngineProcessError::DownloadFailed {
656 url: url.to_owned(),
657 reason: error.to_string(),
658 }
659 .build()
660 })?;
661 if !response.status().is_success() {
662 let status = response.status();
663 return Err(EngineProcessError::DownloadFailed {
664 url: url.to_owned(),
665 reason: format!("unexpected status {status}"),
666 }
667 .build());
668 }
669 response.text().await.map_err(|error| {
670 EngineProcessError::DownloadFailed {
671 url: url.to_owned(),
672 reason: error.to_string(),
673 }
674 .build()
675 })
676}
677
678async fn fetch_bytes(client: &Client, url: &str) -> Result<Vec<u8>> {
679 let response = client.get(url).send().await.map_err(|error| {
680 EngineProcessError::DownloadFailed {
681 url: url.to_owned(),
682 reason: error.to_string(),
683 }
684 .build()
685 })?;
686 if !response.status().is_success() {
687 let status = response.status();
688 return Err(EngineProcessError::DownloadFailed {
689 url: url.to_owned(),
690 reason: format!("unexpected status {status}"),
691 }
692 .build());
693 }
694 response
695 .bytes()
696 .await
697 .map(|bytes| bytes.to_vec())
698 .map_err(|error| {
699 EngineProcessError::DownloadFailed {
700 url: url.to_owned(),
701 reason: error.to_string(),
702 }
703 .build()
704 })
705}
706
707fn checksum_for_artifact(manifest: &str, artifact: &str) -> Option<String> {
708 manifest.lines().find_map(|line| {
709 let mut parts = line.split_whitespace();
710 let checksum = parts.next()?;
711 let name = parts.next()?.trim_start_matches('*');
712 (checksum.len() == 64 && name == artifact).then(|| checksum.to_owned())
713 })
714}
715
716fn sha256_hex(bytes: &[u8]) -> String {
717 let digest = Sha256::digest(bytes);
718 let mut out = String::with_capacity(digest.len() * 2);
719 for byte in digest {
720 use std::fmt::Write;
721 let _ = write!(&mut out, "{byte:02x}");
722 }
723 out
724}
725
726fn engine_artifact_name() -> String {
727 let arch = match std::env::consts::ARCH {
728 "x86_64" => "x86_64",
729 "aarch64" => "aarch64",
730 other => other,
731 };
732 let target = match std::env::consts::OS {
733 "linux" => format!("{arch}-unknown-linux-musl"),
734 "macos" => format!("{arch}-apple-darwin"),
735 "windows" => format!("{arch}-pc-windows-gnu.exe"),
736 other => format!("{arch}-{other}"),
737 };
738 format!("rivet-engine-{target}")
739}
740
741fn exe_name(base: &str) -> String {
742 if cfg!(windows) {
743 format!("{base}.exe")
744 } else {
745 base.to_owned()
746 }
747}
748
749fn make_executable(path: &Path) -> Result<()> {
750 #[cfg(unix)]
751 {
752 use std::os::unix::fs::PermissionsExt;
753 let mut permissions = std::fs::metadata(path)
754 .with_context(|| format!("read metadata for `{}`", path.display()))?
755 .permissions();
756 permissions.set_mode(0o755);
757 std::fs::set_permissions(path, permissions)
758 .with_context(|| format!("mark `{}` executable", path.display()))?;
759 }
760 #[cfg(not(unix))]
761 {
762 let _ = path;
763 }
764 Ok(())
765}
766
767impl Drop for EngineProcessManager {
768 fn drop(&mut self) {
769 if let Some(handle) = self.watcher.take() {
770 handle.abort();
776 tracing::debug!(
777 "aborted engine watcher; engine continues running (intentional orphan)"
778 );
779 }
780 }
781}
782
783fn spawn_engine_watcher(mut child: Child, pid: u32) -> JoinHandle<()> {
788 tokio::spawn(async move {
789 match child.wait().await {
790 Ok(status) if status.success() => {
791 tracing::warn!(
792 pid,
793 ?status,
794 "engine process exited cleanly while rivetkit was still running; \
795 rivetkit expected the engine to outlive it"
796 );
797 }
798 Ok(status) => {
799 tracing::error!(
800 pid,
801 ?status,
802 "engine process crashed while rivetkit was still running"
803 );
804 }
805 Err(error) => {
806 tracing::error!(
807 pid,
808 ?error,
809 "failed to wait on engine process; cannot detect crashes"
810 );
811 }
812 }
813 })
814}
815
816async fn probe_existing_engine(endpoint: &str) -> Result<Option<EngineHealthResponse>> {
823 let health_url = engine_health_url(endpoint);
824 let client = Client::builder()
825 .build()
826 .context("build reqwest client for engine probe")?;
827
828 let response = match client
829 .get(&health_url)
830 .timeout(Duration::from_secs(1))
831 .send()
832 .await
833 {
834 Ok(response) => response,
835 Err(_) => return Ok(None),
836 };
837
838 if !response.status().is_success() {
839 return Ok(None);
840 }
841
842 let health = response
843 .json::<EngineHealthResponse>()
844 .await
845 .context("decode existing engine health response")?;
846
847 match health.runtime.as_deref() {
848 Some(ENGINE_RUNTIME) => Ok(Some(health)),
849 Some(RIVETKIT_RUNTIME) => Err(EngineProcessError::PortOccupied {
850 endpoint: endpoint.to_owned(),
851 runtime: RIVETKIT_RUNTIME.to_owned(),
852 }
853 .build()),
854 Some(other) => Err(EngineProcessError::PortOccupied {
855 endpoint: endpoint.to_owned(),
856 runtime: other.to_owned(),
857 }
858 .build()),
859 None => Err(EngineProcessError::PortOccupied {
860 endpoint: endpoint.to_owned(),
861 runtime: "unknown".to_owned(),
862 }
863 .build()),
864 }
865}
866
867fn engine_health_url(endpoint: &str) -> String {
868 format!("{}/health", endpoint.trim_end_matches('/'))
869}
870
871fn storage_root() -> Result<PathBuf> {
872 if let Ok(path) = std::env::var("RIVETKIT_STORAGE_PATH") {
873 return Ok(PathBuf::from(path).join(".rivetkit"));
874 }
875 let home = std::env::var("HOME")
876 .map(PathBuf::from)
877 .or_else(|_| std::env::current_dir())
878 .context("locate home directory for engine storage path")?;
879 Ok(home.join(".rivetkit"))
880}
881
882fn ensure_dir(path: &Path) -> Result<()> {
883 std::fs::create_dir_all(path).with_context(|| format!("create directory `{}`", path.display()))
884}
885
886fn open_log_file(path: &Path) -> Result<std::fs::File> {
887 std::fs::OpenOptions::new()
888 .create(true)
889 .append(true)
890 .open(path)
891 .with_context(|| format!("open log file `{}`", path.display()))
892}
893
894fn log_timestamp() -> String {
895 let now = std::time::SystemTime::now()
896 .duration_since(std::time::UNIX_EPOCH)
897 .unwrap_or_default();
898 format!("{}", now.as_secs())
899}
900
901async fn wait_for_engine_health(health_url: &str) -> Result<EngineHealthResponse> {
902 const HEALTH_MAX_WAIT: Duration = Duration::from_secs(10);
903 const HEALTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
904 const HEALTH_INITIAL_BACKOFF: Duration = Duration::from_millis(100);
905 const HEALTH_MAX_BACKOFF: Duration = Duration::from_secs(1);
906
907 let client = Client::builder()
908 .build()
909 .context("build reqwest client for engine health check")?;
910 let deadline = Instant::now() + HEALTH_MAX_WAIT;
911 let mut attempt = 0u32;
912 let mut backoff = HEALTH_INITIAL_BACKOFF;
913
914 loop {
915 attempt += 1;
916
917 let last_error = match client
918 .get(health_url)
919 .timeout(HEALTH_REQUEST_TIMEOUT)
920 .send()
921 .await
922 {
923 Ok(response) if response.status().is_success() => {
924 let health = response
925 .json::<EngineHealthResponse>()
926 .await
927 .context("decode engine health response")?;
928 return Ok(health);
929 }
930 Ok(response) => format!("unexpected status {}", response.status()),
931 Err(error) => error.to_string(),
932 };
933
934 if Instant::now() >= deadline {
935 return Err(EngineProcessError::HealthCheckFailed {
936 attempts: attempt,
937 reason: last_error,
938 }
939 .build());
940 }
941
942 tokio::time::sleep(backoff).await;
943 backoff = std::cmp::min(backoff * 2, HEALTH_MAX_BACKOFF);
944 }
945}
946
947async fn terminate_failed_spawn(child: &mut Child) -> Result<()> {
952 const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
953
954 if child
955 .try_wait()
956 .context("check engine process status")?
957 .is_some()
958 {
959 return Ok(());
960 }
961
962 child
963 .start_kill()
964 .context("kill half-started engine process")?;
965 match tokio::time::timeout(SHUTDOWN_TIMEOUT, child.wait()).await {
966 Ok(result) => {
967 let status = result.context("wait for half-started engine to exit")?;
968 tracing::info!(?status, "half-started engine process exited");
969 Ok(())
970 }
971 Err(_) => {
972 tracing::warn!("half-started engine process did not exit within timeout");
973 Ok(())
974 }
975 }
976}
977
978fn invalid_endpoint(endpoint: &str, reason: &str) -> anyhow::Error {
979 EngineProcessError::InvalidEndpoint {
980 endpoint: endpoint.to_owned(),
981 reason: reason.to_owned(),
982 }
983 .build()
984}
985
986#[cfg(test)]
987mod tests {
988 use std::collections::HashMap;
989
990 use tokio::io::{AsyncReadExt, AsyncWriteExt};
991 use tokio::net::TcpListener;
992
993 use super::*;
994
995 fn test_config(releases_endpoint: String, auto_download: bool) -> EngineResolverConfig {
996 EngineResolverConfig {
997 endpoint: "http://127.0.0.1:1".to_owned(),
998 explicit_binary_path: None,
999 bind_host: None,
1000 bind_port: None,
1001 public_url: None,
1002 auto_download,
1003 version: "test-version".to_owned(),
1004 releases_endpoint,
1005 }
1006 }
1007
1008 fn stamp_with_bind(bind_host: &str) -> EngineRuntimeStamp {
1009 EngineRuntimeStamp {
1010 pid: 1,
1011 endpoint: "http://127.0.0.1:6420".to_owned(),
1012 bind_host: bind_host.to_owned(),
1013 public_url: None,
1014 }
1015 }
1016
1017 #[test]
1018 fn binds_loopback_only_detects_loopback_addresses() {
1019 assert!(stamp_with_bind("127.0.0.1").binds_loopback_only());
1020 assert!(stamp_with_bind("localhost").binds_loopback_only());
1021 assert!(stamp_with_bind("::1").binds_loopback_only());
1022 }
1023
1024 #[test]
1025 fn binds_loopback_only_false_for_reachable_addresses() {
1026 assert!(!stamp_with_bind("0.0.0.0").binds_loopback_only());
1027 assert!(!stamp_with_bind("::").binds_loopback_only());
1028 assert!(!stamp_with_bind("192.168.1.5").binds_loopback_only());
1029 }
1030
1031 #[test]
1032 fn resolve_bind_host_prefers_override_then_endpoint() {
1033 let mut config = test_config(String::new(), false);
1034 config.endpoint = "http://127.0.0.1:6420".to_owned();
1035 assert_eq!(resolve_bind_host(&config).unwrap(), "127.0.0.1");
1036 config.bind_host = Some("0.0.0.0".to_owned());
1037 assert_eq!(resolve_bind_host(&config).unwrap(), "0.0.0.0");
1038 }
1039
1040 #[test]
1041 fn engine_env_uses_development_recovery_thresholds() {
1042 let env = engine_env(&test_config(String::new(), false)).expect("build engine env");
1043 let env = env.into_iter().collect::<HashMap<_, _>>();
1044
1045 assert_eq!(env["RIVET__PEGBOARD__RETRY_RESET_DURATION"], "100");
1046 assert_eq!(env["RIVET__PEGBOARD__BASE_RETRY_TIMEOUT"], "100");
1047 assert_eq!(env["RIVET__PEGBOARD__RESCHEDULE_BACKOFF_MAX_EXPONENT"], "1");
1048 assert_eq!(env["RIVET__PEGBOARD__RUNNER_ELIGIBLE_THRESHOLD"], "5000");
1049 assert_eq!(env["RIVET__PEGBOARD__RUNNER_LOST_THRESHOLD"], "7000");
1050 assert_eq!(env["RIVET__PEGBOARD__ENVOY_ELIGIBLE_THRESHOLD"], "5000");
1051 assert_eq!(env["RIVET__PEGBOARD__ENVOY_LOST_THRESHOLD"], "7000");
1052 assert_eq!(env["RIVET__PEGBOARD__MIN_METADATA_POLL_INTERVAL"], "1000");
1053 assert_eq!(env["RIVET__RUNTIME__WORKER_SHUTDOWN_DURATION"], "1");
1054 assert_eq!(env["RIVET__RUNTIME__GUARD_SHUTDOWN_DURATION"], "1");
1055 assert_eq!(env["RIVET__RUNTIME__FORCE_SHUTDOWN_DURATION"], "2");
1056 }
1057
1058 #[cfg(unix)]
1059 #[test]
1060 fn pid_is_alive_true_for_self_false_for_missing() {
1061 assert!(pid_is_alive(std::process::id()));
1062 assert!(!pid_is_alive(0x7FFF_FFFF));
1064 }
1065
1066 #[tokio::test]
1067 async fn resolver_prefers_existing_engine_before_filesystem_paths() {
1068 let temp = tempfile::tempdir().expect("create temp dir");
1069 let local = temp
1070 .path()
1071 .join("target")
1072 .join("debug")
1073 .join(exe_name("rivet-engine"));
1074 std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir");
1075 std::fs::write(&local, b"local").expect("write local binary");
1076 let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
1077
1078 let resolved = resolve_engine_binary_after_probe(
1079 &test_config("http://127.0.0.1:1".to_owned(), false),
1080 true,
1081 &[temp.path().to_path_buf()],
1082 cached,
1083 )
1084 .await
1085 .expect("resolve engine");
1086
1087 assert_eq!(resolved, ResolvedEngine::Existing);
1088 }
1089
1090 #[tokio::test]
1091 async fn resolver_prefers_local_binary_before_cached_binary() {
1092 let temp = tempfile::tempdir().expect("create temp dir");
1093 let local = temp
1094 .path()
1095 .join("target")
1096 .join("debug")
1097 .join(exe_name("rivet-engine"));
1098 std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir");
1099 std::fs::write(&local, b"local").expect("write local binary");
1100 let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
1101 std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir");
1102 std::fs::write(&cached, b"cached").expect("write cached binary");
1103
1104 let resolved = resolve_engine_binary_after_probe(
1105 &test_config("http://127.0.0.1:1".to_owned(), false),
1106 false,
1107 &[temp.path().to_path_buf()],
1108 cached,
1109 )
1110 .await
1111 .expect("resolve engine");
1112
1113 assert_eq!(resolved, ResolvedEngine::Binary(local));
1114 }
1115
1116 #[tokio::test]
1117 async fn resolver_reuses_cached_binary_without_download() {
1118 let temp = tempfile::tempdir().expect("create temp dir");
1119 let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
1120 std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir");
1121 std::fs::write(&cached, b"cached").expect("write cached binary");
1122
1123 let resolved = resolve_engine_binary_after_probe(
1124 &test_config("http://127.0.0.1:1".to_owned(), false),
1125 false,
1126 &[],
1127 cached.clone(),
1128 )
1129 .await
1130 .expect("resolve engine");
1131
1132 assert_eq!(resolved, ResolvedEngine::Binary(cached));
1133 }
1134
1135 #[tokio::test]
1136 async fn resolver_reports_actionable_error_without_binary_or_download() {
1137 let temp = tempfile::tempdir().expect("create temp dir");
1138 let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
1139
1140 let error = resolve_engine_binary_after_probe(
1141 &test_config("http://127.0.0.1:1".to_owned(), false),
1142 false,
1143 &[],
1144 cached,
1145 )
1146 .await
1147 .expect_err("missing binary should fail");
1148 let message = error.to_string();
1149
1150 assert!(message.contains("No usable engine binary was found"));
1151 assert!(message.contains("Build `rivet-engine`"));
1152 assert!(message.contains("RIVET_ENGINE_BINARY_PATH"));
1153 }
1154
1155 #[tokio::test]
1156 async fn resolver_download_checks_manifest_checksum() {
1157 let temp = tempfile::tempdir().expect("create temp dir");
1158 let cached = temp.path().join("cache").join(exe_name("rivet-engine"));
1159 let artifact = engine_artifact_name();
1160 let expected = sha256_hex(b"different bytes");
1161 let manifest = format!("{expected} {artifact}\n");
1162 let releases_endpoint = spawn_download_server(HashMap::from([
1163 (
1164 format!("/rivet/test-version/engine/SHA256SUMS"),
1165 manifest.into_bytes(),
1166 ),
1167 (
1168 format!("/rivet/test-version/engine/{artifact}"),
1169 b"actual bytes".to_vec(),
1170 ),
1171 ]))
1172 .await;
1173
1174 let error = resolve_engine_binary_after_probe(
1175 &test_config(releases_endpoint, true),
1176 false,
1177 &[],
1178 cached,
1179 )
1180 .await
1181 .expect_err("checksum mismatch should fail");
1182
1183 assert!(
1184 error
1185 .to_string()
1186 .contains("Engine binary checksum mismatch")
1187 );
1188 }
1189
1190 async fn spawn_download_server(routes: HashMap<String, Vec<u8>>) -> String {
1191 let listener = TcpListener::bind("127.0.0.1:0")
1192 .await
1193 .expect("bind download server");
1194 let addr = listener.local_addr().expect("download server address");
1195 tokio::spawn(async move {
1196 for _ in 0..routes.len() {
1197 let (mut socket, _) = listener.accept().await.expect("accept download request");
1198 let mut buffer = [0_u8; 2048];
1199 let n = socket
1200 .read(&mut buffer)
1201 .await
1202 .expect("read download request");
1203 let request = String::from_utf8_lossy(&buffer[..n]);
1204 let path = request
1205 .split_whitespace()
1206 .nth(1)
1207 .expect("request path")
1208 .to_owned();
1209 let body = routes.get(&path).expect("route body");
1210 let header = format!(
1211 "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
1212 body.len()
1213 );
1214 socket
1215 .write_all(header.as_bytes())
1216 .await
1217 .expect("write response header");
1218 socket.write_all(body).await.expect("write response body");
1219 }
1220 });
1221 format!("http://{addr}")
1222 }
1223}