@team-agent/installer 0.5.2 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +3 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/Cargo.toml +20 -0
- package/crates/team-agent/src/cli/emit.rs +5 -0
- package/crates/team-agent/src/cli/mod.rs +121 -56
- package/crates/team-agent/src/cli/tests/named_address.rs +4 -0
- package/crates/team-agent/src/codex_app_server.rs +62 -1
- package/crates/team-agent/src/conpty/backend.rs +120 -8
- package/crates/team-agent/src/coordinator/backoff.rs +88 -2
- package/crates/team-agent/src/coordinator/conpty_shim.rs +730 -0
- package/crates/team-agent/src/coordinator/health.rs +97 -11
- package/crates/team-agent/src/coordinator/mod.rs +8 -0
- package/crates/team-agent/src/coordinator/tests/daemon.rs +2 -1
- package/crates/team-agent/src/coordinator/tests/tick_core.rs +6 -0
- package/crates/team-agent/src/diagnose/orphans.rs +18 -7
- package/crates/team-agent/src/leader/provider_attribution.rs +19 -34
- package/crates/team-agent/src/leader/tests/lease_api.rs +7 -0
- package/crates/team-agent/src/lib.rs +14 -1
- package/crates/team-agent/src/lifecycle/launch.rs +80 -91
- package/crates/team-agent/src/lifecycle/lock.rs +40 -33
- package/crates/team-agent/src/lifecycle/restart/agent.rs +10 -18
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -0
- package/crates/team-agent/src/mcp_server/wire.rs +6 -7
- package/crates/team-agent/src/messaging/tests/runtime.rs +5 -0
- package/crates/team-agent/src/packaging/tests.rs +41 -6
- package/crates/team-agent/src/packaging/types.rs +31 -3
- package/crates/team-agent/src/platform/argv.rs +324 -0
- package/crates/team-agent/src/platform/errors.rs +95 -0
- package/crates/team-agent/src/platform/file_lock.rs +418 -0
- package/crates/team-agent/src/platform/mod.rs +66 -0
- package/crates/team-agent/src/platform/process.rs +555 -0
- package/crates/team-agent/src/state/persist.rs +205 -25
- package/crates/team-agent/src/tmux_backend.rs +63 -13
- package/crates/team-agent/src/transport_factory.rs +124 -5
- package/package.json +4 -4
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
//! Process lifecycle + snapshot + termination platform primitives.
|
|
2
|
+
//!
|
|
3
|
+
//! Batch 0 provides:
|
|
4
|
+
//! - `Pid`, `SignalKind`, `ProcessLiveness`, `ProcessInfo`,
|
|
5
|
+
//! `TerminationOutcome`
|
|
6
|
+
//! - Unix impl: byte-equivalent to the current inline
|
|
7
|
+
//! `libc::kill(pid, 0)` / `waitpid(WNOHANG)` / `ps -axo` logic (no
|
|
8
|
+
//! caller migration in Batch 0)
|
|
9
|
+
//! - Windows impl: `unimplemented!()` stubs annotated with the
|
|
10
|
+
//! Windows API Batch 3 will use (`OpenProcess`,
|
|
11
|
+
//! `GetExitCodeProcess`, `TerminateProcess`, Toolhelp snapshot,
|
|
12
|
+
//! Job Objects for shim-owned worker teardown)
|
|
13
|
+
//!
|
|
14
|
+
//! CR C-6: `terminate_pid` / `terminate_group` return
|
|
15
|
+
//! `TerminationOutcome` so a caller that requested `TerminateGraceful`
|
|
16
|
+
//! can see that Windows downgraded to `TerminateForce` and emit a
|
|
17
|
+
//! `platform.terminate_force_only` event (N38 交底).
|
|
18
|
+
//!
|
|
19
|
+
//! CR C-4: this module is NOT importable from `messaging/`. Grep guard
|
|
20
|
+
//! `platform_process_caller_whitelist_batch0.rs` enforces the boundary.
|
|
21
|
+
|
|
22
|
+
use std::io;
|
|
23
|
+
|
|
24
|
+
/// A native process id. Portable u32 for both Unix and Windows.
|
|
25
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
26
|
+
pub struct Pid(pub u32);
|
|
27
|
+
|
|
28
|
+
impl From<u32> for Pid {
|
|
29
|
+
fn from(v: u32) -> Self {
|
|
30
|
+
Self(v)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Termination request kind. On Unix `TerminateGraceful` maps to
|
|
35
|
+
/// `SIGTERM`, `TerminateForce` maps to `SIGKILL`. On Windows there is
|
|
36
|
+
/// no grace period equivalent — see `TerminationOutcome::ForceOnly`.
|
|
37
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
38
|
+
pub enum SignalKind {
|
|
39
|
+
TerminateGraceful,
|
|
40
|
+
TerminateForce,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// Result of a `terminate_pid` / `terminate_group` call. `Graceful`
|
|
44
|
+
/// means the OS honored the request kind; `ForceOnly` means Windows
|
|
45
|
+
/// downgraded a `TerminateGraceful` to `TerminateProcess` (CR C-6
|
|
46
|
+
/// N38 交底 — caller must emit a `platform.terminate_force_only`
|
|
47
|
+
/// event).
|
|
48
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
49
|
+
pub enum TerminationOutcome {
|
|
50
|
+
/// Requested `SignalKind` was honored (Unix `SIGTERM`/`SIGKILL`
|
|
51
|
+
/// or Windows console-control-event for owned console groups).
|
|
52
|
+
Requested,
|
|
53
|
+
/// Windows downgraded `TerminateGraceful` to `TerminateProcess`
|
|
54
|
+
/// (no grace period). Includes a machine-readable reason so
|
|
55
|
+
/// callers can log it in the audit event.
|
|
56
|
+
ForceOnly {
|
|
57
|
+
reason: &'static str,
|
|
58
|
+
},
|
|
59
|
+
/// The pid/group was already gone by the time the call resolved.
|
|
60
|
+
/// Not an error.
|
|
61
|
+
AlreadyGone,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Live/Dead/Unknown liveness classification. `Unknown { reason }`
|
|
65
|
+
/// covers the "we don't have OS permission to check" case + Windows
|
|
66
|
+
/// paths that Batch 3 will fill in.
|
|
67
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
68
|
+
pub enum ProcessLiveness {
|
|
69
|
+
Live,
|
|
70
|
+
Dead,
|
|
71
|
+
Unknown { reason: String },
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// One row from a process snapshot. Fields that the OS does not
|
|
75
|
+
/// expose (Windows has no `pgid`/`session` concept in the Unix sense)
|
|
76
|
+
/// are `None`.
|
|
77
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
78
|
+
pub struct ProcessInfo {
|
|
79
|
+
pub pid: u32,
|
|
80
|
+
pub ppid: Option<u32>,
|
|
81
|
+
pub group_id: Option<u32>,
|
|
82
|
+
pub session_id: Option<u32>,
|
|
83
|
+
pub command: Option<String>,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
87
|
+
// Unix implementation.
|
|
88
|
+
//
|
|
89
|
+
// Batch 0 populates these from the current inline logic in
|
|
90
|
+
// `mcp_server/wire.rs`, `coordinator/backoff.rs`,
|
|
91
|
+
// `lifecycle/restart/agent.rs`, `coordinator/health.rs`. No caller
|
|
92
|
+
// migration yet — Batch 2/3 replaces the inline calls with these
|
|
93
|
+
// wrappers. This ensures Unix behavior is byte-equivalent when the
|
|
94
|
+
// migration lands.
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
#[cfg(unix)]
|
|
98
|
+
mod unix_impl {
|
|
99
|
+
//! Byte-preserving Unix implementation. Every fn body is the
|
|
100
|
+
//! current inline code from the callers (`coordinator/health.rs`,
|
|
101
|
+
//! `cli/mod.rs`, `coordinator/backoff.rs`, `mcp_server/wire.rs`,
|
|
102
|
+
//! `lifecycle/restart/agent.rs`) with zero behavioral drift.
|
|
103
|
+
use super::*;
|
|
104
|
+
|
|
105
|
+
pub fn current_parent_pid() -> Option<u32> {
|
|
106
|
+
// Byte-equivalent to `mcp_server/wire.rs:319` and
|
|
107
|
+
// `coordinator/backoff.rs:258` Unix branches.
|
|
108
|
+
let raw = unsafe { libc::getppid() };
|
|
109
|
+
u32::try_from(raw).ok()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
pub fn current_process_group() -> Option<u32> {
|
|
113
|
+
// Byte-equivalent to `cli/mod.rs:1708`.
|
|
114
|
+
let raw = unsafe { libc::getpgrp() };
|
|
115
|
+
u32::try_from(raw).ok()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
pub fn pid_liveness(pid: u32) -> Result<ProcessLiveness, io::Error> {
|
|
119
|
+
// Byte-equivalent to `lifecycle/restart/agent.rs::pid_is_alive`
|
|
120
|
+
// (EPERM = Live because sender can't signal but process
|
|
121
|
+
// exists). Callers get the same True/False split.
|
|
122
|
+
let ret = unsafe { libc::kill(pid as i32, 0) };
|
|
123
|
+
if ret == 0 {
|
|
124
|
+
return Ok(ProcessLiveness::Live);
|
|
125
|
+
}
|
|
126
|
+
let err = io::Error::last_os_error();
|
|
127
|
+
match err.raw_os_error() {
|
|
128
|
+
Some(code) if code == libc::EPERM => Ok(ProcessLiveness::Live),
|
|
129
|
+
Some(code) if code == libc::ESRCH => Ok(ProcessLiveness::Dead),
|
|
130
|
+
_ => Err(err),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Non-erroring convenience over `pid_liveness`. Returns `true`
|
|
135
|
+
/// when the process is Live (or unknown-but-not-clearly-dead so
|
|
136
|
+
/// callers behave conservatively), `false` when Dead.
|
|
137
|
+
///
|
|
138
|
+
/// Byte-equivalent to `cli/mod.rs::process_is_live` +
|
|
139
|
+
/// `coordinator/health.rs::pid_is_running` "signal_rc == 0 || EPERM"
|
|
140
|
+
/// branch.
|
|
141
|
+
pub fn pid_is_alive(pid: u32) -> bool {
|
|
142
|
+
matches!(pid_liveness(pid), Ok(ProcessLiveness::Live))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
pub fn process_snapshot() -> Result<Vec<ProcessInfo>, io::Error> {
|
|
146
|
+
// Reserved for Batch 3 follow-up that migrates the `ps -axo
|
|
147
|
+
// pid=,ppid=` shellouts in `coordinator/health.rs` and
|
|
148
|
+
// `cli/mod.rs` here. Left as `Ok(Vec::new())` so downstream
|
|
149
|
+
// callers can migrate incrementally in a future batch; keeping
|
|
150
|
+
// the ps shellout at the callsites for now preserves the
|
|
151
|
+
// exact current output-parsing byte-shape.
|
|
152
|
+
Ok(Vec::new())
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
pub fn process_tree(_root: u32) -> Result<Vec<u32>, io::Error> {
|
|
156
|
+
Ok(Vec::new())
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// Send a SIGTERM (`TerminateGraceful`) or SIGKILL
|
|
160
|
+
/// (`TerminateForce`) to `pid`. Byte-equivalent to the
|
|
161
|
+
/// `libc::kill(pid, SIGTERM|SIGKILL)` shell-out inline at
|
|
162
|
+
/// `coordinator/health.rs:280,284,731` + `cli/mod.rs:1848`.
|
|
163
|
+
///
|
|
164
|
+
/// Unix always returns `TerminationOutcome::Requested` because
|
|
165
|
+
/// `SIGTERM` is a real grace signal (Windows sees the C-6
|
|
166
|
+
/// downgrade path); the `AlreadyGone` case is preserved when
|
|
167
|
+
/// `kill()` returns ESRCH (the target was reaped between check
|
|
168
|
+
/// and send).
|
|
169
|
+
pub fn terminate_pid(
|
|
170
|
+
pid: u32,
|
|
171
|
+
kind: SignalKind,
|
|
172
|
+
) -> Result<TerminationOutcome, io::Error> {
|
|
173
|
+
let signal = match kind {
|
|
174
|
+
SignalKind::TerminateGraceful => libc::SIGTERM,
|
|
175
|
+
SignalKind::TerminateForce => libc::SIGKILL,
|
|
176
|
+
};
|
|
177
|
+
let pid_t = match libc::pid_t::try_from(pid) {
|
|
178
|
+
Ok(p) => p,
|
|
179
|
+
Err(_) => return Ok(TerminationOutcome::AlreadyGone),
|
|
180
|
+
};
|
|
181
|
+
let rc = unsafe { libc::kill(pid_t, signal) };
|
|
182
|
+
if rc == 0 {
|
|
183
|
+
return Ok(TerminationOutcome::Requested);
|
|
184
|
+
}
|
|
185
|
+
let err = io::Error::last_os_error();
|
|
186
|
+
match err.raw_os_error() {
|
|
187
|
+
Some(code) if code == libc::ESRCH => Ok(TerminationOutcome::AlreadyGone),
|
|
188
|
+
_ => Err(err),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// Send a signal to a process group (`kill(-pgid, ...)`).
|
|
193
|
+
/// Byte-equivalent to `cli/mod.rs:1854` (`libc::kill(-pgid, signal)`)
|
|
194
|
+
/// used by `send_process_signal_group`.
|
|
195
|
+
pub fn terminate_group(
|
|
196
|
+
group_id: u32,
|
|
197
|
+
kind: SignalKind,
|
|
198
|
+
) -> Result<TerminationOutcome, io::Error> {
|
|
199
|
+
let signal = match kind {
|
|
200
|
+
SignalKind::TerminateGraceful => libc::SIGTERM,
|
|
201
|
+
SignalKind::TerminateForce => libc::SIGKILL,
|
|
202
|
+
};
|
|
203
|
+
let pgid_t = match libc::pid_t::try_from(group_id) {
|
|
204
|
+
Ok(p) => p,
|
|
205
|
+
Err(_) => return Ok(TerminationOutcome::AlreadyGone),
|
|
206
|
+
};
|
|
207
|
+
// `-pgid` targets every process in that group. Preserves the
|
|
208
|
+
// existing shutdown semantics inline in cli/mod.rs.
|
|
209
|
+
let rc = unsafe { libc::kill(-pgid_t, signal) };
|
|
210
|
+
if rc == 0 {
|
|
211
|
+
return Ok(TerminationOutcome::Requested);
|
|
212
|
+
}
|
|
213
|
+
let err = io::Error::last_os_error();
|
|
214
|
+
match err.raw_os_error() {
|
|
215
|
+
Some(code) if code == libc::ESRCH => Ok(TerminationOutcome::AlreadyGone),
|
|
216
|
+
_ => Err(err),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// Best-effort waitpid so a killed child doesn't accumulate as a
|
|
221
|
+
/// zombie. Byte-equivalent to `coordinator/health.rs:350-358` and
|
|
222
|
+
/// `cli/mod.rs:1871-1879`.
|
|
223
|
+
pub fn reap_child_if_possible(pid: u32) {
|
|
224
|
+
let pid_t = match libc::pid_t::try_from(pid) {
|
|
225
|
+
Ok(p) => p,
|
|
226
|
+
Err(_) => return,
|
|
227
|
+
};
|
|
228
|
+
let mut status = 0;
|
|
229
|
+
unsafe {
|
|
230
|
+
// WNOHANG so we never block; if the child isn't ours
|
|
231
|
+
// (ECHILD) libc quietly returns -1 which we ignore.
|
|
232
|
+
libc::waitpid(pid_t, &mut status, libc::WNOHANG);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
238
|
+
// Windows implementation.
|
|
239
|
+
//
|
|
240
|
+
// Batch 0 provides `unimplemented!()` stubs so `cargo check --target
|
|
241
|
+
// x86_64-pc-windows-msvc` sees the module compile past the signature
|
|
242
|
+
// boundary. The RED CI baseline comes from every failing file OUTSIDE
|
|
243
|
+
// this module (tmux_backend, coordinator/health, cli/mod, state/persist,
|
|
244
|
+
// lifecycle/restart/agent, mcp_server/wire, etc.) — Batches 1-4 will
|
|
245
|
+
// remove those, and Batches 3/4 will implement the stubs below.
|
|
246
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
#[cfg(not(unix))]
|
|
249
|
+
mod windows_impl {
|
|
250
|
+
//! Batch 3 real Windows implementation. Uses `OpenProcess` +
|
|
251
|
+
//! `GetExitCodeProcess` for liveness (CR MUST-11 positive
|
|
252
|
+
//! authority: kernel handle + typed status),
|
|
253
|
+
//! `TerminateProcess(hProcess, 1)` for hard kill, Toolhelp
|
|
254
|
+
//! snapshot for parent-pid discovery. **CR C-6 anchor**:
|
|
255
|
+
//! `SignalKind::TerminateGraceful` maps to `TerminationOutcome::ForceOnly`
|
|
256
|
+
//! because `TerminateProcess` has no grace period equivalent —
|
|
257
|
+
//! callers use this signal to emit the `platform.terminate_force_only`
|
|
258
|
+
//! N38 audit event so the semantic difference from
|
|
259
|
+
//! Unix `SIGTERM` is visible in the event log rather than silent.
|
|
260
|
+
use super::*;
|
|
261
|
+
|
|
262
|
+
pub fn current_parent_pid() -> Option<u32> {
|
|
263
|
+
use windows::Win32::System::Diagnostics::ToolHelp::{
|
|
264
|
+
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
|
265
|
+
TH32CS_SNAPPROCESS,
|
|
266
|
+
};
|
|
267
|
+
use windows::Win32::System::Threading::GetCurrentProcessId;
|
|
268
|
+
let my_pid = unsafe { GetCurrentProcessId() };
|
|
269
|
+
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.ok()?;
|
|
270
|
+
let mut entry = PROCESSENTRY32W {
|
|
271
|
+
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
|
|
272
|
+
..Default::default()
|
|
273
|
+
};
|
|
274
|
+
let mut result = unsafe { Process32FirstW(snapshot, &mut entry) };
|
|
275
|
+
while result.is_ok() {
|
|
276
|
+
if entry.th32ProcessID == my_pid {
|
|
277
|
+
let ppid = entry.th32ParentProcessID;
|
|
278
|
+
unsafe { let _ = windows::Win32::Foundation::CloseHandle(snapshot); };
|
|
279
|
+
return Some(ppid);
|
|
280
|
+
}
|
|
281
|
+
result = unsafe { Process32NextW(snapshot, &mut entry) };
|
|
282
|
+
}
|
|
283
|
+
unsafe { let _ = windows::Win32::Foundation::CloseHandle(snapshot); };
|
|
284
|
+
None
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
pub fn current_process_group() -> Option<u32> {
|
|
288
|
+
// Windows has no direct process-group equivalent. Job Objects
|
|
289
|
+
// provide grouping semantics per shim/worker (design §Route B:
|
|
290
|
+
// "owned worker teardown: prefer Job Objects in the ConPTY
|
|
291
|
+
// shim so `kill_session` is exact"). Return `None` honestly so
|
|
292
|
+
// caller code (cli/mod.rs::shutdown_protection_set) sees the
|
|
293
|
+
// "no pgid to protect" branch — the current-PID protection
|
|
294
|
+
// still applies via `current_process_id`.
|
|
295
|
+
None
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
pub fn pid_liveness(pid: u32) -> Result<ProcessLiveness, io::Error> {
|
|
299
|
+
use windows::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
|
|
300
|
+
use windows::Win32::System::Threading::{
|
|
301
|
+
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
|
|
302
|
+
};
|
|
303
|
+
// `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` — minimal
|
|
304
|
+
// access rights needed for `GetExitCodeProcess`.
|
|
305
|
+
// MUST-11 positive authority: we own a real kernel handle.
|
|
306
|
+
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) };
|
|
307
|
+
let handle = match handle {
|
|
308
|
+
Ok(h) => h,
|
|
309
|
+
Err(err) => {
|
|
310
|
+
// Both raw Win32 error codes and their HRESULT
|
|
311
|
+
// encodings (HRESULT_FROM_WIN32) are accepted so
|
|
312
|
+
// future windows-crate API changes don't silently
|
|
313
|
+
// break the code mapping.
|
|
314
|
+
let raw = err.code().0 as u32;
|
|
315
|
+
const ERROR_INVALID_PARAMETER: u32 = 87;
|
|
316
|
+
const E_INVALIDARG_HRESULT: u32 = 0x80070057;
|
|
317
|
+
const ERROR_ACCESS_DENIED: u32 = 5;
|
|
318
|
+
const E_ACCESSDENIED_HRESULT: u32 = 0x80070005;
|
|
319
|
+
if raw == ERROR_INVALID_PARAMETER || raw == E_INVALIDARG_HRESULT {
|
|
320
|
+
return Ok(ProcessLiveness::Dead);
|
|
321
|
+
}
|
|
322
|
+
if raw == ERROR_ACCESS_DENIED || raw == E_ACCESSDENIED_HRESULT {
|
|
323
|
+
return Ok(ProcessLiveness::Live);
|
|
324
|
+
}
|
|
325
|
+
return Err(io::Error::from_raw_os_error(err.code().0 as i32));
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
let mut exit_code: u32 = 0;
|
|
329
|
+
let result = unsafe { GetExitCodeProcess(handle, &mut exit_code) };
|
|
330
|
+
unsafe { let _ = CloseHandle(handle); };
|
|
331
|
+
result.map_err(|e| io::Error::from_raw_os_error(e.code().0 as i32))?;
|
|
332
|
+
// `STILL_ACTIVE` (0x103) — the process is still running.
|
|
333
|
+
if exit_code == STILL_ACTIVE.0 as u32 {
|
|
334
|
+
Ok(ProcessLiveness::Live)
|
|
335
|
+
} else {
|
|
336
|
+
Ok(ProcessLiveness::Dead)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/// Non-erroring convenience over `pid_liveness`. Windows mirrors
|
|
341
|
+
/// the Unix behavior (Unknown branch reported conservatively as
|
|
342
|
+
/// "not Live" so drain/reap don't loop forever).
|
|
343
|
+
pub fn pid_is_alive(pid: u32) -> bool {
|
|
344
|
+
matches!(pid_liveness(pid), Ok(ProcessLiveness::Live))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
pub fn process_snapshot() -> Result<Vec<ProcessInfo>, io::Error> {
|
|
348
|
+
// Reserved for Batch 3 follow-up. `CreateToolhelp32Snapshot`
|
|
349
|
+
// + `Process32First`/`Process32Next` would populate this,
|
|
350
|
+
// but the coordinator/health.rs + cli/mod.rs `ps -axo`
|
|
351
|
+
// callers on Unix still parse a string table so we defer
|
|
352
|
+
// that migration to a later batch to preserve the exact
|
|
353
|
+
// wire shape today.
|
|
354
|
+
Ok(Vec::new())
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
pub fn process_tree(_root: u32) -> Result<Vec<u32>, io::Error> {
|
|
358
|
+
Ok(Vec::new())
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
pub fn terminate_pid(
|
|
362
|
+
pid: u32,
|
|
363
|
+
kind: SignalKind,
|
|
364
|
+
) -> Result<TerminationOutcome, io::Error> {
|
|
365
|
+
use windows::Win32::Foundation::CloseHandle;
|
|
366
|
+
use windows::Win32::System::Threading::{
|
|
367
|
+
OpenProcess, TerminateProcess, PROCESS_TERMINATE,
|
|
368
|
+
};
|
|
369
|
+
// CR C-6 anchor: Windows has no SIGTERM analogue for a
|
|
370
|
+
// non-console child. `TerminateGraceful` cannot be honored;
|
|
371
|
+
// we still perform the physical `TerminateProcess` (design
|
|
372
|
+
// §Change-Induced Risks: "ConPTY shim should own Job Objects
|
|
373
|
+
// to preserve precise worker teardown") but return
|
|
374
|
+
// `ForceOnly` so callers emit `platform.terminate_force_only`
|
|
375
|
+
// for the audit log.
|
|
376
|
+
let handle = unsafe { OpenProcess(PROCESS_TERMINATE, false, pid) };
|
|
377
|
+
let handle = match handle {
|
|
378
|
+
Ok(h) => h,
|
|
379
|
+
Err(err) => {
|
|
380
|
+
// `err.code().0` is a HRESULT, not a raw Win32 error.
|
|
381
|
+
// For Win32 errors it takes the form
|
|
382
|
+
// `HRESULT_FROM_WIN32(win32)` = `0x8007<win32 low 16>`.
|
|
383
|
+
// Accept BOTH the raw Win32 form (in case a future
|
|
384
|
+
// windows-crate API returns it) and the HRESULT form.
|
|
385
|
+
let raw = err.code().0 as u32;
|
|
386
|
+
const ERROR_INVALID_PARAMETER: u32 = 87;
|
|
387
|
+
const E_INVALIDARG_HRESULT: u32 = 0x80070057; // HRESULT_FROM_WIN32(87)
|
|
388
|
+
if raw == ERROR_INVALID_PARAMETER || raw == E_INVALIDARG_HRESULT {
|
|
389
|
+
// pid doesn't exist (or reaped between recorded
|
|
390
|
+
// spawn and this shutdown call).
|
|
391
|
+
return Ok(TerminationOutcome::AlreadyGone);
|
|
392
|
+
}
|
|
393
|
+
return Err(io::Error::from_raw_os_error(err.code().0 as i32));
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
let result = unsafe { TerminateProcess(handle, 1) };
|
|
397
|
+
unsafe { let _ = CloseHandle(handle); };
|
|
398
|
+
result.map_err(|e| io::Error::from_raw_os_error(e.code().0 as i32))?;
|
|
399
|
+
Ok(match kind {
|
|
400
|
+
SignalKind::TerminateGraceful => TerminationOutcome::ForceOnly {
|
|
401
|
+
reason: "windows_no_sigterm_equivalent_for_non_console_child",
|
|
402
|
+
},
|
|
403
|
+
SignalKind::TerminateForce => TerminationOutcome::Requested,
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
pub fn terminate_group(
|
|
408
|
+
_group_id: u32,
|
|
409
|
+
_kind: SignalKind,
|
|
410
|
+
) -> Result<TerminationOutcome, io::Error> {
|
|
411
|
+
// Windows has no `-pgid` sentinel. Design §Route B: "owned
|
|
412
|
+
// worker teardown: prefer Job Objects in the ConPTY shim".
|
|
413
|
+
// Job-object teardown is a shim-side concern, not a top-level
|
|
414
|
+
// API. Return AlreadyGone honestly here so shutdown code
|
|
415
|
+
// falls back to per-pid termination (the caller's outer
|
|
416
|
+
// loop retries per-pid after the group attempt is a no-op).
|
|
417
|
+
Ok(TerminationOutcome::AlreadyGone)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
pub fn reap_child_if_possible(_pid: u32) {
|
|
421
|
+
// Windows has no zombie waitpid model. Child handles are
|
|
422
|
+
// owned by the spawner and closed at drop; nothing to do.
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
427
|
+
// Re-export the platform-appropriate impl at module top-level so
|
|
428
|
+
// callers write `platform::process::pid_liveness(pid)` without cfg.
|
|
429
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
#[cfg(unix)]
|
|
432
|
+
pub use unix_impl::*;
|
|
433
|
+
|
|
434
|
+
#[cfg(not(unix))]
|
|
435
|
+
pub use windows_impl::*;
|
|
436
|
+
|
|
437
|
+
#[cfg(test)]
|
|
438
|
+
mod tests {
|
|
439
|
+
use super::*;
|
|
440
|
+
|
|
441
|
+
#[test]
|
|
442
|
+
fn parent_pid_returns_some_value_on_both_platforms_after_batch_3() {
|
|
443
|
+
// Batch 3 real implementation: both Unix (`getppid`) and
|
|
444
|
+
// Windows (Toolhelp32 snapshot) return the process's parent
|
|
445
|
+
// pid. A `None` here on either platform is a regression.
|
|
446
|
+
let ppid = current_parent_pid();
|
|
447
|
+
assert!(ppid.is_some(), "current_parent_pid must return Some on both unix and windows");
|
|
448
|
+
assert!(ppid.unwrap() > 0);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
#[test]
|
|
452
|
+
fn pid_liveness_own_pid_is_live_on_unix() {
|
|
453
|
+
// Batch 0 unix impl is byte-equivalent to
|
|
454
|
+
// `lifecycle/restart/agent.rs::pid_is_alive` for our own pid,
|
|
455
|
+
// which must always be Live.
|
|
456
|
+
#[cfg(unix)]
|
|
457
|
+
{
|
|
458
|
+
let my_pid = std::process::id();
|
|
459
|
+
let result = pid_liveness(my_pid).expect("own pid must be checkable");
|
|
460
|
+
assert_eq!(result, ProcessLiveness::Live);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
#[test]
|
|
465
|
+
fn windows_terminate_graceful_returns_force_only_with_reason_when_downgraded() {
|
|
466
|
+
// CR C-6 anchor: caller can distinguish Requested vs
|
|
467
|
+
// ForceOnly and emit the `platform.terminate_force_only`
|
|
468
|
+
// audit event only when the OS downgraded. Use an
|
|
469
|
+
// impossible pid so `AlreadyGone` short-circuits the actual
|
|
470
|
+
// kill — we're only checking the shape of the outcome enum.
|
|
471
|
+
//
|
|
472
|
+
// On Unix the SAME call returns `AlreadyGone` (kill returned
|
|
473
|
+
// ESRCH) because `SIGTERM` is a real signal there.
|
|
474
|
+
#[cfg(not(unix))]
|
|
475
|
+
{
|
|
476
|
+
// On Windows the AlreadyGone short-circuit runs first;
|
|
477
|
+
// to exercise the ForceOnly path we'd need a real
|
|
478
|
+
// process. This test just documents the ForceOnly variant
|
|
479
|
+
// exists and matches what a live-target call would return.
|
|
480
|
+
let outcome = TerminationOutcome::ForceOnly {
|
|
481
|
+
reason: "windows_no_sigterm_equivalent_for_non_console_child",
|
|
482
|
+
};
|
|
483
|
+
match outcome {
|
|
484
|
+
TerminationOutcome::ForceOnly { reason } => {
|
|
485
|
+
assert!(reason.contains("windows"));
|
|
486
|
+
}
|
|
487
|
+
other => panic!("expected ForceOnly variant, got {other:?}"),
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#[test]
|
|
493
|
+
fn pid_is_alive_returns_true_for_own_pid() {
|
|
494
|
+
// Batch 3 anchor: `pid_is_alive` is the byte-preserving
|
|
495
|
+
// migration target for `lifecycle/restart/agent.rs::pid_is_alive`
|
|
496
|
+
// + `cli/mod.rs::process_is_live`. Callers rely on "our own pid
|
|
497
|
+
// is always alive" invariant to derive drain-loop termination.
|
|
498
|
+
assert!(pid_is_alive(std::process::id()));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#[test]
|
|
502
|
+
fn pid_is_alive_returns_false_for_definitely_dead_pid() {
|
|
503
|
+
// A pid we absolutely never allocate — 0xFFFF_FFFE — is
|
|
504
|
+
// guaranteed to not exist. Windows OpenProcess returns
|
|
505
|
+
// ERROR_INVALID_PARAMETER; Unix `kill(pid, 0)` returns
|
|
506
|
+
// ESRCH. Both map to `false`.
|
|
507
|
+
assert!(!pid_is_alive(0xFFFF_FFFE));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
#[test]
|
|
511
|
+
fn pid_liveness_returns_dead_for_impossible_pid() {
|
|
512
|
+
match pid_liveness(0xFFFF_FFFE) {
|
|
513
|
+
Ok(ProcessLiveness::Dead) => {}
|
|
514
|
+
Ok(other) => panic!("expected Dead for impossible pid, got {other:?}"),
|
|
515
|
+
Err(e) => panic!("expected Ok(Dead), got Err({e:?})"),
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
#[test]
|
|
520
|
+
fn reap_child_if_possible_never_panics_for_arbitrary_pid() {
|
|
521
|
+
// Byte-equivalent to `coordinator/health.rs::reap_child_if_possible`
|
|
522
|
+
// + `cli/mod.rs::reap_child_if_possible` invariant: this is
|
|
523
|
+
// called on foreign pids too (children of coordinator's
|
|
524
|
+
// children), so it must silently no-op if the pid is not
|
|
525
|
+
// reap-able by this process.
|
|
526
|
+
reap_child_if_possible(std::process::id());
|
|
527
|
+
reap_child_if_possible(0xFFFF_FFFE);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
#[test]
|
|
531
|
+
fn terminate_pid_returns_already_gone_for_impossible_pid() {
|
|
532
|
+
// `coordinator/health.rs::terminate_pid` treats "kill returned
|
|
533
|
+
// error because target not-found" as success (idempotent
|
|
534
|
+
// shutdown). Preserve that shape via `AlreadyGone`.
|
|
535
|
+
match terminate_pid(0xFFFF_FFFE, SignalKind::TerminateForce) {
|
|
536
|
+
Ok(TerminationOutcome::AlreadyGone) => {}
|
|
537
|
+
other => panic!("expected AlreadyGone for impossible pid, got {other:?}"),
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
#[test]
|
|
542
|
+
fn signal_kind_and_termination_outcome_are_distinguishable_variants() {
|
|
543
|
+
// Both enums must have distinct variants so callers can
|
|
544
|
+
// pattern-match without cfg.
|
|
545
|
+
assert_ne!(SignalKind::TerminateGraceful, SignalKind::TerminateForce);
|
|
546
|
+
let requested = TerminationOutcome::Requested;
|
|
547
|
+
let force_only = TerminationOutcome::ForceOnly {
|
|
548
|
+
reason: "test",
|
|
549
|
+
};
|
|
550
|
+
let already_gone = TerminationOutcome::AlreadyGone;
|
|
551
|
+
assert_ne!(requested, force_only);
|
|
552
|
+
assert_ne!(requested, already_gone);
|
|
553
|
+
assert_ne!(force_only, already_gone);
|
|
554
|
+
}
|
|
555
|
+
}
|