Skip to content
Open
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
30 changes: 30 additions & 0 deletions src/shell/interpreter.zig
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,17 @@ pub const Interpreter = struct {
exit_code: ?ExitCode = 0,
this_jsvalue: JSValue = .zero,

/// Tracks which resources have been cleaned up to avoid double-free.
/// When the interpreter finishes normally via deinitAfterJSRun, it cleans up
/// the runtime resources (IO, shell env) and sets this to mark them as freed.
/// The finalizer then only cleans up what remains (buffered IO, args, interpreter itself).
cleanup_state: enum {
/// Nothing has been cleaned up yet
needs_full_cleanup,
/// Runtime resources (IO, shell env) have been cleaned up, only buffered IO + args remain
runtime_cleaned,
} = .needs_full_cleanup,
Comment on lines +283 to +292
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider using # prefix for private field.

The cleanup_state field is an internal implementation detail used only by cleanup methods (deinitAfterJSRun, deinitFromFinalizer). Per coding guidelines for src/**/*.zig, private fields should use the # prefix.

♻️ Suggested change
-    cleanup_state: enum {
+    #cleanup_state: enum {
         /// Nothing has been cleaned up yet
         needs_full_cleanup,
         /// Runtime resources (IO, shell env) have been cleaned up, only buffered IO + args remain
         runtime_cleaned,
     } = .needs_full_cleanup,

Note: This would also require updating references in deinitAfterJSRun and deinitFromFinalizer to use this.#cleanup_state.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @src/shell/interpreter.zig around lines 283 - 292, Rename the internal field
cleanup_state on the interpreter struct to use the private-field prefix
(#cleanup_state) and update all references to it in deinitAfterJSRun and
deinitFromFinalizer (and any other methods that touch cleanup_state) to use
this.#cleanup_state; ensure the enum declaration remains the same but is bound
to the new #cleanup_state identifier and adjust any pattern matches or
assignments to reference the renamed field.


__alloc_scope: if (bun.Environment.enableAllocScopes) bun.AllocationScope else void,
estimated_size_for_gc: usize = 0,

Expand Down Expand Up @@ -1225,15 +1236,34 @@ pub const Interpreter = struct {
this.keep_alive.disable();
this.root_shell.deinitImpl(false, false);
this.this_jsvalue = .zero;
// Mark that we've cleaned up runtime resources, so the finalizer knows
// it only needs to clean up buffered IO, args, and the interpreter itself
this.cleanup_state = .runtime_cleaned;
}

fn deinitFromFinalizer(this: *ThisInterpreter) void {
log("Interpreter(0x{x}) deinitFromFinalizer (cleanup_state={s})", .{ @intFromPtr(this), @tagName(this.cleanup_state) });

switch (this.cleanup_state) {
.needs_full_cleanup => {
// The interpreter never finished normally, so we need to clean up everything
this.root_io.deref();
this.root_shell.deinitImpl(false, false);
},
.runtime_cleaned => {
// deinitAfterJSRun already cleaned up IO and shell env, nothing more to do here
},
}

// Clean up the buffered IO that was not freed by deinitImpl
// (deinitImpl is called with free_buffered_io=false)
if (this.root_shell._buffered_stderr == .owned) {
this.root_shell._buffered_stderr.owned.deinit(bun.default_allocator);
}
if (this.root_shell._buffered_stdout == .owned) {
this.root_shell._buffered_stdout.owned.deinit(bun.default_allocator);
}

this.this_jsvalue = .zero;
this.args.deinit();
this.allocator.destroy(this);
Expand Down
76 changes: 76 additions & 0 deletions test/regression/issue-shell-double-free.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { $ } from "bun";
import { expect, test } from "bun:test";

// Regression test for shell interpreter double-free issue
// The bug occurred when the GC finalizer tried to free memory that was
// already partially freed when the shell finished execution.
// This was particularly reproducible on Windows.
//
// The issue was that deinitAfterJSRun() would partially deinitialize
// the interpreter, and then deinitFromFinalizer() would try to free
// resources that were already freed or in an inconsistent state.
//
// NOTE: This bug is non-deterministic and may not always crash, even
// without the fix. The test serves to verify the fix doesn't break
// normal operation and documents the usage pattern that triggered the bug.

Comment on lines +1 to +16
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

File should be renamed to follow regression test naming convention.

Per coding guidelines, regression tests for specific GitHub issues should be placed in test/regression/issue/${issueNumber}.test.ts. Since this test is for issue #23177, it should be renamed.

#!/bin/bash
# Verify the issue directory exists and check naming conventions
if [ -d "test/regression/issue" ]; then
  echo "test/regression/issue directory exists"
  ls -la test/regression/issue/ 2>/dev/null | head -20
else
  echo "test/regression/issue directory does not exist"
  ls -la test/regression/ 2>/dev/null | head -20
fi
🤖 Prompt for AI Agents
In @test/regression/issue-shell-double-free.test.ts around lines 1 - 16, Rename
the test file issue-shell-double-free.test.ts to issue-23177.test.ts and move it
into the regression issue tests directory (create that directory if it doesn't
exist), then update any references or imports that point to the old filename so
CI and test discovery still find the test.

test("shell interpreter should handle GC during concurrent execution", async () => {
// This test mimics the opencode usage pattern where shell commands
// are used to resolve paths during permission checks
const promises = [];

for (let i = 0; i < 50; i++) {
// Similar to the opencode pattern: $`realpath ${arg}`.quiet().nothrow().text()
promises.push($`echo "/tmp/test${i}"`.quiet().nothrow().text());
}

const results = await Promise.all(promises);
expect(results.length).toBe(50);

// Multiple GC passes to trigger finalizers
// The bug would manifest as a segfault during finalization
for (let i = 0; i < 5; i++) {
Bun.gc(true);
await Bun.sleep(1);
}
});

test("shell interpreter sequential with frequent GC", async () => {
// Sequential execution with GC after each command
// increases pressure on the finalizer
for (let i = 0; i < 100; i++) {
const result = await $`echo "test ${i}"`.quiet().nothrow().text();
expect(result.trim()).toBe(`test ${i}`);

// Force GC frequently to trigger finalizers while
// other interpreters might still be finishing
if (i % 5 === 0) {
Bun.gc(true);
}
}

// Final GC pass
Bun.gc(true);
});

test("shell interpreter error handling with GC", async () => {
// Test that error paths also properly clean up
const promises = [];

for (let i = 0; i < 20; i++) {
// Mix of successful and potentially failing commands
if (i % 3 === 0) {
promises.push($`echo "success ${i}"`.quiet().nothrow().text());
} else {
promises.push($`echo "test ${i}"`.quiet().text());
}
}

await Promise.all(promises);

// GC should not crash even with mixed success/error states
for (let i = 0; i < 3; i++) {
Bun.gc(true);
await Bun.sleep(1);
}
});
Comment on lines +56 to +76
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Test doesn't actually exercise error paths as intended.

Both command variants (echo "success ${i}" and echo "test ${i}") will succeed. The .nothrow() modifier only affects how non-zero exit codes are handled, but echo always exits with 0. To test error handling with GC, consider using commands that actually fail:

♻️ Suggested improvement
 test("shell interpreter error handling with GC", async () => {
   // Test that error paths also properly clean up
   const promises = [];

   for (let i = 0; i < 20; i++) {
     // Mix of successful and potentially failing commands
     if (i % 3 === 0) {
       promises.push($`echo "success ${i}"`.quiet().nothrow().text());
     } else {
-      promises.push($`echo "test ${i}"`.quiet().text());
+      // Use a failing command with nothrow to test error cleanup
+      promises.push($`exit 1`.quiet().nothrow().text());
     }
   }

   await Promise.all(promises);

   // GC should not crash even with mixed success/error states
   for (let i = 0; i < 3; i++) {
     Bun.gc(true);
     await Bun.sleep(1);
   }
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("shell interpreter error handling with GC", async () => {
// Test that error paths also properly clean up
const promises = [];
for (let i = 0; i < 20; i++) {
// Mix of successful and potentially failing commands
if (i % 3 === 0) {
promises.push($`echo "success ${i}"`.quiet().nothrow().text());
} else {
promises.push($`echo "test ${i}"`.quiet().text());
}
}
await Promise.all(promises);
// GC should not crash even with mixed success/error states
for (let i = 0; i < 3; i++) {
Bun.gc(true);
await Bun.sleep(1);
}
});
test("shell interpreter error handling with GC", async () => {
// Test that error paths also properly clean up
const promises = [];
for (let i = 0; i < 20; i++) {
// Mix of successful and potentially failing commands
if (i % 3 === 0) {
promises.push($`echo "success ${i}"`.quiet().nothrow().text());
} else {
// Use a failing command with nothrow to test error cleanup
promises.push($`exit 1`.quiet().nothrow().text());
}
}
await Promise.all(promises);
// GC should not crash even with mixed success/error states
for (let i = 0; i < 3; i++) {
Bun.gc(true);
await Bun.sleep(1);
}
});