Skip to content

napi: drive napi_async_work through the addon's pointer instead of &mut self receivers - #37750

Open
robobun wants to merge 4 commits into
mainfrom
farm/867fdbb9/napi-async-work-raw-receiver
Open

napi: drive napi_async_work through the addon's pointer instead of &mut self receivers#37750
robobun wants to merge 4 commits into
mainfrom
farm/867fdbb9/napi-async-work-raw-receiver

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • The four entry points on the work now take the addon's raw pointer and read or write one field per statement; no reference to the whole work is formed anywhere. Callers (the three napi_*_async_work C functions, the dispatch arm, the teardown release path) pass the pointer through instead of turning it into &mut.
  • The property to check: on the pool thread, filling in the embedded task is the last access to the work and the post goes through a loop handle cloned out beforehand; on the JS thread, everything needed during and after complete is copied out before the call and the pointer is not touched after it.
  • The struct docs now say which thread writes each field. Only the atomic status is written by both threads, which is what lets cancel and run overlap and what the per-field accesses rest on. No behaviour change is intended: the same fields are read and written and the post goes to the same loop.
  • Verification, as reported in the original: a new source lint flags exactly the old posting site on main and nothing with this change; a new addon test cancels a work while execute is running and has complete delete it; the napi and node-api async suites were run on the ASAN build. The Miri result is from the reduction, not from Bun. run_from_js has no lint and relies on the addon tests.

Background

  • napi_async_work is the Node-API object an addon gets from napi_create_async_work. Bun heap-allocates it, but the addon owns it and frees it with napi_delete_async_work, conventionally from inside complete (node-addon-api's AsyncWorker does this).
  • Lifecycle: napi_queue_async_work hands the work to Bun's work pool; a pool thread runs the addon's execute, then posts the work back to the JS thread, which runs complete. napi_cancel_async_work may be called from JS at any time and only succeeds if execute has not started.
  • Embedded (intrusive) task: the work contains its own queue node (concurrent_task), so posting it means filling in a field of the work and linking that field into the JS thread's queue. From the moment the post lands the JS thread owns the allocation, even though the posting function has not returned yet.
  • Reference protection: Rust treats a reference argument (&mut self included) as valid and exclusive for the whole call (noalias and dereferenceable in codegen), so another thread writing or freeing that memory during the call is UB even if the callee never touches the reference again. A raw pointer makes no such promise, and a (*this).field access only claims that field for that statement.
  • Source lints: tests under test/internal/source-lints regex-scan the Rust tree for banned shapes and fail on any hit; this PR adds one for "fill the embedded task with the receiver's address", a sibling of the lint from bundler: hand a finished Bun.build back through its pointer, not a &mut receiver #37723.

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

Original description

Problem

napi_async_work (src/runtime/napi/napi_body.rs) is an allocation the addon owns. Its pool-thread side posted it back to the JS thread like this:

fn run(&mut self) {
    let self_ptr: *mut Self = self;
    ...
    self.post_to_js_thread(self_ptr);
}
fn post_to_js_thread(&mut self, self_ptr: *mut Self) {
    let ct = NonNull::from(self.concurrent_task.from(self_ptr, AutoDeinit::ManualDeinit));
    let Posted::Queued = self.loop_handle.post_task(ct) else { ... };
}

As soon as the post lands, the JS thread drains the embedded task and runs run_from_js, whose complete callback belongs to the addon and normally calls napi_delete_async_work on this very work (node-addon-api's AsyncWorker does; so do the fixtures in test/napi/napi-app/async_tests.cpp). That can happen before post_task has returned, i.e. while three references into the allocation are still live function arguments: run's &mut self, post_to_js_thread's &mut self (which has just written concurrent_task through itself), and the &self.loop_handle the post went through. On the JS thread, run_from_js(&mut self) called complete directly, so on every ordinary completion the work was freed while that receiver was a live argument too. Its own comment said so ("the 'this' value here may already be freed by the user in complete") and moved poll_ref out beforehand, which handles the later reads but not the receiver itself.

A reference argument is protected for the whole call, and another party reading, writing or freeing memory it covers is UB under both aliasing models whether or not the function touches the reference again (rustc's codegen relies on the same thing: the argument is noalias and dereferenceable for the whole call). bun_runtime cannot run under Miri, so the reduction below has exactly these shapes: the embedded task filled in and posted from two &mut self frames, a JS thread that frees the work from the completion callback, and the two receivers the fix replaces. Under Tree Borrows (what bun run rust:miri uses) the JS thread's first access to the work is rejected against post_to_js_thread's receiver, and the free is rejected against run_from_js's:

pool side:  error: Undefined Behavior: reborrow through <2132> at alloc1240[0x10] is forbidden
            = help: the accessed tag <2132> is foreign to the protected tag <4229>
            = help: this reborrow (acting as a foreign read access) would cause the protected tag <4229> (currently Unique) to become Disabled
            help: the protected tag <4229> was created here  -->  fn post_to_js_thread_before(&mut self, self_ptr: *mut Self)
JS side:    error: Undefined Behavior: deallocation through <8922> at alloc1240[0x0] is forbidden
            help: the protected tag <8894> was created here  -->  fn run_from_js_before(&mut self)
fixed:      ok

Stacked Borrows reports the same two sites (not granting access to tag ... because that would remove [Unique for <..>] which is strongly protected) and also passes the fixed shape. No crash is known from this; nothing in Bun reads through the references after the hand-over today. It is the contract that is wrong, the same class as #37681, #37703, #37723 and the threadsafe-function conversion in #37741 (a different set of functions in the same file; this PR does not touch them).

Two smaller things of the same kind in the same functions: schedule(&mut self) handed the pool &raw mut self.task, a pointer projected from the reference, while IntrusiveWorkTask::from_task_ptr documents that the pointer it gets back must carry provenance for the whole allocation (WorkPool::schedule_owned projects from the raw pointer for exactly this reason); and cancel(&mut self) on the JS thread and run(&mut self) on the pool thread each claimed the whole struct exclusively while the other was legitimately running (napi_cancel_async_work during execute is an ordinary, documented call).

Reduction run under Miri
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread;

struct SendPtr(*mut ConcurrentTask);
unsafe impl Send for SendPtr {}

// LoopHandle: refcounted, cloneable, posts to the JS thread. Waiting for the
// consumer inside post_task pins the interleaving the real code allows: the JS
// thread runs the completion before the posting call has returned.
#[derive(Clone)]
struct Handle(Arc<HandleInner>);
struct HandleInner { post: Mutex<mpsc::Sender<SendPtr>>, consumed: Mutex<mpsc::Receiver<()>> }
impl Handle {
    fn post_task(&self, task: SendPtr) {
        self.0.post.lock().unwrap().send(task).unwrap();
        self.0.consumed.lock().unwrap().recv().unwrap();
    }
    fn embedded_work_finished(&self) {}
}

#[derive(Default)]
struct ConcurrentTask { of: *mut Work }
impl ConcurrentTask {
    fn from(&mut self, of: *mut Work) -> &mut ConcurrentTask { self.of = of; self }
}

struct AddonData { work: *mut Work }
struct Work {
    concurrent_task: ConcurrentTask,
    loop_handle: Handle,
    status: AtomicU32,
    complete: fn(u32, *mut AddonData),
    data: *mut AddonData,
}

// The addon's complete callback: napi_delete_async_work, then free its data.
fn complete(status: u32, data: *mut AddonData) {
    assert_eq!(status, 2);
    unsafe { drop(Box::from_raw((*data).work)); drop(Box::from_raw(data)); }
}

impl Work {
    // main
    fn run_before(&mut self) {
        let self_ptr: *mut Self = self;
        let handle = self.loop_handle.clone();
        self.status.store(2, Ordering::SeqCst);
        self.post_to_js_thread_before(self_ptr);
        handle.embedded_work_finished();
    }
    fn post_to_js_thread_before(&mut self, self_ptr: *mut Self) {
        let ct: *mut ConcurrentTask = self.concurrent_task.from(self_ptr);
        self.loop_handle.post_task(SendPtr(ct));
    }
    fn run_from_js_before(&mut self) {
        let complete = self.complete;
        complete(self.status.load(Ordering::SeqCst), self.data);
    }
    // this PR
    unsafe fn run_after(this: *mut Self) {
        let handle = unsafe { (*this).loop_handle.clone() };
        unsafe { (*this).status.store(2, Ordering::SeqCst) };
        let ct: *mut ConcurrentTask = unsafe { (*this).concurrent_task.from(this) };
        handle.post_task(SendPtr(ct));
        handle.embedded_work_finished();
    }
    unsafe fn run_from_js_after(this: *mut Self) {
        let (complete, status, data) =
            unsafe { ((*this).complete, (*this).status.load(Ordering::SeqCst), (*this).data) };
        complete(status, data);
    }
}

fn main() {
    let mode = std::env::args().nth(1).unwrap_or_default();
    let (post, queue) = mpsc::channel::<SendPtr>();
    let (consumed_tx, consumed) = mpsc::channel::<()>();
    let handle = Handle(Arc::new(HandleInner { post: Mutex::new(post), consumed: Mutex::new(consumed) }));
    let data = Box::into_raw(Box::new(AddonData { work: std::ptr::null_mut() }));
    let work = Box::into_raw(Box::new(Work {
        concurrent_task: ConcurrentTask::default(),
        loop_handle: handle,
        status: AtomicU32::new(0),
        complete,
        data,
    }));
    unsafe { (*data).work = work };

    let js_mode = mode.clone();
    let js_thread = thread::spawn(move || {
        let task = queue.recv().unwrap();
        let work = unsafe { (*task.0).of };
        if js_mode == "js" { unsafe { (*work).run_from_js_before() } } else { unsafe { Work::run_from_js_after(work) } }
        consumed_tx.send(()).unwrap();
    });
    // The pool thread.
    if mode == "pool" { unsafe { (*work).run_before() } } else { unsafe { Work::run_after(work) } }
    js_thread.join().unwrap();
    println!("{mode}: ok");
}

MIRIFLAGS=-Zmiri-tree-borrows cargo miri run -- pool and -- js fail with the errors quoted above; -- fixed prints fixed: ok. The default Stacked Borrows run gives the same three results.

Fix

The addon, the pool thread and the event-loop queue all hold the work as a raw pointer, so the entry points now keep it one. schedule, run, cancel and run_from_js take this: *mut napi_async_work; each reads or writes the individual fields it needs through statement-scoped (*this).field accesses (the atomic status through its own &AtomicU32, which is what lets cancel and run overlap), and nothing forms a reference to the whole work at any point:

  • run copies the LoopHandle (cloned), execute, env and data out of the work first, runs execute, then in one last access stores the final status (or cancels) and fills in the embedded task with (*this).concurrent_task.from(this, ..); the post goes through the cloned handle and only the clone is touched afterwards. This is the shape S3HttpSimpleTask::http_callback (src/runtime/webcore/s3/simple_request.rs) already uses for its embedded task, and the clone-the-handle-first order async_job_run in node_zlib_binding.rs uses; post_to_js_thread is folded into it.
  • run_from_js takes poll_ref, complete, env, status and data out of the work before calling complete and does not use this afterwards. The post-complete exception check goes through the env pointer, which the global keeps alive (GlobalObject::m_napiEnvs) independently of the work's own ref; that was already what the old code relied on, and the comment now says so. The struct's field docs record which thread writes each field (the JS thread still reads global and scheduled, and CASes status, while the pool has the work; the pool writes status and concurrent_task; the rest is immutable after new), which is the invariant the field-by-field accesses rest on.
  • schedule projects the task pointer from the work pointer (&raw mut (*this).task), which is what from_task_ptr asks for.
  • napi_queue_async_work, napi_cancel_async_work and napi_delete_async_work null-check and pass the pointer through instead of going via as_mut(); the NapiAsyncWork dispatch arm (src/runtime/dispatch.rs) uses cast_ptr!, and Taskable::release_unrun calls the associated function.

No behaviour change: the same fields are read and written (execute/env/data are now copied out before the status CAS instead of after it, which is unobservable: nothing else writes them), the status store still happens before the VM borrow is released, the post still goes to the same loop (the clone refers to the same VM handle), refusal is still unreachable for the same reason (counted work), and the teardown path still runs complete from the queue release.

Seen while reviewing, not changed here

Running an addon's complete from the queue release at worker teardown is pre-existing behaviour this PR keeps. If that complete queues another work (node-addon-api's chaining pattern), napi_queue_async_work runs into embedded_work_scheduled's closed-handle assertion on debug builds and, on release builds, run's refusal unreachable! on the pool thread. That reproduces identically on main and is being handled separately; it is mentioned because schedule and run are in this diff.

Tests

  • test/internal/source-lints/self-receiver-intrusive-post.test.ts (new) bans filling an embedded task with the receiver's address: .from(..) applied to self spelled as a pointer (directly or through a reborrow: from_mut(&mut *self), NonNull::from(&mut *self), a bare &mut *self; the same spelling list self-receiver-reclaim.test.ts uses), to a local of the same function bound to one, or to a parameter of a method that also has a reference receiver and is typed as a raw pointer to the method's own type, written Self or by the enclosing impl's name (the post_to_js_thread(&mut self, self_ptr: *mut Self) shape, and the same thing spelled *mut napi_async_work). It checks its patterns against positive and negative examples, including the two-function shape as it was on main in both spellings. Against main it reports exactly src/runtime/napi/napi_body.rs:1871; with this change it reports nothing and the tree needs no allowlist. The heap-task constructors (Task::init / create_from / from_callback) are the population bundler: hand a finished Bun.build back through its pointer, not a &mut receiver #37723's lint covers, so the two do not overlap; each lint in this family currently carries its own copy of the spelling list and scan scaffold, and folding those into a shared helper is a separate cleanup once the in-flight siblings have landed. run_from_js has no syntactic marker a lint can pin; it is covered by the addon tests below, which delete the work inside complete under the ASAN build.
  • test/napi/napi.test.ts: new case test_napi_async_work_cancel_running (addon side in async_tests.cpp) queues a work, waits for execute to start on the pool thread, calls napi_cancel_async_work from the JS thread while it is running, and checks through complete (which then deletes the work) that the cancel reported napi_generic_failure and the completion napi_ok, comparing against Node's output (compared line by line, since the addon's printf goes through the Windows CRT and emits \r\n there, which the first revision of this test tripped over on the Windows lanes). This is the cancel / run overlap the field-scoped accesses exist for; the existing cancel test only cancels work that has not started.

On the debug (ASAN) build: test/napi/napi.test.ts (the napi_async_work, handle-scope and async-complete exception cases, plus the whole file: 166 pass, the one failure being the unrelated bigint conversion case hitting the 5 s default timeout on this machine, which it also does without this change and passes with a longer timeout), the node-api suites test_async (its 500-iteration test-loop.js passes; test.js and friends are pre-existing todos in that suite), test_worker_terminate (passes), and test_instance_data, test_uv_threadpool_size, test_async_cleanup_hook (build, with their pre-existing todo entries unchanged), and bun test test/internal/source-lints/ (86 pass). cargo clippy -p bun_runtime and rustfmt --check are clean on the touched files.

…ut self receivers

napi_async_work::run(&mut self) posted the work to the JS thread through
post_to_js_thread(&mut self, self_ptr), and the JS thread runs the addon's
complete callback (which normally calls napi_delete_async_work) as soon as
the post lands, so the allocation could be freed while both &mut self
receivers, and the &self of the loop_handle field the post went through,
were still protected arguments. run_from_js(&mut self) had the same shape
with complete itself doing the free, and schedule(&mut self) handed the
pool a task pointer projected from the reference.

schedule, run, cancel and run_from_js now take the raw work pointer, read
the fields they need through statement-scoped accesses, post through a
cloned LoopHandle, and touch nothing after the hand-over; the extern "C"
entry points and the dispatch arm pass the pointer straight through.

Adds a source lint for posting a receiver's address through an embedded
task (.from(..) with self's address, a local bound to it, or a *mut Self
parameter next to a reference receiver) and an addon test that cancels a
work while its execute callback is running.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The async work lifecycle now uses explicit unsafe raw-pointer functions. A repository lint detects intrusive mutable receiver posts. New N-API tests cover cancellation while execute is running and verify completion status.

Changes

N-API async work safety

Layer / File(s) Summary
Async work pointer lifecycle
src/runtime/napi/napi_body.rs, src/runtime/dispatch.rs
Async work scheduling, execution, cancellation, and JavaScript completion use raw-pointer functions with documented lifetime contracts.
Intrusive receiver lint
test/internal/source-lints/self-receiver-intrusive-post.test.ts
A source-lint scans tracked Rust files for unsafe publication of reference receivers through intrusive tasks.
Running cancellation validation
test/napi/napi-app/async_tests.cpp, test/napi/napi-app/module.js, test/napi/napi.test.ts
Tests cancel async work during execute and verify cancellation, completion, and promise results.

Possibly related PRs

  • oven-sh/bun#37741: Refactors related N-API asynchronous operations to raw-pointer APIs and adds source-lint coverage.
  • oven-sh/bun#37701: Refactors cross-thread operations and adds intrusive mutable receiver checks.
  • oven-sh/bun#37740: Applies similar raw-pointer task handling and source-lint enforcement.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely describes the main raw-pointer conversion from &mut self receivers for napi_async_work.
Description check ✅ Passed The description explains the problem, fix, background, affected behavior, and verification results, including tests and known unrelated failures.

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

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 PM PT - Aug 12th, 2026

@robobun, your commit c321da5 has some failures in Build #93247 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 37750

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

bun-37750 --bun

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review (head c321da5).

How the shape was confirmed: the new lint in test/internal/source-lints/self-receiver-intrusive-post.test.ts reports exactly src/runtime/napi/napi_body.rs:1871 (the .from(self_ptr, ..) post inside post_to_js_thread(&mut self, self_ptr: *mut Self)) against main's src/, and nothing with this branch's. The standalone reduction in the description fails under Miri (Tree Borrows and Stacked Borrows) for both the pool-side post and the JS-side complete in the shapes main has, and passes in the shape this PR switches to.

Revisions since opening: the new addon test compares its output line by line (the first revision failed on the Windows lanes of build 92850 because the addon's printf emits \r\n there); the comments in the converted functions were cut down, with the ownership model on the struct and each field's writing thread on the field (ad26827, c321da5); and the lint also recognises the receiver's address taken through a reborrow (from_mut(&mut *self), NonNull::from(&mut *self), bare &mut *self) and a pointer parameter typed by the impl's name, with self-test cases for each (c321da5). A pre-existing crash seen while reviewing (an addon queuing new work from a complete that teardown runs) reproduces on main and is being handled separately; the description has the details.

Behavioural coverage run on the debug (ASAN) build: test/napi/napi.test.ts (including the new running-cancel case, whose complete deletes the work), the node-api test_async loop test and test_worker_terminate, and test/internal/source-lints/.

CI: build 93247 (head c321da5) and build 92883 (ad26827) both ended with every job that ran green (192 of 194, napi lanes included; one unrelated flaky test per build passed on retry). The two remaining jobs in each were the darwin 26 aarch64 - test-bun shards, which expired without an agent ever starting them, so the red aggregate is an agent-pool matter, not this diff; re-running those two jobs once darwin 26 agents are available is all that is needed, and I am not pushing retriggers for it.

Related but separate: #37741 converts the threadsafe-function entry points in the same file; the two touch different functions.

Comment thread test/napi/napi.test.ts
printf() through the Windows CRT writes \r\n, so the exact-string
comparison failed on the Windows lanes; compare line by line like the
file's other printf-backed assertions.
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
…m comments

The shared-ownership rule lives on the struct doc once; the per-function
docs keep only the thread and the one fact each caller needs.
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed a82cd9e (line-by-line comparison in the new addon test; build 92850 failed on the Windows lanes because the addon's printf emits CRLF there) and ad26827 (comment trim: the ownership rule is stated once on the struct, run reads its fields in two blocks). The review threads above are answered and resolved; the status comment at the top is updated.

@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: 2

🤖 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 `@test/napi/napi-app/async_tests.cpp`:
- Around line 371-376: Update the async entry point in async_tests.cpp to
validate that info[0] exists and is callable before allocating RunningCancelData
or queuing async work; on invalid input, propagate the established N-API error
instead of returning undefined or proceeding with side effects.
- Around line 374-383: Update the async setup flow surrounding NODE_API_CALL and
complete_for_running_cancel to release RunningCancelData, the callback
reference, and the async-work handle on every setup failure. Keep ownership with
the setup code until napi_queue_async_work succeeds, then transfer it to
complete_for_running_cancel so the completion path performs cleanup exactly
once.
🪄 Autofix

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: 063547b0-d6f3-48bf-86f0-f102f60f7fec

📥 Commits

Reviewing files that changed from the base of the PR and between f59f705 and ad26827.

📒 Files selected for processing (6)
  • src/runtime/dispatch.rs
  • src/runtime/napi/napi_body.rs
  • test/internal/source-lints/self-receiver-intrusive-post.test.ts
  • test/napi/napi-app/async_tests.cpp
  • test/napi/napi-app/module.js
  • test/napi/napi.test.ts

Comment thread test/napi/napi-app/async_tests.cpp
Comment thread test/napi/napi-app/async_tests.cpp
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Answered the two review threads on the new addon fixture (setup-failure cleanup and argument validation): both paths are unreachable from the fixture's only caller and the fixture follows the shape of create_promise / test_cancel_async_work next to it, so it is left as is. No code change; head is still ad26827.

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

Thanks for the CRLF fix in a82cd9e — the split-on-either-ending assertion now matches the file's other printf-backed cases. This run found no further issues, but given it rewrites the receiver/lifetime contract for napi_async_work across the pool↔JS boundary in unsafe Rust, a human pass is still worthwhile.

What was reviewed:

  • run/run_from_js: confirmed no reference into *this survives past .from(this, ..) / complete; the NonNull::from temporary and the &mut concurrent_task auto-ref both die before handle.post_task.
  • env_ref held across complete: same shape the old _hs scope guard already relied on; env is kept alive by GlobalObject::m_napiEnvs, not the work's own ref.
  • napi_cancel_async_work while run is on the pool thread: the debug-assert reads only (*work_).global, which run never writes; cancel touches only the atomic.
  • New test_cancel_running_async_work: bounded busy-wait is in the spawned subprocess (not the test runner), pool thread is independent of the blocked JS thread, and the fallback branch fails loudly.
Extended reasoning...

Overview

Converts napi_async_work's entry points (schedule, run, cancel, run_from_js) from &mut self receivers to unsafe fn(this: *mut Self), and updates the three extern "C" napi functions plus the dispatch arm and Taskable::release_unrun to pass the addon's raw pointer through instead of materializing &mut. post_to_js_thread is folded into run. Adds a source lint (self-receiver-intrusive-post.test.ts) banning .from(self-as-ptr, ..) inside a reference-receiver method, and a napi test that cancels work while execute is running on the pool thread and deletes the work in complete.

Security risks

None. No user-facing input handling, no auth/crypto/permissions. The change tightens an aliasing contract; it does not add new capability.

Level of scrutiny

High. This is memory safety in native code — REVIEW.md's most-blocked category — with cross-thread lifetime (pool thread hands the work to the JS thread, whose complete callback frees it, potentially before the posting call returns). The transformation is mechanical and follows the established pattern from #37681/#37703/#37723/#37741 in the same series, and the PR includes a Miri reduction proving both the defect and the fix under Tree Borrows and Stacked Borrows. But the code path is exercised by every addon that uses napi_async_work (node-addon-api's AsyncWorker), so a maintainer should confirm the field-access ordering in run (read loop_handle/execute/env/data up front, CAS status, run execute, store status + fill task, post through the cloned handle) matches their model of the protocol.

Other factors

My prior finding (Windows CRLF in the new test's .toBe(...join("\n"))) was fixed in a82cd9e, and the comment-cop flags on the per-function doc comments were addressed in ad26827 by consolidating the ownership rule onto the struct doc. All inline threads on the PR are resolved. The source lint has positive/negative fixtures including the exact two-function shape from main and asserts scanned > 0 to guard against vacuous passes. The behavioral test runs under checkSameOutput (so Node parity is asserted) and its complete deletes the work under the ASAN build, covering the run_from_js receiver that the lint cannot express syntactically. No outstanding reviewer comments.

robobun added a commit that referenced this pull request Aug 12, 2026
…ption

The embedded-task .from() spelling is covered by the sibling lint in #37750,
so this one goes back to the heap-task constructors only.
…per-thread fields

The lint now recognises the receiver's address taken through a reborrow
(from_mut(&mut *self), NonNull::from(&mut *self), a bare &mut *self) and a
pointer parameter typed by the enclosing impl's name as well as Self, with
self-test cases for each; the tree stays at zero hits and main's site is
still the one report.

napi_async_work's field docs now say which thread writes each field, the
SAFETY comments in schedule and run refer to that instead of overstating
what the JS thread touches, and the BackRef note left over from the
LoopHandle conversion is gone.
Comment thread src/runtime/napi/napi_body.rs
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed c321da5 after another review pass over the diff: the lint now also catches the receiver's address taken through a reborrow (from_mut(&mut *self), NonNull::from(&mut *self), a bare &mut *self) and a pointer parameter typed by the impl's name rather than Self, each with a self-test case; the tree is still at zero hits and main's site is still the one report. On the Rust side the struct's field docs now say which thread writes each field, the SAFETY comments in schedule and run refer to that instead of overstating what the JS thread touches while the work is in flight (it still reads global and scheduled and CASes status), and the stale BackRef note on loop_handle is gone. The description also notes a pre-existing teardown crash found along the way (work queued from a complete that teardown runs), which reproduces on main and is being handled separately.

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

I re-reviewed after the CRLF fix and comment trim; no issues found this pass. Given the change rewrites cross-thread unsafe pointer handling for napi_async_work (aliasing-model correctness across pool/JS threads), a human look is still worthwhile.

What was reviewed:

  • run/run_from_js/schedule/cancel field-access ordering: last touch of *this precedes the post; env liveness after complete frees the work is via GlobalObject::m_napiEnvs, not the work's own ref.
  • The new running-cancel fixture: bounded busy-wait on the JS thread while the pool starts execute; release_execute is set on both branches so the pool thread cannot spin forever.
  • The source lint's restOfFunction scoping and impl-type resolution against its positive/negative examples.
Extended reasoning...

Overview

This PR converts napi_async_work's entry points (schedule, run, cancel, run_from_js) from &mut self methods to unsafe fn(this: *mut Self) associated functions, so no Rust reference to the whole work is live while another thread (or the addon's complete callback) may free or write to it. The three FFI entry points (napi_queue_async_work, napi_cancel_async_work, napi_delete_async_work) and the dispatch arm now pass the raw pointer through instead of .as_mut(). It adds a source lint (self-receiver-intrusive-post.test.ts) banning the .from(self-address, ..) shape, and a new addon test that cancels a work while execute is running on the pool thread.

Security risks

None. This is internal memory-model correctness; no user-facing API surface, parsing, or trust boundary changes.

Level of scrutiny

High. This is squarely in REVIEW.md's most-blocked category (native memory safety, cross-thread ownership, unsafe Rust). The change is well-motivated (Miri Tree Borrows / Stacked Borrows reproduction in the PR description) and follows the same shape as recent conversions (#37681, #37703, #37723, #37741), but the field-by-field access ordering in run — which fields are read before vs. after the post, and that the cloned LoopHandle (not (*this).loop_handle) is what post_task and embedded_work_finished go through — is exactly the kind of thing a human reviewer should sanity-check. The env pointer's liveness after complete frees the work relies on GlobalObject::m_napiEnvs holding an independent ref, which the code already relied on but is now stated in the comment.

Other factors

  • My earlier finding (Windows CRLF in the new test assertion) was fixed in a82cd9e.
  • The comment-cop bot has one unresolved ping on the struct doc at line 1763; the author already explained in resolved threads that this is the one place the shared-ownership protocol is documented (not a workaround justification), and the per-function comments were trimmed in ad26827 to rely on it. I don't consider it blocking.
  • CI build 92883 was green on all lanes except darwin 26 aarch64, whose jobs never picked up an agent (infrastructure, not the diff).
  • The new source lint has a self-test with positive/negative examples pinning each spelling; I checked that restOfFunction correctly bounds each search to the enclosing function so a binding in one method and a post of the same name in the next don't cross-match.
  • The C++ fixture's bounded busy-wait (10s) on the JS thread is safe: Bun's WorkPool and Node's libuv threadpool dispatch independently of the JS event loop, and release_execute = true is set on both the started and did-not-start branches so the pool thread cannot spin forever.

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.

1 participant