Skip to content

Conversation

@eastlondoner
Copy link
Contributor

Summary

This PR adds AbortSignal support to the Bun shell API ($) via a new .signal() fluent method, enabling cancellation and timeout support for shell commands.

Closes #18247

Motivation

The Bun shell API ($) exposes a high-level, ergonomic interface for spawning pipelines of shell commands. However, it previously lacked the ability to integrate with AbortSignal, making it difficult to:

  • Implement timeouts for shell commands
  • Implement cancellable shell operations
  • Participate in user-driven abort workflows
  • Compose shell commands with modern async patterns involving cancellation

Lower-level APIs like Bun.spawn already support AbortSignal, but the $ shell layer did not—until now.

API

// Timeout after 5 seconds
await $`long-running-command`.signal(AbortSignal.timeout(5000));

// Manual cancellation
const controller = new AbortController();
const promise = $`sleep 100`.signal(controller.signal);
setTimeout(() => controller.abort(), 1000);
await promise; // Rejects with AbortError after 1 second

// With nothrow - returns exit code instead of throwing
const result = await $`sleep 100`.nothrow().signal(AbortSignal.timeout(1000));
console.log(result.exitCode); // 143 (128 + SIGTERM)

Behavioral Semantics

Default behavior (throwing)

When the signal aborts, the ShellPromise rejects with an AbortError (DOMException), matching the behavior of fetch and Bun.spawn:

try {
  await $`sleep 20`.signal(AbortSignal.timeout(100));
} catch (err) {
  // err.name === "AbortError"
  // err.message === "The operation was aborted."
}

With .nothrow()

The command resolves normally with exit code 143 (128 + SIGTERM):

const result = await $`sleep 20`.nothrow().signal(AbortSignal.timeout(100));
// result.exitCode === 143

Custom abort reasons

Custom abort reasons are preserved:

const controller = new AbortController();
controller.abort(new Error("Custom reason"));
// The promise rejects with the custom Error

Already-aborted signals

Passing an already-aborted signal rejects immediately without spawning any processes.

Pipelines

Aborting kills all processes in the pipeline (cmd1 | cmd2 | cmd3).

Helper methods

Works with .text(), .lines(), .json() - they propagate the abort rejection.

Implementation Details

JavaScript Layer (src/js/builtins/shell.ts)

  • Added #signal, #abortedByUs, and #alreadyAborted private fields to ShellPromise
  • Added .signal() method that:
    • Validates the shell hasn't started yet (#throwIfRunning())
    • Detects already-aborted signals and short-circuits without spawning
    • Registers an abort listener that sets #abortedByUs flag
    • Passes the signal to Zig via setSignal()
  • Modified the resolve callback to check for abort conditions and reject with AbortError when appropriate

Zig Layer (src/shell/interpreter.zig)

  • Added abort_signal field and aborted flag to Interpreter struct
  • Added active_subprocesses registry (using ArrayListUnmanaged)
  • Implemented registerSubprocess() / unregisterSubprocess() methods
  • Implemented onAbortSignal() callback that:
    • Sets the aborted flag
    • Calls abortAllCommands() to SIGTERM all registered subprocesses
    • Cleans up the signal reference
  • Added isAborted() check used by builtins and spawn points

Subprocess Management (src/shell/states/Cmd.zig)

  • Added abort check before spawning new processes
  • Registered subprocesses with interpreter after successful spawn
  • Unregistered subprocesses in deinit()

Async Commands (src/shell/states/Async.zig)

  • Added abort checks to prevent spawning background commands after abort

Builtin Cooperative Cancellation

Added isAborted() checks at yield points in long-running builtins:

  • yes.zig - in the infinite write loop
  • cat.zig - in the file read loop
  • cp.zig - before copy tasks
  • seq.zig - in the number generation loop

Type Definitions (packages/bun-types/shell.d.ts)

  • Added .signal() method with full JSDoc documentation

Test Plan

Added comprehensive test suite in test/js/bun/shell/shell-abort.test.ts (14 tests):

  • Basic abort tests

    • AbortController.abort() rejects with AbortError
    • AbortSignal.timeout() rejects with TimeoutError
    • .nothrow() resolves with exit code 143 on abort
    • Custom abort reason is preserved
  • Already-aborted signal tests

    • Already-aborted signal rejects immediately without spawning
    • Already-aborted signal with .nothrow() resolves with exit code 143
  • Pipeline tests

    • Abort kills all processes in pipeline
  • Helper method tests

    • .text() rejects on abort
    • .lines() rejects on abort
    • .json() rejects on abort
  • Edge case tests

    • Signal fires after process exits normally - not treated as abort
    • Calling .signal() after shell has started throws
  • Promise combinator tests

    • Promise.race() does not cancel shell command (preserves JS semantics)
  • AbortError properties tests

    • AbortError has correct name property and is instance of DOMException

Files Changed

File Changes
packages/bun-types/shell.d.ts TypeScript type definitions for .signal()
src/bun.js/api/ParsedShellScript.classes.ts Added setSignal binding
src/js/builtins/shell.ts JS layer signal handling
src/shell/ParsedShellScript.zig Signal storage and transfer
src/shell/interpreter.zig Core abort infrastructure
src/shell/states/Async.zig Background command abort checks
src/shell/states/Cmd.zig Subprocess registration/abort checks
src/shell/builtin/yes.zig Cooperative cancellation
src/shell/builtin/cat.zig Cooperative cancellation
src/shell/builtin/cp.zig Cooperative cancellation
src/shell/builtin/seq.zig Cooperative cancellation
test/js/bun/shell/shell-abort.test.ts Comprehensive test suite

🤖 Generated with Claude Code

Add a fluent `.signal(abortSignal)` method to ShellPromise that enables
cancellation of shell commands using the standard AbortSignal API.

Features:
- AbortController.abort() sends SIGTERM to all spawned processes
- Supports AbortSignal.timeout() for automatic timeouts
- .nothrow() resolves with exit code 143 (128 + SIGTERM) instead of throwing
- Custom abort reasons are preserved in the rejection
- Already-aborted signals reject immediately without spawning processes
- Pipelines abort all processes in the pipeline
- Works with helper methods: .text(), .lines(), .json()
- Calling .signal() after shell starts throws "Shell is already running"

Implementation:
- JS layer (shell.ts): Manages AbortSignal lifecycle, tracks abort state
- Zig layer: Subprocess registry, abort signal wiring, cooperative builtin cancellation
- Builtins (yes, cat, cp, seq): Check isAborted() at yield points for cooperative cancellation

Closes oven-sh#18247

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@eastlondoner eastlondoner force-pushed the claude/shell-abort-signal branch from ca0bd17 to b1de47d Compare December 11, 2025 23:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun Shell: Support passing an AbortSignal to bun shell

2 participants