Skip to content

Comments

Object type streaming#2854

Merged
Brooooooklyn merged 13 commits intonapi-rs:mainfrom
BadMachine:fix/readable-stream-byte-mode-lock
Dec 28, 2025
Merged

Object type streaming#2854
Brooooooklyn merged 13 commits intonapi-rs:mainfrom
BadMachine:fix/readable-stream-byte-mode-lock

Conversation

@BadMachine
Copy link
Contributor

@BadMachine BadMachine commented Aug 8, 2025

  • Fixed streams for object type items
  • Example added
  • Test added

Note

Introduces object-item streaming and overhauls ReadableStream internals for safe lifecycle and cleanup.

  • ReadableStream runtime: Replaces raw boxed stream with shared Arc<StreamState> (Mutex-guarded stream + AtomicBool cancelled); adds cancel callback, registers finalizer via register_invoke to free state on GC, and updates pull paths to use controller enqueue/close with async locking and cancellation checks
  • Bytes path parity: Mirrors the same state/cancel/finalizer handling in the bytes pull implementation
  • API/typing additions: Adds NestedMetadata and StreamItem types and new createReadableStreamWithObject() export in examples (browser/Node WASI shims, type defs, snapshots)
  • Tests: New tests for object streaming (100 items) and cancellation cleanup; snapshot updates
  • Minor: Polishes locked() doc comment

Written by Cursor Bugbot for commit 157dff4. This will update automatically on new commits. Configure here.

Summary by CodeRabbit

  • New Features

    • Added object-streaming ReadableStream support and a new public function to create streams of StreamItem (includes NestedMetadata, name, size).
  • Bug Fixes

    • Improved stream lifecycle, concurrency and cleanup so cancellation and concurrent pulls no longer leak or misuse resources; enqueue/close behavior made more robust.
  • Tests

    • Added tests validating object-streamed chunk shape, total chunks, and cancellation cleanup.

✏️ Tip: You can customize this high-level summary in your review settings.

@graphite-app
Copy link

graphite-app bot commented Aug 8, 2025

How to use the Graphite Merge Queue

Add the label ready-to-merge to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

@BadMachine
Copy link
Contributor Author

Related to #2826

@Brooooooklyn Brooooooklyn requested a review from Copilot August 13, 2025 06:38

This comment was marked as outdated.

@BadMachine
Copy link
Contributor Author

Can smbd look at the PR, please?

@Brooooooklyn Brooooooklyn requested a review from Copilot August 16, 2025 07:40
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

Implements streaming support for object-type items in NAPI readable streams, fixing issues with object serialization in the stream processing pipeline.

  • Fixed streaming functionality for object type items by updating the stream implementation
  • Added example demonstrating object streaming with Foo and StreamItem structs
  • Added comprehensive test coverage for object streaming functionality

Reviewed Changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
examples/napi/src/stream.rs Added object structs and streaming function implementation
examples/napi/index.d.cts Generated TypeScript definitions for new streaming interfaces
examples/napi/index.cjs Added export for new streaming function
examples/napi/example.wasi.cjs Added WASI export for new streaming function
examples/napi/example.wasi-browser.js Added browser export for new streaming function
examples/napi/tests/values.spec.ts Added test cases for object streaming functionality
examples/napi/tests/snapshots/values.spec.ts.md Updated test snapshots
crates/napi/src/bindgen_runtime/js_values/stream/read.rs Fixed core streaming implementation for object types
examples/napi-cargo-test/CHANGELOG.md Updated changelog formatting
crates/napi/CHANGELOG.md Updated changelog formatting
crates/macro/CHANGELOG.md Updated changelog formatting
crates/backend/CHANGELOG.md Updated changelog formatting

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

@Brooooooklyn Brooooooklyn requested a review from Copilot October 3, 2025 13:07
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.


Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

@Brooooooklyn
Copy link
Member

cursor review

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no bugs!


@BadMachine
Copy link
Contributor Author

Any progress?

@BadMachine
Copy link
Contributor Author

BadMachine commented Dec 6, 2025

Hello @Brooooooklyn ?

@Brooooooklyn
Copy link
Member

@BadMachine can you make the CI all green first?

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@Brooooooklyn Brooooooklyn force-pushed the fix/readable-stream-byte-mode-lock branch from 06ee06a to 77906bb Compare December 28, 2025 09:02
@coderabbitai
Copy link

coderabbitai bot commented Dec 28, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

Introduces an internal Arc-based StreamState and PullController to coordinate ReadableStream pull/cancel callbacks and cleanup; updates pull/cancel callbacks to spawn async tasks and manage Arc lifetimes. Adds a new public API create_readable_stream_with_object that streams 100 StreamItem objects via a tokio mpsc channel.

Changes

Cohort / File(s) Change Summary
Bindgen runtime — ReadableStream pull & cancel
crates/napi/src/bindgen_runtime/js_values/stream/read.rs
Added private StreamState (Arc + tokio::sync::Mutex + AtomicBool), PullController, register_invoke/invoke helpers, and cancel_callback; refactored pull callbacks (pull_callback_impl*) to use shared state, check cancellation, spawn async tasks to advance stream, and coordinate Arc cleanup via finalizer.
ReadableStream constructors & wiring
crates/napi/src/bindgen_runtime/js_values/stream/read.rs (constructors/variants)
Updated constructors to allocate/share StreamState pointer, attach pull and cancel callbacks capturing shared state, and register finalizer invokes so Arc is released when underlying_source is GC'd.
Example — Rust stream of objects
examples/napi/src/stream.rs
Added #[napi(object)] types NestedMetadata and StreamItem and new create_readable_stream_with_object() which spawns a background producer sending 100 StreamItem values over a tokio::sync::mpsc channel and returns a ReadableStream from the receiver.
Examples — exports & typings
examples/napi/index.cjs, examples/napi/example.wasi.cjs, examples/napi/example.wasi-browser.js, examples/napi/index.d.cts, examples/napi/__tests__/__snapshots__/values.spec.ts.md
Exposed createReadableStreamWithObject across CJS/WASI/browser example bundles; added TypeScript declarations/interfaces (NestedMetadata, StreamItem) and updated snapshots to include the new export.
Tests
examples/napi/__tests__/values.spec.ts
Added tests: one validating streamed object chunk structure and count, and one verifying readable stream cancellation cleans up resources and that subsequent reads report done.

Sequence Diagram(s)

sequenceDiagram
    participant Test as JS Test
    participant RS as ReadableStream (JS)
    participant Pull as PullCallback
    participant State as StreamState (Arc)
    participant Chan as tokio mpsc Receiver
    participant Producer as Background Producer

    Test->>RS: call createReadableStreamWithObject()
    RS->>Chan: create tokio mpsc (tx, rx)
    RS->>State: create Arc<StreamState> holding rx
    RS->>Producer: spawn producer -> send 100 StreamItem
    RS-->>Test: return ReadableStream

    rect rgba(236,248,255,0.9)
    Note over Producer,Chan: Producer sends StreamItem objects to channel
    Producer->>Chan: tx.send(StreamItem)
    end

    Test->>RS: JS pull -> triggers PullCallback
    Pull->>State: clone Arc, lock mutex, check cancelled
    alt item available
        State->>Chan: rx.recv()
        Chan-->>Pull: StreamItem
        Pull->>RS: controller.enqueue(item)
    else end-of-stream
        Pull->>RS: controller.close()
        Pull->>State: mark end and release ownership
    end

    Test->>RS: call cancel()
    RS->>State: cancel_callback sets cancelled, try cleanup, drop rx/producer refs
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I nibble objects, one hundred bright,
I send them down the channel at night,
Pulls lock, I hop, enqueue with care,
On cancel I tidy, no thread left to spare,
Hooray — streams rest, carrots for the flight! 🥕

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Object type streaming' directly addresses the main change: adding object-type streaming capabilities to ReadableStream with a new create_readable_stream_with_object API, examples, and tests.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8cae6b1 and 157dff4.

📒 Files selected for processing (1)
  • crates/napi/src/bindgen_runtime/js_values/stream/read.rs
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot
🔇 Additional comments (8)
crates/napi/src/bindgen_runtime/js_values/stream/read.rs (8)

1-27: LGTM: Imports support async-aware lifecycle coordination.

The addition of tokio::sync::Mutex and c_void correctly supports the new Arc-based shared state and FFI finalizer pattern.


216-291: Well-structured Arc lifecycle with finalizer-based cleanup.

The constructor correctly:

  • Creates shared StreamState and converts to raw pointer
  • Passes the same state_ptr to pull, cancel, and finalizer
  • Registers finalizer to free Arc when underlying_source is GC'd

This pattern establishes clear ownership: the finalizer owns Arc cleanup, while callbacks borrow.


293-528: Consistent Arc lifecycle across all constructor variants.

All four ReadableStream constructors (generic, with custom class, bytes, bytes with custom class) follow the identical pattern for state management and finalizer registration.


641-698: Correct finalizer-based memory management.

The invoke finalizer is the sole owner of Arc cleanup, consuming it via Arc::from_raw when the underlying_source JS object is GC'd. This centralizes memory management and prevents use-after-free issues.


700-740: PullController correctly manages bound FunctionRefs.

Extracting and binding controller methods as FunctionRefs allows callbacks to explicitly manage their lifecycle, addressing previous leak concerns.


742-778: cancel_callback safely borrows Arc without freeing.

The increment+from_raw pattern correctly creates a temporary Arc reference that drops at function end, while the original Arc remains for the finalizer. Using try_lock() prevents event loop blocking.


797-859: Excellent: Re-checking cancelled flag prevents race condition.

The implementation correctly addresses the TOCTOU race by re-checking state.cancelled at line 836 after async work completes. If cancel_callback is invoked while stream.next().await is pending, the flag check prevents enqueueing the stale item.

Additionally, FunctionRefs are dropped outside the result scope (lines 853-854), ensuring cleanup on all code paths including errors.


878-944: pull_callback_impl_bytes mirrors the safe pattern.

The bytes variant follows the identical Arc borrowing, cancellation re-check, and FunctionRef cleanup pattern as the generic implementation.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (4)
examples/napi/src/stream.rs (2)

60-81: Prefer deriving Default for StreamItem instead of a manual impl

All fields on StreamItem already implement Default, and Foo above uses #[derive(Default)]. You can remove the manual impl Default and derive it directly on the struct to reduce boilerplate and keep things consistent.

Proposed refactor
 #[napi(object)]
-pub struct StreamItem {
+#[derive(Default)]
+pub struct StreamItem {
   pub something: Foo,
   pub name: String,
   pub size: i32,
 }
-
-impl Default for StreamItem {
-  fn default() -> Self {
-    Self {
-      something: Default::default(),
-      name: Default::default(),
-      size: Default::default(),
-    }
-  }
-}

Based on learnings, this keeps the #[napi(object)] pattern simple and idiomatic.


83-105: Minor nits in the object-stream example loop (_it name and panics`)

The overall object-streaming logic looks good and mirrors the byte stream example, but there are two small nits:

  • The loop variable _it is actually used to set size; the leading underscore conventionally signals “unused”. Renaming it avoids confusing linters/readers.
  • Using panic! on TrySendError::{Closed, Full} will crash the thread if the receiver is dropped early. That matches the existing byte-stream example but isn’t ideal as “best practices” sample code.

If you want to tweak it:

Optional refactor
-  std::thread::spawn(move || {
-    for _it in 0..100 {
+  std::thread::spawn(move || {
+    for it in 0..100 {
       let item = StreamItem {
         something: Default::default(),
         name: Default::default(),
-        size: _it,
+        size: it,
       };
       match tx.try_send(Ok(item)) {
-        Err(TrySendError::Closed(_)) => {
-          panic!("closed");
-        }
-        Err(TrySendError::Full(_)) => {
-          panic!("queue is full");
-        }
+        Err(TrySendError::Closed(_)) => {
+          // Receiver dropped early; just stop producing.
+          break;
+        }
+        Err(TrySendError::Full(_)) => {
+          // Should not happen with 100 items into a capacity-100 channel,
+          // but bail out gracefully if it does.
+          break;
+        }
         Ok(_) => {}
       }
     }
   });
crates/napi/src/bindgen_runtime/js_values/stream/read.rs (1)

342-395: Commented-out type: "bytes" in create_with_stream_bytes could use clarification

You now omit underlying_source.set("type", "bytes")? for the default constructor but still set it in with_stream_bytes_and_readable_stream_class. That’s likely intentional for the locking/byte-mode behavior this PR is fixing, but the lone commented-out line is easy to misinterpret later.

Consider either removing the dead line entirely or replacing it with a brief comment explaining why the plain constructor intentionally does not set type: "bytes" while the class-based one does.

examples/napi/__tests__/values.spec.ts (1)

254-259: Object-stream test covers the new API well; minor assertion message typo

The new create readable stream from channel with object test nicely mirrors the byte-stream test and thoroughly checks the shape and count of emitted items, so it should catch regressions in the object streaming path.

Only nit: the first assertion message has a small typo:

-    t.truthy(chunk?.something, `Element ${index} doesnt have chunk.something`)
+    t.truthy(chunk?.something, `Element ${index} doesn't have chunk.something`)

Also applies to: 2054-2075

🧹 Nitpick comments (1)
crates/napi/src/bindgen_runtime/js_values/stream/read.rs (1)

543-598: Controller-based pull callbacks look correct; consider validating the controller argument

Switching pull_callback_impl to use the controller argument and call enqueue/close directly is the right direction and matches the object-stream behavior you’re testing. One small robustness improvement: napi_get_cb_info assumes one argument and you destructure let [controller] = args without checking argc.

If this callback were ever invoked with 0 args (host bug or different embedding), controller would be a null napi_value and Object::from_napi_value would fail in a less-obvious way. You could make this fail fast and explicit:

Optional guard for the controller argument
 fn pull_callback_impl<
   T: ToNapiValue + Send + 'static,
   S: Stream<Item = Result<T>> + Unpin + Send + 'static,
 >( 
   env: sys::napi_env,
   info: sys::napi_callback_info,
 ) -> Result<sys::napi_value> {
   let mut data = ptr::null_mut();
   let mut argc = 1;
   let mut args = [ptr::null_mut(); 1];
   check_status!(unsafe {
     sys::napi_get_cb_info(
       env,
       info,
       &mut argc,
       args.as_mut_ptr(),
       ptr::null_mut(),
       &mut data,
     )
   }, "Get ReadableStream.pull callback info failed")?;
+
+  if argc != 1 {
+    return Err(Error::new(
+      Status::InvalidArg,
+      "ReadableStream pull callback expected a single controller argument",
+    ));
+  }

…and apply the same pattern to pull_callback_impl_bytes.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 76d06b3 and 77906bb.

⛔ Files ignored due to path filters (1)
  • examples/napi/__tests__/__snapshots__/values.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (8)
  • crates/napi/src/bindgen_runtime/js_values/stream/read.rs
  • examples/napi/__tests__/__snapshots__/values.spec.ts.md
  • examples/napi/__tests__/values.spec.ts
  • examples/napi/example.wasi-browser.js
  • examples/napi/example.wasi.cjs
  • examples/napi/index.cjs
  • examples/napi/index.d.cts
  • examples/napi/src/stream.rs
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-11-25T09:31:23.877Z
Learnt from: CR
Repo: napi-rs/napi-rs PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-25T09:31:23.877Z
Learning: Applies to /crates/**/*.rs : Use `#[napi(object)]` attribute for plain objects that should be mapped to TypeScript interfaces

Applied to files:

  • examples/napi/src/stream.rs
  • crates/napi/src/bindgen_runtime/js_values/stream/read.rs
  • examples/napi/index.d.cts
📚 Learning: 2025-11-25T09:31:23.877Z
Learnt from: CR
Repo: napi-rs/napi-rs PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-25T09:31:23.877Z
Learning: Applies to /crates/**/*.rs : Use `#[napi]` attribute on impl blocks to define classes exported to JavaScript/TypeScript

Applied to files:

  • crates/napi/src/bindgen_runtime/js_values/stream/read.rs
📚 Learning: 2025-11-25T09:31:23.877Z
Learnt from: CR
Repo: napi-rs/napi-rs PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-25T09:31:23.877Z
Learning: Applies to /crates/**/*.rs : Use `#[napi(js_name = "...")]` attribute to rename Rust items in JavaScript/TypeScript

Applied to files:

  • crates/napi/src/bindgen_runtime/js_values/stream/read.rs
📚 Learning: 2025-11-25T09:31:23.877Z
Learnt from: CR
Repo: napi-rs/napi-rs PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-25T09:31:23.877Z
Learning: Run `yarn workspace examples/napi build` before testing to ensure code generation reflects current Rust changes

Applied to files:

  • crates/napi/src/bindgen_runtime/js_values/stream/read.rs
🧬 Code graph analysis (2)
examples/napi/__tests__/values.spec.ts (1)
examples/napi/example.wasi-browser.js (2)
  • createReadableStreamWithObject (219-219)
  • createReadableStreamWithObject (219-219)
examples/napi/src/stream.rs (2)
examples/napi/index.d.cts (2)
  • Foo (772-774)
  • StreamItem (1117-1121)
crates/napi/src/bindgen_runtime/js_values/stream/read.rs (2)
  • Env (59-61)
  • new (214-260)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot
🔇 Additional comments (5)
examples/napi/index.d.cts (1)

647-648: TS declarations for object streaming look consistent with the Rust API

createReadableStreamWithObject(): ReadableStream<StreamItem> and the Foo/StreamItem interfaces line up with the Rust #[napi(object)] definitions and the new example stream. No issues from a typings perspective.

Also applies to: 772-775, 1117-1121

examples/napi/__tests__/__snapshots__/values.spec.ts.md (1)

687-688: Snapshot updates correctly mirror new type declarations

The snapshot now includes createReadableStreamWithObject, Foo, and StreamItem in lockstep with index.d.cts. This keeps the type-def snapshot in sync with the generated declarations.

Also applies to: 812-815, 1157-1162

examples/napi/example.wasi-browser.js (1)

217-220: WASI browser shim export for createReadableStreamWithObject is wired correctly

Re-exporting __napiModule.exports.createReadableStreamWithObject alongside the other stream helpers is consistent with the rest of this file.

examples/napi/example.wasi.cjs (1)

262-265: CJS WASI wrapper cleanly exposes createReadableStreamWithObject

The new export is consistent with the other createReadableStream* bindings and should work across environments that use this WASI CJS entry.

examples/napi/index.cjs (1)

726-728: New CommonJS export for createReadableStreamWithObject looks correct

Forwarding nativeBinding.createReadableStreamWithObject via module.exports matches the established pattern for other exports and aligns with the tests.

- Restore type: "bytes" for byte stream methods (zero-copy BYOB optimization)
- Add documentation to Foo, StreamItem structs and create_readable_stream_with_object
- Fix variable naming: _it → i in stream example
- Update TypeScript type definition snapshots

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…eStream

- Add StreamState<S> with Arc<Mutex<Option<Stream>>> + AtomicBool for safe
  state sharing between pull and cancel callbacks (prevents double-free and
  use-after-free)
- Add cancel_callback<S> that sets cancelled flag and drops stream cleanly
- Add PullController<T> helper to deduplicate controller binding logic
- Switch from std::sync::Mutex to tokio::sync::Mutex for async compatibility
- Register cancel callback on all 4 stream creation methods
- Add cancellation test to verify cleanup works correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/napi/src/bindgen_runtime/js_values/stream/read.rs (2)

229-268: Memory leak: Arc strong count never cleaned up.

The Arc::into_raw at line 232 creates a raw pointer that "leaks" the Arc's strong count. However, neither callback consumes this original strong count:

  • pull_callback_impl (lines 742-744) calls increment_strong_count before from_raw, creating a new reference
  • cancel_callback (lines 705-710) calls from_raw then forget, leaving the count intact

The original strong count from into_raw is never decremented, causing the StreamState and its contained stream to leak when the JavaScript object is garbage collected.

Solution: Register a JavaScript finalizer on the underlying source object to decrement the strong count when the stream is GC'd, or restructure ownership to ensure cleanup.

🔎 Example approach using napi_add_finalizer
// After creating underlying_source, before creating the stream:
extern "C" fn finalize_stream_state<S>(
  _env: sys::napi_env,
  data: *mut c_void,
  _hint: *mut c_void,
) {
  if !data.is_null() {
    // Consume the original Arc strong count
    let _ = unsafe { Arc::from_raw(data.cast::<StreamState<S>>()) };
  }
}

unsafe {
  sys::napi_add_finalizer(
    env.raw(),
    underlying_source.0.value,
    state_ptr,
    Some(finalize_stream_state::<S>),
    ptr::null_mut(),
    ptr::null_mut(),
  );
}

755-780: Blocking lock in callback context risks stalling execution.

Line 772 uses blocking_lock() in the callback that runs after the async operation completes. This callback likely executes on the Node.js event loop thread, where blocking operations should be avoided.

Furthermore, there's a potential race condition: between the cancellation check (line 748) and acquiring the lock (line 757), the stream could be cancelled. If cancel_callback is concurrently calling blocking_lock() (line 708), this could cause contention or unpredictable behavior.

Recommendations:

  1. Use async cleanup in the callback since it's already in an async context
  2. Restructure to avoid needing the lock in the callback - the stream is already None if fully consumed, or handle cleanup in a cancellation-aware manner
🔎 Proposed async cleanup approach
     move |env, val| {
       if let Some(val) = val {
         let enqueue_fn = controller.enqueue.borrow_back(env)?;
         enqueue_fn.call(val)?;
       } else {
         let close_fn = controller.close.borrow_back(env)?;
         close_fn.call(())?;
-        // Stream ended - take and drop the stream
-        let _ = state.stream.blocking_lock().take();
       }
       drop(controller.enqueue);
       drop(controller.close);
       Ok(())
     },

The async block already handles the stream properly by returning None when it's done, so explicit cleanup here may be redundant.

♻️ Duplicate comments (2)
crates/napi/src/bindgen_runtime/js_values/stream/read.rs (2)

295-365: Same memory leak pattern repeated across all constructors.

All four ReadableStream constructor variants (with_readable_stream_class, create_with_stream_bytes, with_stream_bytes_and_readable_stream_class) have the same memory leak issue described in the previous comment where the original Arc strong count from into_raw is never consumed.

Additionally: Consider extracting the common state creation and callback registration logic into a helper function to reduce the significant code duplication across these four constructors.

Also applies to: 370-439, 442-516


822-851: Same blocking lock issue in byte stream variant.

This byte-stream variant has the identical problem as pull_callback_impl: line 843 uses blocking_lock() in the completion callback context, which likely runs on the event loop thread and should avoid blocking operations.

Apply the same fix as suggested for the non-bytes variant.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9d98ad1 and 419ae51.

📒 Files selected for processing (2)
  • crates/napi/src/bindgen_runtime/js_values/stream/read.rs
  • examples/napi/__tests__/values.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • examples/napi/tests/values.spec.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot
🔇 Additional comments (4)
crates/napi/src/bindgen_runtime/js_values/stream/read.rs (4)

2-2: LGTM: Import additions support the new async coordination pattern.

The addition of c_void for raw pointer handling in callbacks, the switch to tokio::sync::Mutex for async-aware locking, and the CallbackContext import all align well with the new StreamState and cancellation infrastructure.

Also applies to: 14-14, 21-21


91-91: Grammar fix applied correctly.

The comment now properly reads "whether the readable stream is locked" instead of the previous grammatically incorrect version. This addresses the past review feedback.


629-644: Well-designed coordination primitive.

The StreamState struct effectively coordinates between pull and cancel callbacks using:

  • Arc for shared ownership across callback boundaries
  • tokio::sync::Mutex for async-safe stream access
  • AtomicBool for efficient lock-free cancellation checks

This design properly addresses the memory safety concerns mentioned in past reviews.


646-686: Excellent abstraction for controller method extraction.

The PullController helper cleanly encapsulates the logic for extracting and binding the enqueue and close methods from the ReadableStream controller, reducing code duplication across the pull callback implementations.

… and reference leak

Addresses feedback from CodeRabbit, Cursor, and Copilot:

1. **Event loop blocking (Critical)**: Replace `blocking_lock()` with `try_lock()`
   in cancel_callback to avoid stalling the Node.js event loop when the async
   pull task holds the lock.

2. **Arc memory leak**: Add `cleanup_done: AtomicBool` to StreamState with
   `try_claim_cleanup()` method. Uses compare_exchange to ensure exactly one
   callback (cancel or final pull) performs the Arc cleanup, preventing both
   double-free and memory leak.

3. **FunctionRef leak on error**: Restructure pull callbacks to use inner
   closure pattern, ensuring `controller.enqueue` and `controller.close` are
   always dropped even when errors occur mid-callback.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
crates/napi/src/bindgen_runtime/js_values/stream/read.rs (2)

770-778: Critical: Same undefined behavior from double Arc::from_raw.

Same issue as in cancel_callback: lines 772 and 807 both call Arc::from_raw on the same data pointer, causing undefined behavior.

🔎 Proposed fix

Replace lines 770-778 with:

- let (controller, data) = PullController::<T>::from_callback_info(env, info)?;
-
- // Get the Arc<StreamState> - increment ref count so we don't drop the original
- let state = unsafe {
-   Arc::increment_strong_count(data.cast::<StreamState<S>>());
-   Arc::from_raw(data.cast::<StreamState<S>>())
- };
+ let (controller, data) = PullController::<T>::from_callback_info(env, info)?;
+ 
+ // Reconstruct the Arc from the raw pointer
+ let state = unsafe { Arc::from_raw(data.cast::<StreamState<S>>()) };

And replace lines 802-808 with:

        } else {
          let close_fn = controller.close.borrow_back(env)?;
          close_fn.call(())?;
          // Stream ended - try to take the stream (use try_lock to avoid blocking)
          if let Ok(mut guard) = state.stream.try_lock() {
            let _ = guard.take();
          }
          // Try to claim cleanup responsibility for the original Arc
-         if state.try_claim_cleanup() {
-           drop(unsafe { Arc::from_raw(data.cast::<StreamState<S>>()) });
-         }
+         // No need to do anything here - cleanup is handled by dropping state
+         // or by cancel_callback if cancellation happens
        }

Then after the closure, add logic to put the Arc back if still needed:

      // Always clean up FunctionRefs regardless of success/failure
      drop(controller.enqueue);
      drop(controller.close);
+     
+     // If stream is not ended and not cancelled, put Arc back for next pull
+     if result.is_ok() && val.is_some() && !state.cancelled.load(Ordering::SeqCst) {
+       let _ = Arc::into_raw(state);
+     } // else: let state drop to clean up
+     
      result
    },

Actually, this needs more careful thought about the lifecycle. The cleaner fix is to use ManuallyDrop and explicit Arc cloning:

let state = unsafe { Arc::from_raw(data.cast::<StreamState<S>>()) };
let state_for_async = state.clone();

// ... use state_for_async in async block ...

// In the callback closure, check if we should put the Arc back:
if val.is_some() && !state.cancelled.load(Ordering::SeqCst) && !state.try_claim_cleanup() {
  // Stream continues, put it back
  let _ = Arc::into_raw(state);
} // else: state drops, cleaning up

Also applies to: 802-808


847-856: Critical: Same undefined behavior in bytes variant.

Same double Arc::from_raw issue as in the generic variant: lines 850 and 889 both call from_raw on the same data pointer, causing undefined behavior. Apply the same fix pattern as suggested for pull_callback_impl.

Also applies to: 884-890

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 419ae51 and effc053.

📒 Files selected for processing (1)
  • crates/napi/src/bindgen_runtime/js_values/stream/read.rs
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot
🔇 Additional comments (7)
crates/napi/src/bindgen_runtime/js_values/stream/read.rs (7)

91-91: Grammar fix looks good.

The comment now correctly reads "whether the readable stream is locked" instead of "regardless".


629-657: Well-designed shared state mechanism.

The combination of Arc, tokio::sync::Mutex, and atomic flags provides safe coordination between pull and cancel callbacks. The try_claim_cleanup method using compare_exchange ensures only one code path performs final cleanup, addressing previous memory leak concerns.


659-699: LGTM: Clean controller extraction and binding.

The PullController helper properly extracts and binds the enqueue/close methods from the JavaScript controller, enabling later invocation from async contexts.


792-816: Excellent: Inner closure ensures FunctionRef cleanup on all paths.

The inner closure pattern (lines 794-811) with explicit drops at lines 813-814 ensures controller.enqueue and controller.close are always cleaned up, even when errors occur. This properly addresses the FunctionRef leak concern from previous reviews.


874-897: Good: Same FunctionRef cleanup pattern applied consistently.

The bytes variant also uses the inner closure pattern to ensure FunctionRefs are cleaned up on all code paths.


229-268: Consistent constructor pattern with proper cancel callback registration.

All four ReadableStream constructors consistently:

  • Create shared StreamState and convert to raw pointer
  • Register both pull and cancel callbacks with the state pointer
  • Pass the underlying_source to ReadableStream constructor

The addition of cancel callback registration across all variants ensures proper cleanup on stream cancellation, addressing previous concerns about resource leaks.

Also applies to: 306-345, 379-418, 456-495


729-731: Good: Non-blocking lock attempts prevent event loop stalls.

The use of try_lock() instead of blocking_lock() across all cleanup paths (cancel and stream end) properly addresses the previous critical concern about blocking the Node.js event loop. If the lock is held by another callback, that's fine—the other callback will see the cancellation flag and handle cleanup.

Also applies to: 802-804, 884-886

- Changed cancel_callback to use single from_raw + into_raw pattern
  instead of increment+from_raw followed by second from_raw
- Added detailed comments explaining the borrow patterns:
  - cancel_callback: takes ownership, puts back if not cleaning up
  - pull_callback: increment+from_raw for borrowing across multiple calls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@Brooooooklyn
Copy link
Member

Addressed CodeRabbit review comment (double Arc::from_raw)

Fixed in commit d3a0c27.

cancel_callback now uses the cleaner pattern suggested:

  • Single Arc::from_raw to take ownership
  • If cleanup is not claimed, use Arc::into_raw to put it back for pull callback to handle later
  • If cleanup is claimed, the Arc drops naturally at end of scope

This eliminates the undefined behavior from calling from_raw twice on the same pointer.

pull_callback_impl keeps the increment+from_raw pattern (needed because multiple pull calls can happen before stream ends), but added detailed comments explaining why each pattern is used.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between effc053 and d3a0c27.

📒 Files selected for processing (1)
  • crates/napi/src/bindgen_runtime/js_values/stream/read.rs
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot
🔇 Additional comments (6)
crates/napi/src/bindgen_runtime/js_values/stream/read.rs (6)

91-91: LGTM: Grammar fix applied.

The documentation comment now correctly reads "whether the readable stream is locked" instead of "regardless".


230-268: LGTM: Proper state initialization and callback binding.

The constructor correctly creates shared StreamState, converts it to a raw pointer, and binds both pull and cancel callbacks with the same state pointer for proper coordination.


629-657: LGTM: Well-designed shared state coordination.

The StreamState design is sound:

  • Mutex<Option<Pin<Box<S>>>> properly protects the stream for async access
  • AtomicBool flags enable lock-free cancellation checking
  • try_claim_cleanup() using compare_exchange ensures exactly one callback performs final Arc cleanup

659-699: LGTM: Clean abstraction for controller binding.

The PullController helper properly extracts and binds the controller's enqueue and close methods as FunctionRefs for safe cross-boundary invocation.


765-825: LGTM: Proper borrowing pattern in pull callback.

The pull_callback_impl correctly uses increment_strong_count + from_raw to borrow the Arc without consuming the original reference from into_raw. The inner closure pattern ensures FunctionRef cleanup on all paths, and early cancellation check prevents wasted work.

The cleanup coordination at lines 812-814 is sound if the race condition in cancel_callback (flagged separately) is addressed.

Note: This approval depends on fixing the potential UAF in cancel_callback to ensure it cannot call Arc::from_raw(data) after this code has already consumed the original Arc.


851-911: LGTM: Consistent pattern for byte streams.

The pull_callback_impl_bytes correctly mirrors the logic of pull_callback_impl with appropriate byte-specific handling (converting to Vec<u8> and wrapping in BufferSlice).

Brooooooklyn and others added 2 commits December 28, 2025 22:44
- Rename `Foo` to `NestedMetadata` for better clarity in examples
- Replace manual `Default` impl with `#[derive(Default)]` for `StreamItem`
- Update TypeScript definitions and snapshots

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@Brooooooklyn
Copy link
Member

Addressed review comments (commit 7ca9866)

Fixed:

  • Renamed Foo struct to NestedMetadata for better clarity in example code
  • Replaced manual Default impl with #[derive(Default)] for StreamItem (removes redundant code)

Already fixed in previous commits:

  • ✅ Grammar: "regardless" → "whether" (already correct)
  • ✅ Variable _iti (already fixed)
  • type: "bytes" consistency (already consistent - both byte stream functions set it)
  • ✅ Double Arc::from_raw UB (fixed in d3a0c27)
  • blocking_lock()try_lock() (fixed in effc053)
  • ✅ Arc memory leak (fixed with cleanup_done AtomicBool)
  • ✅ FunctionRef leak on error (fixed with inner closure pattern)

Not changed (intentional):

  • panic! in example code: This is existing pattern in test/example code (see create_readable_stream which has same pattern). For test scenarios, panic is acceptable.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d3a0c27 and 7ca9866.

⛔ Files ignored due to path filters (1)
  • examples/napi/__tests__/__snapshots__/values.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (4)
  • crates/napi/src/bindgen_runtime/js_values/stream/read.rs
  • examples/napi/__tests__/__snapshots__/values.spec.ts.md
  • examples/napi/index.d.cts
  • examples/napi/src/stream.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • examples/napi/src/stream.rs
  • examples/napi/tests/snapshots/values.spec.ts.md
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-25T09:31:23.877Z
Learnt from: CR
Repo: napi-rs/napi-rs PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-25T09:31:23.877Z
Learning: Applies to /crates/**/*.rs : Use `#[napi(object)]` attribute for plain objects that should be mapped to TypeScript interfaces

Applied to files:

  • examples/napi/index.d.cts
🧬 Code graph analysis (2)
crates/napi/src/bindgen_runtime/js_values/stream/read.rs (2)
crates/napi/src/threadsafe_function.rs (2)
  • new (53-59)
  • raw (441-443)
crates/napi/src/env.rs (2)
  • raw (1474-1476)
  • from_raw (72-74)
examples/napi/index.d.cts (1)
examples/napi/example.wasi-browser.js (2)
  • createReadableStreamWithObject (219-219)
  • createReadableStreamWithObject (219-219)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (43)
  • GitHub Check: ASAN - windows-latest
  • GitHub Check: aarch64-pc-windows-msvc - node@24 - toolchain@ stable
  • GitHub Check: x86_64-pc-windows-msvc - node@24 - toolchain@ stable
  • GitHub Check: aarch64-pc-windows-msvc - node@22 - toolchain@ stable
  • GitHub Check: x86_64-pc-windows-msvc - node@22 - toolchain@ stable
  • GitHub Check: aarch64-apple-darwin - node@24 - toolchain@ stable
  • GitHub Check: i686-pc-windows-msvc - node@24 - toolchain@ stable
  • GitHub Check: ASAN - ubuntu-24.04
  • GitHub Check: aarch64-apple-darwin - node@20 - toolchain@ stable
  • GitHub Check: i686-pc-windows-msvc - node@22 - toolchain@ stable
  • GitHub Check: x86_64-unknown-linux-gnu - node@24 - toolchain@ stable
  • GitHub Check: aarch64-apple-darwin - node@22 - toolchain@ stable
  • GitHub Check: x86_64-unknown-linux-gnu - node@20 - toolchain@ stable
  • GitHub Check: x86_64-unknown-linux-gnu - node@24 - toolchain@ 1.88.0
  • GitHub Check: x86_64-unknown-linux-gnu - node@22 - toolchain@ 1.88.0
  • GitHub Check: x86_64-unknown-linux-gnu - node@22 - toolchain@ stable
  • GitHub Check: x86_64-pc-windows-msvc - node@20 - toolchain@ stable
  • GitHub Check: stable - armv7-unknown-linux-gnueabihf - node@22
  • GitHub Check: Build only test - loongarch64-unknown-linux-gnu
  • GitHub Check: Build only test - riscv64gc-unknown-linux-gnu
  • GitHub Check: Test node wasi target (--cfg tokio_unstable)
  • GitHub Check: build - s390x-unknown-linux-gnu
  • GitHub Check: Build only test - armv7-linux-androideabi
  • GitHub Check: Build only test - aarch64-unknown-linux-ohos
  • GitHub Check: build-and-test-msys2 (UCRT64)
  • GitHub Check: Test freebsd target
  • GitHub Check: Test latest bun
  • GitHub Check: Build only test - aarch64-linux-android
  • GitHub Check: build - aarch64-unknown-linux-gnu
  • GitHub Check: build-and-test-msys2 (MINGW64)
  • GitHub Check: build-and-test-msys2 (CLANG64)
  • GitHub Check: build - powerpc64le-unknown-linux-gnu
  • GitHub Check: build - x86_64-unknown-linux-musl
  • GitHub Check: build - x86_64-unknown-linux-gnu
  • GitHub Check: Test node wasi target
  • GitHub Check: build - aarch64-unknown-linux-musl
  • GitHub Check: Lint SourceCode
  • GitHub Check: Zig-Cross-Compile-On-Linux (aarch64-apple-darwin)
  • GitHub Check: Zig-Cross-Compile-On-Linux (armv7-unknown-linux-musleabihf)
  • GitHub Check: Zig-Cross-Compile-On-Linux (aarch64-unknown-linux-musl)
  • GitHub Check: Zig-Cross-Compile-On-Linux (x86_64-unknown-linux-musl)
  • GitHub Check: Memory leak detect job
  • GitHub Check: Cursor Bugbot
🔇 Additional comments (5)
examples/napi/index.d.cts (1)

647-651: LGTM — New object streaming API declarations are well-formed.

The new createReadableStreamWithObject() function and supporting types (NestedMetadata, StreamItem) are correctly declared and follow established patterns in this auto-generated file. The JSDoc comments appropriately document the purpose of these additions.

Also applies to: 912-915, 1122-1127

crates/napi/src/bindgen_runtime/js_values/stream/read.rs (4)

629-657: Well-designed shared state coordination.

The StreamState struct correctly implements the coordination pattern:

  • cleanup_done with compare_exchange ensures exactly one callback performs final cleanup
  • SeqCst ordering provides strong guarantees across the cancel/pull paths
  • Good documentation explaining the purpose of each field

659-699: LGTM — Clean helper abstraction for controller method binding.

The PullController correctly extracts and binds the enqueue/close methods, ensuring proper this context when called from Rust.


701-739: Correct ownership pattern in cancel callback.

The implementation properly addresses previous review concerns:

  • Single Arc::from_raw followed by conditional Arc::into_raw avoids double-free UB
  • try_lock prevents event loop blocking
  • try_claim_cleanup() ensures single-owner cleanup

229-268: Consistent stream construction across all paths.

All four construction methods (new, with_readable_stream_class, create_with_stream_bytes, with_stream_bytes_and_readable_stream_class) consistently:

  • Create shared StreamState via Arc::into_raw
  • Install both pull and cancel callbacks with the same state_ptr
  • Follow the established pattern for underlying source setup

Also applies to: 306-345, 379-418, 456-495

The previous implementation had a potential use-after-free:
1. pull_callback could claim cleanup and free the Arc
2. cancel_callback could be invoked afterward with a dangling pointer
3. Arc::from_raw on freed memory = undefined behavior

This fix:
- Registers a invoke on underlying_source using napi_add_finalizer
- The invoke frees the Arc when underlying_source is garbage collected
- Both cancel_callback and pull_callback now only "borrow" using
  increment+from_raw pattern, never freeing the Arc directly
- Removed cleanup_done AtomicBool and try_claim_cleanup (no longer needed)

This ensures memory is only freed after all callbacks are guaranteed
to have completed, eliminating the use-after-free race condition.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@Brooooooklyn
Copy link
Member

Fixed use-after-free vulnerability (commit 8cae6b1)

Cursor correctly identified a potential use-after-free in cancel_callback:

The bug:

  1. pull_callback ends stream → claims cleanup → frees Arc (memory freed!)
  2. cancel_callback is invoked → Arc::from_raw(data) on freed memory → UB!

The fix:

  • Register a invoke on underlying_source using napi_add_finalizer
  • The invoke frees the Arc when the underlying_source object is garbage collected
  • Both cancel_callback and pull_callback now only "borrow" using the increment+from_raw pattern
  • Removed cleanup_done AtomicBool and try_claim_cleanup() (no longer needed)

This ensures the Arc is only freed after all callbacks are guaranteed to have completed (when the JS object is GC'd), eliminating the race condition entirely.

The callback closure after async work completes now re-checks the
cancelled flag before calling enqueue. This prevents enqueueing items
if cancel was called while stream.next().await was in progress.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@Brooooooklyn Brooooooklyn merged commit 8385bef into napi-rs:main Dec 28, 2025
75 checks passed
@github-actions github-actions bot mentioned this pull request Dec 28, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants