wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
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
//! Finding and stopping processes by what they are running.
//!
//! # The trap this exists to close
//!
//! `pgrep -f` and `pkill -f` match against a process's WHOLE command line —
//! including the command line of the process doing the asking. A launcher
//! names the thing it launches, so a launcher asking "is that thing running?"
//! is asking a question its own arguments answer yes to, and "stop that thing"
//! stops the asker.
//!
//! That is not hypothetical. The desktop clients were renamed from `electron`
//! to their product name `unifloom`, which put the word into the
//! launcher's own argv:
//!
//! ```text
//! wsb run --client unifloom --kill-existing …
//! pkill -TERM -f 'unifloom|MachineFabric'
//! ```
//!
//! The second line killed the first. The run ended `terminated by signal 15`
//! two seconds in, and a run WITHOUT `--kill-existing` would have refused to
//! start at all, reporting an instance already running that was only ever
//! itself.
//!
//! # The rule
//!
//! A process never counts or signals itself, or anything that started it.
//! Every match goes through here, and the survivors are signalled BY PID
//! rather than by pattern — a second `pkill -f` would re-match the asker
//! however carefully the first answer was filtered.
//!
//! The rule is about lineage, not about names: it holds whatever the clients
//! are called next.

use std::collections::BTreeSet;

/// One process the pattern matched.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Match {
    pub pid: u32,
    /// The command line, as `pgrep -fl` reports it — for saying WHICH process
    /// is still there when one will not die.
    pub command: String,
}

/// Every process matching `pattern`, except this one and its ancestors.
pub fn matching(pattern: &str) -> Vec<Match> {
    let listed = crate::git_backend::command_without_repo_scope("pgrep")
        .args(["-fl", pattern])
        .output();
    let Ok(listed) = listed else {
        return Vec::new();
    };
    // pgrep exits 1 when nothing matched, which is an answer and not a failure.
    let text = String::from_utf8_lossy(&listed.stdout).into_owned();
    parse_matches(&text, &lineage())
}

/// Split `pgrep -fl` output and drop our own lineage.
///
/// Separate from the process call so the rule can be tested without arranging
/// a process tree.
pub fn parse_matches(text: &str, ours: &BTreeSet<u32>) -> Vec<Match> {
    text.lines()
        .filter_map(|line| {
            let line = line.trim();
            if line.is_empty() {
                return None;
            }
            let (pid, command) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
            let pid: u32 = pid.trim().parse().ok()?;
            if ours.contains(&pid) {
                return None;
            }
            Some(Match {
                pid,
                command: command.trim().to_string(),
            })
        })
        .collect()
}

/// This process and every process that started it.
///
/// Walked with `ps`, which answers on both hosts this worktree builds for;
/// `/proc` would be Linux only. Capped, because a cycle in the answer must not
/// become a loop here — a wrong ancestor list is a bug, an infinite one is a
/// hang.
pub fn lineage() -> BTreeSet<u32> {
    let mut found = BTreeSet::new();
    let mut pid = std::process::id();
    for _ in 0..32 {
        if !found.insert(pid) {
            break;
        }
        let Some(parent) = parent_of(pid) else { break };
        if parent <= 1 {
            break;
        }
        pid = parent;
    }
    found
}

fn parent_of(pid: u32) -> Option<u32> {
    let output = crate::git_backend::command_without_repo_scope("ps")
        .args(["-o", "ppid=", "-p", &pid.to_string()])
        .output()
        .ok()?;
    String::from_utf8_lossy(&output.stdout).trim().parse().ok()
}

/// Send one signal to named pids.
///
/// Nothing is matched again here: the decision about WHICH processes was made
/// once, and re-deciding it with a pattern is how the asker gets signalled
/// after being carefully excluded from the answer.
pub fn signal(pids: &[u32], signal: &str) -> Vec<Signalled> {
    // The name is read ONCE, before any platform is reached, so neither arm can
    // be handed one it does not know. The Windows arm used to ignore the name
    // and force-kill whatever it was given: an unknown signal that unix refuses
    // ended the process there — TEST11152 took down its own test binary.
    let known = Signal::named(signal);
    pids.iter()
        .map(|pid| Signalled {
            pid: *pid,
            outcome: match &known {
                Ok(signal) => signal_one(*pid, *signal),
                Err(refusal) => SignalOutcome::Failed(refusal.clone()),
            },
        })
        .collect()
}

/// The signals a stop is made of.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Signal {
    Term,
    Kill,
    Int,
}

impl Signal {
    fn named(name: &str) -> Result<Self, String> {
        match name {
            "TERM" => Ok(Signal::Term),
            "KILL" => Ok(Signal::Kill),
            "INT" => Ok(Signal::Int),
            other => Err(format!("unknown signal {other}")),
        }
    }
}

/// What happened to one process.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Signalled {
    pub pid: u32,
    pub outcome: SignalOutcome,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignalOutcome {
    /// The signal was delivered.
    Sent,
    /// There is no such process: it had already ended.
    Gone,
    /// This user may not signal it. Another user owns it.
    NotPermitted,
    /// Anything else the kernel said.
    Failed(String),
}

impl SignalOutcome {
    /// Whether the caller has to do something about it.
    pub fn is_failure(&self) -> bool {
        matches!(self, SignalOutcome::NotPermitted | SignalOutcome::Failed(_))
    }
}

/// Signal one process, and find out what happened.
///
/// Through `libc::kill` rather than the `kill` command: the command's exit
/// status collapses every reason into "non-zero", and the difference between
/// "it had already ended" and "you are not allowed to stop it" is the
/// difference between nothing to do and needing another user's permission.
/// The old code discarded even that, which is why stopping something appeared
/// to do nothing at all.
#[cfg(unix)]
fn signal_one(pid: u32, signal: Signal) -> SignalOutcome {
    let number = match signal {
        Signal::Term => libc::SIGTERM,
        Signal::Kill => libc::SIGKILL,
        Signal::Int => libc::SIGINT,
    };
    // SAFETY: `kill` with a positive pid signals exactly that process, and a
    // pid that no longer exists is answered with ESRCH rather than harm.
    let sent = unsafe { libc::kill(pid as libc::pid_t, number) };
    if sent == 0 {
        return SignalOutcome::Sent;
    }
    let error = std::io::Error::last_os_error();
    match error.raw_os_error() {
        Some(libc::ESRCH) => SignalOutcome::Gone,
        Some(libc::EPERM) => SignalOutcome::NotPermitted,
        _ => SignalOutcome::Failed(error.to_string()),
    }
}

/// Windows has no signals to send: every one of them ends the process tree,
/// which is what a stop is for. What still differs is the ANSWER — taskkill
/// exits 128 when there is no such process, and that is `Gone`, the outcome a
/// stop wanted, not a failure to report.
#[cfg(windows)]
fn signal_one(pid: u32, _signal: Signal) -> SignalOutcome {
    const NO_SUCH_PROCESS: i32 = 128;
    let status = crate::git_backend::command_without_repo_scope("taskkill")
        .args(["/PID", &pid.to_string(), "/T", "/F"])
        .output();
    match status {
        Ok(out) if out.status.success() => SignalOutcome::Sent,
        Ok(out) if out.status.code() == Some(NO_SUCH_PROCESS) => SignalOutcome::Gone,
        Ok(out) => SignalOutcome::Failed(
            String::from_utf8_lossy(&out.stderr).trim().to_string(),
        ),
        Err(error) => SignalOutcome::Failed(error.to_string()),
    }
}

/// Whether a process is still there.
///
/// Signal 0 asks the kernel without delivering anything. Used to check that a
/// stop actually stopped something, rather than reporting the request as the
/// result.
#[cfg(unix)]
pub fn is_alive(pid: u32) -> bool {
    // SAFETY: signal 0 performs the permission and existence checks and
    // delivers nothing.
    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}

#[cfg(windows)]
pub fn is_alive(pid: u32) -> bool {
    crate::git_backend::command_without_repo_scope("tasklist")
        .args(["/FI", &format!("PID eq {pid}")])
        .output()
        .map(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
        .unwrap_or(false)
}

/// How long a process asked to stop has to stop by itself.
///
/// Long enough to end a tree of compilers politely and write what it knows;
/// short enough that whoever asked is still watching.
pub const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(20);

/// The descendants of `root` in a table of (pid, parent pid), deepest first.
///
/// Separate from the process call so the walk can be tested without arranging
/// a process tree. Deepest first, so a parent is ended after what it started
/// and cannot start it again.
pub fn descendants_in(table: &[(u32, u32)], root: u32) -> Vec<u32> {
    let mut found: Vec<u32> = Vec::new();
    let mut seen: BTreeSet<u32> = BTreeSet::from([root]);
    let mut level = vec![root];
    while !level.is_empty() {
        let next: Vec<u32> = table
            .iter()
            .filter(|(pid, parent)| level.contains(parent) && seen.insert(*pid))
            .map(|(pid, _)| *pid)
            .collect();
        found.extend(&next);
        level = next;
    }
    found.reverse();
    found
}

/// Every process this one started, directly or through others, deepest first.
#[cfg(unix)]
pub fn descendants() -> Result<Vec<u32>, String> {
    let output = crate::git_backend::command_without_repo_scope("ps")
        .args(["-axo", "pid=,ppid="])
        .output()
        .map_err(|e| format!("running ps: {e}"))?;
    if !output.status.success() {
        return Err(format!("ps failed: {}", String::from_utf8_lossy(&output.stderr).trim()));
    }
    let table: Vec<(u32, u32)> = String::from_utf8_lossy(&output.stdout)
        .lines()
        .filter_map(|line| {
            let mut fields = line.split_whitespace();
            Some((fields.next()?.parse().ok()?, fields.next()?.parse().ok()?))
        })
        .collect();
    Ok(descendants_in(&table, std::process::id()))
}

/// End everything this process started: asked first, then made to.
#[cfg(unix)]
fn end_descendants() {
    let started = match descendants() {
        Ok(started) => started,
        Err(error) => {
            crate::errln!("wsb: cannot list what this process started ({error}); nothing was ended");
            return;
        }
    };
    for pid in &started {
        crate::platform::end_politely(*pid as i32);
    }
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
    while std::time::Instant::now() < deadline && started.iter().any(|pid| is_alive(*pid)) {
        std::thread::sleep(std::time::Duration::from_millis(100));
    }
    for pid in started.iter().filter(|pid| is_alive(**pid)) {
        crate::platform::end_hard(*pid as i32);
    }
}

/// Make a stop request final: when `flag` has been set for `grace` and this
/// process is still running, end what it started and exit.
///
/// Whoever takes the stop signals away from their default
/// ([`crate::platform::stop_requested`]) owes this. The flag is read by the
/// loops that know about it, and a process is not always in one: a test run
/// sent SIGTERM while it was outside its scheduler's loop set the flag,
/// nothing read it, and the run stayed up until it was killed, holding its
/// snapshot (2026-10-08).
///
/// A watching thread rather than a check in every phase: a phase added later
/// is covered without knowing this exists.
pub fn end_when_stopped(flag: std::sync::Arc<std::sync::atomic::AtomicBool>, grace: std::time::Duration) {
    static WATCHING: std::sync::Once = std::sync::Once::new();
    WATCHING.call_once(|| {
        std::thread::spawn(move || {
            while !flag.load(std::sync::atomic::Ordering::SeqCst) {
                std::thread::sleep(std::time::Duration::from_millis(200));
            }
            std::thread::sleep(grace);
            crate::errln!(
                "wsb: asked to stop {} s ago and still running; ending what it started and exiting",
                grace.as_secs()
            );
            #[cfg(unix)]
            end_descendants();
            // Windows ends a tree as one act, this process included.
            #[cfg(windows)]
            crate::platform::end_group(std::process::id(), false);
            std::process::exit(STOPPED_EXIT_CODE);
        });
    });
}

/// What a process ended by a stop request exits with: 128 + SIGINT, the
/// shell's convention for "interrupted".
pub const STOPPED_EXIT_CODE: i32 = 130;

/// Wait for `child`, passing a stop request on to it.
///
/// A parent that waits on a child and is itself asked to stop has two wrong
/// answers available: die and leave the child running, or wait as if nothing
/// had been asked. The child is asked to stop, given `grace` to do it — and
/// then a margin, because the child gives what IT started the same grace —
/// and ended if it has not.
pub fn wait_passing_stop(
    child: &mut std::process::Child,
    flag: &std::sync::atomic::AtomicBool,
    grace: std::time::Duration,
) -> std::io::Result<std::process::ExitStatus> {
    let mut asked: Option<std::time::Instant> = None;
    let mut ended_hard = false;
    loop {
        if let Some(status) = child.try_wait()? {
            return Ok(status);
        }
        if flag.load(std::sync::atomic::Ordering::SeqCst) {
            match asked {
                None => {
                    crate::platform::end_politely(child.id() as i32);
                    asked = Some(std::time::Instant::now());
                }
                Some(at) if !ended_hard && at.elapsed() > grace + grace / 2 => {
                    crate::platform::end_hard(child.id() as i32);
                    ended_hard = true;
                }
                Some(_) => {}
            }
        }
        std::thread::sleep(std::time::Duration::from_millis(100));
    }
}

/// Whether anything but us is running this.
pub fn any_running(pattern: &str) -> bool {
    !matching(pattern).is_empty()
}

/// What is still there, one per line, for a refusal that has to name them.
pub fn describe(matches: &[Match]) -> String {
    matches
        .iter()
        .map(|found| format!("{} {}", found.pid, found.command))
        .collect::<Vec<_>>()
        .join("\n")
}

#[cfg(test)]
#[path = "process_tests.rs"]
mod tests;