Skip to main content

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::{Child, Command, ExitStatus, Stdio};
19use std::sync::mpsc;
20use std::time::{Duration, Instant};
21
22/// Bytes read per iteration. Matches a typical file-system block multiple and
23/// the transport window; the buffer is reused so RSS stays at this size.
24const WINDOW: usize = 65_536;
25
26/// How long [`warm_spawn`] waits for the child to exit.
27pub const SPAWN_BUDGET: Duration = Duration::from_secs(2);
28
29/// How long a warmup child may run before it is killed.
30///
31/// Much longer than [`SPAWN_BUDGET`] because macOS checks a new executable on
32/// its first run, and that check is what warming pays for. It took 0.2-2.3s
33/// in tests, and can take longer. A child still running when its caller stops
34/// waiting is left to finish, so the next exec of the path is warm.
35pub const RUNAWAY_LIMIT: Duration = Duration::from_secs(60);
36
37/// Read `path` into the OS file cache without executing it.
38///
39/// The file length is taken from metadata so special files with no end (such as
40/// `/dev/zero`) cannot hang the caller. Missing, unreadable or directory paths
41/// return an error. Replacement treats those errors as non-fatal.
42pub fn warm_executable(path: &Path) -> io::Result<()> {
43    let mut file = File::open(path)?;
44    let metadata = file.metadata()?;
45    if metadata.is_dir() {
46        return Err(io::Error::new(
47            io::ErrorKind::IsADirectory,
48            "warmup path is a directory",
49        ));
50    }
51    let mut remaining = metadata.len();
52    let mut buffer = vec![0u8; WINDOW];
53    while remaining > 0 {
54        let want = WINDOW.min(remaining as usize);
55        let got = file.read(&mut buffer[..want])?;
56        if got == 0 {
57            break;
58        }
59        remaining -= got as u64;
60    }
61    Ok(())
62}
63
64/// Spawn `path` with `args` and wait up to [`SPAWN_BUDGET`] for a successful
65/// exit.
66///
67/// Stdio is detached. The child must exit without connecting to a daemon or
68/// taking its locks. A child still running at the budget is not killed:
69/// this returns `TimedOut` and leaves it to finish in the background, up to
70/// [`RUNAWAY_LIMIT`]. On Linux, a spawn refused because the file is still open
71/// for writing somewhere (`ETXTBSY`) is retried within the same limit.
72pub fn warm_spawn(path: &Path, args: &[impl AsRef<OsStr>]) -> io::Result<()> {
73    warm_spawn_until(path, args, Instant::now() + SPAWN_BUDGET)
74}
75
76/// As [`warm_spawn`], waiting until `deadline` instead of [`SPAWN_BUDGET`].
77///
78/// The child is killed at [`RUNAWAY_LIMIT`] after the spawn, or at `deadline`
79/// if that is later.
80pub fn warm_spawn_until(
81    path: &Path,
82    args: &[impl AsRef<OsStr>],
83    deadline: Instant,
84) -> io::Result<()> {
85    let exited = start(path, args, deadline, RUNAWAY_LIMIT)?;
86    match exited.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
87        Ok(Ok(status)) if status.success() => Ok(()),
88        Ok(Ok(status)) => Err(io::Error::other(format!(
89            "warmup {} exited {status}",
90            path.display()
91        ))),
92        Ok(Err(error)) => Err(error),
93        Err(_) => Err(io::Error::new(
94            io::ErrorKind::TimedOut,
95            format!(
96                "warmup {} is still running after its budget; left to finish",
97                path.display()
98            ),
99        )),
100    }
101}
102
103/// Spawn and reap the child on its own thread, which reports how it exited.
104/// The caller may stop listening at any time; the child is still reaped. It is
105/// killed `runaway` after it was spawned, or at `deadline` if that is later.
106fn start(
107    path: &Path,
108    args: &[impl AsRef<OsStr>],
109    deadline: Instant,
110    runaway: Duration,
111) -> io::Result<mpsc::Receiver<io::Result<ExitStatus>>> {
112    let mut command = Command::new(path);
113    command
114        .args(args)
115        .stdin(Stdio::null())
116        .stdout(Stdio::null())
117        .stderr(Stdio::null());
118    let (report, exited) = mpsc::channel();
119    // The spawn runs on the reaper thread too: on macOS the first-run check
120    // can make the spawn itself slow, and the caller's budget covers it.
121    std::thread::Builder::new()
122        .name("kunobi-daemon-warmup".into())
123        .spawn(move || {
124            // The caller may stop waiting sooner; the child still gets to run.
125            let busy_until = deadline.max(Instant::now() + runaway);
126            let result = spawn_when_free(&mut command, busy_until, || {}).and_then(|child| {
127                let limit = deadline.max(Instant::now() + runaway);
128                reap(child, limit)
129            });
130            let _ = report.send(result);
131        })?;
132    Ok(exited)
133}
134
135/// Spawn `command`, retrying while its executable is busy until `until`.
136/// `on_busy` runs after each refused attempt.
137///
138/// Linux refuses to exec a file that any process holds open for writing
139/// (`ETXTBSY`). Warmup runs right after a new executable is written, and in a
140/// multi-threaded process a child forked by another thread holds a copy of
141/// the writer's descriptor until that child execs. The window is short.
142fn spawn_when_free(
143    command: &mut Command,
144    until: Instant,
145    mut on_busy: impl FnMut(),
146) -> io::Result<Child> {
147    let mut pause = Duration::from_millis(1);
148    loop {
149        // Released before any retry wait; see `crate::spawn_lock`.
150        let spawned = {
151            let _spawn = crate::spawn_lock::spawning();
152            command.spawn()
153        };
154        match spawned {
155            Err(error)
156                if error.kind() == io::ErrorKind::ExecutableFileBusy && Instant::now() < until =>
157            {
158                on_busy();
159                #[expect(
160                    clippy::disallowed_methods,
161                    reason = "ETXTBSY has no readiness event; retry with backoff until the caller deadline."
162                )]
163                std::thread::sleep(pause.min(until.saturating_duration_since(Instant::now())));
164                pause = (pause * 2).min(Duration::from_millis(50));
165            }
166            result => return result,
167        }
168    }
169}
170
171/// Wait for `child` to exit, killing it at `limit`. It runs off the caller's
172/// thread, which waits on a channel instead. A child whose exit cannot be
173/// observed is killed and reaped too, never dropped still running.
174fn reap(mut child: Child, limit: Instant) -> io::Result<ExitStatus> {
175    let stopped = match exits_by(&mut child, limit) {
176        Ok(true) => return child.wait(),
177        Ok(false) => Err(io::Error::new(
178            io::ErrorKind::TimedOut,
179            "warmup child killed at its runaway limit",
180        )),
181        Err(error) => Err(error),
182    };
183    let _ = child.kill();
184    child.wait()?;
185    stopped
186}
187
188/// Whether `child` exits by `limit`, told by the OS when it does.
189#[cfg(feature = "local")]
190fn exits_by(child: &mut Child, limit: Instant) -> io::Result<bool> {
191    let opened = crate::local::ProcessHandle::for_child(child);
192    exits_by_watching(child, opened, limit)
193}
194
195/// [`exits_by`] with the result of opening the child's exit handle. A handle
196/// that could not be opened, for example for want of a descriptor, leaves
197/// the child to be asked directly.
198#[cfg(feature = "local")]
199fn exits_by_watching(
200    child: &mut Child,
201    opened: io::Result<crate::local::ProcessHandle>,
202    limit: Instant,
203) -> io::Result<bool> {
204    crate::local::wait_child(|| child.try_wait(), opened.ok().as_mut(), limit)
205}
206
207#[cfg(not(feature = "local"))]
208use polled_exit_by as exits_by;
209
210/// Whether `child` exits by `limit`. Without the `local` feature there is no
211/// exit event, and the standard library has no timed wait for a child, so
212/// this asks the child with a growing pause.
213#[cfg(not(feature = "local"))]
214fn polled_exit_by(child: &mut Child, limit: Instant) -> io::Result<bool> {
215    crate::backoff::poll(|| Ok(child.try_wait()?.is_some()), Some(limit))
216}
217
218pub(crate) fn prefault_all(paths: impl IntoIterator<Item = impl AsRef<Path>>) {
219    for path in paths {
220        let _ = warm_executable(path.as_ref());
221    }
222}
223
224#[cfg(all(test, unix))]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn a_runaway_child_is_killed_at_its_limit() {
230        let runaway = Duration::from_millis(50);
231        let exited = start(Path::new("/bin/sleep"), &["30"], Instant::now(), runaway).unwrap();
232        let error = exited.recv().unwrap().unwrap_err();
233        assert_eq!(error.kind(), io::ErrorKind::TimedOut);
234    }
235
236    #[test]
237    fn a_child_that_exits_reports_its_status_before_the_limit() {
238        let started = Instant::now();
239        let runaway = Duration::from_secs(60);
240        let exited = start(Path::new("/bin/sh"), &["-c", "exit 4"], started, runaway).unwrap();
241        let status = exited.recv().unwrap().unwrap();
242        assert_eq!(status.code(), Some(4));
243        assert!(started.elapsed() < Duration::from_secs(30));
244    }
245
246    #[cfg(feature = "local")]
247    #[test]
248    fn a_child_whose_exit_handle_cannot_be_opened_is_still_waited_for() {
249        let failed = || Err(io::Error::other("out of descriptors"));
250        let mut exits = Command::new("/bin/sh")
251            .args(["-c", "exit 4"])
252            .spawn()
253            .unwrap();
254        let started = Instant::now();
255        let deadline = started + Duration::from_secs(30);
256        assert!(exits_by_watching(&mut exits, failed(), deadline).unwrap());
257        assert!(started.elapsed() < Duration::from_secs(20));
258        assert_eq!(exits.wait().unwrap().code(), Some(4));
259
260        let mut runs = Command::new("/bin/sleep").arg("30").spawn().unwrap();
261        let deadline = Instant::now() + Duration::from_millis(50);
262        assert!(!exits_by_watching(&mut runs, failed(), deadline).unwrap());
263        runs.kill().unwrap();
264        runs.wait().unwrap();
265    }
266
267    #[cfg(target_os = "linux")]
268    #[test]
269    fn a_busy_executable_is_retried_until_it_is_free() {
270        use std::io::Write;
271        use std::os::unix::fs::PermissionsExt;
272        let dir = tempfile::tempdir().unwrap();
273        let shim = dir.path().join("shim.sh");
274        let mut writer = File::create(&shim).unwrap();
275        writer.write_all(b"#!/bin/sh\nexit 0\n").unwrap();
276        std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
277
278        // While this process holds the file open for writing, exec fails.
279        let mut command = Command::new(&shim);
280        let busy = spawn_when_free(&mut command, Instant::now(), || {}).unwrap_err();
281        assert_eq!(busy.kind(), io::ErrorKind::ExecutableFileBusy);
282
283        // The writer closes only after a retrying spawn was refused, so the
284        // retry is what succeeds.
285        let (refused, first_refusal) = mpsc::channel();
286        let retrying = std::thread::spawn(move || {
287            let deadline = Instant::now() + Duration::from_secs(30);
288            spawn_when_free(&mut command, deadline, || {
289                let _ = refused.send(());
290            })
291            .and_then(|mut child| child.wait())
292        });
293        first_refusal.recv().unwrap();
294        drop(writer);
295        assert!(retrying.join().unwrap().unwrap().success());
296    }
297}