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
49 changes: 46 additions & 3 deletions packages/bun-usockets/src/eventing/libuv.c
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,21 @@ void us_loop_pump(struct us_loop_t *loop) {
uv_run(loop->uv_loop, UV_RUN_NOWAIT);
}

/* Same semantics as the deprecated uv_loop_new(): NULL on failure. Allocated
* with our own malloc so us_loop_free can pair it with free(); uv_loop_new()
* uses libuv's replaceable allocator, which Bun swaps out for mimalloc. */
static uv_loop_t *create_uv_loop(void) {
uv_loop_t *uv_loop = (uv_loop_t *)malloc(sizeof(uv_loop_t));
if (!uv_loop) {
return 0;
}
if (uv_loop_init(uv_loop)) {
free(uv_loop);
return 0;
}
return uv_loop;
}

struct us_loop_t *us_create_loop(void *hint,
void (*wakeup_cb)(struct us_loop_t *loop),
void (*pre_cb)(struct us_loop_t *loop),
Expand All @@ -159,7 +174,7 @@ struct us_loop_t *us_create_loop(void *hint,
struct us_loop_t *loop =
(struct us_loop_t *)calloc(1, sizeof(struct us_loop_t) + ext_size);

loop->uv_loop = hint ? hint : uv_loop_new();
loop->uv_loop = hint ? hint : create_uv_loop();
loop->is_default = hint != 0;

loop->uv_pre = malloc(sizeof(uv_prepare_t));
Expand All @@ -185,6 +200,16 @@ struct us_loop_t *us_create_loop(void *hint,
return loop;
}

/* uv_walk callback for us_loop_free: close every handle still registered on
* the loop. Their owners are gone by the time us_loop_free runs, so there is
* no close callback to invoke. uv_walk already skips libuv-internal handles. */
static void close_walk_cb(uv_handle_t *handle, void *arg) {
(void)arg;
if (!uv_is_closing(handle)) {
uv_close(handle, 0);
}
}

// based on if this was default loop or not
void us_loop_free(struct us_loop_t *loop) {
// ref and close down prepare and check
Expand All @@ -203,8 +228,26 @@ void us_loop_free(struct us_loop_t *loop) {
// we need to run the loop one last round to call all close callbacks
// we cannot do this if we do not own the loop, default
if (!loop->is_default) {
uv_run(loop->uv_loop, UV_RUN_NOWAIT);
uv_loop_delete(loop->uv_loop);
uv_loop_t *uv_loop = loop->uv_loop;
/* A handle nothing ever closes would keep the loop registered in libuv
* forever; everything else is already closing and only needs uv_run. */
uv_walk(uv_loop, close_walk_cb, 0);
/* uv_run retires closing handles as their in-flight IOCP completions
* arrive; with every handle closed it drains to empty and returns 0. It
* returns nonzero only when uv_stop cut it short, so re-enter. */
while (uv_run(uv_loop, UV_RUN_DEFAULT)) {
}
int close_rc = uv_loop_close(uv_loop);
/* The loop is empty here so the close cannot return UV_EBUSY. Succeeding
* is what removes the loop from libuv's global uv__loops[] registry,
* which uv__wake_all_loops() walks on every Windows suspend/resume. */
#if ASSERT_ENABLED
if (close_rc != 0) {
BUN_PANIC("us_loop_free: uv_loop_close failed after draining the loop");
}
#endif
(void)close_rc;
free(uv_loop);
}

// now we can free our part
Expand Down
18 changes: 11 additions & 7 deletions src/libuv_sys/libuv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,15 +437,19 @@ impl Loop {
}
// SAFETY: `loop_` is the live per-thread loop initialized in `get()`.
if let Some(err) = unsafe { uv_loop_close(loop_) }.raw_errno() {
// Only EBUSY means handles are
// still open; walk + close them, run once to flush close
// callbacks, then close again (must succeed). `uv_loop_close`
// documents no other failure code.
// Only EBUSY means handles are still open; walk + close them,
// drain, then close again. `uv_loop_close` documents no other
// failure code.
if err == (UV_EBUSY as c_int).unsigned_abs() as u16 {
unsafe { uv_walk(loop_, Some(close_walk_cb), ptr::null_mut()) };
let _ = unsafe { uv_run(loop_, RunMode::Default) };
// NOTE the call is unconditional — the close must run in
// release builds too.
// `uv_run(Default)` returns 0 once the loop is dead and
// nonzero only when `uv_stop` cut it short, so re-enter;
// with every handle closed above it must drain to empty.
// SAFETY: `loop_` is the live per-thread loop.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
while unsafe { uv_run(loop_, RunMode::Default) } != 0 {}
// Unconditional (not debug-only): removing the loop from
// libuv's global uv__loops[] registry is what makes it safe
// to release the TLS storage when this thread exits.
let rc = unsafe { uv_loop_close(loop_) };
debug_assert_eq!(rc, ReturnCode::ZERO);
}
Expand Down
64 changes: 63 additions & 1 deletion test/js/bun/spawn/spawn-many-teardown.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";

// Regression coverage for a Windows-only panic: integer overflow in
// uv.Loop.active_handles during process teardown when a large number of
Expand Down Expand Up @@ -61,3 +61,65 @@ test("tearing down hundreds of spawned subprocesses at exit does not overflow th
// surface as a non-zero exit code.
expect(exitCode).toBe(0);
}, 120_000);

// https://github.com/oven-sh/bun/issues/28175
// Exercises us_loop_free on the per-VM spawnSync libuv loop at Worker teardown
// under the debug build, which aborts the child (BUN_PANIC) if the loop drain
// leaves uv_loop_close() unable to deregister the loop from uv__loops[].
test.skipIf(!isWindows)(
"the per-worker spawnSync libuv loop is fully drained and closed at worker teardown",
async () => {
using dir = tempDir("spawnsync-uvloop-teardown", {
"worker.mjs": /* js */ `
import { parentPort } from "node:worker_threads";
const exe = process.execPath;
// An extra "pipe" stdio entry lands a uv_pipe_t on the spawn-sync loop
// that nothing reads; finalizeStreams only uv_close()s it, so its close
// callback is still pending when the worker tears down.
Bun.spawnSync({ cmd: [exe, "-e", "1"], stdio: ["ignore", "pipe", "pipe", "pipe"] });
// A stdin write the child never drains plus a timeout, so the timeout
// timer (whose callback calls uv_stop on the loop) and the writer are
// both live late into the teardown.
Bun.spawnSync({
cmd: [exe, "-e", "Bun.sleepSync(60_000)"],
timeout: 25,
stdin: Buffer.alloc(1 << 20, 66),
stdout: "pipe",
stderr: "pipe",
});
parentPort.postMessage("done");
`,
"main.mjs": /* js */ `
import { Worker } from "node:worker_threads";
for (let i = 0; i < 6; i++) {
const worker = new Worker(new URL("./worker.mjs", import.meta.url));
const { promise, resolve, reject } = Promise.withResolvers();
worker.on("message", resolve);
worker.on("error", reject);
worker.on("exit", code => reject(new Error("worker exited before posting: " + code)));
await promise;
await worker.terminate();
}
console.log("OK");
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "main.mjs"],
env: bunEnv,
cwd: String(dir),
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});

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

if (exitCode !== 0) {
console.error(stderr);
}
expect(stdout.trim()).toBe("OK");
expect(exitCode).toBe(0);
},
120_000,
);
Loading