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
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 3 additions & 6 deletions src/jsc/bindings/NoOrphansTracker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
// birth records its uniqueid — is narrowed by the freeze-then-rescan loop in
// `killTracked()` but cannot be fully closed from userspace.
//
// All state is process-global; spawnSync is single-threaded by design (see
// Bun__currentSyncPID), so no locking.
// All state is process-global; this is only reached from the thread that
// armed the parent-death watchdog (pdeathsig::is_arming_thread), so no locking.
Comment on lines +29 to +30

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Pre-existing header comment; the edit here only corrects a cross-reference that this PR made stale (it pointed at the Bun__currentSyncPID note, which now says the opposite). It documents why the file has no locking, which matters more now that the sibling signal-forwarding code does.


#include "root.h"

Expand Down Expand Up @@ -64,10 +64,7 @@ static_assert(sizeof(ProcUniqIdentifierInfo) == 56, "xnu ABI");

class NoOrphansTracker {
public:
// Function-local static: lazy first-use construction, no global ctor,
// thread-safe per C++11 [stmt.dcl]. spawnSync is single-threaded anyway
// (see Bun__currentSyncPID), but this keeps the binary's static-init
// section clean.
// Function-local static keeps this out of the binary's static-init section.
static NoOrphansTracker& get()
{
static NoOrphansTracker instance;
Expand Down
13 changes: 12 additions & 1 deletion src/jsc/bindings/c-bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -937,11 +937,14 @@ extern "C" int ffi_fileno(FILE* file)
#if OS(LINUX) || OS(DARWIN) || OS(FREEBSD)
#include <signal.h>
#include <pthread.h>
#include <wtf/Lock.h>

// Note: We only ever use bun.spawnSync on the main thread.
// Bun.openInEditor runs spawnSync on detached threads, so register/unregister can overlap.
Comment thread
claude[bot] marked this conversation as resolved.
extern "C" int64_t Bun__currentSyncPID = 0;
static int Bun__pendingSignalToSend = 0;
static struct sigaction previous_actions[NSIG];
static WTF::Lock signalForwardingLock;
static int signalForwardingDepth = 0;

// npm's signal list minus SIGIOT/SIGPOLL (aliases of SIGABRT/SIGIO; listing both would overwrite previous_actions[N]).
// https://github.com/npm/cli/blob/fefd509992a05c2dfddbe7bc46931c42f1da69d7/workspaces/arborist/lib/signals.js#L26-L57
Expand Down Expand Up @@ -1004,6 +1007,10 @@ extern "C" void Bun__sendPendingSignalIfNecessary()

extern "C" void Bun__registerSignalsForForwarding()
{
WTF::Locker<WTF::Lock> locker(signalForwardingLock);
if (signalForwardingDepth++ != 0)
return;

Bun__pendingSignalToSend = 0;
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
Expand Down Expand Up @@ -1032,6 +1039,10 @@ extern "C" void Bun__unregisterSignalsForForwarding()
{
Bun__currentSyncPID = 0;

WTF::Locker<WTF::Lock> locker(signalForwardingLock);
if (--signalForwardingDepth != 0)
return;

#define UNREGISTER_SIGNAL(SIG) \
if (sigaction(SIG, &previous_actions[SIG], NULL) == -1) { \
}
Expand Down
1 change: 0 additions & 1 deletion src/spawn/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ scopeguard.workspace = true
bun_analytics.workspace = true
bun_dispatch.workspace = true
bun_core.workspace = true
bun_crash_handler.workspace = true
bun_event_loop.workspace = true
bun_io.workspace = true
bun_output.workspace = true
Expand Down
4 changes: 2 additions & 2 deletions src/spawn/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
//! `bun_runtime` re-exports them. The only non-leaf dependencies are
//! `bun_io` (`FilePoll`/`KeepAlive`/`EventLoopCtx`), `bun_ptr`
//! (`ThreadSafeRefCount`), `bun_io` (`BufferedWriter`), `bun_event_loop`,
//! `bun_threading`, and `bun_crash_handler` — none of which depend back on
//! this crate, so no cycle.
//! and `bun_threading` — none of which depend back on this crate, so no
//! cycle.
Comment on lines +12 to +13

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is the pre-existing crate doc listing bun_spawn's dependencies; the only change here is removing bun_crash_handler from that list since c1d46cb drops the dependency. Leaving the rest as-is.


use core::ffi::c_char;

Expand Down
4 changes: 1 addition & 3 deletions src/spawn/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2853,8 +2853,7 @@ mod spawn_process_body {
};

/// RAII guard around `Bun__registerSignalsForForwarding`: registers on
/// construction, unregisters and restores the crash-handler signal
/// disposition on drop.
/// construction, unregisters on drop.
#[cfg(unix)]
struct SignalForwarding;
#[cfg(unix)]
Expand All @@ -2869,7 +2868,6 @@ mod spawn_process_body {
impl Drop for SignalForwarding {
fn drop(&mut self) {
Bun__unregisterSignalsForForwarding();
bun_crash_handler::reset_on_posix();
}
Comment thread
claude[bot] marked this conversation as resolved.
}

Expand Down
51 changes: 51 additions & 0 deletions test/js/bun/util/open-in-editor-gc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,54 @@ test.skipIf(!isLinux)("Bun.openInEditor does not break GC signal handling", asyn

await Promise.all(runs);
});

// Each detached editor thread goes through bun.spawnSync's signal-forwarding
// register/unregister, which swaps every forwarded signal's disposition in a
// process-global table. With many threads overlapping, an unsynchronized
// table ends up restoring SIG_DFL (or the forwarding handler itself) instead
// of the handler that was installed before the burst, so a JS signal
// listener registered beforehand silently stops working or kills the process.
test.skipIf(!isLinux)("Bun.openInEditor bursts do not drop previously installed signal handlers", async () => {
const sleep = ["/usr/bin/sleep", "/bin/sleep"].find(p => existsSync(p));
expect(sleep).toBeDefined();

using dir = tempDir("open-in-editor-signal-table", {
"run.js": `
let fired = false;
process.on("SIGUSR2", () => { fired = true; });

let spawned = 0;
for (let i = 0; i < 64; i++) {
try { Bun.openInEditor("0.1", { editor: ${JSON.stringify(sleep)} }); spawned++; } catch {}
}
if (spawned === 0) { console.log("no editor threads spawned"); process.exit(2); }

// The editor children each run for 100ms, so by now the burst has
// started and the forwarding handler owns SIGUSR2; the original handler
// only comes back once the last thread unregisters. Keep delivering
// until it does (or the table was corrupted and it never does).
await Bun.sleep(50);
const deadline = Date.now() + 3000;
while (!fired && Date.now() < deadline) {
process.kill(process.pid, "SIGUSR2");
await Bun.sleep(20);
}
Comment thread
claude[bot] marked this conversation as resolved.
console.log(fired ? "ok" : "handler lost");
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "run.js"],
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("ok");
expect(proc.signalCode).toBeNull();
expect(exitCode).toBe(0);
});
Loading