alef 0.78.0

Opinionated polyglot binding generator for Rust libraries
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! The one place that knows the order of a deadline-bounded subprocess's lifecycle.
//!
//! Spawn into a group, register that group for signal forwarding, wait, and on expiry kill the
//! group rather than the child. The order is not interchangeable: [`configure_process_group`] has
//! to run before `spawn` because it configures the `Command`, and [`termination::track`] has to
//! run after it because it needs the pid. Getting either wrong yields a tree that outlives its own
//! deadline, or one that Ctrl-C can no longer reach -- neither of which is visible at the call
//! site. Callers get [`GroupChild`] instead of the three calls in the right order. ~keep

use crate::process::termination::TrackedProcessGroup;
use crate::process::{WaitTimeout as _, configure_process_group, kill_process_tree, termination};

/// What a bounded wait found.
pub(crate) enum Deadline {
    /// The child exited on its own, with this status.
    Exited(std::process::ExitStatus),
    /// The budget elapsed first. The whole process group has already been killed and reaped.
    Expired,
}

/// A child that leads its own process group, held together with the registry slot that forwards
/// this process's termination signals to that group.
///
/// The slot is released when the value is dropped, so a group that is no longer being waited on
/// stops being swept by the Ctrl-C handler. On Windows the same field holds the job object that
/// makes the kill tree-wide, so dropping it early there costs the tree its kill rather than its
/// signal forwarding. ~keep
pub(crate) struct GroupChild {
    child: std::process::Child,
    tracked: TrackedProcessGroup,
}

impl GroupChild {
    /// Spawns `command` as the leader of a new process group and registers that group for
    /// termination forwarding.
    ///
    /// # Errors
    ///
    /// Returns an error when the child cannot be spawned.
    pub(crate) fn spawn(command: &mut std::process::Command) -> std::io::Result<Self> {
        configure_process_group(command);
        let child = command.spawn()?;
        let tracked = termination::track(&child);
        Ok(Self { child, tracked })
    }

    pub(crate) fn take_stdout(&mut self) -> Option<std::process::ChildStdout> {
        self.child.stdout.take()
    }

    pub(crate) fn take_stderr(&mut self) -> Option<std::process::ChildStderr> {
        self.child.stderr.take()
    }

    /// Kills the child and every descendant it started, then reaps the child.
    ///
    /// Safe to call on a child that has already exited: the group kill fails, the fallback
    /// `Child::kill` fails, and the reap returns the status already collected.
    ///
    /// The reap itself is bounded by [`crate::process::KILL_REAP_LIMIT`] rather than a plain
    /// `Child::wait`. A `SIGKILL` that failed to reach its target for any reason must not turn
    /// "the tree is being torn down" into a second, unconditional hang directly behind the first
    /// -- see the constant's own doc comment. ~keep
    pub(crate) fn kill_tree(&mut self) {
        kill_process_tree(&mut self.child, &self.tracked);
        match self.child.wait_timeout(crate::process::KILL_REAP_LIMIT) {
            Ok(Some(_)) | Err(_) => {}
            Ok(None) => {
                tracing::error!(
                    pid = self.child.id(),
                    reap_limit_secs = crate::process::KILL_REAP_LIMIT.as_secs(),
                    "child was not reaped within the kill limit after being signalled; abandoning \
                     the wait instead of hanging"
                );
            }
        }
    }

    /// Waits up to `budget` for the child to exit, tearing its whole tree down when the budget
    /// runs out first -- or as soon as the child is found sitting in the kernel's stopped state
    /// for [`crate::process::STOPPED_PROCESS_GRACE`], whichever comes first.
    ///
    /// A stopped child can never exit on its own, so treating it identically to a merely slow one
    /// -- waiting out the same `budget` before acting -- turns a detectable condition into a wait
    /// bounded only by whatever timeout the caller happened to pass. See
    /// [`crate::process::is_process_stopped`]. ~keep
    ///
    /// `command` names the command in the timeout warning and is only formatted when the deadline
    /// is actually missed.
    ///
    /// # Errors
    ///
    /// Returns an error when the child cannot be waited on. The tree is killed in that case too:
    /// a wait that failed leaves nothing else able to reap it.
    pub(crate) fn wait_within(
        &mut self,
        budget: std::time::Duration,
        command: &impl std::fmt::Debug,
    ) -> std::io::Result<Deadline> {
        let deadline = std::time::Instant::now() + budget;
        let mut stopped_since: Option<std::time::Instant> = None;
        loop {
            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
            if remaining.is_zero() {
                tracing::warn!(
                    command = ?command,
                    budget_seconds = budget.as_secs(),
                    "command exceeded its timeout; killing its process group"
                );
                self.kill_tree();
                return Ok(Deadline::Expired);
            }
            let chunk = remaining.min(crate::process::STOPPED_PROCESS_POLL_INTERVAL);
            match self.child.wait_timeout(chunk) {
                Ok(Some(status)) => return Ok(Deadline::Exited(status)),
                Ok(None) => {}
                Err(error) => {
                    self.kill_tree();
                    return Err(error);
                }
            }
            if crate::process::is_process_stopped(self.child.id()) {
                let since = *stopped_since.get_or_insert_with(std::time::Instant::now);
                if since.elapsed() >= crate::process::STOPPED_PROCESS_GRACE {
                    tracing::warn!(
                        command = ?command,
                        "child process is stopped rather than running; killing its process group \
                         instead of waiting out the rest of its timeout budget"
                    );
                    self.kill_tree();
                    return Ok(Deadline::Expired);
                }
            } else {
                stopped_since = None;
            }
        }
    }
}

/// Runs on every platform deliberately. The Unix arm of the tree kill was well covered and the
/// Windows arm by nothing at all, which is the entire reason `kill_process_tree` shipped there as
/// a direct-child kill wearing a tree-kill name. Anything gated on `unix` here re-opens that gap.
/// ~keep
#[cfg(test)]
mod tests {
    use super::{Deadline, GroupChild};
    use std::time::Duration;

    const SETTLE_POLL: Duration = Duration::from_millis(20);
    const SETTLE_LIMIT: Duration = Duration::from_secs(10);

    /// How long the probe stays alive once it has announced its grandchild. Only reached when a
    /// kill failed to arrive, so it is a diagnosis window rather than a wait anything depends on.
    /// ~keep
    const PROBE_LIFETIME: Duration = Duration::from_secs(60);

    /// Names the file the probe announces its grandchild's pid in. Its presence is also what tells
    /// the probe it is running as a probe rather than as an ordinary ignored test. ~keep
    const GRANDCHILD_PROBE_MARKER: &str = "ALEF_PROCESS_GRANDCHILD_PROBE";
    const GRANDCHILD_PROBE_NAME: &str = "process::timed::tests::grandchild_probe_child";

    /// A command that outlives every deadline in this module. Windows has no `sleep`, and
    /// `timeout /t` refuses to run without a console, so a loopback `ping` is the console-free
    /// stand-in there -- the same substitution `snippets::session`'s hook tests already make. ~keep
    fn long_lived_command() -> std::process::Command {
        let mut command = if cfg!(windows) {
            let mut ping = std::process::Command::new("ping");
            ping.args(["-n", "61", "127.0.0.1"]);
            ping
        } else {
            let mut sleep = std::process::Command::new("sleep");
            sleep.arg("60");
            sleep
        };
        command
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null());
        command
    }

    /// A shell command that exits immediately with `code`.
    fn exiting_command(code: i32) -> std::process::Command {
        let (shell, flag) = if cfg!(windows) { ("cmd", "/C") } else { ("sh", "-c") };
        let mut command = std::process::Command::new(shell);
        command.args([flag, &format!("exit {code}")]);
        command
    }

    #[cfg(unix)]
    fn is_alive(pid: u32) -> bool {
        // SAFETY: signal 0 performs error checking only and sends nothing.
        unsafe { libc::kill(pid.cast_signed(), 0) == 0 }
    }

    /// Windows has no `kill(pid, 0)`. A process that is fully gone can no longer be opened at all,
    /// and one that has exited while a handle to it survives reports its real exit code, so
    /// `STILL_ACTIVE` is the only answer that means "running". ~keep
    #[cfg(windows)]
    fn is_alive(pid: u32) -> bool {
        use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
        use windows_sys::Win32::System::Threading::{
            GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
        };

        // SAFETY: opening by pid; failure returns a null handle, checked before any use.
        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
        if handle.is_null() {
            return false;
        }
        let mut exit_code = 0_u32;
        // SAFETY: `handle` is open and `exit_code` is a live, writable `u32`.
        let read = unsafe { GetExitCodeProcess(handle, &raw mut exit_code) };
        // SAFETY: the handle was opened here and is closed exactly once.
        unsafe {
            CloseHandle(handle);
        }
        read != 0 && exit_code == STILL_ACTIVE.cast_unsigned()
    }

    #[cfg(unix)]
    fn terminate(pid: u32) {
        // SAFETY: signalling one pid this test's own probe created.
        unsafe {
            libc::kill(pid.cast_signed(), libc::SIGKILL);
        }
    }

    #[cfg(windows)]
    fn terminate(pid: u32) {
        use windows_sys::Win32::Foundation::CloseHandle;
        use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess};

        // SAFETY: opening by pid; failure returns a null handle, checked before any use.
        let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
        if handle.is_null() {
            return;
        }
        // SAFETY: `handle` was opened with `PROCESS_TERMINATE` and is closed exactly once.
        unsafe {
            TerminateProcess(handle, 1);
            CloseHandle(handle);
        }
    }

    fn wait_until_gone(pid: u32) -> bool {
        let deadline = std::time::Instant::now() + SETTLE_LIMIT;
        while std::time::Instant::now() < deadline {
            if !is_alive(pid) {
                return true;
            }
            std::thread::sleep(SETTLE_POLL);
        }
        !is_alive(pid)
    }

    /// Runs this test binary against [`grandchild_probe_child`], pointing it at `marker`.
    fn probe_command(marker: &std::path::Path) -> std::process::Command {
        let mut command = std::process::Command::new(std::env::current_exe().expect("the test binary"));
        command
            .args(["--exact", GRANDCHILD_PROBE_NAME, "--ignored", "--test-threads=1"])
            .env(GRANDCHILD_PROBE_MARKER, marker)
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null());
        command
    }

    /// Blocks until the probe has announced a pid that names a live process.
    fn announced_grandchild(marker: &std::path::Path) -> u32 {
        let deadline = std::time::Instant::now() + SETTLE_LIMIT;
        loop {
            assert!(
                std::time::Instant::now() < deadline,
                "the probe never announced a grandchild"
            );
            if let Ok(contents) = std::fs::read_to_string(marker)
                && let Ok(pid) = contents.trim().parse::<u32>()
                && is_alive(pid)
            {
                return pid;
            }
            std::thread::sleep(SETTLE_POLL);
        }
    }

    /// Not a test: the middle process of the two tests below. Starts a grandchild that outlives
    /// every deadline they set, announces its pid, and then waits to be killed, so those tests can
    /// ask what the kill actually reached rather than whether a kill branch was entered. The
    /// grandchild is a real spawn rather than a backgrounded shell job because `Child::id` is the
    /// one way to learn its pid that reads the same on both platforms -- `$!` has no `cmd`
    /// equivalent. Inert unless the environment names a marker file, so an ordinary `--ignored`
    /// run does nothing. ~keep
    #[test]
    #[ignore = "spawned as a subprocess by the process-tree kill tests"]
    #[expect(
        clippy::zombie_processes,
        reason = "the grandchild is meant to outlive this process; waiting on it is what the tree kill has to make unnecessary"
    )]
    fn grandchild_probe_child() {
        let Ok(marker) = std::env::var(GRANDCHILD_PROBE_MARKER) else {
            return;
        };
        let grandchild = long_lived_command().spawn().expect("spawn the grandchild");
        std::fs::write(&marker, grandchild.id().to_string()).expect("announce the grandchild");
        std::thread::sleep(PROBE_LIFETIME);
    }

    /// The defect this module exists to close, and the one Windows went on carrying: `Child::kill`
    /// ends the direct child alone, so the grandchild it started outlives the deadline -- a Gradle
    /// daemon in the incident that prompted the Unix fix, a `ping` holding alef's output pipes open
    /// past the timeout on Windows. Asserting that the kill branch was entered would prove nothing:
    /// the orphaned tree was produced by code that did enter its kill branch. ~keep
    #[test]
    fn an_expired_deadline_kills_the_grandchild_too() {
        let directory = tempfile::tempdir().expect("scratch directory");
        let marker = directory.path().join("grandchild.pid");
        let mut child = GroupChild::spawn(&mut probe_command(&marker)).expect("spawn the process group");
        let grandchild = announced_grandchild(&marker);

        let outcome = child
            .wait_within(Duration::from_millis(200), &GRANDCHILD_PROBE_NAME)
            .expect("waiting on a live child is not an error");

        assert!(matches!(outcome, Deadline::Expired));
        assert!(
            wait_until_gone(grandchild),
            "grandchild {grandchild} survived its parent's deadline"
        );
    }

    /// The sabotage check for the test above: the same probe, killed the way `Child::kill` kills,
    /// must leave its grandchild running. Without it that test stays green on any platform whose
    /// tree kill has quietly degraded to a direct-child kill, so long as the grandchild happens to
    /// die on its own -- which is the exact shape the Windows arm was in. ~keep
    #[test]
    fn killing_the_direct_child_alone_leaves_the_grandchild_running() {
        let directory = tempfile::tempdir().expect("scratch directory");
        let marker = directory.path().join("grandchild.pid");
        let mut child = probe_command(&marker).spawn().expect("spawn the probe");
        let grandchild = announced_grandchild(&marker);

        let _ = child.kill();
        let _ = child.wait();
        std::thread::sleep(SETTLE_POLL);

        let survived = is_alive(grandchild);
        terminate(grandchild);
        assert!(
            survived,
            "grandchild {grandchild} died without a tree kill, so the test above proves nothing"
        );
    }

    /// The second sabotage check: were the wait to report expiry for a child that had exited on
    /// its own, or the kill to run unconditionally, the tests above would pass for the wrong
    /// reason. The exact status is asserted because a bounded wait that loses the child's exit
    /// code turns every timed command into a silent success. ~keep
    #[test]
    fn a_child_that_beats_its_deadline_reports_its_own_exit_status() {
        let mut child = GroupChild::spawn(&mut exiting_command(7)).expect("spawn the process group");

        let outcome = child
            .wait_within(Duration::from_secs(30), &"exit 7")
            .expect("waiting on a live child is not an error");

        match outcome {
            Deadline::Exited(status) => assert_eq!(status.code(), Some(7)),
            Deadline::Expired => panic!("a command that exits immediately must not report expiry"),
        }
    }

    /// The regression this whole change exists for: a child stuck in the kernel's stopped state
    /// (macOS/Linux `STAT=T`, e.g. from a job-control `SIGTTIN`/`SIGTTOU` after being placed in a
    /// background process group) never makes progress on its own, so a wait that treats it like an
    /// ordinary slow command waits out the *entire* budget before doing anything about it. A large
    /// enough budget -- the shape a real toolchain timeout takes -- turns that into an effectively
    /// unbounded hang.
    ///
    /// The budget passed here is deliberately far larger than
    /// [`crate::process::STOPPED_PROCESS_GRACE`], so a regression that fell back to waiting out the
    /// whole budget would still be caught by the `elapsed` assertion -- fast, not by hanging the
    /// suite for the full budget either, since the budget itself is bounded. ~keep
    #[cfg(unix)]
    #[test]
    fn a_stopped_child_is_detected_and_killed_well_before_the_timeout_budget() {
        let mut child = GroupChild::spawn(&mut long_lived_command()).expect("spawn the process group");
        let pid = child.child.id();

        // SAFETY: signalling one pid this test just spawned and owns.
        unsafe {
            libc::kill(pid.cast_signed(), libc::SIGSTOP);
        }
        let stop_confirmed_by = std::time::Instant::now() + SETTLE_LIMIT;
        while !crate::process::is_process_stopped(pid) {
            assert!(
                std::time::Instant::now() < stop_confirmed_by,
                "the fixture child never reached the kernel's stopped state"
            );
            std::thread::sleep(SETTLE_POLL);
        }

        let budget = Duration::from_secs(30);
        let started = std::time::Instant::now();
        let outcome = child
            .wait_within(budget, &"stopped-fixture")
            .expect("waiting on a stopped child is not an error");
        let elapsed = started.elapsed();

        assert!(
            matches!(outcome, Deadline::Expired),
            "a stopped child can never exit on its own and must report as expired"
        );
        assert!(
            elapsed < budget / 2,
            "a stopped child must be detected and killed well inside its timeout budget, not after \
             waiting it out: took {elapsed:?} against a {budget:?} budget"
        );
        assert!(wait_until_gone(pid), "stopped child {pid} survived its own detection");
    }
}