Skip to content
17 changes: 17 additions & 0 deletions experiments/pid-child-shape.mjs
Original file line number Diff line number Diff line change
@@ -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;
30 changes: 30 additions & 0 deletions experiments/pid-current-behavior.mjs
Original file line number Diff line number Diff line change
@@ -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);
43 changes: 43 additions & 0 deletions experiments/pid-getter-check.mjs
Original file line number Diff line number Diff line change
@@ -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);
33 changes: 33 additions & 0 deletions experiments/pid-group-and-exec.mjs
Original file line number Diff line number Diff line change
@@ -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(() => {});
17 changes: 17 additions & 0 deletions experiments/pid-identity.mjs
Original file line number Diff line number Diff line change
@@ -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(() => {});
18 changes: 18 additions & 0 deletions experiments/pid-real-command.mjs
Original file line number Diff line number Diff line change
@@ -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);
16 changes: 16 additions & 0 deletions js/.changeset/issue-18-process-pid.md
Original file line number Diff line number Diff line change
@@ -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`.
138 changes: 138 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <cmd.pid> => /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
Expand Down Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions js/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading