kunobi_daemon/warmup.rs
1//! Prefault a client shim so the next exec of the same path is not cold.
2//!
3//! The daemon cannot keep a pre-spawned shim: stdin and stdout belong to the
4//! process that launched the client. What it can do is fault the file into the
5//! OS page cache, and (on macOS) populate the per-path first-exec cache by
6//! spawning a child that exits immediately.
7//!
8//! Call [`warm_executable`] at daemon start and after a replacement commit.
9//! [`warm_spawn`] is for an argv the child understands as "exit without
10//! talking to the daemon" (for example `--warmup`). Neither function runs on a
11//! timer: a periodic exec fights idle-wakeup budgets, and the expensive miss is
12//! a new path after replace, not eviction of a few hundred kilobytes.
13
14use std::ffi::OsStr;
15use std::fs::File;
16use std::io::{self, Read};
17use std::path::Path;
18use std::process::{Command, Stdio};
19use std::time::{Duration, Instant};
20
21/// Bytes read per iteration. Matches a typical file-system block multiple and
22/// the transport window; the buffer is reused so RSS stays at this size.
23const WINDOW: usize = 65_536;
24
25/// How long [`warm_spawn`] waits before killing the child.
26pub const SPAWN_BUDGET: Duration = Duration::from_secs(2);
27
28/// Read `path` into the OS file cache without executing it.
29///
30/// The file length is taken from metadata so special files with no end (such as
31/// `/dev/zero`) cannot hang the caller. Missing, unreadable or directory paths
32/// return an error. Replacement treats those errors as non-fatal.
33pub fn warm_executable(path: &Path) -> io::Result<()> {
34 let mut file = File::open(path)?;
35 let metadata = file.metadata()?;
36 if metadata.is_dir() {
37 return Err(io::Error::new(
38 io::ErrorKind::IsADirectory,
39 "warmup path is a directory",
40 ));
41 }
42 let mut remaining = metadata.len();
43 let mut buffer = vec![0u8; WINDOW];
44 while remaining > 0 {
45 let want = WINDOW.min(remaining as usize);
46 let got = file.read(&mut buffer[..want])?;
47 if got == 0 {
48 break;
49 }
50 remaining -= got as u64;
51 }
52 Ok(())
53}
54
55/// Spawn `path` with `args`, wait for a successful exit, then return.
56///
57/// Stdio is detached. The child must exit without connecting to a daemon or
58/// taking its locks. Uses [`SPAWN_BUDGET`].
59pub fn warm_spawn(path: &Path, args: &[impl AsRef<OsStr>]) -> io::Result<()> {
60 warm_spawn_until(path, args, Instant::now() + SPAWN_BUDGET)
61}
62
63/// As [`warm_spawn`], with an explicit deadline.
64pub fn warm_spawn_until(
65 path: &Path,
66 args: &[impl AsRef<OsStr>],
67 deadline: Instant,
68) -> io::Result<()> {
69 let mut child = Command::new(path)
70 .args(args)
71 .stdin(Stdio::null())
72 .stdout(Stdio::null())
73 .stderr(Stdio::null())
74 .spawn()?;
75 loop {
76 match child.try_wait()? {
77 Some(status) if status.success() => return Ok(()),
78 Some(status) => {
79 return Err(io::Error::other(format!(
80 "warmup {} exited {status}",
81 path.display()
82 )));
83 }
84 None if Instant::now() >= deadline => {
85 let _ = child.kill();
86 let _ = child.wait();
87 return Err(io::Error::new(
88 io::ErrorKind::TimedOut,
89 format!("warmup {} exceeded its budget", path.display()),
90 ));
91 }
92 None => {
93 let slice = Duration::from_millis(1)
94 .min(deadline.saturating_duration_since(Instant::now()));
95 if slice.is_zero() {
96 continue;
97 }
98 std::thread::sleep(slice);
99 }
100 }
101 }
102}
103
104pub(crate) fn prefault_all(paths: impl IntoIterator<Item = impl AsRef<Path>>) {
105 for path in paths {
106 let _ = warm_executable(path.as_ref());
107 }
108}