objectiveai_sdk/process.rs
1//! Process-management helpers that pair with [`crate::lockfile`]'s owner
2//! lookups: given a pid (e.g. from [`crate::lockfile::owners`] /
3//! [`crate::lockfile::owners_in_tree`]), ask the OS to terminate it —
4//! plus the repo-wide [`no_window`] spawn hygiene.
5
6/// Windows `CREATE_NO_WINDOW`: spawn the child without a console
7/// window. Our services are windowless (daemon, laboratory host, db
8/// supervisor, the viewer shell), so every console-subsystem child
9/// they spawn — podman, postgres, plugins, tools, runners, the cli
10/// re-execing itself — otherwise allocates and FLASHES its own
11/// console at the user. Applied at every runtime spawn site in the
12/// repository; no-op off Windows.
13#[cfg(any(
14 feature = "http",
15 feature = "mcp",
16 feature = "cli-executor",
17 feature = "cli-listener",
18 feature = "lockfile",
19 feature = "subprocess-reaper"
20))]
21pub fn no_window(command: &mut tokio::process::Command) {
22 #[cfg(windows)]
23 {
24 use std::os::windows::process::CommandExt;
25 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
26 command.creation_flags(CREATE_NO_WINDOW);
27 }
28 #[cfg(not(windows))]
29 {
30 let _ = command;
31 }
32}
33
34
35/// The one-line stdout readiness handshake every persistent server the
36/// daemon spawns (db / api / mcp / viewer / laboratory host) emits the
37/// moment it is serving: `{"type":"ready","address":...}`. The daemon —
38/// which holds the child's stdout pipe for its whole life (the children
39/// are OS-leashed to it) — reads lines until this parses, caches the
40/// address, and keeps draining. `address` is the server's client
41/// connect coordinate (`http://…` for api/mcp, `postgresql://…` for
42/// db) or `None` for servers with no listener (viewer, laboratory
43/// host — they dial out; the line is pure readiness).
44///
45/// This replaced the old lockfile-readiness scheme: servers no longer
46/// publish locks (the daemon is their sole spawner and owns their
47/// lifetime), so there is nothing on disk to discover. The daemon's
48/// OWN lock is the one survivor — short-lived CLI processes need a
49/// rendezvous outside any pipe to find an already-running daemon.
50#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51#[serde(tag = "type", rename = "ready")]
52pub struct ServerReady {
53 pub address: Option<String>,
54}
55
56/// Print the readiness line to stdout and flush. UNCONDITIONAL by
57/// contract — never behind a suppress-output flag: the spawner blocks
58/// on this line, and the daemon's persistent drain makes any later
59/// stray writes harmless.
60pub fn print_ready(address: Option<&str>) {
61 use std::io::Write;
62 let line = serde_json::to_string(&ServerReady {
63 address: address.map(str::to_string),
64 })
65 .expect("ServerReady serializes");
66 let mut stdout = std::io::stdout().lock();
67 let _ = writeln!(stdout, "{line}");
68 let _ = stdout.flush();
69}
70
71/// Parse one stdout line as the readiness handshake. `None` for
72/// anything else (servers may print other lines; the reader skips
73/// them).
74pub fn parse_ready(line: &str) -> Option<ServerReady> {
75 serde_json::from_str(line.trim()).ok()
76}
77
78/// Send a terminate to `pid` — Unix `SIGTERM`, Windows `TerminateProcess`.
79/// Returns 1 if a live process with that pid was signalled, 0 otherwise
80/// (no such process, or the signal/open failed). Idempotent and
81/// best-effort: killing an already-dead pid is a 0, not an error.
82///
83/// Gated on the two features that carry the nix / windows-sys deps —
84/// the module itself exists for every subprocess-spawning feature
85/// (see the `no_window` gate).
86#[cfg(all(unix, any(feature = "lockfile", feature = "subprocess-reaper")))]
87pub fn kill_pid(pid: u32) -> usize {
88 // SAFETY: `kill(2)` with a valid signal number is sound — it only
89 // checks the target's existence and posts a signal, touching no
90 // memory. `SIGTERM` to a nonexistent pid returns -1 (ESRCH), which
91 // we map to 0.
92 let rc = unsafe { nix::libc::kill(pid as nix::libc::pid_t, nix::libc::SIGTERM) };
93 if rc == 0 { 1 } else { 0 }
94}
95
96#[cfg(all(windows, any(feature = "lockfile", feature = "subprocess-reaper")))]
97pub fn kill_pid(pid: u32) -> usize {
98 use windows_sys::Win32::Foundation::CloseHandle;
99 use windows_sys::Win32::System::Threading::{
100 OpenProcess, PROCESS_TERMINATE, TerminateProcess,
101 };
102 // SAFETY: `OpenProcess` returns null on failure (no such pid, or no
103 // rights) — we check before use. `TerminateProcess` and `CloseHandle`
104 // operate only on the handle we just opened; the handle is closed on
105 // every path.
106 unsafe {
107 let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
108 if handle.is_null() {
109 return 0;
110 }
111 let ok = TerminateProcess(handle, 1);
112 CloseHandle(handle);
113 if ok != 0 { 1 } else { 0 }
114 }
115}