From e5e8a4308a5713f945415e5c7f22dfdc680f7b5d Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Sep 2025 22:53:07 +0300 Subject: [PATCH 1/8] Initial commit with task details for issue #18 Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/link-foundation/command-stream/issues/18 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..7beb68c1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/link-foundation/command-stream/issues/18 +Your prepared branch: issue-18-16ce2f52 +Your prepared working directory: /tmp/gh-issue-solver-1757447582813 + +Proceed. \ No newline at end of file From 11d0ea6db6456550748c37eb3db3b3bc5fe9f7e5 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Sep 2025 22:53:24 +0300 Subject: [PATCH 2/8] Remove CLAUDE.md - PR created successfully --- CLAUDE.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 7beb68c1..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -Issue to solve: https://github.com/link-foundation/command-stream/issues/18 -Your prepared branch: issue-18-16ce2f52 -Your prepared working directory: /tmp/gh-issue-solver-1757447582813 - -Proceed. \ No newline at end of file From a7555bcd853e60934ee6b9efccf5dd82a568d5c8 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Sep 2025 22:59:11 +0300 Subject: [PATCH 3/8] Add comprehensive PID access documentation and examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add process-pid-access.mjs example demonstrating 7 different ways to access PIDs - Update README.md with Process ID access section in main documentation - Update examples/README.md with process management section - Document command.child.pid property in API reference - Show best practices for safe PID access and error handling - Cover all three methods to start processes and access PIDs Resolves #18 - provides clear examples and documentation on how to get PID of started commands. ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 49 ++++++++ examples/README.md | 14 +++ examples/process-pid-access.mjs | 191 ++++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100755 examples/process-pid-access.mjs diff --git a/README.md b/README.md index fc45e260..d25bcd83 100644 --- a/README.md +++ b/README.md @@ -431,6 +431,53 @@ console.log(traditional.stdout); // "still works\n" - **`streams.*`** - Available **immediately** when command starts, for real-time interaction - **`buffers.*` & `strings.*`** - Complete **snapshots** available only **after** command finishes +### Process ID (PID) Access + +Get the process ID of running commands for monitoring and management: + +```javascript +import { $ } from 'command-stream'; + +// Method 1: Access PID after starting via streams (recommended) +const command = $`ping -c 5 google.com`; +const stdout = await command.streams.stdout; + +if (command.child && command.child.pid) { + console.log(`Command PID: ${command.child.pid}`); + console.log('Process is running, you can monitor it externally'); +} + +const result = await command; + +// Method 2: Access PID with explicit start +const longCmd = $`sleep 10`; +await longCmd.start(); + +console.log(`Sleep PID: ${longCmd.child.pid}`); +// PID remains available even after completion + +// Method 3: Safe PID access with error handling +function getPidSafely(cmd, name) { + if (cmd.child && cmd.child.pid) { + return cmd.child.pid; + } else { + console.log(`PID not available for ${name}`); + return null; + } +} + +const echoCmd = $`echo "Hello"`; +await echoCmd.streams.stdout; +const pid = getPidSafely(echoCmd, 'echo command'); +``` + +**Key Points:** +- Access PID via: `command.child.pid` +- Process must be started first (use `.streams.*`, `.start()`, or `.stream()`) +- Always check if `command.child` and `command.child.pid` exist +- PID remains accessible even after command completion +- Very fast commands may finish before PID access + ### Shell Replacement (.sh โ†’ .mjs) Replace bash scripts with JavaScript while keeping shell semantics: @@ -828,6 +875,8 @@ The enhanced `$` function returns a `ProcessRunner` instance that extends `Event - `stdout`: Direct access to child process stdout stream - `stderr`: Direct access to child process stderr stream - `stdin`: Direct access to child process stdin stream +- `child`: Reference to the underlying Node.js ChildProcess object +- `child.pid`: Process ID (PID) of the running command (available after process starts) ### Default Options diff --git a/examples/README.md b/examples/README.md index fbe01d36..c44416d5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -149,6 +149,11 @@ The simplest examples to get started: - `syntax-piping-comparison.mjs` - Command chaining comparison - `syntax-multiple-listeners.mjs` - Multiple event listeners comparison +### ๐Ÿ†” Process Management + +**PID Access:** +- `process-pid-access.mjs` - Complete guide to accessing Process IDs (PIDs) of started commands + ### ๐Ÿงช Testing and Debugging **Core Functionality Tests:** @@ -279,12 +284,21 @@ The simplest examples to get started: - โœ… **No resource leaks** - Virtual commands are properly closed - โœ… **Clean exit** - No hanging processes after iteration stops +### ๐Ÿ†” Process Management +- โœ… **PID access** - Access process IDs via `command.child.pid` +- โœ… **Process lifecycle** - PID available after process starts, remains after completion +- โœ… **Multiple startup methods** - Access via streams, start(), or stream() +- โœ… **Safe PID handling** - Best practices for checking PID availability + ## Usage Examples ```bash # Run a basic example bun examples/ping-streaming-simple.mjs +# Learn how to get process PIDs +node examples/process-pid-access.mjs + # Test ANSI color handling node examples/colors-default-preserved.mjs diff --git a/examples/process-pid-access.mjs b/examples/process-pid-access.mjs new file mode 100755 index 00000000..f599ff35 --- /dev/null +++ b/examples/process-pid-access.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +// Example: How to get PID of started commands +// This demonstrates different ways to access the process ID of running commands + +import { $ } from '../src/$.mjs'; + +console.log('๐Ÿ†” Process ID (PID) Access Examples\n'); + +// Example 1: Basic PID access with auto-start via streams +console.log('1๏ธโƒฃ Basic PID Access (streams auto-start):'); +const echoCmd = $`echo "Hello World"`; + +// Accessing streams automatically starts the process +const stdout = await echoCmd.streams.stdout; + +// Now the PID should be available +if (echoCmd.child && echoCmd.child.pid) { + console.log(` โœ… Command PID: ${echoCmd.child.pid}`); + console.log(` Command: echo "Hello World"`); +} else { + console.log(' โš ๏ธ PID not available'); +} + +// Wait for completion and show output +const result1 = await echoCmd; +console.log(` Output: ${result1.stdout.trim()}`); +console.log(` Exit code: ${result1.code}\n`); + +// Example 2: PID access with explicit start +console.log('2๏ธโƒฃ PID Access with Explicit Start:'); +const sleepCmd = $`sleep 2`; + +// Start the command explicitly +await sleepCmd.start(); + +// Give it a moment to fully initialize +await new Promise(resolve => setTimeout(resolve, 10)); + +if (sleepCmd.child && sleepCmd.child.pid) { + console.log(` โœ… Sleep command PID: ${sleepCmd.child.pid}`); + console.log(` Command: sleep 2`); + console.log(` Status: running...`); +} else { + console.log(' โš ๏ธ PID not available'); +} + +// Wait for completion +const result2 = await sleepCmd; +console.log(` Sleep completed with exit code: ${result2.code}\n`); + +// Example 3: Multiple commands with PID tracking using streams +console.log('3๏ธโƒฃ Multiple Commands PID Tracking:'); +const commands = [ + $`sleep 0.5`, // Use sleep to keep process alive longer + $`sleep 0.5`, + $`sleep 0.5` +]; + +const pids = []; + +// Start all commands and collect PIDs using streams access +for (let i = 0; i < commands.length; i++) { + const cmd = commands[i]; + // Access streams to auto-start the process + const stdout = await cmd.streams.stdout; + + if (cmd.child && cmd.child.pid) { + pids.push(cmd.child.pid); + console.log(` โœ… Command ${i + 1} PID: ${cmd.child.pid}`); + } else { + console.log(` โš ๏ธ Command ${i + 1} PID: not available`); + } +} + +// Wait for all to complete +const results = await Promise.all(commands); +console.log(` All ${results.length} commands completed\n`); + +// Example 4: PID access with streaming +console.log('4๏ธโƒฃ Streaming with PID Access:'); +const pingCmd = $`ping -c 3 127.0.0.1`; + +// Start streaming - this auto-starts the process +const stream = pingCmd.stream(); + +// Small delay to let the process fully initialize +await new Promise(resolve => setTimeout(resolve, 100)); + +if (pingCmd.child && pingCmd.child.pid) { + console.log(` โœ… Ping command PID: ${pingCmd.child.pid}`); + console.log(` Streaming ping output:`); + + // Process streaming output + for await (const chunk of stream) { + if (chunk.type === 'stdout') { + const line = chunk.data.toString().trim(); + if (line && line.includes('ping') || line.includes('bytes') || line.includes('time=')) { + console.log(` ๐Ÿ“ก ${line}`); + } + } + } +} else { + console.log(' โš ๏ธ Could not access PID for streaming command'); +} + +console.log('\n'); + +// Example 5: PID with event-based processing +console.log('5๏ธโƒฃ Event-based Processing with PID:'); +const eventCmd = $`sleep 1` // Use sleep for a longer-running process + .on('stdout', (chunk) => { + console.log(` ๐Ÿ“‹ Event: Received output: ${chunk.toString().trim()}`); + }) + .on('end', (result) => { + console.log(` ๐Ÿ“‹ Event: Command finished with exit code ${result.code}`); + }); + +// Access streams to start the process, then check PID +const eventStdout = await eventCmd.streams.stdout; +if (eventCmd.child && eventCmd.child.pid) { + console.log(` ๐Ÿ“‹ โœ… Event-based command PID: ${eventCmd.child.pid}`); +} + +// Wait for completion +await eventCmd; + +console.log('\n'); + +// Example 6: PID availability timeline with proper initialization +console.log('6๏ธโƒฃ PID Availability Timeline:'); +const timelineCmd = $`sleep 0.5`; + +console.log(' ๐Ÿ• Before accessing streams: PID available?', !!(timelineCmd.child && timelineCmd.child.pid)); + +// Access streams to start the process +const timelineStdout = await timelineCmd.streams.stdout; +console.log(' ๐Ÿ• After accessing streams: PID available?', !!(timelineCmd.child && timelineCmd.child.pid)); + +if (timelineCmd.child && timelineCmd.child.pid) { + console.log(` ๐Ÿ• โœ… PID during execution: ${timelineCmd.child.pid}`); +} + +await timelineCmd; +console.log(' ๐Ÿ• After completion: PID available?', !!(timelineCmd.child && timelineCmd.child.pid)); + +console.log('\n'); + +// Example 7: Error handling and best practices +console.log('7๏ธโƒฃ Best Practices for PID Access:'); + +function getPidSafely(command, commandName) { + try { + if (command.child && command.child.pid) { + return command.child.pid; + } else { + console.log(` โš ๏ธ PID not available for ${commandName}`); + console.log(` ๐Ÿ’ก Tip: Access .streams or call .start() first`); + return null; + } + } catch (error) { + console.log(` โŒ Error accessing PID for ${commandName}:`, error.message); + return null; + } +} + +const safeCmd = $`sleep 0.2`; + +// Method 1: Access streams to initialize +const safeStdout = await safeCmd.streams.stdout; +const pid = getPidSafely(safeCmd, 'sleep command'); +if (pid) { + console.log(` โœ… Successfully got PID: ${pid}`); +} + +await safeCmd; + +console.log('\n๐Ÿ All PID examples completed!'); +console.log('\n๐Ÿ“š Key Takeaways:'); +console.log(' โ€ข Access PID via: command.child.pid'); +console.log(' โ€ข Process must be started first - use command.streams.* or command.start()'); +console.log(' โ€ข PID becomes available once child process is created'); +console.log(' โ€ข Always check if command.child and command.child.pid exist'); +console.log(' โ€ข PID remains available even after command completion'); +console.log(' โ€ข Use getPidSafely() pattern for robust error handling'); +console.log('\n๐Ÿ”ง Three ways to start a process and access PID:'); +console.log(' 1. await command.streams.stdout (recommended)'); +console.log(' 2. await command.start()'); +console.log(' 3. command.stream() (for streaming)'); +console.log('\n๐Ÿ’ก Pro tip: For very fast commands, consider using sleep or long-running'); +console.log(' commands to ensure PID remains accessible long enough.'); \ No newline at end of file From 9523c22992b3c1ff89be568503bcf0d2cd578a0b Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 16 Sep 2026 08:52:04 +0000 Subject: [PATCH 4/8] Expose the process id of a started command in both languages Issue #18 asks for documentation on reading the pid of a started command. There was nothing to document: neither implementation had a supported accessor, and the informal handle was unusable. In JavaScript the only handle was `runner.child.pid`. `_cleanup()` releases `child` when the command finishes, so reading it afterwards throws on null; it is also not populated immediately after `start()`, and absent for built-in commands with no indication of why. In Rust nothing was reachable at all: `run()` takes the child handle in order to await it, and `StreamingRunner` spawns its child inside a background task. Both implementations now record the id at spawn time, next to the child handle, and expose it as a stable value that survives cleanup: - JS: `runner.pid`, populated on the async and sync spawn paths - Rust: `ProcessRunner::pid()` - Rust streaming: `OutputStream::pid()` and `OutputStream::wait_for_pid()`, fed by a watch channel, since the spawn happens in a background task `wait_for_pid` covers the startup window where the stream exists but the child does not yet. The value is `undefined`/`None` for built-in commands, which run in-process and have no operating system process to identify. Grouping the streaming task's channels into `StreamChannels` keeps `run_streaming_process` within the argument limit now that the pid channel joins them. Tests mirror each other across languages and cover every execution path, including what the id actually names: a command string is run by a shell, so the id names that shell (which leads its own process group, and is how `kill()` reaches the command underneath), while exec mode names the command itself. Those two assertions rely on `ps`, so they are POSIX only; the rest run on the full CI matrix. --- experiments/pid-child-shape.mjs | 13 ++ experiments/pid-current-behavior.mjs | 30 +++++ experiments/pid-getter-check.mjs | 43 ++++++ experiments/pid-group-and-exec.mjs | 21 +++ experiments/pid-identity.mjs | 15 +++ experiments/pid-real-command.mjs | 18 +++ js/src/$.process-runner-base.mjs | 21 +++ js/src/$.process-runner-execution.mjs | 7 + js/tests/process-pid.test.mjs | 168 ++++++++++++++++++++++++ rust/src/lib.rs | 38 ++++++ rust/src/stream.rs | 64 ++++++++- rust/tests/process_pid.rs | 180 ++++++++++++++++++++++++++ 12 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 experiments/pid-child-shape.mjs create mode 100644 experiments/pid-current-behavior.mjs create mode 100644 experiments/pid-getter-check.mjs create mode 100644 experiments/pid-group-and-exec.mjs create mode 100644 experiments/pid-identity.mjs create mode 100644 experiments/pid-real-command.mjs create mode 100644 js/tests/process-pid.test.mjs create mode 100644 rust/tests/process_pid.rs diff --git a/experiments/pid-child-shape.mjs b/experiments/pid-child-shape.mjs new file mode 100644 index 00000000..2e21fde1 --- /dev/null +++ b/experiments/pid-child-shape.mjs @@ -0,0 +1,13 @@ +// Experiment: inspect the child object shape while the process is alive. +import { $ } from '../js/src/$.mjs'; + +const a = $`sleep 0.5`; +const s = await a.streams.stdout; +console.log('typeof a.child =', typeof a.child, a.child === null ? '(null)' : ''); +if (a.child) { + console.log('constructor =', a.child.constructor?.name); + console.log('pid =', a.child.pid); + console.log('own keys =', Object.keys(a.child).slice(0, 30)); +} +console.log('stream obtained =', s ? s.constructor?.name : s); +await a; diff --git a/experiments/pid-current-behavior.mjs b/experiments/pid-current-behavior.mjs new file mode 100644 index 00000000..d944df5a --- /dev/null +++ b/experiments/pid-current-behavior.mjs @@ -0,0 +1,30 @@ +// Experiment: what does the current JS API expose about the child PID? +// Run: bun experiments/pid-current-behavior.mjs +import { $ } from '../js/src/$.mjs'; + +console.log('--- 1. before start ---'); +const a = $`sleep 0.3`; +console.log('a.child =', a.child); +console.log('a.pid =', a.pid); + +console.log('--- 2. after streams access (auto-start) ---'); +await a.streams.stdout; +console.log('a.child?.pid =', a.child?.pid); +console.log('a.pid =', a.pid); + +console.log('--- 3. after completion ---'); +const result = await a; +console.log('exit code =', result.code); +console.log('a.child =', a.child); +console.log('a.pid =', a.pid); +try { + console.log('a.child.pid =', a.child.pid); +} catch (e) { + console.log('a.child.pid THROWS =', e.constructor.name + ': ' + e.message); +} + +console.log('--- 4. plain await, never touched before finish ---'); +const b = $`echo hi`; +await b; +console.log('b.child =', b.child); +console.log('b.pid =', b.pid); diff --git a/experiments/pid-getter-check.mjs b/experiments/pid-getter-check.mjs new file mode 100644 index 00000000..2d4c4515 --- /dev/null +++ b/experiments/pid-getter-check.mjs @@ -0,0 +1,43 @@ +// Experiment: verify the new `pid` getter across every execution path. +import { $ } from '../js/src/$.mjs'; + +const show = (label, v) => console.log(label.padEnd(34), v); + +// 1. real async command +const a = $`/bin/sleep 0.4`; +await a.streams.stdout; +const live = a.pid; +show('async, while running', live); +await a; +show('async, after completion', a.pid); +show('async, pid stable', a.pid === live); + +// 2. plain await, never inspected mid-flight +const b = $`/bin/echo hi`; +await b; +show('plain await', b.pid); + +// 3. virtual command (runs in-process, no child) +const c = $`echo hi`; +await c; +show('virtual command', c.pid); + +// 4. before start +const d = $`/bin/true`; +show('before start', d.pid); +await d; + +// 5. sync mode +const e = $`/bin/echo sync`; +e.sync(); +show('sync mode', e.pid); + +// 6. streaming iteration +const f = $`/bin/sh -c 'echo one; echo two'`; +let seen; +for await (const chunk of f.stream()) { + seen ??= f.pid; + void chunk; +} +show('during stream()', seen); +show('after stream()', f.pid); diff --git a/experiments/pid-group-and-exec.mjs b/experiments/pid-group-and-exec.mjs new file mode 100644 index 00000000..c90726de --- /dev/null +++ b/experiments/pid-group-and-exec.mjs @@ -0,0 +1,21 @@ +// Experiment: is the reported pid the process-group leader, and what does +// exec mode (no shell) report? +import { ProcessRunner } from '../js/src/process-runner.mjs'; +import { $ } from '../js/src/$.mjs'; +import { execSync } from 'node:child_process'; + +console.log('--- shell mode ---'); +const shellCmd = $`/bin/sleep 2`; +await shellCmd.streams.stdout; +console.log(execSync(`ps -o pid=,pgid=,args= -p ${shellCmd.pid}`).toString().trim()); +console.log('self pgid :', process.pid, execSync(`ps -o pgid= -p ${process.pid}`).toString().trim()); +shellCmd.kill(); +await shellCmd.catch(() => {}); + +console.log('--- exec mode (no shell) ---'); +const execCmd = new ProcessRunner({ mode: 'exec', file: '/bin/sleep', args: ['2'] }); +await execCmd.streams.stdout; +console.log('reported pid :', execCmd.pid); +console.log(execSync(`ps -o pid=,pgid=,args= -p ${execCmd.pid}`).toString().trim()); +execCmd.kill(); +await execCmd.catch(() => {}); diff --git a/experiments/pid-identity.mjs b/experiments/pid-identity.mjs new file mode 100644 index 00000000..98a18b38 --- /dev/null +++ b/experiments/pid-identity.mjs @@ -0,0 +1,15 @@ +// Experiment: which process does the reported pid name - the shell wrapper or +// the command itself? Answer decides how the docs must describe it. +import { $ } from '../js/src/$.mjs'; +import { execSync } from 'node:child_process'; + +const cmd = $`/bin/sleep 2`; +await cmd.streams.stdout; +const pid = cmd.pid; +const ps = execSync(`ps -o pid=,ppid=,args= -p ${pid}`).toString().trim(); +console.log('reported pid :', pid); +console.log('ps :', ps); +console.log('children :', + execSync(`pgrep -P ${pid} -a || true`).toString().trim() || '(none)'); +cmd.kill(); +await cmd.catch(() => {}); diff --git a/experiments/pid-real-command.mjs b/experiments/pid-real-command.mjs new file mode 100644 index 00000000..ea90f90a --- /dev/null +++ b/experiments/pid-real-command.mjs @@ -0,0 +1,18 @@ +// Experiment: PID visibility for a real (non-virtual) external command. +import { $ } from '../js/src/$.mjs'; + +const a = $`/bin/sleep 0.5`; // absolute path bypasses the virtual `sleep` +const s = await a.streams.stdout; +console.log('child ctor =', a.child?.constructor?.name ?? String(a.child)); +console.log('child.pid =', a.child?.pid); +console.log('stream =', s ? s.constructor?.name : String(s)); +const r = await a; +console.log('after await: child =', a.child, 'code =', r.code); + +console.log('--- explicit start() ---'); +const b = $`/bin/sleep 0.5`; +const started = b.start(); +console.log('start() returns =', started?.constructor?.name); +console.log('b.child?.pid =', b.child?.pid); +await b; +console.log('after await: b.child =', b.child); diff --git a/js/src/$.process-runner-base.mjs b/js/src/$.process-runner-base.mjs index 548379f8..3cb3b494 100644 --- a/js/src/$.process-runner-base.mjs +++ b/js/src/$.process-runner-base.mjs @@ -229,6 +229,11 @@ class ProcessRunner extends StreamEmitter { this.result = null; this.child = null; + // Process id of the spawned child, recorded at spawn time. `child` is + // released by _cleanup() once the command finishes, so reading the pid from + // it only works while the process is alive; this copy is what makes the + // `pid` getter answer after completion too (issue #18). + this._pid = undefined; this.started = false; this.finished = false; @@ -268,6 +273,22 @@ class ProcessRunner extends StreamEmitter { this.finished = false; } + /** + * Process id of the command, or `undefined` when there is no operating + * system process to identify. + * + * It is `undefined` before the command starts, and stays `undefined` for + * built-in (virtual) commands such as `echo` or `sleep`, which run inside + * this process and never spawn a child. Once a real command has been + * spawned the value is stable: it remains readable after the command + * finishes, unlike `child`, which is released during cleanup. + * + * @returns {number|undefined} + */ + get pid() { + return this._pid; + } + // Stream property getters get stdout() { trace( diff --git a/js/src/$.process-runner-execution.mjs b/js/src/$.process-runner-execution.mjs index bc537887..548b1b99 100644 --- a/js/src/$.process-runner-execution.mjs +++ b/js/src/$.process-runner-execution.mjs @@ -604,6 +604,10 @@ function executeSyncProcess(argv, options) { * @returns {object} Result */ function processSyncResult(runner, result, globalShellSettings) { + // The synchronous spawn has already exited by the time it returns, but it + // still reports the pid it ran under, so `runner.pid` answers here too. + runner._pid = result.child?.pid ?? runner._pid; + if (runner.options.mirror) { if (result.stdout) { safeWrite(process.stdout, result.stdout); @@ -923,6 +927,9 @@ async function executeChildProcess(runner, argv, config) { const { stdin, isInteractive } = config; runner.child = spawnChild(argv, config); + // Record the pid while the child is still held. _cleanup() drops `child` on + // completion, so this copy is what keeps `runner.pid` readable afterwards. + runner._pid = runner.child?.pid; if (runner.child) { trace( diff --git a/js/tests/process-pid.test.mjs b/js/tests/process-pid.test.mjs new file mode 100644 index 00000000..677354fd --- /dev/null +++ b/js/tests/process-pid.test.mjs @@ -0,0 +1,168 @@ +// Tests for issue #18: +// "We need example in docs on how to get PID of started command" +// +// Before the fix there was no supported way to read the pid. The only handle +// was `runner.child.pid`, which: +// 1. is `null.pid` (a TypeError) after the command finishes, because +// _cleanup() releases `child` in finish(); +// 2. is not populated right after `start()`, which returns a promise rather +// than a spawned child; +// 3. is absent for built-in commands with no indication of why. +// +// `runner.pid` records the id at spawn time, so it survives cleanup and answers +// the same way on every execution path. +import { test, expect, describe } from 'bun:test'; +import './test-helper.mjs'; // installs beforeEach/afterEach resetGlobalState +import { $ } from '../src/$.mjs'; +import { ProcessRunner } from '../src/process-runner.mjs'; +import { execSync } from 'node:child_process'; + +const isWindows = process.platform === 'win32'; +const quiet = { mirror: false, capture: true }; + +// The runtime running these tests is the one executable guaranteed to exist on +// every platform in the matrix, and it is never a built-in, so it always +// produces a real child process. +const runtime = process.execPath; +const idleFor = (seconds) => + $(quiet)`${runtime} -e ${`setTimeout(() => {}, ${seconds * 1000})`}`; +const printHello = () => $(quiet)`${runtime} -e ${'process.stdout.write("hi")'}`; + +describe('issue #18 - process id access', () => { + test('is undefined before the command starts', async () => { + const runner = printHello(); + expect(runner.started).toBe(false); + expect(runner.pid).toBeUndefined(); + + await runner; // settle it so the runner is not left dangling + }); + + test('is available while the command is still running', async () => { + const runner = idleFor(5); + await runner.streams.stdout; + + expect(typeof runner.pid).toBe('number'); + expect(runner.pid).toBeGreaterThan(0); + + runner.kill(); + await runner.catch(() => {}); + }); + + test('survives completion, unlike child which is released', async () => { + const runner = idleFor(0.2); + await runner.streams.stdout; + const whileRunning = runner.pid; + expect(typeof whileRunning).toBe('number'); + + await runner; + + // The regression this guards: `child` is intentionally dropped by + // _cleanup(), so `runner.child.pid` throws once the command is done. + expect(runner.child).toBeNull(); + expect(runner.pid).toBe(whileRunning); + }); + + test('is available after a plain await, without touching the streams', async () => { + const runner = printHello(); + const result = await runner; + + expect(result.code).toBe(0); + expect(typeof runner.pid).toBe('number'); + }); + + test('is available in sync mode', () => { + const runner = printHello(); + const result = runner.sync(); + + expect(result.code).toBe(0); + expect(typeof runner.pid).toBe('number'); + }); + + test('is available while iterating a stream', async () => { + const runner = printHello(); + let seenDuringIteration; + + for await (const chunk of runner.stream()) { + seenDuringIteration ??= runner.pid; + void chunk; + } + + expect(typeof seenDuringIteration).toBe('number'); + expect(runner.pid).toBe(seenDuringIteration); + }); + + test('stays undefined for built-in commands, which spawn no process', async () => { + // `echo` is a built-in: it runs inside this process, so there is no + // operating system process to identify. + const runner = $(quiet)`echo hello`; + const result = await runner; + + expect(result.stdout).toBe('hello\n'); + expect(runner.pid).toBeUndefined(); + }); + + test('distinct commands report distinct ids', async () => { + const first = idleFor(5); + const second = idleFor(5); + await Promise.all([first.streams.stdout, second.streams.stdout]); + + expect(first.pid).not.toBe(second.pid); + + first.kill(); + second.kill(); + await Promise.all([first.catch(() => {}), second.catch(() => {})]); + }); + + test('names a live process that can be signalled', async () => { + const runner = idleFor(5); + await runner.streams.stdout; + const pid = runner.pid; + + // Signal 0 performs the permission/existence check without delivering + // anything, which is exactly the "is my command still running?" question + // the pid is wanted for. + expect(() => process.kill(pid, 0)).not.toThrow(); + + runner.kill(); + await runner.catch(() => {}); + }); +}); + +// `ps` is the reference for "which process is this really?", and it is POSIX +// only. The behavior it pins down (shell wrapper vs. exact executable) is not +// Unix-specific, but its verification is. +describe.skipIf(isWindows)('issue #18 - what the id names', () => { + test('a shell command reports the shell that runs it', async () => { + // Worth pinning down because it is surprising: a command string goes + // through the platform shell, so the pid names that shell and the command + // itself is its child. The shell leads its own process group, which is how + // kill() reaches both. + const runner = $(quiet)`/bin/sleep 5`; + await runner.streams.stdout; + const pid = runner.pid; + + const args = execSync(`ps -o args= -p ${pid}`).toString().trim(); + expect(args).toContain('/bin/sleep 5'); + expect(args).not.toBe('/bin/sleep 5'); // a shell wrapper, not the command + + const pgid = Number(execSync(`ps -o pgid= -p ${pid}`).toString().trim()); + expect(pgid).toBe(pid); + + runner.kill(); + await runner.catch(() => {}); + }); + + test('exec mode reports the command itself, with no shell in between', async () => { + const runner = new ProcessRunner( + { mode: 'exec', file: '/bin/sleep', args: ['5'] }, + quiet + ); + await runner.streams.stdout; + + const args = execSync(`ps -o args= -p ${runner.pid}`).toString().trim(); + expect(args).toBe('/bin/sleep 5'); + + runner.kill(); + await runner.catch(() => {}); + }); +}); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 367e7b3d..6843b148 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -334,6 +334,11 @@ pub struct ProcessRunner { command: String, options: RunOptions, child: Option, + /// Process id of the spawned child, recorded at spawn time. `run()` takes + /// the child in order to await it, so reading the id from it only works + /// between `start()` and `run()`; this copy is what makes `pid()` answer + /// after the command has finished too (issue #18). + pid: Option, result: Option, started: bool, finished: bool, @@ -361,6 +366,7 @@ impl ProcessRunner { command: command.into(), options, child: None, + pid: None, result: None, started: false, finished: false, @@ -489,6 +495,10 @@ impl ProcessRunner { // Spawn the process let child = cmd.spawn()?; + // Record the id while the child is still held. `run()` takes the child + // in order to await it, so this copy is what keeps `pid()` readable + // afterwards. + self.pid = child.id(); self.child = Some(child); Ok(()) @@ -722,6 +732,34 @@ impl ProcessRunner { self.result.as_ref() } + /// Process id of the command, or `None` when there is no operating system + /// process to identify. + /// + /// It is `None` before the command starts, and stays `None` for built-in + /// (virtual) commands such as `echo` or `sleep`, which run inside this + /// process and never spawn a child. Once a real command has been spawned + /// the value is stable: it remains readable after the command finishes, + /// unlike the child handle, which [`run`](Self::run) consumes. + /// + /// Mirrors the JavaScript `runner.pid` property. + /// + /// ```no_run + /// use command_stream::{ProcessRunner, RunOptions}; + /// + /// # #[tokio::main] + /// # async fn main() -> command_stream::Result<()> { + /// let mut runner = ProcessRunner::new("/bin/sleep 1", RunOptions::default()); + /// runner.start().await?; + /// println!("running as pid {:?}", runner.pid()); + /// runner.run().await?; + /// println!("still readable: {:?}", runner.pid()); + /// # Ok(()) + /// # } + /// ``` + pub fn pid(&self) -> Option { + self.pid + } + /// Get the command string pub fn command(&self) -> &str { &self.command diff --git a/rust/src/stream.rs b/rust/src/stream.rs index f72c27e0..b96e81ee 100644 --- a/rust/src/stream.rs +++ b/rust/src/stream.rs @@ -61,7 +61,7 @@ use std::process::Stdio; use std::time::Duration; use tokio::io::BufReader; use tokio::process::Command; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, watch}; use tokio::task::JoinHandle; use crate::signal::{ @@ -194,6 +194,10 @@ impl StreamingRunner { let (tx, rx) = mpsc::channel(1024); // Unbounded so a synchronous Drop can request a kill without awaiting. let (kill_tx, kill_rx) = mpsc::unbounded_channel::(); + // The child is spawned inside the task below, so its id is not known + // when this returns. The task publishes it here as soon as the spawn + // succeeds; `OutputStream::pid` reads the latest value (issue #18). + let (pid_tx, pid_rx) = watch::channel(None); // Spawn the process handling task let command = self.command.clone(); @@ -207,8 +211,13 @@ impl StreamingRunner { let kill_signal = self.kill_signal.clone(); let task = tokio::spawn(async move { + let channels = StreamChannels { + output_tx: tx, + kill_rx, + pid_tx, + }; let result = - run_streaming_process(command, cwd, env, stdin_content, grace, tx, kill_rx).await; + run_streaming_process(command, cwd, env, stdin_content, grace, channels).await; if let Err(error) = &result { trace_lazy("StreamingRunner", || format!("Error: {error}")); } @@ -221,6 +230,7 @@ impl StreamingRunner { kill_tx, kill_signal, killed: false, + pid_rx, }, task, ) @@ -264,6 +274,7 @@ pub struct OutputStream { kill_tx: mpsc::UnboundedSender, kill_signal: String, killed: bool, + pid_rx: watch::Receiver>, } impl OutputStream { @@ -272,6 +283,33 @@ impl OutputStream { self.rx.recv().await } + /// Process id of the streamed command, as currently known. + /// + /// The child is spawned by a background task, so this is `None` for the + /// short window between [`StreamingRunner::stream`] returning and the spawn + /// completing, and stays `None` if the spawn failed. From the first + /// delivered chunk onwards it is set, and it remains readable after the + /// process has exited. Use [`wait_for_pid`](Self::wait_for_pid) to avoid + /// the startup window. + pub fn pid(&self) -> Option { + *self.pid_rx.borrow() + } + + /// Process id of the streamed command, waiting for the spawn to complete. + /// + /// Resolves as soon as the child exists, and returns `None` if the process + /// could never be spawned. This is the streaming counterpart of awaiting a + /// stream before reading `runner.pid` in JavaScript. + pub async fn wait_for_pid(&mut self) -> Option { + // `wait_for` checks the current value first, so an already-published id + // returns without waiting. An error means the sending task is gone, + // which only happens when the spawn failed. + match self.pid_rx.wait_for(|pid| pid.is_some()).await { + Ok(pid) => *pid, + Err(_) => None, + } + } + /// Stop the process using the configured kill signal (default `SIGTERM`). /// /// This can be called from inside the consumption loop to stop a @@ -337,6 +375,17 @@ impl Drop for OutputStream { } } +/// The channels `run_streaming_process` communicates over: output chunks out, +/// kill requests in, and the child's id published once the spawn succeeds. +struct StreamChannels { + /// Carries the output chunks, and finally the `Exit` chunk, to the consumer. + output_tx: mpsc::Sender, + /// Carries kill requests, by signal name, in from the consumer. + kill_rx: mpsc::UnboundedReceiver, + /// Publishes the child's id, which is only known inside the spawning task. + pid_tx: watch::Sender>, +} + /// How long the runner waits, in milliseconds, at the two points where it gives /// something a chance to finish on its own before forcing the issue. #[derive(Debug, Clone, Copy)] @@ -356,9 +405,13 @@ async fn run_streaming_process( env: Option>, stdin_content: Option, grace: GraceWindows, - tx: mpsc::Sender, - mut kill_rx: mpsc::UnboundedReceiver, + channels: StreamChannels, ) -> Result<()> { + let StreamChannels { + output_tx: tx, + mut kill_rx, + pid_tx, + } = channels; trace_lazy("StreamingRunner", || match &command { StreamingCommand::Shell(command) => format!("Starting: {command}"), StreamingCommand::Argv { program, args } => { @@ -408,6 +461,9 @@ async fn run_streaming_process( } let mut child = cmd.spawn()?; + // Publish the id before any awaiting, so a consumer asking for it as soon + // as the first chunk arrives already sees it. + let _ = pid_tx.send(child.id()); // Write stdin if needed if let Some(content) = stdin_content { diff --git a/rust/tests/process_pid.rs b/rust/tests/process_pid.rs new file mode 100644 index 00000000..11c5cf2b --- /dev/null +++ b/rust/tests/process_pid.rs @@ -0,0 +1,180 @@ +//! Integration tests for issue #18: reading the process id of a started +//! command. +//! +//! These mirror `js/tests/process-pid.test.mjs`. Before the fix neither +//! implementation exposed the id at all: `ProcessRunner::run` takes the child +//! handle in order to await it, and `StreamingRunner` spawns its child inside a +//! background task, so nothing was reachable from the public API. + +use command_stream::{OutputChunk, ProcessRunner, RunOptions, StreamingRunner}; + +/// A real (non built-in) command that idles long enough to be observed, and the +/// same thing again as a quick command. Written per platform because the shells +/// involved share no syntax for sleeping. +#[cfg(unix)] +const IDLE_COMMAND: &str = "/bin/sleep 5"; +#[cfg(windows)] +const IDLE_COMMAND: &str = "ping -n 6 127.0.0.1"; + +#[cfg(unix)] +const QUICK_COMMAND: &str = "/bin/echo hi"; +#[cfg(windows)] +const QUICK_COMMAND: &str = "cmd /c echo hi"; + +fn quiet() -> RunOptions { + RunOptions { + mirror: false, + capture: true, + ..Default::default() + } +} + +#[tokio::test] +async fn pid_is_none_before_the_command_starts() { + let runner = ProcessRunner::new(QUICK_COMMAND, quiet()); + assert_eq!(runner.pid(), None); +} + +#[tokio::test] +async fn pid_is_available_after_start() { + let mut runner = ProcessRunner::new(IDLE_COMMAND, quiet()); + runner.start().await.unwrap(); + + let pid = runner.pid().expect("a spawned command has a pid"); + assert!(pid > 0); + + runner.kill().unwrap(); + let _ = runner.run().await; +} + +#[tokio::test] +async fn pid_survives_completion() { + let mut runner = ProcessRunner::new(QUICK_COMMAND, quiet()); + runner.start().await.unwrap(); + let while_running = runner.pid().expect("a spawned command has a pid"); + + runner.run().await.unwrap(); + + // The point of recording it at spawn time: `run()` consumed the child + // handle, so the id could no longer be recovered from it. + assert!(runner.is_finished()); + assert_eq!(runner.pid(), Some(while_running)); +} + +#[tokio::test] +async fn pid_is_available_after_a_plain_run() { + let mut runner = ProcessRunner::new(QUICK_COMMAND, quiet()); + let result = runner.run().await.unwrap(); + + assert!(result.is_success()); + assert!(runner.pid().is_some()); +} + +#[tokio::test] +async fn pid_stays_none_for_builtin_commands() { + // `echo` is a built-in: it runs inside this process, so there is no + // operating system process to identify. + let mut runner = ProcessRunner::new("echo hello", quiet()); + let result = runner.run().await.unwrap(); + + assert!(result.stdout.contains("hello")); + assert_eq!(runner.pid(), None); +} + +#[tokio::test] +async fn distinct_commands_report_distinct_pids() { + let mut first = ProcessRunner::new(IDLE_COMMAND, quiet()); + let mut second = ProcessRunner::new(IDLE_COMMAND, quiet()); + first.start().await.unwrap(); + second.start().await.unwrap(); + + assert_ne!(first.pid(), second.pid()); + + first.kill().unwrap(); + second.kill().unwrap(); + let _ = first.run().await; + let _ = second.run().await; +} + +#[tokio::test] +async fn streaming_pid_resolves_once_the_child_exists() { + let mut stream = StreamingRunner::new(QUICK_COMMAND).stream(); + + let pid = stream.wait_for_pid().await.expect("the child was spawned"); + assert!(pid > 0); + + // Draining to the exit chunk proves the id belongs to the process that + // actually ran, not to a handle abandoned on the way. + let mut exit_code = None; + while let Some(chunk) = stream.next().await { + if let OutputChunk::Exit(code) = chunk { + exit_code = Some(code); + } + } + + assert_eq!(exit_code, Some(0)); + assert_eq!(stream.pid(), Some(pid)); +} + +#[tokio::test] +async fn streaming_pid_is_set_by_the_time_output_arrives() { + let mut stream = StreamingRunner::new(QUICK_COMMAND).stream(); + + let mut pid_at_first_chunk = None; + while let Some(chunk) = stream.next().await { + if let OutputChunk::Stdout(_) = chunk { + pid_at_first_chunk = stream.pid(); + break; + } + } + + assert!( + pid_at_first_chunk.is_some(), + "the id is published before the first chunk is sent" + ); +} + +/// `ps` is the reference for "which process is this really?", and it is POSIX +/// only. The behavior it pins down - a command string being run *by a shell*, +/// so the id names that shell - is not Unix-specific, but its verification is. +#[cfg(unix)] +#[tokio::test] +async fn a_shell_command_reports_the_shell_that_runs_it() { + let mut runner = ProcessRunner::new(IDLE_COMMAND, quiet()); + runner.start().await.unwrap(); + let pid = runner.pid().expect("a spawned command has a pid"); + + let ps = std::process::Command::new("ps") + .args(["-o", "args=", "-p", &pid.to_string()]) + .output() + .unwrap(); + let args = String::from_utf8_lossy(&ps.stdout).trim().to_string(); + + assert!(args.contains("/bin/sleep 5"), "unexpected process: {args}"); + assert_ne!(args, "/bin/sleep 5", "expected a shell wrapper"); + + runner.kill().unwrap(); + let _ = runner.run().await; +} + +/// The child is spawned into its own process group, which is what lets `kill()` +/// reach the command running underneath the shell. The group is named by the +/// reported id, so a caller can signal the group themselves. +#[cfg(unix)] +#[tokio::test] +async fn the_reported_id_leads_its_own_process_group() { + let mut runner = ProcessRunner::new(IDLE_COMMAND, quiet()); + runner.start().await.unwrap(); + let pid = runner.pid().expect("a spawned command has a pid"); + + let ps = std::process::Command::new("ps") + .args(["-o", "pgid=", "-p", &pid.to_string()]) + .output() + .unwrap(); + let pgid: u32 = String::from_utf8_lossy(&ps.stdout).trim().parse().unwrap(); + + assert_eq!(pgid, pid); + + runner.kill().unwrap(); + let _ = runner.run().await; +} From 3c6ee95beb4d59dc22ac927fa60fb08e4416306a Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 16 Sep 2026 09:02:22 +0000 Subject: [PATCH 5/8] Document the process id, with a runnable example in each language Issue #18 asked for the documentation; the previous commit added the API it needed. Both READMEs now carry a "Process ID of a Running Command" section covering when the id becomes available, that it survives completion, what it actually names, and why built-in commands have none. Two facts are worth stating explicitly because they surprise people, and both are verified by the tests rather than asserted here: a command string is run by a shell, so the id names that shell and leads its own process group; and a command that does not exist is reported by that shell, so there is still an id even though nothing the caller asked for ran (code 127). The ways to get the command's own id instead - `mode: 'exec'` in JavaScript, `StreamingRunner::from_argv` in Rust - are documented alongside, together with the fact that a missing executable there is a genuine spawn failure with no id. Each language gets a runnable walkthrough, `js/examples/process-pid-access.mjs` and `rust/examples/process_pid_access.rs`, which print the real `ps` output for the shell wrapper, its process group, and the command underneath. The stale entries the old version of this branch left in js/examples/README.md are corrected: they pointed at `command.child.pid`, claimed the id survives completion when reading it that way throws, and used a path that no longer exists after the monorepo split. Release triggers for both packages, and two tests added while writing the docs so the claims in them are checked: the 127 case above, and `wait_for_pid()` returning rather than hanging when the spawn fails. Trimming the two comments in $.process-runner-execution.mjs keeps the file under the 1500-line limit eslint enforces; the full explanation lives on the `pid` getter in $.process-runner-base.mjs. --- experiments/pid-child-shape.mjs | 6 +- experiments/pid-group-and-exec.mjs | 20 ++- experiments/pid-identity.mjs | 6 +- experiments/pid-real-command.mjs | 2 +- js/.changeset/issue-18-process-pid.md | 16 ++ js/README.md | 130 ++++++++++++++++ js/examples/README.md | 14 +- js/examples/process-pid-access.mjs | 120 ++++++++++++++ js/src/$.process-runner-execution.mjs | 7 +- js/tests/process-pid.test.mjs | 26 +++- rust/README.md | 104 +++++++++++++ .../20260916_090000_process_pid.md | 17 ++ rust/examples/process_pid_access.rs | 146 ++++++++++++++++++ rust/tests/process_pid.rs | 26 ++++ 14 files changed, 620 insertions(+), 20 deletions(-) create mode 100644 js/.changeset/issue-18-process-pid.md create mode 100644 js/examples/process-pid-access.mjs create mode 100644 rust/changelog.d/20260916_090000_process_pid.md create mode 100644 rust/examples/process_pid_access.rs diff --git a/experiments/pid-child-shape.mjs b/experiments/pid-child-shape.mjs index 2e21fde1..3110b761 100644 --- a/experiments/pid-child-shape.mjs +++ b/experiments/pid-child-shape.mjs @@ -3,7 +3,11 @@ import { $ } from '../js/src/$.mjs'; const a = $`sleep 0.5`; const s = await a.streams.stdout; -console.log('typeof a.child =', typeof a.child, a.child === null ? '(null)' : ''); +console.log( + 'typeof a.child =', + typeof a.child, + a.child === null ? '(null)' : '' +); if (a.child) { console.log('constructor =', a.child.constructor?.name); console.log('pid =', a.child.pid); diff --git a/experiments/pid-group-and-exec.mjs b/experiments/pid-group-and-exec.mjs index c90726de..e279122e 100644 --- a/experiments/pid-group-and-exec.mjs +++ b/experiments/pid-group-and-exec.mjs @@ -7,15 +7,27 @@ import { execSync } from 'node:child_process'; console.log('--- shell mode ---'); const shellCmd = $`/bin/sleep 2`; await shellCmd.streams.stdout; -console.log(execSync(`ps -o pid=,pgid=,args= -p ${shellCmd.pid}`).toString().trim()); -console.log('self pgid :', process.pid, execSync(`ps -o pgid= -p ${process.pid}`).toString().trim()); +console.log( + execSync(`ps -o pid=,pgid=,args= -p ${shellCmd.pid}`).toString().trim() +); +console.log( + 'self pgid :', + process.pid, + execSync(`ps -o pgid= -p ${process.pid}`).toString().trim() +); shellCmd.kill(); await shellCmd.catch(() => {}); console.log('--- exec mode (no shell) ---'); -const execCmd = new ProcessRunner({ mode: 'exec', file: '/bin/sleep', args: ['2'] }); +const execCmd = new ProcessRunner({ + mode: 'exec', + file: '/bin/sleep', + args: ['2'], +}); await execCmd.streams.stdout; console.log('reported pid :', execCmd.pid); -console.log(execSync(`ps -o pid=,pgid=,args= -p ${execCmd.pid}`).toString().trim()); +console.log( + execSync(`ps -o pid=,pgid=,args= -p ${execCmd.pid}`).toString().trim() +); execCmd.kill(); await execCmd.catch(() => {}); diff --git a/experiments/pid-identity.mjs b/experiments/pid-identity.mjs index 98a18b38..60b36ad9 100644 --- a/experiments/pid-identity.mjs +++ b/experiments/pid-identity.mjs @@ -9,7 +9,9 @@ const pid = cmd.pid; const ps = execSync(`ps -o pid=,ppid=,args= -p ${pid}`).toString().trim(); console.log('reported pid :', pid); console.log('ps :', ps); -console.log('children :', - execSync(`pgrep -P ${pid} -a || true`).toString().trim() || '(none)'); +console.log( + 'children :', + execSync(`pgrep -P ${pid} -a || true`).toString().trim() || '(none)' +); cmd.kill(); await cmd.catch(() => {}); diff --git a/experiments/pid-real-command.mjs b/experiments/pid-real-command.mjs index ea90f90a..47ce8eb5 100644 --- a/experiments/pid-real-command.mjs +++ b/experiments/pid-real-command.mjs @@ -1,7 +1,7 @@ // Experiment: PID visibility for a real (non-virtual) external command. import { $ } from '../js/src/$.mjs'; -const a = $`/bin/sleep 0.5`; // absolute path bypasses the virtual `sleep` +const a = $`/bin/sleep 0.5`; // absolute path bypasses the virtual `sleep` const s = await a.streams.stdout; console.log('child ctor =', a.child?.constructor?.name ?? String(a.child)); console.log('child.pid =', a.child?.pid); diff --git a/js/.changeset/issue-18-process-pid.md b/js/.changeset/issue-18-process-pid.md new file mode 100644 index 00000000..706fb12e --- /dev/null +++ b/js/.changeset/issue-18-process-pid.md @@ -0,0 +1,16 @@ +--- +'command-stream': minor +--- + +Expose the process id of a started command as `command.pid`. Issue #18 asked for +documentation on reading it, and there was nothing to document: the only handle +was `command.child.pid`, which throws once the command finishes (cleanup +releases `child`), is not populated right after `start()`, and is absent for +built-in commands with no indication of why. The id is now recorded at spawn +time, so the same value is reported from `await`, `.sync()`, `.stream()` and the +`streams` getters, and it stays readable after the command is done. + +Documents the behavior in "Process ID of a Running Command" - including what the +id names (the shell wrapper, which leads its own process group, unless `exec` +mode is used) and why built-in commands have none - and adds a runnable +`examples/process-pid-access.mjs`. diff --git a/js/README.md b/js/README.md index a1cb3d5b..58504858 100644 --- a/js/README.md +++ b/js/README.md @@ -25,6 +25,7 @@ A modern $ shell utility library with streaming, async iteration, and EventEmitt - ๐ŸŽฏ **Backward Compatible**: Existing `await $` syntax continues to work + Bun.$ `.text()` method - ๐Ÿ›ก๏ธ **Type Safe**: Full TypeScript support (coming soon) - ๐Ÿ”ง **Built-in Commands**: 22 essential commands work identically across platforms +- ๐Ÿ†” **Process Identity**: Read the process id with `command.pid`, before, during and after the run ## Comparison with Other Libraries @@ -684,6 +685,129 @@ const process = $`long-command` process.start(); ``` +### Process ID of a Running Command + +`pid` is the id of the operating system process behind a command. It is recorded +when the process is spawned, so โ€” unlike `child`, which is released during +cleanup โ€” it stays readable after the command has finished: + +```javascript +const cmd = $`/bin/sleep 5`; +cmd.pid; // undefined โ€” nothing has been spawned yet + +cmd.start(); +await cmd.streams.stdout; // resolves once the child exists +console.log(cmd.pid); // 51234 + +cmd.kill(); +await cmd.catch(() => {}); + +console.log(cmd.pid); // 51234 โ€” still there +console.log(cmd.child); // null โ€” released by cleanup +``` + +The same value is reported on every execution path: `await`, `.sync()`, +`.stream()`, and the `streams` getters. + +```javascript +const awaited = $`sh -c 'echo done'`; +await awaited; +awaited.pid; // the process that just ran + +const blocking = $`sh -c 'echo done'`; +blocking.sync(); +blocking.pid; // sync mode records it too +``` + +#### What the id names + +A command string is handed to a shell, so the id names **the shell**, and the +command itself runs as its child: + +```console +$ ps -o args= -p 51234 +/bin/sh -l -c /bin/sleep 5 +``` + +The shell is spawned as the leader of its own process group, so the group id +equals the pid. That is what lets `kill()` reach the command underneath the +wrapper (see +[Grandchildren and process groups](#grandchildren-and-process-groups)), and it +means you can signal the group yourself: + +```javascript +process.kill(-cmd.pid, 'SIGTERM'); // the shell and everything under it +``` + +A consequence worth knowing: a command that does not exist is reported by the +shell that looked for it, so there is still a pid even though nothing you asked +for ran. + +```javascript +const missing = $`no-such-command`; +await missing.catch(() => {}); +missing.pid; // the shell's pid +(await missing.catch((error) => error)).code; // 127 โ€” "command not found" +``` + +To get the id of the command itself, with no shell in between, use the `exec` +command specification, which bypasses the shell entirely: + +```javascript +import { ProcessRunner } from 'command-stream'; + +const cmd = new ProcessRunner({ + mode: 'exec', + file: '/bin/sleep', + args: ['5'], +}); +await cmd.streams.stdout; +// ps -o args= -p => /bin/sleep 5 +``` + +With no shell to fall back on, a missing executable in `exec` mode is a failed +spawn, and `pid` stays `undefined`. + +#### Built-in commands have no id + +[Built-in commands](#built-in-commands--new) such as `echo`, `sleep` and `cat` +run inside your process and never spawn anything, so there is no operating +system process to identify and `pid` stays `undefined`: + +```javascript +const builtin = $`echo hello`; +await builtin; +builtin.pid; // undefined + +const external = $`/bin/echo hello`; +await external; +external.pid; // a real pid โ€” the path bypasses the built-in +``` + +This is the difference to check for before using the id, rather than assuming +every command has one: + +```javascript +function isStillRunning(cmd) { + if (cmd.pid === undefined) return false; // never spawned, or a built-in + try { + process.kill(cmd.pid, 0); // signal 0 only performs the existence check + return true; + } catch { + return false; + } +} +``` + +A runnable walkthrough of all of the above is in +[`js/examples/process-pid-access.mjs`](examples/process-pid-access.mjs). + +#### Rust parity + +The Rust crate exposes the same value as `ProcessRunner::pid()`, plus +`OutputStream::pid()` and `OutputStream::wait_for_pid()` for streaming commands. +See [the Rust process id documentation](../rust/README.md#process-id-of-a-running-command). + ### Synchronous Execution ```javascript @@ -1593,6 +1717,12 @@ As with any shell-enabled process, pass only trusted `file` and `args` values; s - `stdout`: Direct access to child process stdout stream - `stderr`: Direct access to child process stderr stream - `stdin`: Direct access to child process stdin stream +- `pid`: Process id of the spawned command, or `undefined` before it starts and + for built-in commands, which spawn no process. Recorded at spawn time, so it + remains readable after the command finishes โ€” see + [Process ID of a Running Command](#process-id-of-a-running-command) +- `child`: The underlying child process object while the command is running, + and `null` once it has finished and been cleaned up ### Default Options diff --git a/js/examples/README.md b/js/examples/README.md index d18ca31a..0ca5e8e3 100644 --- a/js/examples/README.md +++ b/js/examples/README.md @@ -177,7 +177,8 @@ The simplest examples to get started: ### ๐Ÿ†” Process Management **PID Access:** -- `process-pid-access.mjs` - Complete guide to accessing Process IDs (PIDs) of started commands + +- `process-pid-access.mjs` - Reading `command.pid`: when it becomes available, what it names, and how to use it ### ๐Ÿงช Testing and Debugging @@ -328,10 +329,11 @@ The simplest examples to get started: - โœ… **Clean exit** - No hanging processes after iteration stops ### ๐Ÿ†” Process Management -- โœ… **PID access** - Access process IDs via `command.child.pid` -- โœ… **Process lifecycle** - PID available after process starts, remains after completion -- โœ… **Multiple startup methods** - Access via streams, start(), or stream() -- โœ… **Safe PID handling** - Best practices for checking PID availability + +- โœ… **PID access** - Read the process id via `command.pid` +- โœ… **Process lifecycle** - Recorded at spawn time, so it stays readable after the command finishes +- โœ… **Every execution path** - Same value from `await`, `sync()`, `stream()` and the `streams` getters +- โœ… **Built-in commands** - `undefined` for commands that run in-process and spawn nothing ## Usage Examples @@ -340,7 +342,7 @@ The simplest examples to get started: bun js/examples/ping-streaming-simple.mjs # Learn how to get process PIDs -node examples/process-pid-access.mjs +node js/examples/process-pid-access.mjs # Test ANSI color handling node js/examples/colors-default-preserved.mjs diff --git a/js/examples/process-pid-access.mjs b/js/examples/process-pid-access.mjs new file mode 100644 index 00000000..faa828ac --- /dev/null +++ b/js/examples/process-pid-access.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +// Reading the process id of a started command (issue #18). +// +// Run it: node js/examples/process-pid-access.mjs +// +// `runner.pid` is the id of the operating system process behind a command. +// It is recorded when the process is spawned, so it stays readable after the +// command finishes - unlike `runner.child`, which is released during cleanup. +// The scenarios below cover when it becomes available, what it actually names, +// and what it is useful for. +// +// The inspection commands (`ps`, `pgrep`) make this a POSIX-only example; the +// `pid` property itself works everywhere. +import { $ } from '../src/$.mjs'; +import { ProcessRunner } from '../src/process-runner.mjs'; +import { execSync } from 'node:child_process'; + +// Mirroring is off so the scenario output stays readable. +const quiet = { mirror: false, capture: true }; + +// `sleep` is a built-in of this library (see scenario 3), so the real +// executable is spelled out whenever an actual process is needed. +const SLEEP = '/bin/sleep'; + +// 1. The id appears when the process is spawned, not when the runner is built. +// Awaiting `streams.stdout` is the point at which the spawn has happened. +console.log('=== 1. Before, during and after the command ==='); +const worker = $(quiet)`${SLEEP} 5`; +console.log(`before start: ${worker.pid}`); // undefined - nothing spawned yet + +worker.start(); +await worker.streams.stdout; // resolves once the child exists + +console.log(`while running: ${worker.pid}`); + +worker.kill(); +await worker.catch(() => {}); + +// The reason to record the id at spawn time: `child` is deliberately released +// when the command finishes, so `worker.child.pid` would throw here. +console.log(`after exit: ${worker.pid} (child is ${worker.child})`); + +// 2. A plain await needs no ceremony - the id is there once the result is. +console.log('\n=== 2. After a plain await ==='); +const done = $(quiet)`sh -c 'echo done'`; +await done; +console.log(`pid: ${done.pid}`); + +// 3. Built-in commands run inside this process, so there is no separate +// process to identify and the id stays undefined. `echo` and `sleep` are +// two of them, which is why the real `sleep` is used above. +console.log('\n=== 3. Built-in commands have no process id ==='); +const builtin = $(quiet)`echo hello`; +await builtin; +console.log(`built-in echo: ${builtin.pid}`); + +const external = $(quiet)`/bin/echo hello`; +await external; +console.log(`/bin/echo instead: ${external.pid}`); + +// 4. What the id names. A command string is handed to a shell, so the id is +// the shell's, and the command itself runs as its child. The shell leads +// its own process group, which is how kill() reaches both of them. +console.log('\n=== 4. What the id names ==='); +const shellRun = $(quiet)`${SLEEP} 30`; +await shellRun.streams.stdout; +const shellPid = shellRun.pid; + +console.log(`pid ${shellPid} is: ${ps('args=', shellPid)}`); +console.log(`its process group: ${ps('pgid=', shellPid)} (same as the pid)`); +for (const childPid of children(shellPid)) { + console.log(` child ${childPid}: ${ps('args=', childPid)}`); +} + +// Signalling the whole group reaches the command under the shell. This is +// what kill() does internally; having the pid lets you do it yourself. +process.kill(-shellPid, 'SIGTERM'); +await shellRun.catch(() => {}); + +// 5. `mode: 'exec'` skips the shell, so the id names the command directly. +console.log('\n=== 5. Exec mode names the command itself ==='); +const direct = new ProcessRunner( + { mode: 'exec', file: SLEEP, args: ['30'] }, + quiet +); +await direct.streams.stdout; +console.log(`pid ${direct.pid} is: ${ps('args=', direct.pid)}`); +direct.kill(); +await direct.catch(() => {}); + +// 6. A practical use: asking whether the command is still alive. Signal 0 +// delivers nothing and only performs the existence check. +console.log('\n=== 6. Checking whether the command is still running ==='); +const shortLived = $(quiet)`${SLEEP} 0.3`; +await shortLived.streams.stdout; +console.log(`running: ${isAlive(shortLived.pid)}`); +await shortLived; +console.log(`finished: ${isAlive(shortLived.pid)}`); + +function isAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function ps(format, pid) { + return execSync(`ps -o ${format} -p ${pid}`).toString().trim(); +} + +function children(pid) { + // pgrep exits 1 when there is no match, which execSync turns into a throw. + try { + return execSync(`pgrep -P ${pid}`).toString().trim().split('\n'); + } catch { + return []; + } +} diff --git a/js/src/$.process-runner-execution.mjs b/js/src/$.process-runner-execution.mjs index 548b1b99..24cf24f3 100644 --- a/js/src/$.process-runner-execution.mjs +++ b/js/src/$.process-runner-execution.mjs @@ -604,8 +604,7 @@ function executeSyncProcess(argv, options) { * @returns {object} Result */ function processSyncResult(runner, result, globalShellSettings) { - // The synchronous spawn has already exited by the time it returns, but it - // still reports the pid it ran under, so `runner.pid` answers here too. + // The sync spawn has already exited, but reports the pid it ran under. runner._pid = result.child?.pid ?? runner._pid; if (runner.options.mirror) { @@ -927,9 +926,7 @@ async function executeChildProcess(runner, argv, config) { const { stdin, isInteractive } = config; runner.child = spawnChild(argv, config); - // Record the pid while the child is still held. _cleanup() drops `child` on - // completion, so this copy is what keeps `runner.pid` readable afterwards. - runner._pid = runner.child?.pid; + runner._pid = runner.child?.pid; // recorded before _cleanup() drops `child` if (runner.child) { trace( diff --git a/js/tests/process-pid.test.mjs b/js/tests/process-pid.test.mjs index 677354fd..b44a7266 100644 --- a/js/tests/process-pid.test.mjs +++ b/js/tests/process-pid.test.mjs @@ -26,7 +26,8 @@ const quiet = { mirror: false, capture: true }; const runtime = process.execPath; const idleFor = (seconds) => $(quiet)`${runtime} -e ${`setTimeout(() => {}, ${seconds * 1000})`}`; -const printHello = () => $(quiet)`${runtime} -e ${'process.stdout.write("hi")'}`; +const printHello = () => + $(quiet)`${runtime} -e ${'process.stdout.write("hi")'}`; describe('issue #18 - process id access', () => { test('is undefined before the command starts', async () => { @@ -101,6 +102,29 @@ describe('issue #18 - process id access', () => { expect(runner.pid).toBeUndefined(); }); + test('names the shell when the command inside it does not exist', async () => { + // The shell is spawned successfully and then fails to find the command, so + // there is a process to name even though nothing the caller asked for ran. + const runner = $(quiet)`command-stream-no-such-executable --nope`; + const result = await runner.catch((error) => error); + + expect(result.code).toBe(127); // "command not found" + expect(typeof runner.pid).toBe('number'); + }); + + test('stays undefined when the spawn itself fails', async () => { + // exec mode has no shell to fall back on, so a missing executable means no + // process at all. + const runner = new ProcessRunner( + { mode: 'exec', file: 'command-stream-no-such-executable', args: [] }, + quiet + ); + const result = await runner.catch((error) => error); + + expect(result.code).not.toBe(0); + expect(runner.pid).toBeUndefined(); + }); + test('distinct commands report distinct ids', async () => { const first = idleFor(5); const second = idleFor(5); diff --git a/rust/README.md b/rust/README.md index 28c8e151..c2ec530c 100644 --- a/rust/README.md +++ b/rust/README.md @@ -137,6 +137,110 @@ The exact-argv form bypasses `/bin/sh -c` and `cmd.exe /c`, so it does not require shell-specific quoting. It also accepts OS-native executable and argument values such as `PathBuf` and `OsString`. +## Process ID of a Running Command + +`pid()` is the id of the operating system process behind a command. It is +recorded when the process is spawned, so โ€” unlike the child handle, which `run()` +consumes in order to await it โ€” it stays readable after the command has finished +(issue #18). It mirrors the JavaScript `runner.pid` property +([JS process id documentation](../js/README.md#process-id-of-a-running-command)). + +```rust,no_run +use command_stream::{ProcessRunner, RunOptions}; + +#[tokio::main] +async fn main() -> command_stream::Result<()> { + let mut runner = ProcessRunner::new("/bin/sleep 5", RunOptions::default()); + assert_eq!(runner.pid(), None); // nothing has been spawned yet + + runner.start().await?; + println!("running as {:?}", runner.pid()); // Some(51234) + + runner.kill()?; + runner.run().await?; + + println!("still readable: {:?}", runner.pid()); // Some(51234) + Ok(()) +} +``` + +### Streaming commands + +`StreamingRunner` spawns its child inside a background task, so the id is not +known the moment `stream()` returns. `wait_for_pid()` waits for the spawn to +complete; `pid()` reports whatever is known right now, without waiting: + +```rust,no_run +use command_stream::StreamingRunner; + +#[tokio::main] +async fn main() { + let mut stream = StreamingRunner::new("/bin/sleep 30").stream(); + + assert_eq!(stream.pid(), None); // the spawn has not happened yet + + let pid = stream.wait_for_pid().await.expect("the child was spawned"); + println!("running as {pid}"); + + stream.kill(); + while stream.next().await.is_some() {} +} +``` + +Both return `None` if the spawn fails, so `wait_for_pid()` never waits forever +for a process that will not exist. + +### What the id names + +A command string is handed to a shell, so the id names **the shell**, and the +command itself runs as its child: + +```console +$ ps -o args= -p 51234 +/bin/sh -c /bin/sleep 5 +``` + +The shell is spawned as the leader of its own process group, so the group id +equals the pid. That is what lets `kill()` reach the command underneath the +wrapper (see [Grandchildren and process groups](#grandchildren-and-process-groups)). + +A consequence worth knowing: a command that does not exist is reported by the +shell that looked for it, so there is still an id even though nothing you asked +for ran โ€” the result carries code `127`, "command not found". + +To get the id of the command itself, with no shell in between, use +[`StreamingRunner::from_argv`](#streaming), which bypasses `/bin/sh -c` and +`cmd.exe /c` entirely. With no shell to fall back on, a missing executable is +then a failed spawn, and the id stays `None`. + +### Built-in commands have no id + +Built-in commands such as `echo`, `sleep` and `cat` run inside your process and +never spawn anything, so there is no operating system process to identify and +`pid()` stays `None`: + +```rust,no_run +use command_stream::{ProcessRunner, RunOptions}; + +#[tokio::main] +async fn main() -> command_stream::Result<()> { + let mut builtin = ProcessRunner::new("echo hello", RunOptions::default()); + builtin.run().await?; + assert_eq!(builtin.pid(), None); + + // The absolute path bypasses the built-in, so a real process is spawned. + let mut external = ProcessRunner::new("/bin/echo hello", RunOptions::default()); + external.run().await?; + assert!(external.pid().is_some()); + + Ok(()) +} +``` + +A runnable walkthrough of all of the above is in +[`rust/examples/process_pid_access.rs`](examples/process_pid_access.rs), which +can be run with `cargo run --example process_pid_access`. + ## Signals `kill()` stops a running command. It defaults to `SIGTERM` and works the same way diff --git a/rust/changelog.d/20260916_090000_process_pid.md b/rust/changelog.d/20260916_090000_process_pid.md new file mode 100644 index 00000000..272f2d35 --- /dev/null +++ b/rust/changelog.d/20260916_090000_process_pid.md @@ -0,0 +1,17 @@ +--- +bump: minor +--- + +### Added + +- `ProcessRunner::pid()` reporting the process id of a started command, matching + the JavaScript `command.pid` property (issue #18). The id is recorded at spawn + time, so it stays readable after `run()` has consumed the child handle, and is + `None` for built-in commands, which spawn no process. +- `OutputStream::pid()` and `OutputStream::wait_for_pid()` for streamed + commands, whose child is spawned inside a background task: `pid()` reports what + is known now, `wait_for_pid()` waits for the spawn and returns `None` if it + fails. +- A `## Process ID of a Running Command` section in the README covering what the + id names and why built-in commands have none, plus a runnable + `examples/process_pid_access.rs`. diff --git a/rust/examples/process_pid_access.rs b/rust/examples/process_pid_access.rs new file mode 100644 index 00000000..0f12e725 --- /dev/null +++ b/rust/examples/process_pid_access.rs @@ -0,0 +1,146 @@ +//! Reading the process id of a started command (issue #18). +//! +//! Run it: `cargo run --example process_pid_access` +//! +//! [`ProcessRunner::pid`] is the id of the operating system process behind a +//! command. It is recorded when the process is spawned, so it stays readable +//! after the command finishes - unlike the child handle, which `run()` consumes +//! in order to await it. The scenarios below cover when it becomes available, +//! what it actually names, and what it is useful for. +//! +//! The inspection commands (`ps`, `pgrep`) make this a POSIX-only example; the +//! `pid` accessors themselves work everywhere. +use command_stream::{OutputChunk, ProcessRunner, RunOptions, StreamingRunner}; + +/// `sleep` is a built-in of this library (see scenario 3), so the real +/// executable is spelled out whenever an actual process is needed. +const SLEEP: &str = "/bin/sleep"; + +/// Mirroring is off so the scenario output stays readable. +fn quiet() -> RunOptions { + RunOptions { + mirror: false, + capture: true, + ..Default::default() + } +} + +/// Ask `ps` a single question about one process. +fn ps(format: &str, pid: u32) -> String { + let output = std::process::Command::new("ps") + .args(["-o", format, "-p", &pid.to_string()]) + .output() + .expect("ps is available"); + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +/// The ids of a process's direct children, if it has any. +fn children(pid: u32) -> Vec { + let output = std::process::Command::new("pgrep") + .args(["-P", &pid.to_string()]) + .output() + .expect("pgrep is available"); + String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .map(str::to_string) + .collect() +} + +/// Whether a process still exists. `ps` reports failure when it does not. +fn is_alive(pid: u32) -> bool { + std::process::Command::new("ps") + .args(["-p", &pid.to_string()]) + .output() + .expect("ps is available") + .status + .success() +} + +#[tokio::main] +async fn main() -> command_stream::Result<()> { + // 1. The id appears when the process is spawned, which `start()` is what + // waits for. Before that there is nothing to identify. + println!("=== 1. Before, during and after the command ==="); + let mut worker = ProcessRunner::new(format!("{SLEEP} 5"), quiet()); + println!("before start: {:?}", worker.pid()); // None - nothing spawned yet + + worker.start().await?; + println!("while running: {:?}", worker.pid()); + + worker.kill()?; + let _ = worker.run().await; + + // The reason to record the id at spawn time: `run()` took the child handle + // in order to await it, so the id could no longer be recovered from it. + println!("after exit: {:?}", worker.pid()); + + // 2. A plain run needs no ceremony - the id is there once the result is. + println!("\n=== 2. After a plain run ==="); + let mut done = ProcessRunner::new("sh -c 'echo done'", quiet()); + done.run().await?; + println!("pid: {:?}", done.pid()); + + // 3. Built-in commands run inside this process, so there is no separate + // process to identify and the id stays None. `echo` and `sleep` are two + // of them, which is why the real `sleep` is used above. + println!("\n=== 3. Built-in commands have no process id ==="); + let mut builtin = ProcessRunner::new("echo hello", quiet()); + builtin.run().await?; + println!("built-in echo: {:?}", builtin.pid()); + + let mut external = ProcessRunner::new("/bin/echo hello", quiet()); + external.run().await?; + println!("/bin/echo instead: {:?}", external.pid()); + + // 4. What the id names. A command string is handed to a shell, so the id is + // the shell's, and the command itself runs as its child. The shell leads + // its own process group, which is how kill() reaches both of them. + println!("\n=== 4. What the id names ==="); + let mut shell_run = ProcessRunner::new(format!("{SLEEP} 30"), quiet()); + shell_run.start().await?; + let shell_pid = shell_run.pid().expect("a spawned command has a pid"); + + println!("pid {shell_pid} is: {}", ps("args=", shell_pid)); + println!( + "its process group: {} (same as the pid)", + ps("pgid=", shell_pid) + ); + for child in children(shell_pid) { + let child_pid: u32 = child.parse().expect("pgrep prints ids"); + println!(" child {child_pid}: {}", ps("args=", child_pid)); + } + + // kill() signals the whole group, which is how it reaches the command + // running underneath the shell. + shell_run.kill()?; + let _ = shell_run.run().await; + + // 5. Streaming spawns its child inside a background task, so the id is not + // known the moment `stream()` returns. `wait_for_pid()` waits for the + // spawn; `pid()` reads whatever is known right now, without waiting. + println!("\n=== 5. Streaming commands ==="); + let mut stream = StreamingRunner::new(format!("{SLEEP} 30")).stream(); + println!("immediately after stream(): {:?}", stream.pid()); + + let streamed_pid = stream.wait_for_pid().await.expect("the child was spawned"); + println!("after wait_for_pid(): {streamed_pid}"); + println!("which is: {}", ps("args=", streamed_pid)); + + stream.kill(); + while let Some(chunk) = stream.next().await { + if let OutputChunk::Exit(code) = chunk { + println!("exit code: {code}"); + } + } + + // 6. A practical use: asking whether the command is still alive. + println!("\n=== 6. Checking whether the command is still running ==="); + let mut short_lived = ProcessRunner::new(format!("{SLEEP} 0.3"), quiet()); + short_lived.start().await?; + let short_pid = short_lived.pid().expect("a spawned command has a pid"); + println!("running: {}", is_alive(short_pid)); + short_lived.run().await?; + println!("finished: {}", is_alive(short_pid)); + + Ok(()) +} diff --git a/rust/tests/process_pid.rs b/rust/tests/process_pid.rs index 11c5cf2b..43f648e0 100644 --- a/rust/tests/process_pid.rs +++ b/rust/tests/process_pid.rs @@ -178,3 +178,29 @@ async fn the_reported_id_leads_its_own_process_group() { runner.kill().unwrap(); let _ = runner.run().await; } + +/// A missing command is reported by the shell that was asked to run it, so the +/// shell still has an id even though nothing the caller asked for ran. +#[tokio::test] +async fn a_missing_command_still_names_the_shell_that_looked_for_it() { + let mut runner = ProcessRunner::new("command-stream-no-such-executable --nope", quiet()); + let result = runner.run().await.unwrap(); + + assert_eq!(result.code, 127); // "command not found" + assert!(runner.pid().is_some()); +} + +/// `wait_for_pid` must not wait forever when the process never comes into +/// existence: the task drops the publishing end, which ends the wait. +#[tokio::test] +async fn streaming_wait_for_pid_gives_up_when_the_spawn_fails() { + let mut stream = + StreamingRunner::from_argv("command-stream-no-such-executable", ["--nope"]).stream(); + + let pid = tokio::time::timeout(std::time::Duration::from_secs(5), stream.wait_for_pid()) + .await + .expect("wait_for_pid returns instead of hanging"); + + assert_eq!(pid, None); + assert_eq!(stream.pid(), None); +} From dff3f99daf74321a519d20c318d4ec9dd73b8e1a Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 16 Sep 2026 09:09:13 +0000 Subject: [PATCH 6/8] Stop asserting a shell optimization the platforms disagree on macOS CI failed `a_shell_command_reports_the_shell_that_runs_it`: `ps -o args=` reported `/bin/sleep 5`, not the `/bin/sh -c /bin/sleep 5` the test demanded. macOS `/bin/sh` replaces itself with the command when the string is a single simple command, so there is no wrapper process left to name. Linux `/bin/sh` (dash) forks, which is why it passed there, and JavaScript passed on macOS too because it runs a login shell (`sh -l -c`), which has a profile to read and so cannot exec away. The wrapper was never the point. What both shapes share, and what a caller can rely on, is that the id names the process the library spawned to run the command, and that process leads its own process group. Both suites now assert that, and the READMEs and examples say so instead of promising a shell. --- js/README.md | 5 +++++ js/examples/process-pid-access.mjs | 14 ++++++++++---- js/tests/process-pid.test.mjs | 17 ++++++++++------- rust/README.md | 15 +++++++++++---- rust/examples/process_pid_access.rs | 14 ++++++++++---- rust/tests/process_pid.rs | 14 ++++++++++---- 6 files changed, 56 insertions(+), 23 deletions(-) diff --git a/js/README.md b/js/README.md index 58504858..587ea067 100644 --- a/js/README.md +++ b/js/README.md @@ -729,6 +729,11 @@ $ ps -o args= -p 51234 /bin/sh -l -c /bin/sleep 5 ``` +Do not depend on the wrapper being there. Some shells replace themselves with +the command when the string is a single simple command, in which case the same +id names the command directly. What holds everywhere is that the id names the +process the library spawned to run your command. + The shell is spawned as the leader of its own process group, so the group id equals the pid. That is what lets `kill()` reach the command underneath the wrapper (see diff --git a/js/examples/process-pid-access.mjs b/js/examples/process-pid-access.mjs index faa828ac..7ee31627 100644 --- a/js/examples/process-pid-access.mjs +++ b/js/examples/process-pid-access.mjs @@ -58,9 +58,11 @@ const external = $(quiet)`/bin/echo hello`; await external; console.log(`/bin/echo instead: ${external.pid}`); -// 4. What the id names. A command string is handed to a shell, so the id is -// the shell's, and the command itself runs as its child. The shell leads -// its own process group, which is how kill() reaches both of them. +// 4. What the id names. A command string is handed to a shell, so the id names +// the process that shell put there: usually the shell itself, with the +// command as its child, but some shells replace themselves with a single +// simple command instead. Either way it leads its own process group, which +// is how kill() reaches the whole tree. console.log('\n=== 4. What the id names ==='); const shellRun = $(quiet)`${SLEEP} 30`; await shellRun.streams.stdout; @@ -68,9 +70,13 @@ const shellPid = shellRun.pid; console.log(`pid ${shellPid} is: ${ps('args=', shellPid)}`); console.log(`its process group: ${ps('pgid=', shellPid)} (same as the pid)`); -for (const childPid of children(shellPid)) { +const shellChildren = children(shellPid); +for (const childPid of shellChildren) { console.log(` child ${childPid}: ${ps('args=', childPid)}`); } +if (shellChildren.length === 0) { + console.log(' no children โ€” this shell replaced itself with the command'); +} // Signalling the whole group reaches the command under the shell. This is // what kill() does internally; having the pid lets you do it yourself. diff --git a/js/tests/process-pid.test.mjs b/js/tests/process-pid.test.mjs index b44a7266..b17f6f1c 100644 --- a/js/tests/process-pid.test.mjs +++ b/js/tests/process-pid.test.mjs @@ -153,21 +153,24 @@ describe('issue #18 - process id access', () => { }); // `ps` is the reference for "which process is this really?", and it is POSIX -// only. The behavior it pins down (shell wrapper vs. exact executable) is not -// Unix-specific, but its verification is. +// only. The behavior it pins down (the process behind the id, shell-wrapped or +// not) is not Unix-specific, but its verification is. describe.skipIf(isWindows)('issue #18 - what the id names', () => { - test('a shell command reports the shell that runs it', async () => { + test('a shell command reports the process running it', async () => { // Worth pinning down because it is surprising: a command string goes - // through the platform shell, so the pid names that shell and the command - // itself is its child. The shell leads its own process group, which is how - // kill() reaches both. + // through the platform shell, so the pid names the process the shell put + // there rather than something the caller wrote. Which process that is + // depends on the shell: most fork, leaving the wrapper named with the + // command as its child, while some replace themselves with a single simple + // command (macOS `/bin/sh` does, and Rust CI caught it there). Either way + // the named process leads its own process group, which is how kill() + // reaches the whole tree. const runner = $(quiet)`/bin/sleep 5`; await runner.streams.stdout; const pid = runner.pid; const args = execSync(`ps -o args= -p ${pid}`).toString().trim(); expect(args).toContain('/bin/sleep 5'); - expect(args).not.toBe('/bin/sleep 5'); // a shell wrapper, not the command const pgid = Number(execSync(`ps -o pgid= -p ${pid}`).toString().trim()); expect(pgid).toBe(pid); diff --git a/rust/README.md b/rust/README.md index c2ec530c..a022c658 100644 --- a/rust/README.md +++ b/rust/README.md @@ -192,16 +192,23 @@ for a process that will not exist. ### What the id names -A command string is handed to a shell, so the id names **the shell**, and the -command itself runs as its child: +A command string is handed to a shell, so the id names **the process that shell +put there**. Usually that is the shell itself, with the command running as its +child: ```console $ ps -o args= -p 51234 /bin/sh -c /bin/sleep 5 ``` -The shell is spawned as the leader of its own process group, so the group id -equals the pid. That is what lets `kill()` reach the command underneath the +Some shells replace themselves with the command when the string is a single +simple command, so the same id can name the command directly instead โ€” macOS +`/bin/sh` does this, where the line above reads `/bin/sleep 5`. Do not depend on +either shape; what holds everywhere is that the id names the process the library +spawned to run your command. + +The spawned process leads its own process group, so the group id equals the +pid. That is what lets `kill()` reach the command underneath the wrapper (see [Grandchildren and process groups](#grandchildren-and-process-groups)). A consequence worth knowing: a command that does not exist is reported by the diff --git a/rust/examples/process_pid_access.rs b/rust/examples/process_pid_access.rs index 0f12e725..5621c0c4 100644 --- a/rust/examples/process_pid_access.rs +++ b/rust/examples/process_pid_access.rs @@ -92,9 +92,11 @@ async fn main() -> command_stream::Result<()> { external.run().await?; println!("/bin/echo instead: {:?}", external.pid()); - // 4. What the id names. A command string is handed to a shell, so the id is - // the shell's, and the command itself runs as its child. The shell leads - // its own process group, which is how kill() reaches both of them. + // 4. What the id names. A command string is handed to a shell, so the id + // names the process that shell put there: usually the shell itself, with + // the command as its child, but some shells (macOS `/bin/sh`) replace + // themselves with a single simple command instead. Either way it leads + // its own process group, which is how kill() reaches the whole tree. println!("\n=== 4. What the id names ==="); let mut shell_run = ProcessRunner::new(format!("{SLEEP} 30"), quiet()); shell_run.start().await?; @@ -105,10 +107,14 @@ async fn main() -> command_stream::Result<()> { "its process group: {} (same as the pid)", ps("pgid=", shell_pid) ); - for child in children(shell_pid) { + let shell_children = children(shell_pid); + for child in &shell_children { let child_pid: u32 = child.parse().expect("pgrep prints ids"); println!(" child {child_pid}: {}", ps("args=", child_pid)); } + if shell_children.is_empty() { + println!(" no children - this shell replaced itself with the command"); + } // kill() signals the whole group, which is how it reaches the command // running underneath the shell. diff --git a/rust/tests/process_pid.rs b/rust/tests/process_pid.rs index 43f648e0..95d47535 100644 --- a/rust/tests/process_pid.rs +++ b/rust/tests/process_pid.rs @@ -135,11 +135,18 @@ async fn streaming_pid_is_set_by_the_time_output_arrives() { } /// `ps` is the reference for "which process is this really?", and it is POSIX -/// only. The behavior it pins down - a command string being run *by a shell*, -/// so the id names that shell - is not Unix-specific, but its verification is. +/// only. The behavior it pins down - the id naming the process that was spawned +/// to run the command string - is not Unix-specific, but its verification is. +/// +/// Which process that is depends on the shell. Most shells fork, leaving +/// `/bin/sh -c ` as the named process with the command as its child. +/// Others replace themselves with the command when the string is a single +/// simple command - macOS `/bin/sh` does this, and CI caught it - in which case +/// the id names the command directly. The assertion is therefore on what both +/// shapes share: the named process is running the command that was asked for. #[cfg(unix)] #[tokio::test] -async fn a_shell_command_reports_the_shell_that_runs_it() { +async fn a_shell_command_reports_the_process_running_it() { let mut runner = ProcessRunner::new(IDLE_COMMAND, quiet()); runner.start().await.unwrap(); let pid = runner.pid().expect("a spawned command has a pid"); @@ -151,7 +158,6 @@ async fn a_shell_command_reports_the_shell_that_runs_it() { let args = String::from_utf8_lossy(&ps.stdout).trim().to_string(); assert!(args.contains("/bin/sleep 5"), "unexpected process: {args}"); - assert_ne!(args, "/bin/sleep 5", "expected a shell wrapper"); runner.kill().unwrap(); let _ = runner.run().await; From 9fda1db88a8384f7469317985f1f1dcdfac916f3 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 16 Sep 2026 09:09:49 +0000 Subject: [PATCH 7/8] Match the changeset wording to the corrected documentation --- js/.changeset/issue-18-process-pid.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/js/.changeset/issue-18-process-pid.md b/js/.changeset/issue-18-process-pid.md index 706fb12e..0ccdfbc7 100644 --- a/js/.changeset/issue-18-process-pid.md +++ b/js/.changeset/issue-18-process-pid.md @@ -11,6 +11,6 @@ time, so the same value is reported from `await`, `.sync()`, `.stream()` and the `streams` getters, and it stays readable after the command is done. Documents the behavior in "Process ID of a Running Command" - including what the -id names (the shell wrapper, which leads its own process group, unless `exec` -mode is used) and why built-in commands have none - and adds a runnable -`examples/process-pid-access.mjs`. +id names (the process the shell put there, which leads its own process group, +unless `exec` mode is used to skip the shell) and why built-in commands have +none - and adds a runnable `examples/process-pid-access.mjs`. From c563f53b0ebce70524569a36ba82d55c14f453e6 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 16 Sep 2026 09:19:29 +0000 Subject: [PATCH 8/8] Let each shell report a missing command its own way Windows CI failed `a_missing_command_still_names_the_shell_that_looked_for_it`: the result carried code 1, not the 127 the test demanded. 127 is the POSIX "command not found" convention, and Rust hands command strings to `cmd.exe` on Windows, which exits with 1 instead. The JavaScript suite only passed there by luck: its shell detection prefers Git Bash, which GitHub runners happen to have installed, so a Windows machine without it would have failed the same assertion. The code was never the point. Both suites now assert what holds on every shell -- the command fails and the id is still there -- and keep the exact 127 where it is actually promised, on POSIX. The READMEs say whose convention the code is instead of presenting 127 as the library's answer. --- js/README.md | 3 +++ js/tests/process-pid.test.mjs | 8 +++++++- rust/README.md | 3 ++- rust/tests/process_pid.rs | 10 ++++++++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/js/README.md b/js/README.md index 587ea067..c37cca91 100644 --- a/js/README.md +++ b/js/README.md @@ -755,6 +755,9 @@ missing.pid; // the shell's pid (await missing.catch((error) => error)).code; // 127 โ€” "command not found" ``` +The code is the shell's convention rather than the library's: POSIX shells and +Git Bash use `127`, while `cmd.exe` exits with `1`. + To get the id of the command itself, with no shell in between, use the `exec` command specification, which bypasses the shell entirely: diff --git a/js/tests/process-pid.test.mjs b/js/tests/process-pid.test.mjs index b17f6f1c..6f24da2e 100644 --- a/js/tests/process-pid.test.mjs +++ b/js/tests/process-pid.test.mjs @@ -105,10 +105,16 @@ describe('issue #18 - process id access', () => { test('names the shell when the command inside it does not exist', async () => { // The shell is spawned successfully and then fails to find the command, so // there is a process to name even though nothing the caller asked for ran. + // Which failure code comes back is the shell's own convention: POSIX shells + // use 127 for "command not found", while `cmd.exe` exits with 1. Only the + // failure and the pid are common to both. const runner = $(quiet)`command-stream-no-such-executable --nope`; const result = await runner.catch((error) => error); - expect(result.code).toBe(127); // "command not found" + expect(result.code).not.toBe(0); + if (!isWindows) { + expect(result.code).toBe(127); // "command not found" + } expect(typeof runner.pid).toBe('number'); }); diff --git a/rust/README.md b/rust/README.md index a022c658..9b9848fb 100644 --- a/rust/README.md +++ b/rust/README.md @@ -213,7 +213,8 @@ wrapper (see [Grandchildren and process groups](#grandchildren-and-process-group A consequence worth knowing: a command that does not exist is reported by the shell that looked for it, so there is still an id even though nothing you asked -for ran โ€” the result carries code `127`, "command not found". +for ran. The failure code is the shell's convention rather than the library's: +POSIX shells use `127`, "command not found", while `cmd.exe` exits with `1`. To get the id of the command itself, with no shell in between, use [`StreamingRunner::from_argv`](#streaming), which bypasses `/bin/sh -c` and diff --git a/rust/tests/process_pid.rs b/rust/tests/process_pid.rs index 95d47535..7a07bdc2 100644 --- a/rust/tests/process_pid.rs +++ b/rust/tests/process_pid.rs @@ -187,11 +187,21 @@ async fn the_reported_id_leads_its_own_process_group() { /// A missing command is reported by the shell that was asked to run it, so the /// shell still has an id even though nothing the caller asked for ran. +/// +/// Which failure code it reports is the shell's own convention: POSIX shells +/// use 127 for "command not found", while `cmd.exe` exits with 1 (Windows CI +/// caught the test demanding 127 there). What holds everywhere is that the +/// command fails and the id is still there. #[tokio::test] async fn a_missing_command_still_names_the_shell_that_looked_for_it() { let mut runner = ProcessRunner::new("command-stream-no-such-executable --nope", quiet()); let result = runner.run().await.unwrap(); + assert_ne!( + result.code, 0, + "a command that does not exist cannot succeed" + ); + #[cfg(unix)] assert_eq!(result.code, 127); // "command not found" assert!(runner.pid().is_some()); }