diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index a20749bef690..13d71e7be485 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -933,9 +933,12 @@ static struct sigaction previous_actions[NSIG]; M(SIGIO); #if OS(LINUX) +// SIGPWR is intentionally excluded: JSC uses it for GC thread suspend/resume +// (see wtf/posix/ThreadingPOSIX.cpp). Overriding it here breaks GC and the +// SA_RESETHAND disposition leaves it at SIG_DFL after one delivery, which +// kills the process on the next collection. #define FOR_EACH_LINUX_ONLY_SIGNAL(M) \ M(SIGPOLL); \ - M(SIGPWR); \ M(SIGSTKFLT); #endif diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 4c66b633277c..97ba56e934c0 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -1050,14 +1050,16 @@ pub fn open_in_editor(global_this: &JSGlobalObject, callframe: &CallFrame) -> Js bstr::BStr::new(sliced.slice()), ))); } else if edit.name.as_ptr() == edit.path.as_ptr() { - // detect_editor pointed `name` at `path`'s storage - // (process-lifetime dirname_store); dupe into our - // owned buffer so a later `path` overwrite doesn't - // dangling-alias it. - slot.name_storage = edit.path.to_vec(); - // SAFETY: see above. - edit.name = - unsafe { bun_ptr::detach_lifetime(slot.name_storage.as_slice()) }; + // `detect_editor` aliased `path` to `name` (absolute + // editor path). `name` is backed by `slot.name_storage`, + // which a later call may drop while the detached editor + // thread is still reading argv[0]. Give `path` + // process-lifetime storage, matching every other + // `detect_editor` branch. + edit.path = bun_resolver::fs::FileSystem::instance() + .dirname_store + .append_slice(edit.path) + .expect("unreachable"); } } } diff --git a/test/js/bun/util/open-in-editor-gc.test.ts b/test/js/bun/util/open-in-editor-gc.test.ts new file mode 100644 index 000000000000..d63a41ee700b --- /dev/null +++ b/test/js/bun/util/open-in-editor-gc.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isLinux, tempDir } from "harness"; +import { existsSync, symlinkSync } from "node:fs"; +import { join } from "node:path"; + +// On Linux, JSC uses SIGPWR to suspend/resume threads for GC and the libpas +// scavenger. Bun.openInEditor spawns a detached thread that goes through +// bun.spawnSync, whose signal-forwarding setup must not touch SIGPWR or the +// process is terminated the next time GC/scavenger fires. +test.skipIf(!isLinux)("Bun.openInEditor does not break GC signal handling", async () => { + const sleep = ["/usr/bin/sleep", "/bin/sleep"].find(p => existsSync(p)); + expect(sleep).toBeDefined(); + + using dir = tempDir("open-in-editor-gc", { + "run.js": ` + const a = ${JSON.stringify(sleep)}; + const b = process.argv[2]; + // Alternate absolute editor paths so the cached editor name_storage is + // replaced each call while a detached editor thread may still be + // reading the previous one. + for (let i = 0; i < 8; i++) { + try { Bun.openInEditor("0.3", { editor: i % 2 ? b : a }); } catch {} + } + // Wait for the detached editor threads to complete their register / + // unregister cycle, then for the scavenger to fire SIGPWR. + await Bun.sleep(1000); + Bun.gc(true); + console.log("alive"); + `, + }); + // Second absolute path to the same binary so alternating calls take the + // `!eql_long(prev_name, ...)` branch in open_in_editor. Keep the basename + // `sleep` so BusyBox (Alpine) resolves the multi-call applet from argv[0]. + const sleep2 = join(String(dir), "sleep"); + symlinkSync(sleep!, sleep2); + + const runs = Array.from({ length: 5 }, async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.js", sleep2], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("alive"); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(0); + }); + + await Promise.all(runs); +});