diff --git a/AGENTS.md b/AGENTS.md index d7638a1..0890067 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ zig build -Dtarget=x86_64-windows bun run build # Run tests -bun test +bun run test # Cross-platform smoke tests (Docker, Linux only) bash scripts/cross-platform.sh @@ -144,54 +144,58 @@ The native loader (`napi.ts`) tries glibc first, falls back to musl on Linux. On The pure Zig library (`lib.zig`) is exposed as the `"zigpty"` module in `build.zig`: -| Function | Signature | Description | -| ---------------- | ------------------------------------------ | ----------------------------------------------------------- | -| `forkPty` | `(ForkOptions) !ForkResult` | Fork process with PTY (forkpty + signal handling + execvpe) | -| `openPty` | `(cols, rows) !OpenResult` | Open bare PTY pair | -| `resize` | `(fd, cols, rows, x_pixel, y_pixel) !void` | Resize PTY (ioctl TIOCSWINSZ) | -| `getProcessName` | `(fd, buf) ?[]const u8` | Foreground process name via /proc | -| `waitForExit` | `(pid) ExitInfo` | Blocking wait for child exit (call from background thread) | +| Function | Signature | Description | +| ---------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `forkPty` | `(ForkOptions) !ForkResult` | Fork process with PTY (forkpty + signal handling + execvpe) | +| `openPty` | `(cols, rows) !OpenResult` | Open bare PTY pair | +| `resize` | `(fd, cols, rows, x_pixel, y_pixel) !void` | Resize PTY (ioctl TIOCSWINSZ) | +| `getProcessName` | `(fd, buf) ?[]const u8` | Foreground process name via /proc | +| `getStats` | `(fd, allocator, cwd_buf) ?Stats` | Aggregate rss + cpu across every process in the foreground pgrp. Linux: walks `/proc` filtering by pgrp. macOS: `proc_listpids(PROC_PGRP_ONLY)` + `proc_pidinfo`. Returns leader cwd + totals + per-child array (caller must `stats.deinit(allocator)`). | +| `waitForExit` | `(pid) ExitInfo` | Blocking wait for child exit (call from background thread) | -Types: `ForkOptions`, `ForkResult`, `OpenResult`, `ExitInfo`, `PtyError`, `Fd`, `Pid` +Types: `ForkOptions`, `ForkResult`, `OpenResult`, `ExitInfo`, `Stats`, `ChildStats`, `PtyError`, `Fd`, `Pid` ### Windows Available via `lib.win` (re-exports `pty_windows.zig`): -| Function | Signature | Description | -| --------------- | ----------------------------------------------------- | ---------------------------------------- | -| `createConPty` | `(cols, rows) !ConPtySetup` | Phase 1: create pipes + pseudo console | -| `startProcess` | `(hpc, cmd_line, env_block, cwd) !{process, pid}` | Phase 2: spawn process in ConPTY | -| `spawnConPty` | `(cmd_line, env_block, cwd, cols, rows) !SpawnResult` | Convenience: createConPty + startProcess | -| `readOutput` | `(conout, buf) usize` | Read from output pipe (blocking) | -| `writeInput` | `(conin, data) !void` | Write to input pipe | -| `resizeConsole` | `(hpc, cols, rows) !void` | Resize pseudo console | -| `waitForExit` | `(process) ExitInfo` | Wait for process exit (blocking) | -| `killProcess` | `(process, exit_code) void` | Terminate process | -| `closePty` | `(result) void` | Close all ConPTY handles | - -Types: `SpawnResult`, `ConPtySetup`, `ExitInfo`, `ConPtyError`, `HPCON`, `HANDLE` +| Function | Signature | Description | +| --------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `createConPty` | `(cols, rows) !ConPtySetup` | Phase 1: create pipes + pseudo console | +| `startProcess` | `(hpc, cmd_line, env_block, cwd) !{process, pid}` | Phase 2: spawn process in ConPTY | +| `spawnConPty` | `(cmd_line, env_block, cwd, cols, rows) !SpawnResult` | Convenience: createConPty + startProcess | +| `readOutput` | `(conout, buf) usize` | Read from output pipe (blocking) | +| `writeInput` | `(conin, data) !void` | Write to input pipe | +| `resizeConsole` | `(hpc, cols, rows) !void` | Resize pseudo console | +| `waitForExit` | `(process) ExitInfo` | Wait for process exit (blocking) | +| `killProcess` | `(process, exit_code) void` | Terminate process | +| `closePty` | `(result) void` | Close all ConPTY handles | +| `getStats` | `(process, pid, allocator) ?Stats` | Aggregate rss + cpu across the shell process and its descendant tree via `CreateToolhelp32Snapshot`. Per-descendant data via `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` + `K32GetProcessMemoryInfo` + `GetProcessTimes`. No cwd. | + +Types: `SpawnResult`, `ConPtySetup`, `ExitInfo`, `Stats`, `ChildStats`, `ConPtyError`, `HPCON`, `HANDLE` ## NAPI API (Zig → JS) ### Unix Exports -| Export | Signature | Implementation | -| --------- | ------------------------------------------------------------------------------- | ---------------------------------------- | -| `fork` | `(file, args[], env[], cwd, cols, rows, uid, gid, utf8, cb)` → `{fd, pid, pty}` | `lib.forkPty()` + thread `waitForExit()` | -| `open` | `(cols, rows)` → `{master, slave, pty}` | `lib.openPty()` | -| `resize` | `(fd, cols, rows)` → void | `lib.resize()` | -| `process` | `(fd)` → string | `lib.getProcessName()` | +| Export | Signature | Implementation | +| --------- | -------------------------------------------------------------------------------- | ---------------------------------------- | +| `fork` | `(file, args[], env[], cwd, cols, rows, uid, gid, utf8, cb)` → `{fd, pid, pty}` | `lib.forkPty()` + thread `waitForExit()` | +| `open` | `(cols, rows)` → `{master, slave, pty}` | `lib.openPty()` | +| `resize` | `(fd, cols, rows)` → void | `lib.resize()` | +| `process` | `(fd)` → string | `lib.getProcessName()` | +| `stats` | `(fd)` → `{pid, cwd, rssBytes, cpuUser, cpuSys, count, children[]}` \| undefined | `lib.getStats()` (aggregates full pgrp) | ### Windows Exports -| Export | Signature | Implementation | -| -------- | -------------------------------------------------------------------------- | ----------------------------------------------- | -| `spawn` | `(file, args[], env[], cwd, cols, rows, onData, onExit)` → `{pid, handle}` | `win.spawnConPty()` + read thread + exit thread | -| `write` | `(handle, data)` → void | `win.writeInput()` | -| `resize` | `(handle, cols, rows)` → void | `win.resizeConsole()` | -| `kill` | `(handle)` → void | `win.killProcess()` | -| `close` | `(handle)` → void | `win.closePty()` | +| Export | Signature | Implementation | +| -------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------- | +| `spawn` | `(file, args[], env[], cwd, cols, rows, onData, onExit)` → `{pid, handle}` | `win.spawnConPty()` + read thread + exit thread | +| `write` | `(handle, data)` → void | `win.writeInput()` | +| `resize` | `(handle, cols, rows)` → void | `win.resizeConsole()` | +| `kill` | `(handle)` → void | `win.killProcess()` | +| `close` | `(handle)` → void | `win.closePty()` | +| `stats` | `(handle)` → `{pid, cwd, rssBytes, cpuUser, cpuSys, count, children[]}` \| undefined | `win.getStats()` (shell + descendant tree, cwd always null) | Windows uses `napi_external` to wrap the `WinConPtyContext` handle. Data flows from a Zig read thread to JS via `napi_threadsafe_function` (onData callback). @@ -223,6 +227,19 @@ pty.write("zigpty\n"); Options: `{ timeout?: number }` (default: 30s). Throws on timeout. +### `stats()` + +On-demand snapshot of OS-level process info for the PTY. Returns `{pid, cwd, rssBytes, cpuUser, cpuSys, count, children}` or `null`. `rssBytes`/`cpuUser`/`cpuSys` are **totals** aggregated across the leader and every tracked child. `count` is the total number of processes that were rolled into the totals. `children[]` contains one entry per non-leader process (`{pid, name, rssBytes, cpuUser, cpuSys}`). CPU times are in microseconds, `rssBytes` is resident set size. + +- **Linux**: walks `/proc/*/stat` and aggregates every process whose `pgrp` matches `tcgetpgrp(fd)`. Covers the full foreground job (workers, subshells) as long as they don't `setpgid` away. Leader `cwd` comes from `readlink(/proc//cwd)`. +- **macOS**: `proc_listpids(PROC_PGRP_ONLY, pgrp, ...)` enumerates pids in the pgrp, then `proc_pidinfo(PROC_PIDTASKINFO)` provides rss + cpu per pid. Child names via `proc_name`. Leader `cwd` via `proc_pidinfo(PROC_PIDVNODEPATHINFO)`. +- **Windows**: no pgrp concept under ConPTY, so the unit is the **descendant tree**. `CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS)` snapshots every running process, then transitive descendants of the shell are marked and enumerated. Per-descendant rss + cpu via `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` + `K32GetProcessMemoryInfo` + `GetProcessTimes`. `cwd` is always `null` — reading another process's cwd requires `NtQueryInformationProcess` + remote PEB read, which is fragile across elevation boundaries. +- **PipePty** (fallback): Linux-only. Walks `/proc` and filters by `pgrp == child_pid` (same aggregation as native). Returns `null` on other platforms. + +No background thread — stats are pulled only when called. Returns `null` after close, when the process has exited, or when the walk finds no matching processes. + +Name field caveats: Unix `proc_name`/`/proc//stat` truncates to 15 chars; Windows `szExeFile` allows up to ~31 chars (we truncate to 31). On macOS, Apple-shipped `sleep` / `ls` etc. have a `g`-prefixed `comm` (e.g. `gsleep`) when invoked via symlink — that is the kernel's view, not a bug. + ### Terminal API (Bun-compatible) `spawn()` accepts optional `terminal: TerminalOptions | Terminal` in options for callback-based data (`Uint8Array`) and `Promise`-based exit (`pty.exited`). `IPty` now has `exited: Promise` and `exitCode: number | null`. `Terminal` class (`terminal.ts`) can be standalone (`new Terminal()` opens bare PTY) or passed to `spawn()` (attaches to fork's fd on Unix, ConPTY handle on Windows). Supports `AsyncDisposable`. diff --git a/README.md b/README.md index 42bf85c..e4b038a 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,37 @@ interface IPty { resume(): void; close(): void; waitFor(pattern: string, options?: { timeout?: number }): Promise; + stats(): IPtyStats | null; // OS-level snapshot (cwd, memory, CPU time) } ``` +### `pty.stats()` + +Snapshot OS-level process info aggregated across the PTY's foreground job. `rssBytes`, `cpuUser`, and `cpuSys` are **totals** summed over the leader and every tracked child. `count` is how many processes were rolled into the totals. `children[]` lists each non-leader process (`{pid, name, rssBytes, cpuUser, cpuSys}`) so you can see the breakdown. + +On Unix, aggregation targets the PTY's foreground process group (same target as `pty.process`), so the numbers follow whatever the user is currently running — workers, subshells, build fan-outs. On Windows, the unit is the shell process's transitive descendant tree (ConPTY has no pgrp concept). + +```ts +const pty = spawn("/bin/bash"); +// …user types `cd /tmp && cargo build`… +const s = pty.stats(); +// { +// pid: 4821, // leader (pgrp on Unix, shell on Windows) +// cwd: "/tmp", // leader's cwd; null on Windows +// rssBytes: 2_147_483_648, // total across leader + children +// cpuUser: 8_430_000, // microseconds +// cpuSys: 1_250_000, +// count: 17, // leader + 16 workers +// children: [ +// { pid: 4822, name: "cargo", rssBytes: 128_000_000, cpuUser: 500_000, cpuSys: 80_000 }, +// { pid: 4823, name: "rustc", rssBytes: 512_000_000, cpuUser: 2_000_000, cpuSys: 300_000 }, +// // …14 more… +// ], +// } +``` + +Returns `null` when stats can't be read (process exited, PTY closed, or running in pipe fallback on non-Linux). Polling is on-demand — no background thread, no cost when unused. + ### `pty.waitFor(pattern, options?)` Wait until the PTY output contains the given string. Returns all output collected so far. Useful for AI agents that need to read prompts before responding. diff --git a/src/napi.ts b/src/napi.ts index 562a380..44b7b4d 100644 --- a/src/napi.ts +++ b/src/napi.ts @@ -2,6 +2,24 @@ import { createRequire } from "node:module"; import { arch, platform } from "node:os"; import { fileURLToPath } from "node:url"; +export interface INativeChildStats { + pid: number; + name: string; + rssBytes: number; + cpuUser: number; + cpuSys: number; +} + +export interface INativeStats { + pid: number; + cwd: string | null; + rssBytes: number; + cpuUser: number; + cpuSys: number; + count: number; + children: INativeChildStats[]; +} + export interface INativeUnix { fork( file: string, @@ -21,6 +39,8 @@ export interface INativeUnix { resize(fd: number, cols: number, rows: number, xPixel?: number, yPixel?: number): void; process(fd: number): string | undefined; + + stats(fd: number): INativeStats | undefined; } export interface INativeWindows { @@ -42,6 +62,8 @@ export interface INativeWindows { kill(handle: object): void; close(handle: object): void; + + stats(handle: object): INativeStats | undefined; } export type INative = INativeUnix | INativeWindows; diff --git a/src/pipe.test.ts b/src/pipe.test.ts index 864bf95..6eb588c 100644 --- a/src/pipe.test.ts +++ b/src/pipe.test.ts @@ -531,6 +531,45 @@ describeUnix("PipePty: process name", () => { }); }); +const isLinux = platform() === "linux"; +const describeLinux = isLinux ? describe : describe.skip; + +describeLinux("PipePty: stats (linux fallback)", () => { + it("should report pid, cwd, rss, and cpu times", async () => { + const pty = new PipePty("/bin/cat", [], { cwd: "/tmp" }); + + // Stats are read on-demand via /proc//{cwd,stat} — give cat a moment to start. + await new Promise((r) => setTimeout(r, 50)); + + const stats = pty.stats(); + expect(stats).not.toBeNull(); + expect(stats!.pid).toBe(pty.pid); + expect(stats!.cwd).toBe("/tmp"); + expect(stats!.rssBytes).toBeGreaterThan(0); + expect(stats!.cpuUser).toBeGreaterThanOrEqual(0); + expect(stats!.cpuSys).toBeGreaterThanOrEqual(0); + + pty.kill("SIGTERM"); + }); + + it("should return null after close", () => { + const pty = new PipePty("/bin/cat", []); + pty.close(); + expect(pty.stats()).toBeNull(); + }); +}); + +const describeNonLinux = isLinux ? describe.skip : describe; + +describeNonLinux("PipePty: stats (non-linux fallback)", () => { + it("should return null on non-linux platforms", () => { + const cmd = isWindows ? ["/c", "ping -n 2 127.0.0.1"] : ["-c", "sleep 1"]; + const pty = new PipePty(shell, cmd); + expect(pty.stats()).toBeNull(); + pty.close(); + }); +}); + describe("PipePty: error handling", () => { it("should handle spawn failure gracefully", async () => { const pty = new PipePty("/nonexistent/binary", []); diff --git a/src/pty/_base.ts b/src/pty/_base.ts index b13a026..917debb 100644 --- a/src/pty/_base.ts +++ b/src/pty/_base.ts @@ -1,4 +1,4 @@ -import type { IDisposable, IEvent, IPty, IPtyOptions } from "./types.ts"; +import type { IDisposable, IEvent, IPty, IPtyOptions, IPtyStats } from "./types.ts"; import { Terminal } from "../terminal.ts"; import type { TerminalOptions } from "../terminal.ts"; @@ -143,6 +143,7 @@ export abstract class BasePty implements IPty { abstract pause(): void; abstract resume(): void; abstract close(): void; + abstract stats(): IPtyStats | null; } export function buildEnvPairs( diff --git a/src/pty/pipe.ts b/src/pty/pipe.ts index 965220a..9ad5228 100644 --- a/src/pty/pipe.ts +++ b/src/pty/pipe.ts @@ -19,8 +19,9 @@ * - ^Z (SIGTSTP) is not translated — no controlling terminal to resume from */ import { spawn as cpSpawn, type ChildProcess } from "node:child_process"; +import * as fs from "node:fs"; import * as os from "node:os"; -import type { IPtyOptions } from "./types.ts"; +import type { IPtyOptions, IPtyStats } from "./types.ts"; import { BasePty, DEFAULT_COLS, DEFAULT_ROWS } from "./_base.ts"; // Signal character → signal name mapping @@ -179,6 +180,14 @@ export class PipePty extends BasePty { return this._file; } + stats(): IPtyStats | null { + if (this._closed || this.pid <= 0) return null; + // Linux-only: read from /proc//{cwd,stat}. Other platforms return null + // in fallback mode — no syscalls we can reach from pure TS. + if (os.platform() !== "linux") return null; + return readLinuxStats(this.pid); + } + write(data: string): void { if (this._closed) return; @@ -423,3 +432,145 @@ export class PipePty extends BasePty { } } } + +// USER_HZ is hard-coded to 100 on every Linux kernel — native path does the same +// (see pty_linux.zig) to avoid sysconf's SC-constant mismatch on Android/Bionic. +const CLK_TCK = 100; + +let _pageSize: number | null = null; +function getPageSize(): number { + if (_pageSize !== null) return _pageSize; + // Parse AT_PAGESZ from /proc/self/auxv — needed for ARM64 Linux with 16K pages. + // Entries are stored in the target's native endianness. + try { + const auxv = fs.readFileSync("/proc/self/auxv"); + const is64 = ["arm64", "x64", "ppc64", "s390x", "mips64el", "riscv64", "loong64"].includes( + process.arch, + ); + const isLE = os.endianness() === "LE"; + const wordSize = is64 ? 8 : 4; + const AT_PAGESZ = 6; + const AT_NULL = 0; + const readWord = (off: number): number => { + if (is64) { + const big = isLE ? auxv.readBigUInt64LE(off) : auxv.readBigUInt64BE(off); + return Number(big); + } + return isLE ? auxv.readUInt32LE(off) : auxv.readUInt32BE(off); + }; + for (let i = 0; i + wordSize * 2 <= auxv.length; i += wordSize * 2) { + const key = readWord(i); + if (key === AT_NULL) break; + if (key === AT_PAGESZ) { + _pageSize = readWord(i + wordSize); + return _pageSize; + } + } + } catch {} + _pageSize = 4096; + return _pageSize; +} + +/** Parsed /proc//stat row used for aggregation. */ +interface ProcStatRow { + comm: string; + pgrp: number; + utimeTicks: number; + stimeTicks: number; + rssPages: number; +} + +function parseProcStat(raw: string): ProcStatRow | null { + const firstParen = raw.indexOf("("); + const lastParen = raw.lastIndexOf(")"); + if (firstParen < 0 || lastParen < 0 || lastParen <= firstParen || lastParen + 2 >= raw.length) { + return null; + } + const comm = raw.slice(firstParen + 1, lastParen); + const fields = raw.slice(lastParen + 2).split(" "); + // Indices after last ')': 2=pgrp, 11=utime, 12=stime, 21=rss_pages + const pgrp = Number(fields[2] ?? 0); + if (!Number.isFinite(pgrp)) return null; + return { + comm, + pgrp, + utimeTicks: Number(fields[11] ?? 0), + stimeTicks: Number(fields[12] ?? 0), + rssPages: Number(fields[21] ?? 0), + }; +} + +function readLinuxStats(pid: number): IPtyStats | null { + const pageSize = getPageSize(); + // For shells spawned with `detached: true`, setsid() makes the leader its own + // pgrp, so walking /proc by pgrp aggregates the full job. For non-shell commands + // (no detached), the child inherits the parent's pgrp, so we always include the + // leader pid itself and additionally pick up siblings whose pgrp matches. + let leaderCwd: string | null = null; + try { + leaderCwd = fs.readlinkSync(`/proc/${pid}/cwd`); + } catch {} + + let procDir: string[]; + try { + procDir = fs.readdirSync("/proc"); + } catch { + return null; + } + + let totalRss = 0; + let totalUser = 0; + let totalSys = 0; + let count = 0; + const children: { + pid: number; + name: string; + rssBytes: number; + cpuUser: number; + cpuSys: number; + }[] = []; + + for (const entry of procDir) { + const entryPid = Number(entry); + if (!Number.isInteger(entryPid) || entryPid <= 0) continue; + let raw: string; + try { + raw = fs.readFileSync(`/proc/${entryPid}/stat`, "utf8"); + } catch { + continue; + } + const row = parseProcStat(raw); + if (!row) continue; + if (entryPid !== pid && row.pgrp !== pid) continue; + + const rssBytes = row.rssPages * pageSize; + const cpuUser = Math.floor((row.utimeTicks * 1_000_000) / CLK_TCK); + const cpuSys = Math.floor((row.stimeTicks * 1_000_000) / CLK_TCK); + totalRss += rssBytes; + totalUser += cpuUser; + totalSys += cpuSys; + count += 1; + + if (entryPid !== pid) { + children.push({ + pid: entryPid, + name: row.comm.slice(0, 31), + rssBytes, + cpuUser, + cpuSys, + }); + } + } + + if (count === 0) return null; + + return { + pid, + cwd: leaderCwd, + rssBytes: totalRss, + cpuUser: totalUser, + cpuSys: totalSys, + count, + children, + }; +} diff --git a/src/pty/types.ts b/src/pty/types.ts index c6a4f1c..59effbd 100644 --- a/src/pty/types.ts +++ b/src/pty/types.ts @@ -8,6 +8,40 @@ export interface IDisposable { dispose(): void; } +export interface IPtyChildStats { + /** Process ID. */ + pid: number; + /** Short executable / command name (truncated to ~15 chars on Unix, up to 31 on Windows). */ + name: string; + /** Resident set size (physical memory) in bytes. */ + rssBytes: number; + /** Accumulated user-mode CPU time in microseconds. */ + cpuUser: number; + /** Accumulated system-mode CPU time in microseconds. */ + cpuSys: number; +} + +export interface IPtyStats { + /** Leader PID. On Unix this is the foreground process group of the PTY; on Windows it's the spawned shell process. */ + pid: number; + /** Leader's current working directory. `null` when unavailable (always on Windows, or when the process has exited). */ + cwd: string | null; + /** Total resident set size (physical memory) in bytes, aggregated across leader + children. */ + rssBytes: number; + /** Total accumulated user-mode CPU time in microseconds, aggregated across leader + children. */ + cpuUser: number; + /** Total accumulated system-mode CPU time in microseconds, aggregated across leader + children. */ + cpuSys: number; + /** Total number of processes aggregated (leader + children). Always `>= 1`. */ + count: number; + /** + * Non-leader processes aggregated into the totals. + * - Unix: every other process in the foreground process group. + * - Windows: every transitive descendant of the shell process. + */ + children: IPtyChildStats[]; +} + export interface IPty { /** Process ID of the spawned process. */ pid: number; @@ -43,6 +77,8 @@ export interface IPty { close(): void; /** Wait until the output contains the given string. Resolves with all output collected so far. */ waitFor(pattern: string, options?: { timeout?: number }): Promise; + /** Snapshot OS-level stats (cwd, memory, CPU time) for the PTY's foreground process. Returns null when unavailable. */ + stats(): IPtyStats | null; } export interface IPtyOpenOptions { diff --git a/src/pty/unix.ts b/src/pty/unix.ts index b8939f7..8dfca6a 100644 --- a/src/pty/unix.ts +++ b/src/pty/unix.ts @@ -5,7 +5,7 @@ import { native as _native } from "../napi.ts"; import type { INativeUnix } from "../napi.ts"; const native = _native as INativeUnix; -import type { IPtyOptions } from "./types.ts"; +import type { IPtyOptions, IPtyStats } from "./types.ts"; import { BasePty, DEFAULT_COLS, DEFAULT_ROWS, buildEnvPairs } from "./_base.ts"; import { WriteQueue } from "./_writeQueue.ts"; @@ -94,6 +94,15 @@ export class UnixPty extends BasePty { } } + stats(): IPtyStats | null { + if (this._closed) return null; + try { + return native.stats(this._fd) ?? null; + } catch { + return null; + } + } + write(data: string): void { if (this._closed) return; this._wq.enqueue(data, this._encoding); diff --git a/src/pty/windows.ts b/src/pty/windows.ts index 651c7f0..4eff5d6 100644 --- a/src/pty/windows.ts +++ b/src/pty/windows.ts @@ -1,5 +1,5 @@ import type { INativeWindows } from "../napi.ts"; -import type { IPtyOptions } from "./types.ts"; +import type { IPtyOptions, IPtyStats } from "./types.ts"; import { BasePty, DEFAULT_COLS, DEFAULT_ROWS, buildEnvPairs } from "./_base.ts"; export class WindowsPty extends BasePty { @@ -71,6 +71,15 @@ export class WindowsPty extends BasePty { return this._file; } + stats(): IPtyStats | null { + if (this._closed) return null; + try { + return this._native.stats(this._handle) ?? null; + } catch { + return null; + } + } + write(data: string): void { if (this._closed) return; const doWrite = () => this._native.write(this._handle, data); diff --git a/src/spawn.test.ts b/src/spawn.test.ts index 95af7ff..8dab741 100644 --- a/src/spawn.test.ts +++ b/src/spawn.test.ts @@ -127,6 +127,81 @@ describe("spawn", () => { }); }); +async function pollStats( + pty: { stats(): T | null }, + predicate: (s: T) => boolean, + timeoutMs = 5000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const s = pty.stats(); + if (s !== null && predicate(s)) return s; + await new Promise((r) => setTimeout(r, 25)); + } + throw new Error("pollStats timed out"); +} + +describe("stats", () => { + it("should report pid, rss, and cpu times", async () => { + const exe = isWindows ? "cmd.exe" : "/bin/cat"; + const pty = spawn(exe); + + const stats = await pollStats(pty, (s) => s.pid > 0 && s.rssBytes > 0); + expect(stats.cpuUser).toBeGreaterThanOrEqual(0); + expect(stats.cpuSys).toBeGreaterThanOrEqual(0); + + pty.kill(); + await pty.exited; + }); + + it.skipIf(isWindows)("should report cwd on unix", async () => { + // macOS resolves /tmp → /private/tmp; pass the canonical path so proc_pidinfo's + // PROC_PIDVNODEPATHINFO returns an equal string. + const tmpDir = process.platform === "darwin" ? "/private/tmp" : "/tmp"; + const pty = spawn("/bin/cat", [], { cwd: tmpDir }); + + const stats = await pollStats(pty, (s) => s.cwd === tmpDir); + expect(stats.cwd).toBe(tmpDir); + + pty.kill(); + await pty.exited; + }); + + it("should return null after close", async () => { + const exe = isWindows ? "cmd.exe" : "/bin/cat"; + const pty = spawn(exe); + pty.close(); + expect(pty.stats()).toBeNull(); + await pty.exited; + }); + + it("should aggregate child processes", async () => { + // Unix: sh runs two `sleep` children in a single pgrp — aggregation should + // see count=3 (sh + 2 sleeps). + // Windows: cmd uses `start /B` to actually background two pings in its + // descendant tree (cmd's `&` is sequential, not parallel). + const [exe, args] = isWindows + ? [ + "cmd.exe", + ["/c", "start /B ping -n 10 127.0.0.1 && start /B ping -n 10 127.0.0.1 && timeout /t 10"], + ] + : ["/bin/sh", ["-c", "sleep 2 & sleep 2 & wait"]]; + const pty = spawn(exe, args); + + const stats = await pollStats(pty, (s) => s.count >= 3 && s.children.length >= 2); + expect(stats.count).toBeGreaterThanOrEqual(3); + expect(stats.children.length).toBeGreaterThanOrEqual(2); + for (const c of stats.children) { + expect(c.pid).toBeGreaterThan(0); + expect(typeof c.name).toBe("string"); + expect(c.rssBytes).toBeGreaterThanOrEqual(0); + } + + pty.kill(); + await pty.exited; + }); +}); + describeUnix("process name (unix)", () => { it("should report foreground process name", async () => { const pty = spawn("/bin/bash"); diff --git a/zig/lib.zig b/zig/lib.zig index cb70f62..16fffbd 100644 --- a/zig/lib.zig +++ b/zig/lib.zig @@ -79,11 +79,55 @@ pub const PtyError = error{ TtynameFailed, }; +/// Info for a single non-leader process aggregated into Stats. +/// `name` is a short executable/command name (truncated to 32 bytes). +pub const ChildStats = struct { + pid: Pid, + name: [32]u8, + name_len: u8, + rss_bytes: u64, + cpu_user_us: u64, + cpu_sys_us: u64, + + pub fn nameSlice(self: *const ChildStats) []const u8 { + return self.name[0..self.name_len]; + } +}; + +/// Aggregated process stats for a PTY. +/// Unix: aggregates the PTY's foreground process group. +/// Windows: aggregates the shell process and its descendant tree. +/// +/// Top-level `rss_bytes`/`cpu_user_us`/`cpu_sys_us` are totals across all +/// aggregated processes (leader + children). `pid`/`cwd` refer to the leader +/// (foreground pgrp leader on Unix, shell process on Windows). `children` +/// lists all non-leader processes that were aggregated; `count` is the total +/// number of processes (children.len + 1). +/// +/// `cwd` is a slice into the caller-provided buffer — null on Windows. +/// `children` is owned by the caller's allocator — call `deinit` to free. +/// CPU times are in microseconds. `rss_bytes` is resident set size. +pub const Stats = struct { + pid: Pid, + cwd: ?[]const u8, + rss_bytes: u64, + cpu_user_us: u64, + cpu_sys_us: u64, + count: u32, + children: []const ChildStats, + + pub fn deinit(self: *Stats, allocator: std.mem.Allocator) void { + if (self.children.len > 0) allocator.free(self.children); + self.children = &[_]ChildStats{}; + } +}; + // Unix extern declarations and functions — only compiled on non-Windows pub const forkPty = if (!is_windows) forkPtyUnix else void; pub const openPty = if (!is_windows) openPtyUnix else void; pub const resize = if (!is_windows) resizeUnix else void; pub const getProcessName = if (!is_windows) getProcessNameUnix else void; +pub const getStats = if (!is_windows) getStatsUnix else void; pub const waitForExit = if (!is_windows) waitForExitUnix else void; // --- Unix implementation (behind comptime guard) --- @@ -201,6 +245,10 @@ fn getProcessNameUnix(fd: std.posix.fd_t, buf: []u8) ?[]const u8 { return platform.getProcessName(fd, buf); } +fn getStatsUnix(fd: std.posix.fd_t, allocator: std.mem.Allocator, cwd_buf: []u8) ?Stats { + return platform.getStats(fd, allocator, cwd_buf); +} + fn waitForExitUnix(pid: std.posix.pid_t) ExitInfo { var status: c_int = 0; while (true) { diff --git a/zig/pty.zig b/zig/pty.zig index 00587cd..6468bd8 100644 --- a/zig/pty.zig +++ b/zig/pty.zig @@ -18,12 +18,14 @@ pub const fork = if (!is_windows) unix_napi.fork else void; pub const open = if (!is_windows) unix_napi.open else void; pub const resize = if (!is_windows) unix_napi.resize else void; pub const getProcess = if (!is_windows) unix_napi.getProcess else void; +pub const stats = if (!is_windows) unix_napi.stats else void; // Windows re-exports pub const winSpawn = if (is_windows) win_napi.spawn else void; pub const winWrite = if (is_windows) win_napi.write else void; pub const winResize = if (is_windows) win_napi.resize else void; pub const winKill = if (is_windows) win_napi.kill else void; pub const winClose = if (is_windows) win_napi.close else void; +pub const winStats = if (is_windows) win_napi.stats else void; // --- Shared helpers (used by both platform modules) --- @@ -76,3 +78,68 @@ pub fn returnUndef(env: napi.napi_env) napi.napi_value { _ = napi.napi_get_undefined(env, &undef); return undef; } + +/// Build a JS object from a `lib.Stats` struct. +pub fn buildStatsObject(env: napi.napi_env, s: lib.Stats) !napi.napi_value { + var obj: napi.napi_value = undefined; + try napi.check(env, napi.napi_create_object(env, &obj)); + + var pid_val: napi.napi_value = undefined; + try napi.check(env, napi.napi_create_int64(env, @intCast(s.pid), &pid_val)); + try napi.setProp(env, obj, "pid", pid_val); + + if (s.cwd) |cwd| { + try napi.setProp(env, obj, "cwd", try napi.createString(env, cwd)); + } else { + var null_val: napi.napi_value = undefined; + try napi.check(env, napi.napi_get_null(env, &null_val)); + try napi.setProp(env, obj, "cwd", null_val); + } + + var rss_val: napi.napi_value = undefined; + try napi.check(env, napi.napi_create_int64(env, @intCast(s.rss_bytes), &rss_val)); + try napi.setProp(env, obj, "rssBytes", rss_val); + + var cpu_user_val: napi.napi_value = undefined; + try napi.check(env, napi.napi_create_int64(env, @intCast(s.cpu_user_us), &cpu_user_val)); + try napi.setProp(env, obj, "cpuUser", cpu_user_val); + + var cpu_sys_val: napi.napi_value = undefined; + try napi.check(env, napi.napi_create_int64(env, @intCast(s.cpu_sys_us), &cpu_sys_val)); + try napi.setProp(env, obj, "cpuSys", cpu_sys_val); + + var count_val: napi.napi_value = undefined; + try napi.check(env, napi.napi_create_int64(env, @intCast(s.count), &count_val)); + try napi.setProp(env, obj, "count", count_val); + + // children: Array<{ pid, name, rssBytes, cpuUser, cpuSys }> + var arr: napi.napi_value = undefined; + try napi.check(env, napi.napi_create_array_with_length(env, s.children.len, &arr)); + for (s.children, 0..) |child, i| { + var c_obj: napi.napi_value = undefined; + try napi.check(env, napi.napi_create_object(env, &c_obj)); + + var c_pid: napi.napi_value = undefined; + try napi.check(env, napi.napi_create_int64(env, @intCast(child.pid), &c_pid)); + try napi.setProp(env, c_obj, "pid", c_pid); + + try napi.setProp(env, c_obj, "name", try napi.createString(env, child.nameSlice())); + + var c_rss: napi.napi_value = undefined; + try napi.check(env, napi.napi_create_int64(env, @intCast(child.rss_bytes), &c_rss)); + try napi.setProp(env, c_obj, "rssBytes", c_rss); + + var c_user: napi.napi_value = undefined; + try napi.check(env, napi.napi_create_int64(env, @intCast(child.cpu_user_us), &c_user)); + try napi.setProp(env, c_obj, "cpuUser", c_user); + + var c_sys: napi.napi_value = undefined; + try napi.check(env, napi.napi_create_int64(env, @intCast(child.cpu_sys_us), &c_sys)); + try napi.setProp(env, c_obj, "cpuSys", c_sys); + + try napi.check(env, napi.napi_set_element(env, arr, @intCast(i), c_obj)); + } + try napi.setProp(env, obj, "children", arr); + + return obj; +} diff --git a/zig/pty_darwin.zig b/zig/pty_darwin.zig index 26fb01f..759862d 100644 --- a/zig/pty_darwin.zig +++ b/zig/pty_darwin.zig @@ -1,10 +1,49 @@ /// macOS-specific PTY helpers. const std = @import("std"); const posix = std.posix; +const lib = @import("lib.zig"); extern fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern fn tcgetpgrp(fd: c_int) c_int; extern fn sysctl(name: [*]c_int, namelen: c_uint, oldp: ?*anyopaque, oldlenp: ?*usize, newp: ?*const anyopaque, newlen: usize) c_int; +extern fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; +extern fn proc_listpids(type: u32, typeinfo: u32, buffer: ?*anyopaque, buffersize: c_int) c_int; +extern fn proc_name(pid: c_int, buffer: *anyopaque, buffersize: u32) c_int; + +const MachTimebaseInfo = extern struct { numer: u32, denom: u32 }; +extern fn mach_timebase_info(info: *MachTimebaseInfo) c_int; + +/// Cached `mach_timebase_info`. On Apple Silicon `mach_absolute_time` runs at +/// 24 MHz (numer=125, denom=3 → 41.667 ns/tick); on Intel macs the timebase is +/// 1:1 ns/tick. Cached lazily — `mach_timebase_info` is a syscall on first call. +var cached_timebase: std.atomic.Value(u64) = std.atomic.Value(u64).init(0); + +fn machTimebase() MachTimebaseInfo { + const packed_val = cached_timebase.load(.unordered); + if (packed_val != 0) { + return .{ + .numer = @truncate(packed_val >> 32), + .denom = @truncate(packed_val), + }; + } + var info: MachTimebaseInfo = .{ .numer = 1, .denom = 1 }; + _ = mach_timebase_info(&info); + if (info.denom == 0) info = .{ .numer = 1, .denom = 1 }; + cached_timebase.store((@as(u64, info.numer) << 32) | @as(u64, info.denom), .unordered); + return info; +} + +/// Convert raw `pti_total_user`/`pti_total_system` (mach absolute time units) +/// to microseconds: µs = ticks * numer / (denom * 1000). +fn machTicksToMicros(ticks: u64) u64 { + const tb = machTimebase(); + // Compute as u128 to avoid overflow on Intel (numer=denom=1, ticks already + // ns) when ticks approach u64 max. Apple Silicon's numer=125 keeps the + // intermediate well within u128 range for any realistic CPU time. + const num: u128 = @as(u128, ticks) * @as(u128, tb.numer); + const den: u128 = @as(u128, tb.denom) * 1000; + return @intCast(num / den); +} /// On macOS there is no execvpe — set environ pointer then execvp. pub fn execChild(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, envp: [*:null]const ?[*:0]const u8) void { @@ -52,6 +91,166 @@ pub fn getProcessName(fd: posix.fd_t, buf: []u8) ?[]const u8 { return buf[0..len]; } +// proc_pidinfo flavors (from ) +const PROC_PIDTASKINFO = 4; +const PROC_PIDVNODEPATHINFO = 9; + +// struct proc_taskinfo layout — total size 96 bytes: +// [ 0.. 8) pti_virtual_size u64 +// [ 8.. 16) pti_resident_size u64 ← used for rss +// [16.. 24) pti_total_user u64 (mach absolute time units) +// [24.. 32) pti_total_system u64 (mach absolute time units) +// [32.. 96) thread/page/fault counters (unused) +// +// Note: pti_total_user/pti_total_system are in mach absolute time units, NOT +// nanoseconds. On Apple Silicon mach_absolute_time runs at 24 MHz (numer=125, +// denom=3 → ~41.667 ns/tick). On Intel macs the timebase is 1:1 ns/tick which +// is why this was easy to get wrong. Always convert via machTicksToMicros(). +const PROC_TASKINFO_SIZE = 96; + +// struct proc_vnodepathinfo = { pvi_cdir, pvi_rdir } — each a vnode_info_path. +// struct vnode_info_path = { vip_vi: vnode_info(152), vip_path: [MAXPATHLEN]u8 }. +// struct vnode_info = { vi_stat: vinfo_stat(136), vi_type(4), vi_pad(4), vi_fsid: fsid_t(8) } = 152. +// vinfo_stat breakdown (136 bytes): dev(4) + mode(2) + nlink(2) + ino(8) + uid(4) + gid(4) +// + 4×(atime/mtime/ctime/birthtime pair of i64) = 64 + size(8) + blocks(8) + blksize(4) +// + flags(4) + gen(4) + rdev(4) + qspare[2] i64 (16) = 136. +// → Offset of pvi_cdir.vip_path within proc_vnodepathinfo = 152. +// → Total size = 2 × (152 + 1024) = 2352. +const PROC_VNODEPATHINFO_SIZE = 2352; +const VIP_PATH_OFFSET = 152; +const MAXPATHLEN = 1024; + +// proc_listpids flavors (from ): +// PROC_ALL_PIDS=1, PROC_PGRP_ONLY=2, PROC_TTY_ONLY=3, PROC_UID_ONLY=4, PROC_RUID_ONLY=5 +const PROC_PGRP_ONLY: u32 = 2; + +/// Fetch per-process rss + cpu via PROC_PIDTASKINFO. Returns null if the call +/// fails (process exited) or the layout check fails. +fn taskInfo(pid: c_int) ?struct { rss: u64, user_us: u64, sys_us: u64 } { + var task_buf: [PROC_TASKINFO_SIZE]u8 align(8) = undefined; + const rc = proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &task_buf, PROC_TASKINFO_SIZE); + if (rc != PROC_TASKINFO_SIZE) return null; + return .{ + .rss = std.mem.readInt(u64, task_buf[8..16], .little), + .user_us = machTicksToMicros(std.mem.readInt(u64, task_buf[16..24], .little)), + .sys_us = machTicksToMicros(std.mem.readInt(u64, task_buf[24..32], .little)), + }; +} + +/// Get aggregated stats for the PTY's foreground process group. +/// Enumerates every process in the pgrp via proc_listpids(PROC_PGRP_ONLY), +/// sums rss + cpu, and returns per-process info in `children`. +pub fn getStats(fd: posix.fd_t, allocator: std.mem.Allocator, cwd_buf: []u8) ?lib.Stats { + const pgrp = tcgetpgrp(@intCast(fd)); + if (pgrp < 0) return null; + + // proc_listpids returns bytes written. When the result equals the buffer + // size, the list was probably truncated — retry on the heap with a bigger + // buffer until the kernel reports fewer bytes than we handed it. + const stack_cap: usize = 512; + var stack_buf: [stack_cap]c_int = undefined; + var pids: []c_int = stack_buf[0..]; + var heap_pids: ?[]c_int = null; + defer if (heap_pids) |h| allocator.free(h); + + var got_bytes = proc_listpids(PROC_PGRP_ONLY, @intCast(pgrp), pids.ptr, @intCast(pids.len * @sizeOf(c_int))); + if (got_bytes <= 0) return null; + + // proc_listpids only reports bytes written, so an exact-fill is + // indistinguishable from truncation — we may retry one extra time on a + // perfectly-fitted buffer. Cap at 65536 pids (256KB on the heap) since no + // realistic foreground job has that many processes; beyond the cap we + // accept a truncated view. + var cap: usize = stack_cap; + while (@as(usize, @intCast(got_bytes)) >= cap * @sizeOf(c_int) and cap < 65536) { + cap *= 4; + if (heap_pids) |old| allocator.free(old); + heap_pids = null; + // On alloc failure, fall through with the previous (truncated) result + // — `pids` still points at the prior buffer and `got_bytes` reflects + // its content, so we degrade to a partial enumeration instead of + // returning null. + const h = allocator.alloc(c_int, cap) catch break; + heap_pids = h; + pids = h; + got_bytes = proc_listpids(PROC_PGRP_ONLY, @intCast(pgrp), h.ptr, @intCast(h.len * @sizeOf(c_int))); + if (got_bytes <= 0) return null; + } + + const num_pids: usize = @intCast(@divTrunc(got_bytes, @sizeOf(c_int))); + if (num_pids == 0) return null; + + var children = std.ArrayListUnmanaged(lib.ChildStats){}; + errdefer children.deinit(allocator); + + var total_rss: u64 = 0; + var total_user: u64 = 0; + var total_sys: u64 = 0; + var count: u32 = 0; + + for (pids[0..num_pids]) |pid| { + if (pid <= 0) continue; + const ti = taskInfo(pid) orelse continue; + + if (pid == pgrp) { + total_rss += ti.rss; + total_user += ti.user_us; + total_sys += ti.sys_us; + count += 1; + } else { + var child = lib.ChildStats{ + .pid = pid, + .name = undefined, + .name_len = 0, + .rss_bytes = ti.rss, + .cpu_user_us = ti.user_us, + .cpu_sys_us = ti.sys_us, + }; + // proc_name returns bytes written (not including null). + // Use full ChildStats.name capacity; proc_name truncates to 15 anyway. + const n = proc_name(pid, &child.name, child.name.len); + if (n > 0) child.name_len = @intCast(@min(@as(c_int, @intCast(child.name.len)), n)); + children.append(allocator, child) catch continue; + total_rss += ti.rss; + total_user += ti.user_us; + total_sys += ti.sys_us; + count += 1; + } + } + + if (count == 0) { + children.deinit(allocator); + return null; + } + + // Resolve leader cwd via PROC_PIDVNODEPATHINFO. + var leader_cwd: ?[]const u8 = null; + var vpi_buf: [PROC_VNODEPATHINFO_SIZE]u8 align(8) = undefined; + const vpi_rc = proc_pidinfo(pgrp, PROC_PIDVNODEPATHINFO, 0, &vpi_buf, PROC_VNODEPATHINFO_SIZE); + if (vpi_rc == PROC_VNODEPATHINFO_SIZE) { + const path_slice = vpi_buf[VIP_PATH_OFFSET..][0..MAXPATHLEN]; + const len = std.mem.indexOfScalar(u8, path_slice, 0) orelse MAXPATHLEN; + if (len > 0 and len <= cwd_buf.len) { + @memcpy(cwd_buf[0..len], path_slice[0..len]); + leader_cwd = cwd_buf[0..len]; + } + } + + const owned = children.toOwnedSlice(allocator) catch blk: { + children.deinit(allocator); + break :blk &[_]lib.ChildStats{}; + }; + return lib.Stats{ + .pid = pgrp, + .cwd = leader_cwd, + .rss_bytes = total_rss, + .cpu_user_us = total_user, + .cpu_sys_us = total_sys, + .count = count, + .children = owned, + }; +} + /// Raw exit — bypasses libc's exit() and its atexit handlers. /// After fork, atexit handlers from the parent (Node.js/V8) should not run. pub fn rawExit(status: u8) noreturn { diff --git a/zig/pty_linux.zig b/zig/pty_linux.zig index 3b731a4..8e60fe6 100644 --- a/zig/pty_linux.zig +++ b/zig/pty_linux.zig @@ -1,7 +1,9 @@ /// Linux-specific PTY helpers. const std = @import("std"); +const builtin = @import("builtin"); const posix = std.posix; const linux = std.os.linux; +const lib = @import("lib.zig"); extern fn execvpe(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, envp: [*:null]const ?[*:0]const u8) c_int; extern fn ptsname_r(fd: c_int, buf: [*]u8, buflen: usize) c_int; @@ -185,6 +187,187 @@ pub fn getProcessName(fd: posix.fd_t, buf: []u8) ?[]const u8 { return if (std.mem.lastIndexOfScalar(u8, cmd, '/')) |p| cmd[p + 1 ..] else cmd; } +/// Parsed fields from /proc//stat. +const ProcStat = struct { + pgrp: posix.pid_t, + comm: []const u8, // slice into source buffer + utime_ticks: u64, + stime_ticks: u64, + rss_pages: u64, +}; + +/// Get aggregated stats for the PTY's foreground process group. +/// Walks /proc and sums rss+cpu across every process whose pgrp matches +/// `tcgetpgrp(fd)`. The leader is the pgrp id itself. `children` (owned by +/// `allocator`) lists every other process in the pgrp. +pub fn getStats(fd: posix.fd_t, allocator: std.mem.Allocator, cwd_buf: []u8) ?lib.Stats { + const pgrp = tcgetpgrp(@intCast(fd)); + if (pgrp < 0) return null; + + const clk_tck: u64 = 100; + const page_size = getPageSize(); + + var children = std.ArrayListUnmanaged(lib.ChildStats){}; + errdefer children.deinit(allocator); + + var total_rss: u64 = 0; + var total_user: u64 = 0; + var total_sys: u64 = 0; + var count: u32 = 0; + var leader_cwd: ?[]const u8 = null; + + // Resolve leader cwd first — this is the one field that still refers only + // to the foreground pgrp leader. + var path_buf: [64]u8 = undefined; + if (std.fmt.bufPrint(&path_buf, "/proc/{d}/cwd", .{pgrp})) |cwd_path| { + if (std.posix.readlink(cwd_path, cwd_buf)) |link| { + if (link.len > 0) leader_cwd = link; + } else |_| {} + } else |_| {} + + var dir = std.fs.openDirAbsolute("/proc", .{ .iterate = true }) catch return null; + defer dir.close(); + + var it = dir.iterate(); + while (true) { + const entry = (it.next() catch break) orelse break; + // Don't filter by entry.kind — procfs can return .unknown depending on + // the kernel/mount. parseInt on the name is enough to skip non-pid dirs. + const pid = std.fmt.parseInt(posix.pid_t, entry.name, 10) catch continue; + + const stat_path = std.fmt.bufPrint(&path_buf, "/proc/{d}/stat", .{pid}) catch continue; + const f = std.fs.openFileAbsolute(stat_path, .{}) catch continue; + defer f.close(); + + var stat_buf: [1024]u8 = undefined; + const n = f.read(&stat_buf) catch continue; + if (n == 0) continue; + + const ps = parseProcStat(stat_buf[0..n]) orelse continue; + if (ps.pgrp != pgrp) continue; + + const rss_bytes = ps.rss_pages * page_size; + const cpu_user_us = (ps.utime_ticks * 1_000_000) / clk_tck; + const cpu_sys_us = (ps.stime_ticks * 1_000_000) / clk_tck; + + if (pid == pgrp) { + total_rss += rss_bytes; + total_user += cpu_user_us; + total_sys += cpu_sys_us; + count += 1; + } else { + var child = lib.ChildStats{ + .pid = pid, + .name = undefined, + .name_len = 0, + .rss_bytes = rss_bytes, + .cpu_user_us = cpu_user_us, + .cpu_sys_us = cpu_sys_us, + }; + const nl = @min(ps.comm.len, child.name.len); + if (nl > 0) @memcpy(child.name[0..nl], ps.comm[0..nl]); + child.name_len = @intCast(nl); + children.append(allocator, child) catch continue; + total_rss += rss_bytes; + total_user += cpu_user_us; + total_sys += cpu_sys_us; + count += 1; + } + } + + if (count == 0) { + children.deinit(allocator); + return null; + } + + const owned = children.toOwnedSlice(allocator) catch blk: { + children.deinit(allocator); + break :blk &[_]lib.ChildStats{}; + }; + return lib.Stats{ + .pid = pgrp, + .cwd = leader_cwd, + .rss_bytes = total_rss, + .cpu_user_us = total_user, + .cpu_sys_us = total_sys, + .count = count, + .children = owned, + }; +} + +/// Parse /proc//stat. Format: `pid (comm) state ppid pgrp ...` +/// comm can contain spaces/parens, so we skip to the LAST ')' then count fields. +fn parseProcStat(buf: []const u8) ?ProcStat { + const first_paren = std.mem.indexOfScalar(u8, buf, '(') orelse return null; + const last_paren = std.mem.lastIndexOfScalar(u8, buf, ')') orelse return null; + if (last_paren <= first_paren or last_paren + 2 >= buf.len) return null; + + const comm = buf[first_paren + 1 .. last_paren]; + + // Fields after last ')': state(0) ppid(1) pgrp(2) session(3) tty_nr(4) + // tpgid(5) flags(6) minflt(7) cminflt(8) majflt(9) cmajflt(10) + // utime(11) stime(12) cutime(13) cstime(14) priority(15) nice(16) + // num_threads(17) itrealvalue(18) starttime(19) vsize(20) rss(21) + var it = std.mem.tokenizeScalar(u8, buf[last_paren + 2 ..], ' '); + var idx: usize = 0; + var result = ProcStat{ + .pgrp = -1, + .comm = comm, + .utime_ticks = 0, + .stime_ticks = 0, + .rss_pages = 0, + }; + while (it.next()) |field| : (idx += 1) { + switch (idx) { + 2 => result.pgrp = std.fmt.parseInt(posix.pid_t, field, 10) catch return null, + 11 => result.utime_ticks = std.fmt.parseInt(u64, field, 10) catch 0, + 12 => result.stime_ticks = std.fmt.parseInt(u64, field, 10) catch 0, + 21 => { + result.rss_pages = std.fmt.parseInt(u64, field, 10) catch return null; + return result; + }, + else => {}, + } + } + return null; +} + +var cached_page_size: std.atomic.Value(u64) = std.atomic.Value(u64).init(0); + +/// Resolve page size via AT_PAGESZ in /proc/self/auxv. Avoids sysconf's SC-constant +/// mismatch on Android and handles ARM64 16K pages correctly. +fn getPageSize() u64 { + const cached = cached_page_size.load(.unordered); + if (cached != 0) return cached; + + const size = parseAuxvPageSize() orelse 4096; + cached_page_size.store(size, .unordered); + return size; +} + +fn parseAuxvPageSize() ?u64 { + const AT_NULL: usize = 0; + const AT_PAGESZ: usize = 6; + + const f = std.fs.openFileAbsolute("/proc/self/auxv", .{}) catch return null; + defer f.close(); + + var buf: [1024]u8 = undefined; + const n = f.read(&buf) catch return null; + + // auxv entries are stored in the target's native endianness, not a fixed one. + const endian = builtin.cpu.arch.endian(); + const entry_size = @sizeOf(usize) * 2; + var i: usize = 0; + while (i + entry_size <= n) : (i += entry_size) { + const key = std.mem.readInt(usize, buf[i..][0..@sizeOf(usize)], endian); + const val = std.mem.readInt(usize, buf[i + @sizeOf(usize) ..][0..@sizeOf(usize)], endian); + if (key == AT_NULL) return null; + if (key == AT_PAGESZ) return val; + } + return null; +} + /// Ensure libtermux-exec.so is loaded on Termux/Android. /// This library installs a SIGSYS handler for seccomp softfail — without it, /// syscalls like close_range (kernel <5.9) trigger SECCOMP_RET_TRAP which diff --git a/zig/pty_unix.zig b/zig/pty_unix.zig index 35cc9d8..86d07ea 100644 --- a/zig/pty_unix.zig +++ b/zig/pty_unix.zig @@ -249,3 +249,25 @@ fn getProcessImpl(env: napi.napi_env, info: napi.napi_callback_info) !napi.napi_ return try napi.createString(env, name); } + +/// stats(fd) → { pid, cwd, rssBytes, cpuUser, cpuSys } | undefined +pub fn stats(env: napi.napi_env, info: napi.napi_callback_info) callconv(.c) napi.napi_value { + return statsImpl(env, info) catch return pty.returnUndef(env); +} + +fn statsImpl(env: napi.napi_env, info: napi.napi_callback_info) !napi.napi_value { + var argc: usize = 1; + var argv: [1]napi.napi_value = undefined; + try napi.check(env, napi.napi_get_cb_info(env, info, &argc, &argv, null, null)); + + var fd_i32: i32 = 0; + try napi.check(env, napi.napi_get_value_int32(env, argv[0], &fd_i32)); + + if (fd_i32 < 0) return pty.returnUndef(env); + + var cwd_buf: [4096]u8 = undefined; + var s = lib.getStats(@intCast(fd_i32), alloc, &cwd_buf) orelse return pty.returnUndef(env); + defer s.deinit(alloc); + + return try pty.buildStatsObject(env, s); +} diff --git a/zig/pty_windows.zig b/zig/pty_windows.zig index ace7ce7..02eb4e0 100644 --- a/zig/pty_windows.zig +++ b/zig/pty_windows.zig @@ -198,6 +198,279 @@ extern "kernel32" fn WriteFile( extern "kernel32" fn GetLastError() callconv(.c) DWORD; extern "kernel32" fn CloseHandle(hObject: HANDLE) callconv(.c) BOOL; +const FILETIME = extern struct { + dwLowDateTime: DWORD, + dwHighDateTime: DWORD, +}; + +const PROCESS_MEMORY_COUNTERS = extern struct { + cb: DWORD, + PageFaultCount: DWORD, + PeakWorkingSetSize: usize, + WorkingSetSize: usize, + QuotaPeakPagedPoolUsage: usize, + QuotaPagedPoolUsage: usize, + QuotaPeakNonPagedPoolUsage: usize, + QuotaNonPagedPoolUsage: usize, + PagefileUsage: usize, + PeakPagefileUsage: usize, +}; + +extern "kernel32" fn GetProcessTimes( + hProcess: HANDLE, + lpCreationTime: *FILETIME, + lpExitTime: *FILETIME, + lpKernelTime: *FILETIME, + lpUserTime: *FILETIME, +) callconv(.c) BOOL; + +// K32GetProcessMemoryInfo is in kernel32.dll since Windows 7, avoids linking psapi. +extern "kernel32" fn K32GetProcessMemoryInfo( + hProcess: HANDLE, + ppsmemCounters: *PROCESS_MEMORY_COUNTERS, + cb: DWORD, +) callconv(.c) BOOL; + +// --- Toolhelp32 (process snapshot for descendant walk) --- + +const TH32CS_SNAPPROCESS: DWORD = 0x00000002; +const MAX_PATH: usize = 260; + +const PROCESSENTRY32W = extern struct { + dwSize: DWORD, + cntUsage: DWORD, + th32ProcessID: DWORD, + th32DefaultHeapID: usize, + th32ModuleID: DWORD, + cntThreads: DWORD, + th32ParentProcessID: DWORD, + pcPriClassBase: i32, + dwFlags: DWORD, + szExeFile: [MAX_PATH]u16, +}; + +extern "kernel32" fn CreateToolhelp32Snapshot( + dwFlags: DWORD, + th32ProcessID: DWORD, +) callconv(.c) HANDLE; + +extern "kernel32" fn Process32FirstW( + hSnapshot: HANDLE, + lppe: *PROCESSENTRY32W, +) callconv(.c) BOOL; + +extern "kernel32" fn Process32NextW( + hSnapshot: HANDLE, + lppe: *PROCESSENTRY32W, +) callconv(.c) BOOL; + +const PROCESS_QUERY_LIMITED_INFORMATION: DWORD = 0x1000; + +extern "kernel32" fn OpenProcess( + dwDesiredAccess: DWORD, + bInheritHandle: BOOL, + dwProcessId: DWORD, +) callconv(.c) ?HANDLE; + +fn filetimeToMicros(ft: FILETIME) u64 { + // FILETIME is in 100-nanosecond intervals — divide by 10 for microseconds. + const combined: u64 = (@as(u64, ft.dwHighDateTime) << 32) | @as(u64, ft.dwLowDateTime); + return combined / 10; +} + +const lib = @import("lib.zig"); + +/// Per-process memory + CPU via an open process handle. +fn processStats(process: HANDLE) ?struct { rss: u64, user_us: u64, sys_us: u64 } { + var rss: u64 = 0; + var user_us: u64 = 0; + var sys_us: u64 = 0; + + var pmc = std.mem.zeroes(PROCESS_MEMORY_COUNTERS); + pmc.cb = @sizeOf(PROCESS_MEMORY_COUNTERS); + if (K32GetProcessMemoryInfo(process, &pmc, @sizeOf(PROCESS_MEMORY_COUNTERS)) == 0) return null; + rss = pmc.WorkingSetSize; + + var creation: FILETIME = undefined; + var exit: FILETIME = undefined; + var kernel: FILETIME = undefined; + var user: FILETIME = undefined; + if (GetProcessTimes(process, &creation, &exit, &kernel, &user) == 0) return null; + user_us = filetimeToMicros(user); + sys_us = filetimeToMicros(kernel); + + return .{ .rss = rss, .user_us = user_us, .sys_us = sys_us }; +} + +/// Snapshot entry from Toolhelp32. +const ProcEntry = struct { + pid: DWORD, + ppid: DWORD, + name: [32]u8, + name_len: u8, +}; + +/// Largest length ≤ `max` that ends on a UTF-8 codepoint boundary. +/// Walks back from `max` past any continuation bytes (top bits 0b10). +fn utf8TruncateLen(buf: []const u8, max: usize) usize { + if (buf.len <= max) return buf.len; + var n = max; + while (n > 0 and (buf[n] & 0xC0) == 0x80) : (n -= 1) {} + return n; +} + +/// Enumerate all processes via Toolhelp32 into a heap-allocated slice. +/// Caller owns the returned slice and must `allocator.free` it. +fn snapshotProcesses(allocator: std.mem.Allocator) ![]ProcEntry { + const snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE) return error.SnapshotFailed; + defer closeHandle(snap); + + var pe = std.mem.zeroes(PROCESSENTRY32W); + pe.dwSize = @sizeOf(PROCESSENTRY32W); + + if (Process32FirstW(snap, &pe) == 0) return error.SnapshotFailed; + + var list = std.ArrayListUnmanaged(ProcEntry){}; + errdefer list.deinit(allocator); + + while (true) { + var entry = ProcEntry{ + .pid = pe.th32ProcessID, + .ppid = pe.th32ParentProcessID, + .name = undefined, + .name_len = 0, + }; + + // Convert UTF-16 szExeFile → UTF-8, truncating on a codepoint + // boundary so non-ASCII names never produce invalid UTF-8. + // MAX_PATH * 4 = 1040: worst-case UTF-8 expansion of MAX_PATH UTF-16 + // code units (surrogate pair → 4 UTF-8 bytes). Stack-cheap and + // guarantees the conversion never errors on length. + const wide_len = std.mem.indexOfScalar(u16, &pe.szExeFile, 0) orelse pe.szExeFile.len; + var utf8_buf: [MAX_PATH * 4]u8 = undefined; + const u8_len = std.unicode.utf16LeToUtf8(&utf8_buf, pe.szExeFile[0..wide_len]) catch 0; + const copy_len = utf8TruncateLen(utf8_buf[0..u8_len], entry.name.len); + if (copy_len > 0) @memcpy(entry.name[0..copy_len], utf8_buf[0..copy_len]); + entry.name_len = @intCast(copy_len); + + try list.append(allocator, entry); + + if (Process32NextW(snap, &pe) == 0) break; + } + + return try list.toOwnedSlice(allocator); +} + +/// Get aggregated stats for the shell process and its descendant tree on +/// Windows. ConPTY has no foreground pgrp concept, so we walk Toolhelp32 and +/// mark every transitive descendant of `pid`, then sum memory + CPU across +/// them. `cwd` stays null — reading another process's cwd requires +/// NtQueryInformationProcess + remote PEB read, which is fragile across +/// elevation boundaries. +pub fn getStats(process: HANDLE, pid: u32, allocator: std.mem.Allocator) ?lib.Stats { + // Gate on liveness. + const STILL_ACTIVE: DWORD = 259; + var exit_code: DWORD = 0; + if (GetExitCodeProcess(process, &exit_code) == 0) return null; + if (exit_code != STILL_ACTIVE) return null; + + // Start with leader stats from the already-open process handle. + const leader_stats = processStats(process) orelse return null; + + var children = std.ArrayListUnmanaged(lib.ChildStats){}; + errdefer children.deinit(allocator); + + var total_rss: u64 = leader_stats.rss; + var total_user: u64 = leader_stats.user_us; + var total_sys: u64 = leader_stats.sys_us; + var count: u32 = 1; + + // Best-effort descendant aggregation. Any failure along the way falls + // through to the end of the block — leader-only stats still get returned. + descendants: { + const entries = snapshotProcesses(allocator) catch break :descendants; + defer allocator.free(entries); + if (entries.len == 0) break :descendants; + + const marked = allocator.alloc(bool, entries.len) catch break :descendants; + defer allocator.free(marked); + @memset(marked, false); + + var leader_idx: ?usize = null; + for (entries, 0..) |e, i| { + if (e.pid == pid) { + leader_idx = i; + break; + } + } + const li = leader_idx orelse break :descendants; + + var queue = std.ArrayListUnmanaged(usize){}; + defer queue.deinit(allocator); + + marked[li] = true; + queue.append(allocator, li) catch break :descendants; + + // BFS. For each parent pop, linear-scan for unmarked children. For + // typical descendant trees (few dozen procs) this is fine; Windows + // has no O(1) ppid→children API and building a hashmap would cost + // more than the scan. + var head: usize = 0; + while (head < queue.items.len) : (head += 1) { + const parent_pid = entries[queue.items[head]].pid; + for (entries, 0..) |e, i| { + if (marked[i]) continue; + if (e.ppid != parent_pid) continue; + marked[i] = true; + queue.append(allocator, i) catch break :descendants; + } + } + + for (entries, 0..) |e, i| { + if (!marked[i]) continue; + if (e.pid == pid) continue; + + const h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, e.pid) orelse continue; + defer closeHandle(h); + + const ps = processStats(h) orelse continue; + + var child = lib.ChildStats{ + .pid = e.pid, + .name = undefined, + .name_len = 0, + .rss_bytes = ps.rss, + .cpu_user_us = ps.user_us, + .cpu_sys_us = ps.sys_us, + }; + const nl = @min(e.name_len, child.name.len); + if (nl > 0) @memcpy(child.name[0..nl], e.name[0..nl]); + child.name_len = nl; + children.append(allocator, child) catch continue; + + total_rss += ps.rss; + total_user += ps.user_us; + total_sys += ps.sys_us; + count += 1; + } + } + + const owned = children.toOwnedSlice(allocator) catch blk: { + children.deinit(allocator); + break :blk &[_]lib.ChildStats{}; + }; + return lib.Stats{ + .pid = pid, + .cwd = null, + .rss_bytes = total_rss, + .cpu_user_us = total_user, + .cpu_sys_us = total_sys, + .count = count, + .children = owned, + }; +} + // --- Public API --- /// Phase 1: Create ConPTY pipes and pseudo console (no process yet). diff --git a/zig/root.zig b/zig/root.zig index 62eadde..fd210f6 100644 --- a/zig/root.zig +++ b/zig/root.zig @@ -12,12 +12,14 @@ fn init(env: napi.napi_env, exports: napi.napi_value) callconv(.c) napi.napi_val registerFn(env, exports, "resize", pty.winResize); registerFn(env, exports, "kill", pty.winKill); registerFn(env, exports, "close", pty.winClose); + registerFn(env, exports, "stats", pty.winStats); } else { // Unix PTY exports registerFn(env, exports, "fork", pty.fork); registerFn(env, exports, "open", pty.open); registerFn(env, exports, "resize", pty.resize); registerFn(env, exports, "process", pty.getProcess); + registerFn(env, exports, "stats", pty.stats); } return exports; } diff --git a/zig/win/napi.zig b/zig/win/napi.zig index 788fb32..f73bbfb 100644 --- a/zig/win/napi.zig +++ b/zig/win/napi.zig @@ -377,6 +377,28 @@ fn killImpl(env: napi.napi_env, info: napi.napi_callback_info) !void { win.killProcess(ctx.spawn_result.process, 1); } +/// stats(handle) → { pid, cwd, rssBytes, cpuUser, cpuSys } | undefined +pub fn stats(env: napi.napi_env, info: napi.napi_callback_info) callconv(.c) napi.napi_value { + return statsImpl(env, info) catch return pty.returnUndef(env); +} + +fn statsImpl(env: napi.napi_env, info: napi.napi_callback_info) !napi.napi_value { + var argc: usize = 1; + var argv: [1]napi.napi_value = undefined; + try napi.check(env, napi.napi_get_cb_info(env, info, &argc, &argv, null, null)); + + var ctx_ptr: ?*anyopaque = null; + try napi.check(env, napi.napi_get_value_external(env, argv[0], &ctx_ptr)); + const ctx: *WinConPtyContext = @ptrCast(@alignCast(ctx_ptr orelse return pty.returnUndef(env))); + + if (ctx.spawn_result.process == win.INVALID_HANDLE) return pty.returnUndef(env); + + var s = win.getStats(ctx.spawn_result.process, ctx.spawn_result.pid, alloc) orelse return pty.returnUndef(env); + defer s.deinit(alloc); + + return try pty.buildStatsObject(env, s); +} + /// close(handle) — cleanup ConPTY resources pub fn close(env: napi.napi_env, info: napi.napi_callback_info) callconv(.c) napi.napi_value { closeImpl(env, info) catch {};