Skip to content

process: NUL-terminate the PSI trigger that arms memoryPressure on Linux - #39654

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/6574f663/psi-trigger-nul
Aug 19, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
farm/6574f663/psi-trigger-nul

Conversation

@robobun

@robobun robobun commented Aug 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • process.on("memoryPressure") never fires on Linux.
  • open_psi_fd (src/runtime/node/memory_pressure.rs:153) writes some 150000 2000000 with no terminator. psi_write() in kernel/sched/psi.c replaces the last byte of a write with NUL before it parses. It sees a 200 ms window and returns EINVAL, so the watcher installs with no OS source.

Fix

  • Write the trigger with a trailing NUL. The kernel's own sample writes strlen + 1 bytes.
  • Add two bun:internal-for-testing hooks through $newRustFunction. memoryPressurePsiTrigger() returns the bytes Bun writes. memoryPressureWatcherHasOsBackend() tells whether the watcher holds a live poll.
  • Two tests in test/js/node/process/process-memory-pressure.test.ts. The first checks the bytes on every Linux machine. The second arms a real trigger. It is skipped where the test process cannot create one itself (most CI containers).
  • Verified: the file passes, and the first test fails with only the NUL removed. Also cargo check for Windows, macOS and FreeBSD, clippy, and test/internal/source-lints.

Background

  • PSI (pressure stall information) is the Linux pressure source Bun uses. A write of some <threshold_us> <window_us> to /proc/pressure/memory arms a trigger on that fd.
  • Bun tries that file first, then the cgroup's memory.pressure. kernfs terminates the cgroup write itself, so that fallback worked either way. Most hosts only offer the /proc file.
  • Since kernel 6.4 an unprivileged writer may arm a trigger, but only with a window that is a multiple of 2 s. The truncated window fails that rule.
  • Same byte as fix(process): NUL-terminate the PSI trigger write so memoryPressure arms on Linux #34018 (from a fork, so CI did not run on it). Its author is credited in the commit.
Notes

Kernel source for the claim (v6.6, kernel/sched/psi.c, psi_write):

buf_size = min(nbytes, sizeof(buf));
if (copy_from_user(buf, user_buf, buf_size))
    return -EFAULT;
buf[buf_size - 1] = '\0';

psi_trigger_create then rejects window_us % 2000000 != 0 for a writer without CAP_SYS_RESOURCE. A writer with that capability gets a 200 ms window on the RT polling aggregator instead, which needs a 75% stall to fire rather than 7.5%.

The cgroup path (kernel/cgroup/cgroup.c, pressure_write) receives a kernfs buffer that already has a NUL after the data, so it worked with and without this change.

The first test also checks the two other rules psi_trigger_create applies to an unprivileged writer: the threshold fits in the window, and the window is a multiple of 2 s. The second test checks hasOsBackend before a listener is added (false), with one (true), and after it is removed (false). Its skip guard opens and writes the same two files, in the same order, that open_psi_fd uses.

This container denies O_RDWR on /proc/pressure/memory (EACCES, AppArmor) and mounts the cgroup fs read-only, so the second test is skipped here. Fail-before output with only the NUL removed from PSI_TRIGGER:

(fail) process.on('memoryPressure') > the PSI trigger write ends in NUL

#36525 was an earlier copy of the same fix. It was closed in favor of #34018, and its branch no longer merges. #31021 (opt-in shrink on pressure) still carries the unterminated string and needs this byte too.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/process/process-memory-pressure.test.ts

psi_write() in kernel/sched/psi.c replaces the last byte of the write
with NUL before it parses the trigger. Bun wrote "some 150000 2000000"
with no terminator, so the kernel parsed a 200 ms window and returned
EINVAL. The watcher then installed with no OS backend, and the
memoryPressure event never fired on Linux.

Include the NUL in the write, as the kernel's own sample does.

Add two bun:internal-for-testing hooks. memoryPressurePsiTrigger()
returns the bytes Bun writes. memoryPressureWatcherHasOsBackend()
reports whether the installed watcher holds a live OS source. The first
new test checks the bytes on every Linux machine. The second arms a real
trigger where the kernel allows it, and is skipped elsewhere.

Co-authored-by: catpranks <pranks@catpranks.tv>
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

Limit details: You’ve used all 5 included reviews currently available.

You can run this review on demand instead of waiting.

On-demand reviews are free until September 18, 2026. After that, they cost $0.25 per reviewed file.

  • Run review for free
How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 63e42fda-9a28-4922-9b5b-da9f8e1a27ed

📥 Commits

Reviewing files that changed from the base of the PR and between 4199361 and 5c498a6.

📒 Files selected for processing (4)
  • src/codegen/generate-js2native.ts
  • src/js/internal-for-testing.ts
  • src/runtime/node/memory_pressure.rs
  • test/js/node/process/process-memory-pressure.test.ts

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

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Status

How this was confirmed: kernel/sched/psi.c (psi_write, v6.6) sets buf[buf_size - 1] = '\0' on the bytes it receives, so the 19-byte write on main parses as a 200 ms window. In this environment the PSI files cannot be opened for writing (EACCES on /proc/pressure/memory, read-only cgroup fs), so the proof is the byte test: with only the NUL removed from PSI_TRIGGER, the PSI trigger write ends in NUL fails, and it passes with the change. The test that arms a real trigger runs only where the test process can create one.

Related: #34018 has the same one-byte change from a fork. Its author is credited in the commit here.

@claude claude 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.

LGTM — one-byte fix (NUL-terminate the PSI trigger string) with a clear kernel-source citation, plus test hooks so the regression is caught on every Linux CI run.

What was reviewed:

  • The PSI_TRIGGER constant move to pub(super) and the added \0 — bun_sys::write already took a &[u8], so the extra byte reaches psi_write intact and the kernel's buf[buf_size-1] = '\0' now overwrites the NUL instead of the last window digit.
  • The two new #[bun_jsc::host_fn] hooks and their codegen wiring: rustTarget maps runtime/node/memory_pressure.rs + jsPsiTrigger/jsWatcherHasOsBackend to crate::node::memory_pressure::{js_psi_trigger, js_watcher_has_os_backend}, which match the actual pub(crate) fn names; ArrayBuffer::create_buffer copies the &[u8] so no lifetime concern.
  • has_os_backend's unsafe deref of the type-erased slot — same invariant as take_watcher right above it, and only reachable from the JS thread.
  • The two new tests: the byte-shape test runs on all Linux and would fail without the NUL; the arm test's skipIf guard mirrors open_psi_fd's two-path fallback and closes its probe fd in finally.
Extended reasoning...

Overview

The PR fixes process.on("memoryPressure") never arming on Linux: open_psi_fd was writing b"some 150000 2000000" to /proc/pressure/memory, and psi_write() in the kernel overwrites the last received byte with NUL before parsing, so it saw a 200000 µs window and returned EINVAL. The fix is to include the NUL in the write. The constant is hoisted to module scope as pub(super) const PSI_TRIGGER so a new bun:internal-for-testing hook can return it, and a second hook exposes whether the installed watcher actually holds a live poll. Two tests exercise both hooks; the byte-shape test runs on every Linux machine, the live-arm test skips when the environment can't create a trigger.

Files touched: src/runtime/node/memory_pressure.rs (the fix + two host fns + has_os_backend helper), src/codegen/generate-js2native.ts (one map entry), src/js/internal-for-testing.ts (two exports), test/js/node/process/process-memory-pressure.test.ts (two tests + a capability probe).

Security risks

None. The only runtime-path change is one extra byte in a constant string written to a procfs file that Bun already opens. The new host functions are gated behind bun:internal-for-testing and are read-only (return a constant Buffer / a boolean derived from existing state). No user input is parsed.

Level of scrutiny

Low-to-medium. The production fix is a one-byte constant change with the kernel source line quoted in both the PR body and the code comment, and the PR author verified fail-before/pass-after. The rest is test scaffolding following the same `` / #[bun_jsc::host_fn] pattern used dozens of times in `internal-for-testing.ts`. I traced the codegen path (`rustIdentifierPaths` → `rustTarget` → snake_case) and confirmed it resolves to the actual `pub(crate)` function names in `crate::node::memory_pressure`. I also confirmed `ArrayBuffer::create_buffer(global, &[u8])` copies its input, so returning a reference to the static `PSI_TRIGGER` is fine.

Other factors

  • The has_os_backend unsafe block mirrors the existing take_watcher SAFETY invariant one function above and is only called from a JS host fn on the JS thread, so no concurrent borrow.
  • cfg gating looks right: ArrayBuffer is imported only on Linux/Android; js_psi_trigger returns JSValue::NULL elsewhere; js_watcher_has_os_backend on Windows delegates to isInstalled (correct per the doc comment — Windows either starts the thread or leaves the slot empty). PR body says cargo check passed on Windows/macOS/FreeBSD.
  • Tests follow harness conventions: subprocess spawn with bunExe()/bunEnv, concurrent pipe drain, stdout asserted before exitCode, test.skipIf with a real capability check that closes its probe fd in finally.
  • No prior reviews or outstanding comments on the PR.

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