From 600a190aceda185eb8a2dc153287f679f4c24ca5 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Wed, 11 Feb 2026 22:24:32 +0000 Subject: [PATCH] fix(shell): use-after-free in interpreter when setupIOBeforeRun fails (#26919) When `setupIOBeforeRun()` fails in `runFromJS()` (e.g., stdout/stderr closed on Windows), the error path called `#deinitFromExec()` which freed the interpreter struct via `allocator.destroy(this)`. The GC would later finalize the JSShellInterpreter wrapper, accessing the already-freed memory and causing a segfault. Fix: use `#derefRootShellAndIOIfNeeded(true)` instead, which cleans up runtime resources (IO, shell env) and sets `cleanup_state` to `.runtime_cleaned`, deferring struct deallocation to the GC finalizer. Co-Authored-By: Claude --- src/shell/interpreter.zig | 2 +- test/regression/issue/26919.test.ts | 41 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 test/regression/issue/26919.test.ts diff --git a/src/shell/interpreter.zig b/src/shell/interpreter.zig index cc79cb8971..7ca6c2bf41 100644 --- a/src/shell/interpreter.zig +++ b/src/shell/interpreter.zig @@ -1154,7 +1154,7 @@ pub const Interpreter = struct { _ = callframe; // autofix if (this.setupIOBeforeRun().asErr()) |e| { - defer this.#deinitFromExec(); + defer this.#derefRootShellAndIOIfNeeded(true); const shellerr = bun.shell.ShellErr.newSys(e); return try throwShellErr(&shellerr, .{ .js = globalThis.bunVM().event_loop }); } diff --git a/test/regression/issue/26919.test.ts b/test/regression/issue/26919.test.ts new file mode 100644 index 0000000000..a1e3a5a40b --- /dev/null +++ b/test/regression/issue/26919.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// Regression test for https://github.com/oven-sh/bun/issues/26919 +// When setupIOBeforeRun() fails in runFromJS (e.g., because stdout is closed), +// the error path used to call #deinitFromExec() which freed the interpreter struct. +// The GC would later finalize the already-freed JSShellInterpreter wrapper, +// causing a use-after-free / segfault. +test("issue #26919 - shell interpreter should not segfault when stdout is closed", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const fs = require("fs"); + // Close stdout so that the shell interpreter's setupIOBeforeRun() will fail + // when it tries to dup() stdout. + fs.closeSync(1); + try { + // This should throw an error (not segfault) because stdout is closed + await Bun.$\`echo hello\`; + } catch (e) { + // Write to stderr since stdout is closed + fs.writeSync(2, "caught: " + e.constructor.name + "\\n"); + } + // Force GC to run - this would trigger the use-after-free crash before the fix + Bun.gc(true); + fs.writeSync(2, "done\\n"); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The process should not crash (segfault would give non-zero exit and no "done" message) + expect(stderr).toContain("done"); + expect(exitCode).toBe(0); +});