From c5bc8625a449620050397825cea56244ef1370c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Fri, 13 Mar 2026 12:43:50 -0600 Subject: [PATCH] feat: add cross-platform process tree kill module (ps) (#225) Port Oh My Pi's ps module providing efficient process tree enumeration and termination using platform-native APIs (libproc on macOS, /proc on Linux, Toolhelp32 on Windows). Exposes four napi functions: killTree, listDescendants, processGroupId, and killProcessGroup. Co-authored-by: Claude Opus 4.6 (1M context) --- native/Cargo.lock | 1 + native/crates/engine/Cargo.toml | 3 + native/crates/engine/src/lib.rs | 1 + native/crates/engine/src/ps.rs | 288 ++++++++++++++++++++++ packages/native/package.json | 8 +- packages/native/src/__tests__/ps.test.mjs | 109 ++++++++ packages/native/src/index.ts | 8 + packages/native/src/native.ts | 4 + packages/native/src/ps/index.ts | 52 ++++ packages/native/src/ps/types.ts | 5 + 10 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 native/crates/engine/src/ps.rs create mode 100644 packages/native/src/__tests__/ps.test.mjs create mode 100644 packages/native/src/ps/index.ts create mode 100644 packages/native/src/ps/types.ts diff --git a/native/Cargo.lock b/native/Cargo.lock index ba8fa03da..9f109ed13 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -157,6 +157,7 @@ name = "gsd-engine" version = "0.1.0" dependencies = [ "gsd-grep", + "libc", "napi", "napi-build", "napi-derive", diff --git a/native/crates/engine/Cargo.toml b/native/crates/engine/Cargo.toml index dcd61ef0c..f6193e59c 100644 --- a/native/crates/engine/Cargo.toml +++ b/native/crates/engine/Cargo.toml @@ -17,3 +17,6 @@ napi-derive = "2" [build-dependencies] napi-build = "2" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/native/crates/engine/src/lib.rs b/native/crates/engine/src/lib.rs index 82985849b..6d8410583 100644 --- a/native/crates/engine/src/lib.rs +++ b/native/crates/engine/src/lib.rs @@ -9,3 +9,4 @@ #![allow(clippy::needless_pass_by_value)] mod grep; +mod ps; diff --git a/native/crates/engine/src/ps.rs b/native/crates/engine/src/ps.rs new file mode 100644 index 000000000..f1a5cf759 --- /dev/null +++ b/native/crates/engine/src/ps.rs @@ -0,0 +1,288 @@ +//! Cross-platform process tree management. +//! +//! Provides efficient process tree enumeration and termination without +//! requiring processes to be spawned with `detached: true`. +//! +//! # Platform Implementation +//! - **Linux**: Reads `/proc/{pid}/children` recursively +//! - **macOS**: Uses `libproc` (`proc_listchildpids`) +//! - **Windows**: Uses `CreateToolhelp32Snapshot` to build parent-child +//! relationships + +use napi_derive::napi; + +#[cfg(target_os = "linux")] +mod platform { + use std::fs; + + /// Collect all descendant PIDs of `pid` into `pids`. + /// Skips branches when `/proc/{pid}/children` cannot be read. + pub fn collect_descendants(pid: i32, pids: &mut Vec) { + let children_path = format!("/proc/{pid}/task/{pid}/children"); + let Ok(content) = fs::read_to_string(&children_path) else { + return; + }; + + for part in content.split_whitespace() { + if let Ok(child_pid) = part.parse::() { + pids.push(child_pid); + collect_descendants(child_pid, pids); + } + } + } + + /// Send `signal` to `pid`. + /// Returns true when the signal is delivered successfully. + pub fn kill_pid(pid: i32, signal: i32) -> bool { + // SAFETY: libc::kill is safe to call with any pid/signal combination + unsafe { libc::kill(pid, signal) == 0 } + } + + /// Get the process group id for `pid`. + /// Returns `None` when the process does not exist or is inaccessible. + pub fn process_group_id(pid: i32) -> Option { + // SAFETY: `libc::getpgid` is safe to call with any pid + let pgid = unsafe { libc::getpgid(pid) }; + if pgid < 0 { None } else { Some(pgid) } + } + + /// Send `signal` to the process group `pgid`. + /// Returns true when the signal is delivered successfully. + pub fn kill_process_group(pgid: i32, signal: i32) -> bool { + // SAFETY: libc::kill is safe to call with any pid/signal combination + unsafe { libc::kill(-pgid, signal) == 0 } + } +} + +#[cfg(target_os = "macos")] +mod platform { + use std::ptr; + + #[link(name = "proc", kind = "dylib")] + unsafe extern "C" { + fn proc_listchildpids(ppid: i32, buffer: *mut i32, buffersize: i32) -> i32; + } + + /// Collect all descendant PIDs of `pid` into `pids` using libproc. + /// Skips branches when libproc returns no children. + pub fn collect_descendants(pid: i32, pids: &mut Vec) { + // First call to get count + // SAFETY: passing null buffer with size 0 to query child count is valid per + // libproc API. + let count = unsafe { proc_listchildpids(pid, ptr::null_mut(), 0) }; + if count <= 0 { + return; + } + + let mut buffer = vec![0i32; count as usize]; + // SAFETY: buffer is correctly sized and aligned for `count` i32 elements. + let actual = unsafe { + proc_listchildpids(pid, buffer.as_mut_ptr(), (buffer.len() * size_of::()) as i32) + }; + + if actual <= 0 { + return; + } + + let child_count = actual as usize / size_of::(); + for &child_pid in &buffer[..child_count] { + if child_pid > 0 { + pids.push(child_pid); + collect_descendants(child_pid, pids); + } + } + } + + /// Send `signal` to `pid`. + /// Returns true when the signal is delivered successfully. + pub fn kill_pid(pid: i32, signal: i32) -> bool { + // SAFETY: libc::kill is safe to call with any pid/signal combination + unsafe { libc::kill(pid, signal) == 0 } + } + + /// Get the process group id for `pid`. + /// Returns `None` when the process does not exist or is inaccessible. + pub fn process_group_id(pid: i32) -> Option { + // SAFETY: libc::getpgid is safe to call with any pid + let pgid = unsafe { libc::getpgid(pid) }; + if pgid < 0 { None } else { Some(pgid) } + } + + /// Send `signal` to the process group `pgid`. + /// Returns true when the signal is delivered successfully. + pub fn kill_process_group(pgid: i32, signal: i32) -> bool { + // SAFETY: libc::kill is safe to call with any pid/signal combination + unsafe { libc::kill(-pgid, signal) == 0 } + } +} + +#[cfg(target_os = "windows")] +mod platform { + use std::{collections::HashMap, mem}; + + #[repr(C)] + #[allow(non_snake_case, reason = "Windows PROCESSENTRY32W field names must match Win32 ABI")] + struct PROCESSENTRY32W { + dwSize: u32, + cntUsage: u32, + th32ProcessID: u32, + th32DefaultHeapID: usize, + th32ModuleID: u32, + cntThreads: u32, + th32ParentProcessID: u32, + pcPriClassBase: i32, + dwFlags: u32, + szExeFile: [u16; 260], + } + + type Handle = *mut std::ffi::c_void; + const INVALID_HANDLE_VALUE: Handle = -1isize as Handle; + const TH32CS_SNAPPROCESS: u32 = 0x00000002; + const PROCESS_TERMINATE: u32 = 0x0001; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn CreateToolhelp32Snapshot(dwFlags: u32, th32ProcessID: u32) -> Handle; + fn Process32FirstW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32; + fn Process32NextW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32; + fn CloseHandle(hObject: Handle) -> i32; + fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> Handle; + fn TerminateProcess(hProcess: Handle, uExitCode: u32) -> i32; + } + + /// Build a map of `parent_pid` -> [`child_pids`] for all processes. + fn build_process_tree() -> HashMap> { + let mut tree: HashMap> = HashMap::new(); + + // SAFETY: Toolhelp snapshot APIs are called with initialized structs and valid + // handles. + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snapshot == INVALID_HANDLE_VALUE { + return tree; + } + + let mut entry: PROCESSENTRY32W = mem::zeroed(); + entry.dwSize = mem::size_of::() as u32; + + if Process32FirstW(snapshot, &raw mut entry) != 0 { + loop { + tree + .entry(entry.th32ParentProcessID) + .or_default() + .push(entry.th32ProcessID); + + if Process32NextW(snapshot, &raw mut entry) == 0 { + break; + } + } + } + + CloseHandle(snapshot); + } + + tree + } + + /// Collect all descendant PIDs of `pid` into `pids`. + /// Uses a snapshot of the current process table. + pub fn collect_descendants(pid: i32, pids: &mut Vec) { + let tree = build_process_tree(); + collect_descendants_from_tree(pid as u32, &tree, pids); + } + + fn collect_descendants_from_tree( + pid: u32, + tree: &HashMap>, + pids: &mut Vec, + ) { + if let Some(children) = tree.get(&pid) { + for &child_pid in children { + pids.push(child_pid as i32); + collect_descendants_from_tree(child_pid, tree, pids); + } + } + } + + /// Terminate `pid` (Windows ignores `signal`). + /// Returns true when the process is terminated. + pub fn kill_pid(pid: i32, _signal: i32) -> bool { + // SAFETY: OpenProcess/TerminateProcess are called with kernel-provided process + // IDs and handles are always closed. + unsafe { + let handle = OpenProcess(PROCESS_TERMINATE, 0, pid as u32); + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return false; + } + let result = TerminateProcess(handle, 1); + CloseHandle(handle); + result != 0 + } + } + + /// Process groups are not exposed on Windows. + /// Always returns `None`. + pub const fn process_group_id(_pid: i32) -> Option { + None + } + + /// Process groups are not exposed on Windows. + /// Always returns `false`. + pub const fn kill_process_group(_pgid: i32, _signal: i32) -> bool { + false + } +} + +/// Kill a process tree (the process and all its descendants). +/// +/// Arguments: `pid` is the root process and `signal` is the kill signal. +/// Kills children first (bottom-up) to prevent orphan re-parenting issues. +/// Returns the number of processes successfully killed. +#[napi] +pub fn kill_tree(pid: i32, signal: i32) -> u32 { + let mut descendants = Vec::new(); + platform::collect_descendants(pid, &mut descendants); + + let mut killed = 0u32; + + // Kill children first (deepest first by reversing the DFS order) + for &child_pid in descendants.iter().rev() { + if platform::kill_pid(child_pid, signal) { + killed += 1; + } + } + + // Kill the root process last + if platform::kill_pid(pid, signal) { + killed += 1; + } + + killed +} + +/// List all descendant PIDs of `pid`. +/// +/// Returns an empty array if the process has no children or doesn't exist. +#[napi] +pub fn list_descendants(pid: i32) -> Vec { + let mut descendants = Vec::new(); + platform::collect_descendants(pid, &mut descendants); + descendants +} + +/// Get the process group id for `pid`. +/// Returns `null` when the process is missing or unsupported on the platform. +#[napi] +pub fn process_group_id(pid: i32) -> Option { + platform::process_group_id(pid) +} + +/// Kill an entire process group. +/// +/// Sends `signal` to all processes in the group identified by `pgid`. +/// Returns true when the signal is delivered successfully. +/// Returns false on Windows (process groups not supported). +#[napi] +pub fn kill_process_group(pgid: i32, signal: i32) -> bool { + platform::kill_process_group(pgid, signal) +} diff --git a/packages/native/package.json b/packages/native/package.json index 84de3dfb3..c480955ca 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,14 +1,14 @@ { "name": "@gsd/native", "version": "0.1.0", - "description": "Native Rust bindings for GSD — high-performance grep via N-API", + "description": "Native Rust bindings for GSD \u2014 high-performance grep via N-API", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", "scripts": { "build:native": "node ../../native/scripts/build.js", "build:native:dev": "node ../../native/scripts/build.js --dev", - "test": "node --test src/__tests__/grep.test.mjs" + "test": "node --test src/__tests__/grep.test.mjs src/__tests__/ps.test.mjs" }, "exports": { ".": { @@ -18,6 +18,10 @@ "./grep": { "types": "./src/grep/index.ts", "import": "./src/grep/index.ts" + }, + "./ps": { + "types": "./src/ps/index.ts", + "import": "./src/ps/index.ts" } }, "files": [ diff --git a/packages/native/src/__tests__/ps.test.mjs b/packages/native/src/__tests__/ps.test.mjs new file mode 100644 index 000000000..2cbde4232 --- /dev/null +++ b/packages/native/src/__tests__/ps.test.mjs @@ -0,0 +1,109 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +// Load the native addon directly +const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); +const platformTag = `${process.platform}-${process.arch}`; +const candidates = [ + path.join(addonDir, `gsd_engine.${platformTag}.node`), + path.join(addonDir, "gsd_engine.dev.node"), +]; + +let native; +for (const candidate of candidates) { + try { + native = require(candidate); + break; + } catch { + // try next + } +} + +if (!native) { + console.error("Native addon not found. Run `npm run build:native -w @gsd/native` first."); + process.exit(1); +} + +describe("native ps: listDescendants()", () => { + test("returns an array for the current process", () => { + const descendants = native.listDescendants(process.pid); + assert.ok(Array.isArray(descendants)); + }); + + test("returns empty array for non-existent PID", () => { + // PID 2147483647 is extremely unlikely to exist + const descendants = native.listDescendants(2147483647); + assert.ok(Array.isArray(descendants)); + assert.equal(descendants.length, 0); + }); + + test("finds child processes", async () => { + // Spawn a child that itself spawns a grandchild + const child = spawn("sleep", ["10"], { stdio: "ignore" }); + + // Give the OS a moment to register the process + await new Promise((resolve) => setTimeout(resolve, 100)); + + try { + const descendants = native.listDescendants(process.pid); + assert.ok(descendants.includes(child.pid), "child PID should appear in descendants"); + } finally { + child.kill("SIGKILL"); + } + }); +}); + +describe("native ps: killTree()", () => { + test("kills a process and its children", async () => { + // Spawn a shell that spawns a sleep subprocess + const child = spawn("sh", ["-c", "sleep 60"], { stdio: "ignore" }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const killed = native.killTree(child.pid, 9); + assert.ok(killed >= 1, `should kill at least 1 process, killed: ${killed}`); + + // Verify the child is actually dead + await new Promise((resolve) => { + child.on("exit", resolve); + // Timeout safety — if already exited, resolve immediately + setTimeout(resolve, 500); + }); + }); + + test("returns 0 for non-existent PID", () => { + const killed = native.killTree(2147483647, 9); + assert.equal(killed, 0); + }); +}); + +describe("native ps: processGroupId()", () => { + test("returns a number for the current process", () => { + const pgid = native.processGroupId(process.pid); + if (process.platform === "win32") { + assert.equal(pgid, null); + } else { + assert.equal(typeof pgid, "number"); + assert.ok(pgid > 0); + } + }); + + test("returns null for non-existent PID", () => { + const pgid = native.processGroupId(2147483647); + assert.equal(pgid, null); + }); +}); + +describe("native ps: killProcessGroup()", () => { + test("returns false for non-existent process group", () => { + const result = native.killProcessGroup(2147483647, 15); + assert.equal(result, false); + }); +}); diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts index 3c5cfdf83..b8f3b1d40 100644 --- a/packages/native/src/index.ts +++ b/packages/native/src/index.ts @@ -3,6 +3,7 @@ * * Modules: * - grep: ripgrep-backed regex search (content + filesystem) + * - ps: cross-platform process tree management */ export { searchContent, grep } from "./grep/index.js"; @@ -15,3 +16,10 @@ export type { SearchOptions, SearchResult, } from "./grep/index.js"; + +export { + killTree, + listDescendants, + processGroupId, + killProcessGroup, +} from "./ps/index.js"; diff --git a/packages/native/src/native.ts b/packages/native/src/native.ts index 93aa1a09d..df9c3c8ad 100644 --- a/packages/native/src/native.ts +++ b/packages/native/src/native.ts @@ -43,4 +43,8 @@ function loadNative(): Record { export const native = loadNative() as { search: (content: Buffer | Uint8Array, options: unknown) => unknown; grep: (options: unknown) => unknown; + killTree: (pid: number, signal: number) => number; + listDescendants: (pid: number) => number[]; + processGroupId: (pid: number) => number | null; + killProcessGroup: (pgid: number, signal: number) => boolean; }; diff --git a/packages/native/src/ps/index.ts b/packages/native/src/ps/index.ts new file mode 100644 index 000000000..8f64c9fef --- /dev/null +++ b/packages/native/src/ps/index.ts @@ -0,0 +1,52 @@ +/** + * Cross-platform process tree management via N-API. + * + * Provides efficient process tree enumeration and termination + * using platform-native APIs (libproc on macOS, /proc on Linux, + * Toolhelp32 on Windows). + */ + +import { native } from "../native.js"; + +/** + * Kill a process tree (the process and all its descendants). + * + * Kills children first (bottom-up) to prevent orphan re-parenting issues. + * @param pid - Root process ID + * @param signal - Signal to send (e.g. 9 for SIGKILL, 15 for SIGTERM). Ignored on Windows. + * @returns Number of processes successfully killed. + */ +export function killTree(pid: number, signal: number): number { + return native.killTree(pid, signal); +} + +/** + * List all descendant PIDs of a process. + * + * @param pid - Parent process ID + * @returns Array of descendant PIDs (empty if no children or process doesn't exist). + */ +export function listDescendants(pid: number): number[] { + return native.listDescendants(pid); +} + +/** + * Get the process group ID for a process. + * + * @param pid - Process ID + * @returns Process group ID, or null if the process doesn't exist or on Windows. + */ +export function processGroupId(pid: number): number | null { + return native.processGroupId(pid); +} + +/** + * Kill an entire process group. + * + * @param pgid - Process group ID + * @param signal - Signal to send + * @returns true if the signal was delivered, false on failure or Windows. + */ +export function killProcessGroup(pgid: number, signal: number): boolean { + return native.killProcessGroup(pgid, signal); +} diff --git a/packages/native/src/ps/types.ts b/packages/native/src/ps/types.ts new file mode 100644 index 000000000..3b6ee2edc --- /dev/null +++ b/packages/native/src/ps/types.ts @@ -0,0 +1,5 @@ +/** Result of a process tree kill operation. */ +export interface KillTreeResult { + /** Number of processes successfully killed. */ + killed: number; +}