Free a native microtask's context when the microtask queue discards it

JA
2mo ago
+263-55

What does this PR do?

Fixes a leak of a native microtask's context when the VM is torn down before the microtask runs.

What leaks. JSC__JSGlobalObject__queueMicrotaskCallback packed the context pointer and its callback into the QueuedTask as two doubles. When teardown clears the microtask queue without running it (GlobalObject::forbidExecution — process exit under BUN_DESTRUCT_VM_ON_EXIT, a worker exiting or being terminated), nothing owns that context any more and it is never freed.

When it shows. The WebSocket client delivers handshake-overflow bytes (the first frame arriving in the same read as the 101 response — e.g. a Bun.serve peer that ws.send()s in open) through such a microtask. A script that calls process.exit() — or a worker that is terminated — from inside onopen tears the VM down while that task is still queued, and LeakSanitizer reports:

Direct leak of 40 byte(s) in 1 object(s) allocated from:
    #10 ... Box<bun_http_jsc::websocket_client::InitialDataTask<false>>::new
    #11 ... WebSocket<false>::finish_init  src/http_jsc/websocket_client.rs
    #14 ... WebCore::WebSocket::didConnect

This is what the x64-asan lane flagged in test/js/web/websocket/websocket-proxy.test.ts (the NO_PROXY cases, whose child does exactly ws.onopen = () => { ws.close(); process.exit(0) }). v8::Isolate::RequestInterrupt used the same entry point and leaked its request the same way.

How it is fixed. The trampoline's argument is now a small GC cell, Bun::NativeMicrotaskContext { ctx, run, drop }, so the queue owns the context like any other microtask argument. The trampoline takes ctx out and calls run; if the task is discarded instead, the cell is collected (or destroyed with the VM) and its destructor calls drop. JSGlobalObject::queue_microtask_boxed supplies a drop that frees the Box, RequestInterrupt one that deletes its request. On the WebSocket side, Drop now only detaches the task's back-reference, and InitialDataTask clears the client's handle to it when it is dropped unrun; websocket_client.rs stays free of unsafe.

How did you verify your code works?

New test/js/web/websocket/websocket-client-initial-data-teardown-leak.test.ts: a server that sends in open, and a child that (a) process.exit(0)s from onopen, (b) terminate()s a worker that is inside onopen, both with BUN_DESTRUCT_VM_ON_EXIT=1 and detect_leaks=1. On an ASAN debug build of main both cases fail with the report above; with this change both pass (the LeakSanitizer assertion is gated on isASAN; elsewhere the test asserts a clean exit). strace confirms the 101 and the first frame arrive in one recvfrom in this setup.

Also ran on the ASAN debug build: websocket-client, websocket-client-short-read, websocket-close-connecting, websocket-close-async-dispatch, websocket-proxy-close-reentrancy, websocket-proxy-tunnel-{client,upgrade}-leak, websocket-permessage-deflate, error-event, websocket.test.js, websocket-proxy.test.ts -t NO_PROXY under the CI LSAN env (3×), test/bake/deinitialization.test.ts, test/v8/v8.test.ts, worker_threads.test.ts, worker-terminate-funnels, worker-shutdown-post-leak, worker_destruction, worker.test.ts, worker-terminate-lifetime. The only failures (worker.test.ts "message flood" and one node_fs_binding allocation in worker-terminate-lifetime) reproduce identically on an unmodified debug build of main.