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:
parent
0d390688e3
commit
c5bc8625a4
10 changed files with 477 additions and 2 deletions
1
native/Cargo.lock
generated
1
native/Cargo.lock
generated
|
|
@ -157,6 +157,7 @@ name = "gsd-engine"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"gsd-grep",
|
||||
"libc",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
|
|
|
|||
|
|
@ -17,3 +17,6 @@ napi-derive = "2"
|
|||
|
||||
[build-dependencies]
|
||||
napi-build = "2"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
|
|
|||
|
|
@ -9,3 +9,4 @@
|
|||
#![allow(clippy::needless_pass_by_value)]
|
||||
|
||||
mod grep;
|
||||
mod ps;
|
||||
|
|
|
|||
288
native/crates/engine/src/ps.rs
Normal file
288
native/crates/engine/src/ps.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
109
packages/native/src/__tests__/ps.test.mjs
Normal file
109
packages/native/src/__tests__/ps.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
52
packages/native/src/ps/index.ts
Normal file
52
packages/native/src/ps/index.ts
Normal 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);
|
||||
}
|
||||
5
packages/native/src/ps/types.ts
Normal file
5
packages/native/src/ps/types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/** Result of a process tree kill operation. */
|
||||
export interface KillTreeResult {
|
||||
/** Number of processes successfully killed. */
|
||||
killed: number;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue