Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/packages/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,28 @@ const cli = new CLI('myapp')
cli.run()
```

## Bounded commands and cancellation

Import `runBoundedProcess` from `@stacksjs/cli/process` when a finite worker or
inspection command needs explicit time and stdout/stderr byte limits. It passes
an argument vector directly, drains both streams, waits for exit and returns
`{ exitCode, stdout, stderr }`, including nonzero exits. Set `strictUtf8: true`
when malformed text must be rejected.

The optional `signal` accepts a native `AbortSignal`, such as `request.signal`.
A canceled request prevents launch if it is already aborted, or terminates and
settles the owned process and readers before rejection. The error message is
`<label> canceled`; the original reason stays in `cause` without coercion.
The first failure retains precedence, and cancellation after completion has no
effect. On POSIX cleanup covers the owned process group, including inherited
pipes; Windows cleanup covers the direct child and readers. Descendants that
escape the owned group are outside this contract.

The [package reference](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/cli/README.md#capturing-bounded-command-output)
documents the limits and full options. Choose the executable, argument vector,
working directory and environment on the server. A bounded worker does not
authenticate its caller or grant authority to publish its result.

## Creating Commands

### Basic Command
Expand Down
15 changes: 15 additions & 0 deletions storage/framework/core/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ or failure, including descendants holding inherited pipes. On Windows cleanup
covers the direct process and readers. Use this API for finite inspection or build
probes whose descendants should end with the command.

### Request cancellation

Pass a native `AbortSignal` as `signal`, for example an HTTP request's
`request.signal`. An already-aborted signal rejects before launching a process.
Later cancellation terminates the owned process scope and waits for the child
and readers to settle before rejecting with `Error('<label> canceled')`.
The signal's reason is retained as `cause` without converting it to text.

The first observed failure wins when cancellation overlaps an output or deadline
failure. Listeners are removed after settlement, so later cancellation cannot
change a completed capture. Nonzero exits still return the original result
shape. Cancellation keeps the same POSIX process-group and Windows direct-child
cleanup scope described above; it does not contain a descendant that deliberately
escapes that group.

## 🤖 Usage

```bash
Expand Down
14 changes: 13 additions & 1 deletion storage/framework/core/cli/src/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface BoundedProcessOptions {
label: string
/** Reject malformed UTF-8 on either stream. Valid text keeps its BOM. */
strictUtf8?: boolean
/** Cancel this owned command and settle its child and output readers before rejecting. */
signal?: AbortSignal
}

export interface BoundedProcessResult {
Expand All @@ -21,6 +23,10 @@ export async function runBoundedProcess(command: string[], options: BoundedProce
for (const limit of [options.stdoutBytes, options.stderrBytes]) {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 2_000_000) throw new Error('Invalid bounded process output limit')
}
const signal = options.signal
if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError('Invalid bounded process signal')
const cancellationError = () => new Error(`${options.label} canceled`, { cause: signal?.reason })
if (signal?.aborted) throw cancellationError()
const ownsGroup = process.platform !== 'win32'
const child = Bun.spawn(command, { cwd: options.cwd, env: options.env ?? process.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', detached: ownsGroup })
const readers = [child.stdout.getReader(), child.stderr.getReader()]
Expand All @@ -42,6 +48,7 @@ export async function runBoundedProcess(command: string[], options: BoundedProce
terminate()
for (const reader of readers) cancellations.push(reader.cancel().catch(error => { cleanupFailures.push(error) }))
}
const onAbort = () => stop(cancellationError(), 'canceled')
async function consume(reader: ReadableStreamDefaultReader<Uint8Array>, limit: number, label: string) {
const chunks: Uint8Array[] = []
let bytes = 0
Expand All @@ -68,12 +75,17 @@ export async function runBoundedProcess(command: string[], options: BoundedProce
const timer = setTimeout(() => stop(new Error(`${options.label} timed out`), 'timed out'), options.timeoutMs)
const stdout = consume(readers[0]!, options.stdoutBytes, 'stdout'), stderr = consume(readers[1]!, options.stderrBytes, 'stderr')
try {
signal?.addEventListener('abort', onAbort, { once: true })
// Cancellation can happen after admission but before the listener is armed.
if (signal?.aborted) onAbort()
const [exitCode, output, diagnostic] = await Promise.all([child.exited, stdout, stderr])
if (failed) throw failure
return { exitCode, stdout: output, stderr: diagnostic }
}
catch (error) { stop(error); throw error }
catch (error) { stop(error); throw failure }
finally {
try { signal?.removeEventListener('abort', onAbort) }
catch (error) { cleanupFailures.push(error) }
clearTimeout(timer)
// A failure already terminated this owned group. Killing again can race
// Bun's exit notification and report EPERM for the reaped child.
Expand Down
13 changes: 11 additions & 2 deletions storage/framework/core/cli/tests/output-async.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,22 @@ const payload = '航🌱é'.repeat(300000)
let directory: string
let script: string
let binary: string
let config: string

const environment: Record<string, string> = { APP_ENV: 'test', NODE_ENV: 'test' }
for (const name of ['PATH', 'TMPDIR', 'TMP', 'TEMP', 'SystemRoot', 'WINDIR']) {
const value = process.env[name]
if (value !== undefined) environment[name] = value
}

beforeAll(async () => {
const runtime = join(import.meta.dir, '../../../runtime')
await mkdir(runtime, { recursive: true })
directory = await mkdtemp(join(runtime, 'cli-output-'))
script = join(directory, 'writer.ts')
binary = join(directory, process.platform === 'win32' ? 'writer.exe' : 'writer')
config = join(directory, 'bunfig.toml')
await writeFile(config, 'preload=[]\n[install]\nauto="disable"\n')
await writeFile(script, `
import { writeSync } from 'node:fs'
import { printAsync, printErrorAsync } from ${JSON.stringify(output)}
Expand Down Expand Up @@ -52,7 +61,7 @@ for (const compiled of [false, true]) {
const channel = diagnostic ? 'stderr' : 'stdout'
const name = `${compiled ? 'compiled binary' : 'Bun process'} ${channel}`
test(`${name} awaits complete Unicode output through a slow pipe before immediate exit`, async () => {
const child = Bun.spawn([...(compiled ? [binary] : [process.execPath, '--no-env-file', script]), channel], { stdout: 'pipe', stderr: 'pipe' })
const child = Bun.spawn([...(compiled ? [binary] : [process.execPath, '--no-install', '--no-env-file', `--config=${config}`, script]), channel], { cwd: directory, env: environment, stdout: 'pipe', stderr: 'pipe' })
const timeout = setTimeout(() => child.kill(), 10000)
try {
const stream = diagnostic ? child.stderr : child.stdout
Expand All @@ -78,7 +87,7 @@ for (const compiled of [false, true]) {
}, 15000)

test(`${name} rejects a broken pipe without leaving an error listener`, async () => {
const child = Bun.spawn([...(compiled ? [binary] : [process.execPath, '--no-env-file', script]), `broken-${channel}`], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' })
const child = Bun.spawn([...(compiled ? [binary] : [process.execPath, '--no-install', '--no-env-file', `--config=${config}`, script]), `broken-${channel}`], { cwd: directory, env: environment, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' })
const timeout = setTimeout(() => child.kill(), 10000)
try {
const stream = diagnostic ? child.stderr : child.stdout
Expand Down
14 changes: 14 additions & 0 deletions storage/framework/core/cli/tests/process.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { BoundedProcessOptions, BoundedProcessResult } from '../src/process'
import { runBoundedProcess } from '../src/process'

type Expect<T extends true> = T
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false

export type CancellationOption = Expect<Equal<BoundedProcessOptions['signal'], AbortSignal | undefined>>
export type CaptureResult = Expect<Equal<ReturnType<typeof runBoundedProcess>, Promise<BoundedProcessResult>>>

const options = { timeoutMs: 1000, stdoutBytes: 1024, stderrBytes: 1024, label: 'Typed capture' }
export const cancelable = { ...options, signal: new AbortController().signal } satisfies BoundedProcessOptions
export const unchanged = { ...options } satisfies BoundedProcessOptions
// @ts-expect-error A cancellation flag is not an AbortSignal.
export const invalid = { ...options, signal: true } satisfies BoundedProcessOptions
Loading
Loading