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) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-13 12:43:50 -06:00 committed by GitHub
parent 0d390688e3
commit c5bc8625a4
10 changed files with 477 additions and 2 deletions

1
native/Cargo.lock generated
View file

@ -157,6 +157,7 @@ name = "gsd-engine"
version = "0.1.0"
dependencies = [
"gsd-grep",
"libc",
"napi",
"napi-build",
"napi-derive",

View file

@ -17,3 +17,6 @@ napi-derive = "2"
[build-dependencies]
napi-build = "2"
[target.'cfg(unix)'.dependencies]
libc = "0.2"

View file

@ -9,3 +9,4 @@
#![allow(clippy::needless_pass_by_value)]
mod grep;
mod ps;

View file

@ -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<i32>) {
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::<i32>() {
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<i32> {
// 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<i32>) {
// 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::<i32>()) as i32)
};
if actual <= 0 {
return;
}
let child_count = actual as usize / size_of::<i32>();
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<i32> {
// 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<u32, Vec<u32>> {
let mut tree: HashMap<u32, Vec<u32>> = 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::<PROCESSENTRY32W>() 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<i32>) {
let tree = build_process_tree();
collect_descendants_from_tree(pid as u32, &tree, pids);
}
fn collect_descendants_from_tree(
pid: u32,
tree: &HashMap<u32, Vec<u32>>,
pids: &mut Vec<i32>,
) {
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<i32> {
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<i32> {
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<i32> {
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)
}

View file

@ -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": [

View file

@ -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);
});
});

View file

@ -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";

View file

@ -43,4 +43,8 @@ function loadNative(): Record<string, unknown> {
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;
};

View file

@ -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);
}

View file

@ -0,0 +1,5 @@
/** Result of a process tree kill operation. */
export interface KillTreeResult {
/** Number of processes successfully killed. */
killed: number;
}