diff --git a/experiments/pid-child-shape.mjs b/experiments/pid-child-shape.mjs new file mode 100644 index 00000000..3110b761 --- /dev/null +++ b/experiments/pid-child-shape.mjs @@ -0,0 +1,17 @@ +// 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..e279122e --- /dev/null +++ b/experiments/pid-group-and-exec.mjs @@ -0,0 +1,33 @@ +// 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..60b36ad9 --- /dev/null +++ b/experiments/pid-identity.mjs @@ -0,0 +1,17 @@ +// 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..47ce8eb5 --- /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/.changeset/issue-18-process-pid.md b/js/.changeset/issue-18-process-pid.md new file mode 100644 index 00000000..0ccdfbc7 --- /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 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`. diff --git a/js/README.md b/js/README.md index a1cb3d5b..c37cca91 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,137 @@ 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 +``` + +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 +[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" +``` + +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: + +```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 +1725,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 7cd5f5bb..0ca5e8e3 100644 --- a/js/examples/README.md +++ b/js/examples/README.md @@ -174,6 +174,12 @@ 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` - Reading `command.pid`: when it becomes available, what it names, and how to use it + ### ๐Ÿงช Testing and Debugging **Core Functionality Tests:** @@ -322,12 +328,22 @@ 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** - 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 ```bash # Run a basic example bun js/examples/ping-streaming-simple.mjs +# Learn how to get process PIDs +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..7ee31627 --- /dev/null +++ b/js/examples/process-pid-access.mjs @@ -0,0 +1,126 @@ +#!/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 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; +const shellPid = shellRun.pid; + +console.log(`pid ${shellPid} is: ${ps('args=', shellPid)}`); +console.log(`its process group: ${ps('pgid=', shellPid)} (same as the pid)`); +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. +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-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..24cf24f3 100644 --- a/js/src/$.process-runner-execution.mjs +++ b/js/src/$.process-runner-execution.mjs @@ -604,6 +604,9 @@ function executeSyncProcess(argv, options) { * @returns {object} Result */ function processSyncResult(runner, result, globalShellSettings) { + // The sync spawn has already exited, but reports the pid it ran under. + runner._pid = result.child?.pid ?? runner._pid; + if (runner.options.mirror) { if (result.stdout) { safeWrite(process.stdout, result.stdout); @@ -923,6 +926,7 @@ async function executeChildProcess(runner, argv, config) { const { stdin, isInteractive } = config; runner.child = spawnChild(argv, config); + 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 new file mode 100644 index 00000000..6f24da2e --- /dev/null +++ b/js/tests/process-pid.test.mjs @@ -0,0 +1,201 @@ +// 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('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).not.toBe(0); + if (!isWindows) { + 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); + 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 (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 process running it', async () => { + // Worth pinning down because it is surprising: a command string goes + // 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'); + + 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/README.md b/rust/README.md index 28c8e151..9b9848fb 100644 --- a/rust/README.md +++ b/rust/README.md @@ -137,6 +137,118 @@ 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 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 +``` + +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 +shell that looked for it, so there is still an id even though nothing you asked +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 +`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..5621c0c4 --- /dev/null +++ b/rust/examples/process_pid_access.rs @@ -0,0 +1,152 @@ +//! 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 + // 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?; + 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) + ); + 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. + 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/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..7a07bdc2 --- /dev/null +++ b/rust/tests/process_pid.rs @@ -0,0 +1,222 @@ +//! 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 - 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_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"); + + 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}"); + + 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; +} + +/// 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()); +} + +/// `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); +}