1use 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
22const WINDOW: usize = 65_536;
25
26pub const SPAWN_BUDGET: Duration = Duration::from_secs(2);
28
29pub const RUNAWAY_LIMIT: Duration = Duration::from_secs(60);
36
37pub 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
64pub fn warm_spawn(path: &Path, args: &[impl AsRef<OsStr>]) -> io::Result<()> {
73 warm_spawn_until(path, args, Instant::now() + SPAWN_BUDGET)
74}
75
76pub 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
103fn 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 std::thread::Builder::new()
122 .name("kunobi-daemon-warmup".into())
123 .spawn(move || {
124 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
135fn 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 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
171fn 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#[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#[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#[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 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 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}