Add process.on('memoryPressure') event#32594
Conversation
Exposes OS-level low-memory notifications to JS so callers can release caches or reap idle resources without polling. macOS: EVFILT_MEMORYSTATUS on the main kqueue (same filter libdispatch's DISPATCH_SOURCE_TYPE_MEMORYPRESSURE uses). Level is 'warning' or 'critical' based on the NOTE_MEMORYSTATUS_PRESSURE_* fflags. Linux: PSI trigger on /proc/pressure/memory (falls back to the cgroup v2 memory.pressure file), polled via EPOLLPRI on the main loop. Gracefully no-ops when PSI is unavailable or the trigger write is rejected (no CAP_SYS_RESOURCE on kernels before 6.6). Level is always 'critical'. Windows: CreateMemoryResourceNotification(LowMemoryResourceNotification) waited on the NT thread pool via RegisterWaitForSingleObject; posts back to JS through a uv_async_t. Level is always 'critical'. The watcher is per-VM, stored in RareData, armed on the first listener and disarmed on the last removal via the same onDidChangeListeners hook signals use. It does not keep the event loop alive. FilePoll gains a MemoryPressure registration kind (EVFILT_MEMORYSTATUS on Darwin, EPOLLPRI on Linux) alongside the existing Readable/Writable/ Process/Machport kinds.
|
Updated 1:54 AM PT - Jun 23rd, 2026
❌ @autofix-ci[bot], your commit e538b50 has 4 failures in
🧪 To try this PR locally: bunx bun-pr 32594That installs a local version of the PR into your bun-32594 --bun |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds a new Changesprocess.on("memoryPressure") implementation
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not a duplicate of #31021 / #30403. Those add an internal handler that runs GC and The platform detection overlaps but the integration differs: #31021 uses libdispatch + a parked PSI thread + cross-thread task enqueue; this PR extends |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/jsc/bindings/BunProcess.cpp`:
- Around line 4332-4344: The Process__emitMemoryPressureEvent function calls
emit() which can execute user JavaScript code, but does not check for exceptions
after the call. Add an exception check immediately after the
process->wrapped().emit(ident, args) call using RETURN_IF_EXCEPTION or
appropriate JSError propagation to ensure any exceptions thrown by user code are
properly handled before returning from this native boundary crossing function.
In `@src/runtime/node/memory_pressure.rs`:
- Around line 97-100: The `paths` array is using a hard-coded
`/sys/fs/cgroup/memory.pressure` path that always points to the root cgroup, but
it should instead use the actual current cgroup path for proper fallback
behavior in nested cgroups. Read the current cgroup path from
`/proc/self/cgroup` by parsing the `0::` line for cgroup v2, and dynamically
construct the second fallback path as
`/sys/fs/cgroup/<current-cgroup>/memory.pressure`. This ensures the code
attempts to access the correct memory pressure file within the actual current
cgroup before failing, rather than silently disabling memory pressure monitoring
in containerized environments.
- Around line 391-395: The UnregisterWaitEx call in the wait block does not
check for failure. If UnregisterWaitEx returns 0 (indicating failure), the
Windows thread pool may still invoke the wait_callback with the watcher pointer
after it gets freed, creating a use-after-free vulnerability. Check the return
value of UnregisterWaitEx((*watcher).wait, INVALID_HANDLE_VALUE) and if it
returns 0, restore the watcher back to its slot and return early to keep the
watcher allocated. Only proceed with uv_close and freeing the watcher if the
unregistration succeeds (non-zero return value).
- Around line 318-333: The issue is that when uv_async_init fails in the error
handling path at line 331, the code attempts to drop an uninitialized
MemoryPressureWatcher by calling heap::take on a pointer that was cast from the
uninitialized Box type to the initialized type. This violates Rust safety
because Box::from_raw will attempt to read/drop uninitialized fields. To fix
this, preserve the original uninit pointer type by storing the result of
bun_core::heap::into_raw(Box::<MemoryPressureWatcher>::new_uninit()) in a
separate variable before casting it to the initialized pointer type. Then in the
error path when uv_async_init fails, use the uninit pointer variable with
heap::take instead of the cast initialized pointer, ensuring you drop
uninitialized data correctly.
In `@test/js/node/process/process-memory-pressure.test.ts`:
- Around line 37-55: The test does not currently verify that the native
Bun__MemoryPressure__install and Bun__MemoryPressure__uninstall functions are
actually being called, because emitMemoryPressure bypasses the OS watcher. Add
an internal-for-testing counter or state hook (similar to how emitMemoryPressure
is exposed) that tracks when the native install and uninstall functions are
called. Then modify the test to check this counter/state at key points: after
adding the first listener (process.on for listener a), after removing the last
listener (final process.off for listener b), and after re-adding a listener
(process.on again for listener a). This ensures the test fails for the right
reason if native install/uninstall hooks are not properly triggered.
- Around line 30-97: The test cases are not consistently asserting raw stderr
output from spawned processes, and some use stderr.trim() which can hide
unexpected whitespace or diagnostics. Fix this by ensuring every test that calls
run() and receives stderr (present in all five test cases: the first unnamed
test, "disarms when the last listener is removed", "process.once works",
"listener does not keep the event loop alive", and "removing on exit does not
crash") asserts the raw, untrimmed stderr value before checking exitCode.
Replace any stderr.trim() with raw stderr and add stderr assertions to tests
that currently lack them, asserting that stderr equals an empty string to ensure
no unexpected output is hidden.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: b2ef4a61-bef1-430a-9cc1-3833919aac48
📒 Files selected for processing (12)
packages/bun-types/overrides.d.tssrc/io/posix_event_loop.rssrc/js/internal-for-testing.tssrc/jsc/bindings/BunProcess.cppsrc/jsc/bindings/InternalForTesting.cppsrc/jsc/bindings/InternalForTesting.hsrc/jsc/rare_data.rssrc/runtime/dispatch.rssrc/runtime/node.rssrc/runtime/node/memory_pressure.rssrc/sys/lib.rstest/js/node/process/process-memory-pressure.test.ts
…stall-state hook Windows: the low-memory notification handle is level-triggered, so a recurring thread-pool wait would spin while the condition persists. Use WT_EXECUTEONLYONCE and a 30s uv_timer holdoff before re-arming. Also keeps the MaybeUninit pointer typed for the uv_async_init failure path and chains uv_close through both handles before freeing. Linux: build the cgroup fallback path from the 0:: line in /proc/self/cgroup instead of hard-coding /sys/fs/cgroup/memory.pressure, so nested (non-namespaced) cgroups resolve to the correct file. Tests: add isMemoryPressureWatcherInstalled() to bun:internal-for-testing so the arm/disarm test observes the native install/uninstall path directly rather than relying on listener-count filtering in the emit helper.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/node/memory_pressure.rs`:
- Around line 93-99: The fixed 256-byte buffer at the read of /proc/self/cgroup
can truncate long cgroup paths, causing own_cgroup_pressure_path() to fail
silently and disable the cgroup fallback. Replace the single fixed-size read
operation with a loop that continues reading until the entire file is consumed
or use a dynamic buffer that grows as needed, ensuring all data from the file is
captured before processing the cgroup lines. This prevents data loss when cgroup
paths exceed 256 bytes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 2c55b187-381d-47af-9bf8-a4155952d0ab
📒 Files selected for processing (5)
src/js/internal-for-testing.tssrc/jsc/bindings/InternalForTesting.cppsrc/jsc/bindings/InternalForTesting.hsrc/runtime/node/memory_pressure.rstest/js/node/process/process-memory-pressure.test.ts
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Manually tested on macOS and it works!
❯ bun-32594 ~/code/tmp/mempressure/listen-sim.ts
pid 17774: listening (rss=1.0 GiB).
In another terminal:
sudo memory_pressure -S -l warn
sudo memory_pressure -S -l critical
If "probe:" lines fire and "BUN" lines don't, it's bun's dispatch path.
[4.03s] probe: pid=17776 ballast=1024 MiB
[4.03s] probe: EVFILT_MEMORYSTATUS registered ok (r=0)
[8.96s] BUN memoryPressure: warning (#1)
[8.96s] probe: pressure warning (fflags=0x2 data=0 filter=-14)sudo memory_pressure -S -l warnimport { randomFillSync } from "node:crypto";
const start = performance.now();
const ts = () => ((performance.now() - start) / 1000).toFixed(2);
// 1 GiB ballast so xnu's candidate selection picks us (10 MB floor + footprint ranking).
// Pin to globalThis so JSC can't collect it once we stop referencing it locally.
const ballast = Buffer.allocUnsafe(1 << 30);
randomFillSync(ballast);
(globalThis as any).__ballast = ballast;
setInterval(() => {
// Touch a byte each tick so the optimizer can't prove the buffer is dead
// and the OS can't decide the pages are cold.
ballast[(Math.random() * ballast.length) | 0]++;
}, 1000).unref();
let n = 0;
process.on("memoryPressure", (level) => {
console.log(`[${ts()}s] BUN memoryPressure: ${level} (#${++n})`);
});
// Side-by-side C probe with its own kqueue + 1 GiB ballast.
const probe = Bun.spawn({
cmd: [import.meta.dir + "/probe"],
stdin: "ignore",
stdout: "ignore",
stderr: "pipe",
});
(async () => {
for await (const chunk of probe.stderr) {
for (const line of new TextDecoder().decode(chunk).split("\n")) {
if (line) console.log(`[${ts()}s] ${line}`);
}
}
})();
const cleanup = () => { try { probe.kill("SIGKILL"); } catch {} };
process.on("SIGINT", () => { cleanup(); process.exit(130); });
process.on("SIGTERM", () => { cleanup(); process.exit(143); });
process.on("exit", cleanup);
console.log(
`pid ${process.pid}: listening (rss=${(process.memoryUsage.rss() / 2 ** 30).toFixed(1)} GiB).`,
);
console.log(`In another terminal:`);
console.log(` sudo memory_pressure -S -l warn`);
console.log(` sudo memory_pressure -S -l critical`);
console.log(`If "probe:" lines fire and "BUN" lines don't, it's bun's dispatch path.`);
setInterval(() => {}, 1 << 30);The Linux code looks right.
The Windows code needs more work. It shouldn't be relying on uv_timer and we generally don't use uv_async and it shouldn't be using this much unsafe.
Worst case scenario: we spawn a dedicated thread that just watches for memory changes and nothing else - which would be better than eating a threadpool thread just for that.
Best case scenario: we add the memory pressure registration to libuv's existing handle so that it avoids allocating a thread to tell us when an enum changes
…hread Instead of RegisterWaitForSingleObject + uv_async_t + uv_timer_t, spawn a single dedicated thread that blocks on WaitForMultipleObjects over the low-memory notification handle and a shutdown event. When the notification fires, the thread posts a MemoryPressureTask (which carries only the packed level, no pointer) via the lock-free concurrent task queue, then waits 30s on the shutdown event alone before re-checking (the notification handle is level-triggered and stays signalled while memory is low). Uninstall signals the shutdown event and joins the thread before closing handles. Because the task carries no pointer into the watcher, any task enqueued before the join that runs afterwards is harmless. This removes all libuv handle management and the NT thread-pool wait, and cuts the amount of unsafe roughly in half.
Rewrote in 8ef2cb0 along the lines of your worst-case shape. The Windows backend is now a dedicated thread that blocks on No more On the thread cost: the thread is spawned only when the first Thanks for running the macOS smoke test with |
When the cgroup whose memory.pressure we opened is removed, kernfs reports EPOLLERR|EPOLLPRI permanently on the fd. With a level-triggered registration and no error handling the event loop would spin emitting spurious 'critical' events on every tick. on_poll now checks the FilePoll's Eof/Hup flags and unregisters + closes the fd instead of emitting when the trigger is dead. Also: splice the cgroup path via str::from_utf8 instead of escape_ascii(), which is a Debug-style escaper that doubles backslashes and would corrupt systemd-escaped unit names like machine-qemu\x2d1\x2dvm.scope. And: add prependListener/prependOnceListener type overloads for 'memoryPressure' to match the @types/node convention.
EVFILT_MEMORYSTATUS under EV_CLEAR accumulates transition bits between kevent() drains, so a warn->critical escalation while the loop is busy can deliver fflags=0x6 in one event. Pick the more severe level.
A process.once('memoryPressure', ...) listener (or any listener that
removes itself) reaches Bun__MemoryPressure__uninstall synchronously
from inside on_poll -> emit(). Calling (*watcher.poll).deinit() and
freeing the watcher Box there aliases the &mut FilePoll the dispatch
chain is holding and frees memory the handler is running on.
Add a dispatching flag that on_poll brackets around emit(). When
uninstall sees it set, it clears the RareData slot and nulls
watcher.poll to signal the deferral, but leaves the poll and the Box
for on_poll's tail to tear down via the dispatch-provenance poll
pointer. A re-subscribe inside the listener gets a fresh watcher in
the now-empty slot and is unaffected.
Also factor poll teardown into deinit_poll() so the three call sites
(uninstall normal path, EPOLLERR, deferred tail) share one body.
… on re-subscribe When register() fails, tear the poll down immediately via deinit_poll() and leave watcher.poll null, so uninstall's later !poll.is_null() check skips it. Previously the Err arm closed the PSI fd but still stored the poll (whose .fd still held the closed number) in watcher.poll, and uninstall would close that number again after it had been reassigned. The now-dead registered field is removed. In the deferred-teardown tail: if the listener re-subscribed inside emit(), the RareData slot holds a fresh watcher. On macOS the (0, EVFILT_MEMORYSTATUS) knote is unique per kqueue and the new install's EV_ADD modified it in place, so an EV_DELETE from the old poll would silently strand the new watch. Clear PollMemoryPressure on the old poll first so unregister skips the kevent and only returns the hive slot. Linux is unaffected (each install opens its own PSI fd) but takes the same codepath for uniformity.
|
CI status: build 64121 failed on All other lanes that completed are green, including my test file on every platform that reached it. All 15 review threads are resolved. The diff is ready for re-review; needs a maintainer to retry the darwin lane or merge past the runner infra failure. |
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Nearly all of hte unsafe in here is unnecessary. Rewrite it in idiomatic Rust. All of the code comments you added are explanations for code which should never have been rewritten in the first place.
on_poll now enqueues a MemoryPressureTask (packed level, same shape as PosixSignalTask) rather than calling emit() inside the FilePoll dispatch. That removes the re-entrancy with uninstall entirely, and with it the dispatching flag, the deferred-teardown tail, and the knote-stranding check. uninstall can always deinit the poll directly. The watcher struct is reduced to a single Option<NonNull<FilePoll>> (None when the OS backend is unavailable, so isInstalled still reflects listener presence). VM access goes through the safe bun_vm().as_mut() / loop_ctx() / VirtualMachine::get_mut() accessors instead of raw-pointer derefs. Windows: wrap the kernel handles in an RAII OwnedHandle so early returns close them automatically, and use the safe VM accessors for the slot. Remaining unsafe is the irreducible set: Win32 FFI, the C++ emit FFI, heap::take on the erased RareData slot, the cross-thread VM deref in the watcher thread, and the FilePoll raw-pointer API (which has no safe wrapper in bun_io).
Rewritten in a197aef. The root cause of most of the unsafe was calling Now Net: |
|
CI on the rewrite (build 64160, e538b50) is failing on |
What
A
"memoryPressure"event onprocessthat fires when the OS signals low available memory, so applications can release caches or reap idle subprocesses instead of pollingsysctl//proc.The listener does not keep the event loop alive (same semantics as signal listeners). One OS watch per VM regardless of listener count, armed on the first
.on()and disarmed on the last.off().Backends
EVFILT_MEMORYSTATUSon the main event loop's kqueue withNOTE_MEMORYSTATUS_PRESSURE_WARN | _CRITICAL. Same filter libdispatch'sDISPATCH_SOURCE_TYPE_MEMORYPRESSUREuses."warning"or"critical"from the keventfflagssome 150000 2000000) on/proc/pressure/memory, falling back to the cgroup v2memory.pressurefile, polled viaEPOLLPRIon the main epoll. Silently no-ops where PSI is unavailable or the trigger write is rejected (noCAP_SYS_RESOURCEon kernels before 6.6)."critical"CreateMemoryResourceNotification(LowMemoryResourceNotification)+RegisterWaitForSingleObjecton the NT thread pool; posts back to JS through auv_async_t."critical"Implementation
FilePollgains aMemoryPressureregistration kind alongsideReadable/Writable/Process/Machport. On Darwin it emits akevent64withEVFILT_MEMORYSTATUS; on Linux it registers the PSI fd forEPOLLPRI.on_kqueue_eventthreads thefflagsthroughsize_or_offsetfor this filter so the dispatch arm can read the pressure level.src/runtime/node/memory_pressure.rsholds the per-VM watcher (stored type-erased inRareData), the arm/disarmextern "C"entry points called fromBunProcess.cpp'sonDidChangeListeners, and theFilePolldispatch target. Windows gets its own submodule usinguv_async_t+ the Win32 thread-pool wait.Process__emitMemoryPressureEvent(global, level)inBunProcess.cppbuilds the level string and emits through the existingEventEmitter.Tests
test/js/node/process/process-memory-pressure.test.tsdrives the emit path via abun:internal-for-testinghelper (real OS pressure cannot be induced deterministically in CI, and PSI trigger creation requiresCAP_SYS_RESOURCEon older kernels which CI containers lack). Covers: level argument delivery, arm/disarm across multiple listeners,.once(), re-arm after full removal, event-loop ref behaviour, and unsubscribe duringexit.Verified on Linux: 5/5 pass with the change, 3/5 fail on the released binary.
rust:check-allis green across all 10 targets. Existingprocess-on.test.tsandprocess-signal-listener-count.test.tsare unaffected.Related
#31021 adds an internal memory-pressure handler (opt-in via env var) that runs GC and shrinks the JSC footprint when the OS signals pressure. That PR has no JS-visible hook; this one has no internal GC response. They touch disjoint files and can land independently; a follow-up could have the internal handler also emit this event.