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