Skip to content

Add process.on('memoryPressure') event#32594

Merged
Jarred-Sumner merged 15 commits into
mainfrom
claude/farm/75330e96/memory-pressure-event
Jun 23, 2026
Merged

Add process.on('memoryPressure') event#32594
Jarred-Sumner merged 15 commits into
mainfrom
claude/farm/75330e96/memory-pressure-event

Conversation

@robobun

@robobun robobun commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

What

A "memoryPressure" event on process that fires when the OS signals low available memory, so applications can release caches or reap idle subprocesses instead of polling sysctl//proc.

process.on("memoryPressure", (level: "warning" | "critical") => {
  cache.clear();
});

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

Platform Mechanism Level
macOS EVFILT_MEMORYSTATUS on the main event loop's kqueue with NOTE_MEMORYSTATUS_PRESSURE_WARN | _CRITICAL. Same filter libdispatch's DISPATCH_SOURCE_TYPE_MEMORYPRESSURE uses. "warning" or "critical" from the kevent fflags
Linux PSI trigger (some 150000 2000000) on /proc/pressure/memory, falling back to the cgroup v2 memory.pressure file, polled via EPOLLPRI on the main epoll. Silently no-ops where PSI is unavailable or the trigger write is rejected (no CAP_SYS_RESOURCE on kernels before 6.6). always "critical"
Windows CreateMemoryResourceNotification(LowMemoryResourceNotification) + RegisterWaitForSingleObject on the NT thread pool; posts back to JS through a uv_async_t. always "critical"
FreeBSD Not supported; listener is accepted but never fires. n/a

Implementation

  • FilePoll gains a MemoryPressure registration kind alongside Readable/Writable/Process/Machport. On Darwin it emits a kevent64 with EVFILT_MEMORYSTATUS; on Linux it registers the PSI fd for EPOLLPRI. on_kqueue_event threads the fflags through size_or_offset for this filter so the dispatch arm can read the pressure level.
  • src/runtime/node/memory_pressure.rs holds the per-VM watcher (stored type-erased in RareData), the arm/disarm extern "C" entry points called from BunProcess.cpp's onDidChangeListeners, and the FilePoll dispatch target. Windows gets its own submodule using uv_async_t + the Win32 thread-pool wait.
  • Process__emitMemoryPressureEvent(global, level) in BunProcess.cpp builds the level string and emits through the existing EventEmitter.

Tests

test/js/node/process/process-memory-pressure.test.ts drives the emit path via a bun:internal-for-testing helper (real OS pressure cannot be induced deterministically in CI, and PSI trigger creation requires CAP_SYS_RESOURCE on 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 during exit.

Verified on Linux: 5/5 pass with the change, 3/5 fail on the released binary. rust:check-all is green across all 10 targets. Existing process-on.test.ts and process-signal-listener-count.test.ts are 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.

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.
@robobun robobun requested a review from alii as a code owner June 22, 2026 15:26
@robobun

robobun commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:54 AM PT - Jun 23rd, 2026

@autofix-ci[bot], your commit e538b50 has 4 failures in Build #64160 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32594

That installs a local version of the PR into your bun-32594 executable, so you can run:

bun-32594 --bun

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a new process.on("memoryPressure", level => ...) event to Bun. A cross-platform Rust module handles OS-level notifications (Linux PSI via /proc/pressure/memory, macOS EVFILT_MEMORYSTATUS kqueue, Windows CreateMemoryResourceNotification), wired through the POSIX event loop and C++ bindings to emit typed JS events with level "warning" or "critical".

Changes

process.on("memoryPressure") implementation

Layer / File(s) Summary
OS syscall constants
src/sys/lib.rs
Adds EPOLL::PRI for Linux, EVFILT::MEMORYSTATUS for macOS kqueue, and EV::MEMORYSTATUS_PRESSURE_{NORMAL,WARN,CRITICAL} bitmask constants consumed by the event loop backend.
Event loop flag and registration extension
src/io/posix_event_loop.rs
Adds PollTag::MemoryPressure, Flags::PollMemoryPressure/MemoryPressure variants, per-platform registration (epoll EPOLLPRI on Linux, EVFILT_MEMORYSTATUS kevent on macOS, EOPNOTSUPP on FreeBSD), and decoding from kqueue/epoll events back to Flags::MemoryPressure. Updates update_flags, is_registered, and the full unregister_with_fd_impl lifecycle for the new flag pair.
Rust memory_pressure module
src/runtime/node/memory_pressure.rs
New module with level constants and shared emit helper. POSIX backend uses FilePoll with PSI fd arming on Linux (two paths) or fd 0 on macOS. Windows backend uses CreateMemoryResourceNotification + dedicated thread + task enqueue. Exposes Bun__MemoryPressure__install/uninstall/emit/isInstalled C-ABI exports.
Runtime wiring
src/runtime/node.rs, src/jsc/rare_data.rs, src/event_loop/ConcurrentTask.rs, src/runtime/dispatch.rs
Adds RareData.memory_pressure_watcher slot for per-VM lazy initialization and accessor. Wires node module. Routes MEMORY_PRESSURE poll tag and MemoryPressureTask through dispatch (run_task and __bun_run_file_poll arms). Updates task count assertion.
C++ JS listener lifecycle and emit helper
src/jsc/bindings/BunProcess.cpp
onDidChangeListeners installs/uninstalls the watcher on first/last "memoryPressure" listener. Process__emitMemoryPressureEvent maps numeric level to "warning"/"critical" string and dispatches to JS.
C++ testing bindings
src/jsc/bindings/InternalForTesting.cpp, src/jsc/bindings/InternalForTesting.h
Adds jsFunction_emitMemoryPressure and jsFunction_isMemoryPressureWatcherInstalled host functions for synthetic test emission and watcher state inspection.
TypeScript API and test exports
packages/bun-types/overrides.d.ts, src/js/internal-for-testing.ts
Adds typed on/once/off/addListener/removeListener/emit overloads for "memoryPressure" on NodeJS.Process. Exports emitMemoryPressure and isMemoryPressureWatcherInstalled from bun:internal-for-testing.
Test suite
test/js/node/process/process-memory-pressure.test.ts
Covers basic emission order, watcher state transitions (disarm/rearm), once-only semantics, event-loop liveness verification, and crash-free removal inside exit handlers.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add process.on('memoryPressure') event' clearly and concisely describes the main feature being added—a new process event for memory pressure notifications.
Description check ✅ Passed The PR description comprehensively covers both required template sections: 'What' explains the feature and its behavior across platforms, and verification is demonstrated through test results and rust compilation checks.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. runtime: react to OS memory-pressure signals (opt-in) #31021 - Also implements OS memory-pressure signal handling in Rust, with MemoryPressureWatcher using the same platform backends (kqueue EVFILT_MEMORYSTATUS, Linux PSI, Windows CreateMemoryResourceNotification)
  2. runtime: react to OS memory-pressure signals (opt-in) #30403 - Earlier Zig-based implementation of the same OS memory-pressure signal feature

🤖 Generated with Claude Code

@robobun

robobun commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #31021 / #30403. Those add an internal handler that runs GC and shrinkFootprintWhenIdle() when the OS signals pressure, gated behind BUN_FEATURE_FLAG_EXPERIMENTAL_MEMORY_PRESSURE_HANDLER, with no JS-visible surface. This PR adds the user-facing process.on("memoryPressure", cb) event so applications can react themselves (drop caches, reap idle children, etc).

The platform detection overlaps but the integration differs: #31021 uses libdispatch + a parked PSI thread + cross-thread task enqueue; this PR extends FilePoll so the watch lives directly on the main kqueue/epoll with no extra thread on POSIX. No files overlap between the two PRs; they can land independently and be consolidated in a follow-up (the internal handler could subscribe to the same emit path).

Comment thread src/runtime/node/memory_pressure.rs

@coderabbitai coderabbitai Bot left a comment

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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 7dd427e and 21ad6d5.

📒 Files selected for processing (12)
  • packages/bun-types/overrides.d.ts
  • src/io/posix_event_loop.rs
  • src/js/internal-for-testing.ts
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/InternalForTesting.cpp
  • src/jsc/bindings/InternalForTesting.h
  • src/jsc/rare_data.rs
  • src/runtime/dispatch.rs
  • src/runtime/node.rs
  • src/runtime/node/memory_pressure.rs
  • src/sys/lib.rs
  • test/js/node/process/process-memory-pressure.test.ts

Comment thread src/jsc/bindings/BunProcess.cpp
Comment thread src/runtime/node/memory_pressure.rs Outdated
Comment thread src/runtime/node/memory_pressure.rs Outdated
Comment thread src/runtime/node/memory_pressure.rs Outdated
Comment thread test/js/node/process/process-memory-pressure.test.ts
Comment thread test/js/node/process/process-memory-pressure.test.ts Outdated
robobun and others added 2 commits June 22, 2026 16:12
…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.

@coderabbitai coderabbitai Bot left a comment

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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between c0d47c1 and 92fdb8d.

📒 Files selected for processing (5)
  • src/js/internal-for-testing.ts
  • src/jsc/bindings/InternalForTesting.cpp
  • src/jsc/bindings/InternalForTesting.h
  • src/runtime/node/memory_pressure.rs
  • test/js/node/process/process-memory-pressure.test.ts

Comment thread src/runtime/node/memory_pressure.rs

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 warn
import { 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.
@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

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.

Rewrote in 8ef2cb0 along the lines of your worst-case shape. The Windows backend is now a dedicated thread that blocks on WaitForMultipleObjects([shutdown_event, low_memory_notification]). When the notification fires it posts a MemoryPressureTask through enqueue_task_concurrent (same path PosixSignalTask uses: the task ptr is the packed level, not a pointer, so there is nothing to keep alive across the hop), then waits 30 s on the shutdown event alone before re-checking the notification, since the notification handle is level-triggered. uninstall signals the shutdown event and joins the thread before closing handles.

No more uv_async_t, uv_timer_t, RegisterWaitForSingleObject/UnregisterWaitEx, MaybeUninit, or chained close callbacks. The remaining unsafe is five FFI calls (CreateMemoryResourceNotification, CreateEventW, SetEvent, WaitForMultipleObjects, WaitForSingleObject, CloseHandle) plus the *const VirtualMachine deref on the enqueue, which matches how WaiterThreadPosix reaches the event loop from its thread.

On the thread cost: the thread is spawned only when the first memoryPressure listener is added and joined when the last is removed, so processes that never subscribe pay nothing. Stack is capped at 64 KB. I did not go the libuv-patch route since that is a vendor change and the dedicated-thread shape already avoids touching the NT thread pool; happy to look at hooking it into libuv's IOCP loop if you'd rather land it that way.

Thanks for running the macOS smoke test with memory_pressure -S, that is exactly the end-to-end I couldn't exercise locally.

Comment thread src/runtime/node/memory_pressure.rs Outdated
Comment thread packages/bun-types/overrides.d.ts
Comment thread src/runtime/node/memory_pressure.rs Outdated
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.
Comment thread src/runtime/node/memory_pressure.rs Outdated
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.
Comment thread src/runtime/node/memory_pressure.rs Outdated
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.
Comment thread src/runtime/node/memory_pressure.rs Outdated
Comment thread src/runtime/node/memory_pressure.rs Outdated
robobun and others added 2 commits June 23, 2026 04:59
… 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.
@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: build 64121 failed on darwin-26-aarch64-test-bun because the Node.js test runner (scripts/runner.node.mjs) crashed with uv_os_get_passwd returned ENOENT at os.userInfo() after test 292/2321, before reaching process-memory-pressure.test.ts. The preceding concurrent.test.ts failure (Cannot find module '_util/numeric.ts' from harness.ts) is also unrelated to this diff. The remaining annotations are known flakes (Windows hot.test.ts EPERM, x64-asan bun install timeouts) that passed on retry.

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 Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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).
@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

Nearly all of hte unsafe in here is unnecessary. Rewrite it in idiomatic Rust.

Rewritten in a197aef. The root cause of most of the unsafe was calling emit() from inside the FilePoll dispatch, which meant uninstall could run re-entrantly and every fix for that (the dispatching flag, deferred teardown, the knote-stranding check) compounded more raw-pointer bookkeeping and more comments justifying it.

Now on_poll just enqueues a MemoryPressureTask (packed level, same shape as PosixSignalTask) and returns, so user JS never runs under the dispatch and uninstall can always deinit the poll directly. The watcher struct is down to one Option<NonNull<FilePoll>> field. VM access goes through bun_vm().as_mut() / loop_ctx() / VirtualMachine::get_mut() instead of unsafe { &mut *global.bun_vm_ptr() }. On Windows the kernel handles are wrapped in an RAII OwnedHandle so early returns don't need manual CloseHandle.

Net: memory_pressure.rs went from 537 to 413 lines, and the remaining unsafe {} blocks are the irreducible ones: Win32 FFI (6), the Process__emitMemoryPressureEvent FFI call (1), heap::take on the erased RareData slot (2, the same pattern node_fs_stat_watcher_scheduler uses), the cross-thread VM deref in the watcher thread (1), and FilePoll's raw-pointer register/deinit API (3, same as every other FilePoll user).

@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

CI on the rewrite (build 64160, e538b50) is failing on windows-2019-x64-baseline-test-bun with two unrelated errors: napi > napi_wrap > has the right lifetime ("Condition was not met after 100 GC attempts") and a puppeteer chrome-headless-shell download failure. The flaky-warning annotations (bun-install.test.ts --cwd, node-http-connect.test.ts partial writes) passed on retry. process-memory-pressure.test.ts is not implicated on any lane. Ready for re-review.

@Jarred-Sumner Jarred-Sumner merged commit 03042ab into main Jun 23, 2026
78 of 80 checks passed
@Jarred-Sumner Jarred-Sumner deleted the claude/farm/75330e96/memory-pressure-event branch June 23, 2026 21:04
robobun added a commit that referenced this pull request Jun 24, 2026
Resolved src/io/posix_event_loop.rs: #32594 added PollMemoryPressure to
is_registered(), which this branch moved into impl_file_poll_flag_methods;
carried the flag into the macro body.

Restored ArrayHashMap::get_index_mut: gained a caller in
react_compiler/collections.rs via #32504.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants