Skip to content

fix(shell): prevent use-after-free in IOWriter when commands are freed - #26662

Closed
robobun wants to merge 2 commits into
mainfrom
claude/fix-shell-iowriter-use-after-free
Closed

robobun wants to merge 2 commits into
mainfrom
claude/fix-shell-iowriter-use-after-free

Conversation

@robobun

@robobun robobun commented Feb 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Fix use-after-free crash in shell IOWriter when commands complete and are freed while pending writes exist
  • Add cancelPendingChunks() to all shell command types that use IOWriter (Builtin, Cmd, Pipeline, CondExpr, Subshell)
  • Add cancelChunksWithRawPtr() to IOWriter to support canceling by raw pointer address

Problem

When shell commands complete and are freed, they may still have pending chunks in the IOWriter queue. If a write callback fires after the command is freed, it would try to call onIOWriterChunk on freed memory, causing a use-after-free crash (typically segfault at address 0x0).

This was reported in #26660 where OpenCode on Windows crashed with many concurrent shell commands. The previous fix in #26626 added cleanup_state to prevent double-frees during GC finalization, but didn't address the IOWriter pending chunks issue.

Solution

Cancel all pending IOWriter chunks that reference a command before freeing it by:

  1. Adding cancelPendingChunks() calls to deinit() of:

    • Builtin.zig - for all shell builtins (echo, cd, ls, etc.)
    • Cmd.zig - for external commands and builtin wrappers
    • Pipeline.zig - for pipeline commands
    • CondExpr.zig - for conditional expressions
    • Subshell.zig - for subshell commands
  2. Adding cancelChunksWithRawPtr() to IOWriter to support canceling chunks by raw pointer address, needed for the Builtin case where we have a generic pointer to the impl union field.

Test plan

  • Added regression test test/regression/issue/26660.test.ts that spawns many concurrent shell commands
  • Verified existing shell tests pass
  • Build compiles successfully

Closes #26660

🤖 Generated with Claude Code

When shell commands (builtins, Cmd, Pipeline, etc.) complete and are freed,
they may still have pending chunks in the IOWriter queue. If a write callback
fires after the command is freed, it would try to call onIOWriterChunk on
freed memory, causing a use-after-free crash (typically at address 0x0).

This fixes the issue by canceling all pending IOWriter chunks that reference
a command before freeing it. The fix adds cancelPendingChunks() calls to:
- Builtin.deinit() - for all shell builtins (echo, cd, ls, etc.)
- Cmd.deinit() - for external commands and builtin wrappers
- Pipeline.deinit() - for pipeline commands
- CondExpr.deinit() - for conditional expressions
- Subshell.deinit() - for subshell commands

Also adds cancelChunksWithRawPtr() to IOWriter to support canceling
chunks by raw pointer address, needed for the Builtin case where we
have a generic pointer to the impl union field.

Closes #26660

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@robobun

robobun commented Feb 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:05 AM PT - Feb 1st, 2026

❌ Your commit aa3b023a has 3 failures in Build #36339 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 26662

That installs a local version of the PR into your bun-26662 executable, so you can run:

bun-26662 --bun

@github-actions github-actions Bot added the claude label Feb 1, 2026
@coderabbitai

coderabbitai Bot commented Feb 1, 2026 •

Copy link
Copy Markdown
Contributor

Walkthrough

Adds raw-pointer-based cancellation to IOWriter and calls to cancel pending IOWriter chunks from deinit across builtins and several shell state types to avoid use-after-free; adds a concurrency regression test exercising many concurrent shell commands.

Changes

Cohort / File(s) Summary
Core IOWriter Enhancement
src/shell/IOWriter.zig
Added cancelChunksWithRawPtr(this: *IOWriter, actual_ptr: usize) void; existing cancelChunks() now forwards to this helper to support cancellation using raw pointers.
Shell Builtin Cleanup
src/shell/Builtin.zig
Introduced cancelPendingChunks() and call it from deinit to cancel any pending IOWriter chunks referencing the builtin before freeing.
Shell State Object Cleanup
src/shell/states/Cmd.zig, src/shell/states/CondExpr.zig, src/shell/states/Pipeline.zig, src/shell/states/Subshell.zig
Each file gains a cancelPendingChunks() helper and calls it from deinit to cancel stdout/stderr IOWriter chunks that reference the state object prior to teardown.
Concurrency Regression Test
test/regression/issue/26660.test.ts
Added a test suite (five tests) that spawns many concurrent shell commands covering heavy concurrency, output-heavy and mixed builtin scenarios; asserts no panics/segfaults, expected stdout, and zero exit codes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely describes the main change: preventing use-after-free in IOWriter when commands are freed.
Description check ✅ Passed The PR description provides a comprehensive explanation covering the problem, solution, and test plan, matching the template requirements well.
Linked Issues check ✅ Passed The PR successfully addresses the use-after-free issue #26660 by adding cancelPendingChunks() to all affected command types and cancelChunksWithRawPtr() to IOWriter, plus a regression test.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the use-after-free bug: IOWriter cancellation mechanism and its integration into all affected shell command types.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@test/regression/issue/26660.test.ts`:
- Around line 26-39: Add a negative spawn case in the same test to exercise
error paths: spawn another process (using Bun.spawn like the existing proc) that
runs a short script which either throws or calls process.exit(1), then await its
stdout/stderr and proc.exited (use a different variable name than proc to avoid
clobbering), and assert that the returned exitCode is non‑zero and stderr
contains an error indicator (e.g., "Error" or the thrown message); ensure you
mirror the existing pattern (Bun.spawn, .stdout.text(), .stderr.text(), .exited)
so the failure path is covered alongside the current success assertions.
- Around line 10-32: Replace the tempDir-based single-file test with an inline
script using the -e flag: remove the tempDir call that creates "test.ts"
(tempDir("issue-26660", { "test.ts": `...` })) and instead pass the script
content directly to Bun via Bun.spawn's cmd by replacing [bunExe(), "run",
"test.ts"] with [bunExe(), "run", "-e", "<script>"] (keeping the same script
body used in the temp file). Update the Bun.spawn call that sets
cwd/env/stderr/stdout accordingly and apply the same refactor to the other two
tests that use tempDir for single-file scripts.
- Around line 36-37: Remove the forbidden stderr string-absence assertions by
deleting the two lines that assert expect(stderr).not.toContain("Segmentation
fault") and expect(stderr).not.toContain("panic") from the test (leave the rest
of the test logic intact); rely on the existing exitCode and expected stdout
assertions instead to determine success/failure and do not add any other
negative substring checks on stderr in this test (file:
test/regression/issue/26660.test.ts).

Comment thread test/regression/issue/26660.test.ts Outdated
Comment on lines +26 to +39
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "test.ts"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stderr).not.toContain("Segmentation fault");
expect(stderr).not.toContain("panic");
expect(stdout).toContain("All shell commands completed successfully");
expect(exitCode).toBe(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add an explicit failing-spawn case to cover error paths.
Right now only the success path is exercised. Add a small negative-case spawn (e.g., process.exit(1) or a throw) and assert non‑zero exitCode + stderr.

As per coding guidelines: Always check exit codes and test error scenarios when spawning processes in tests.

🤖 Prompt for AI Agents
In `@test/regression/issue/26660.test.ts` around lines 26 - 39, Add a negative
spawn case in the same test to exercise error paths: spawn another process
(using Bun.spawn like the existing proc) that runs a short script which either
throws or calls process.exit(1), then await its stdout/stderr and proc.exited
(use a different variable name than proc to avoid clobbering), and assert that
the returned exitCode is non‑zero and stderr contains an error indicator (e.g.,
"Error" or the thrown message); ensure you mirror the existing pattern
(Bun.spawn, .stdout.text(), .stderr.text(), .exited) so the failure path is
covered alongside the current success assertions.

Comment thread test/regression/issue/26660.test.ts Outdated
- Replace tempDir-based tests with inline scripts using -e flag
- Remove forbidden stderr string-absence assertions
- Add error path test case for failing commands

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Issue reported in GitHub Issue #26625 persists despite @robobun's merged PR.

2 participants