Skip to content

fix(runtime): host Node-API 9/10 addons and report classes as functions in napi_typeof - #10569

Closed
proggeramlug wants to merge 3 commits into
mainfrom
fix/10456-10461-napi-v10-typeof
Closed

proggeramlug wants to merge 3 commits into
mainfrom
fix/10456-10461-napi-v10-typeof

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR fixes two bugs in the Node-API host (perry.nativeAddons, #8523). Both block the real argon2, better-sqlite3 and sharp addons from replacing Perry's Rust rewrites.

Root cause

#10456

  • initialize_addon (crates/perry-runtime/src/node_api_host/loader.rs:373-381 at 7661bc0) rejected any node_api_module_get_api_version_v1() result above NAPI_VERSION = 8 (node_api_host/mod.rs:58).
  • The design doc presented the 8 ceiling as deliberate, to avoid claiming the version 9/10 extras.

#10461

  • napi_typeof (node_api_host/values.rs:411-447) duplicated its own tag ladder:
    • is_number() || is_int32() came before anything else, so an INT32-tagged class ref (INT32_TAG | class_id) was a number.
    • Only closure::is_closure_ptr counted as a function, so a class object (OBJECT_TYPE_CLASS) was an object.
  • The typeof operator's classifier (builtins/arithmetic.rs classify_value_typeof) already handles both shapes.
  • The host also produced that same ambiguous INT32 encoding itself. napi_create_int32/napi_create_uint32 returned INT32_TAG | n, and class ids are small and sequential, so an addon's small integer could already read as a class. In the baseline, JS-side String(addon.returnsThree()) printed [class (anonymous)] and total += addon.typeofLoop(...) concatenated function source text; the perf fixture below shows both.

Fix

Version 10 surface. Every stable v9 and v10 declaration in Node 26.5.1's js_native_api.h/node_api.h is now exported and implemented, and listed in symbols.txt:

Version Entry point Behavior
9 node_api_symbol_for Global Symbol.for registry
9 node_api_create_syntax_error, node_api_throw_syntax_error Same path as the other error helpers; code is installed when given
9 node_api_get_module_file_name file:// URL of the addon's canonical sidecar path
10 node_api_create_property_key_{latin1,utf8,utf16} Ordinary strings; Perry has no internalized form
10 node_api_create_external_string_{latin1,utf16} Copied: copied = true and the finalizer runs before returning. This is Node's own NewExternalString path when V8 cannot adopt storage.
10 node_api_create_buffer_from_arraybuffer Buffer view sharing the ArrayBuffer storage. Non-ArrayBuffer returns napi_invalid_arg; an out-of-range window throws ERR_OUT_OF_RANGE, like Node 26.

Version gate (node_api_host/modules.rs effective_module_version), using Node's rules:

  • An addon that declares no version, or anything below 8, runs as 8.
  • 9 and 10 run as declared.
  • Higher versions and NAPI_VERSION_EXPERIMENTAL are rejected before the initializer runs. Experimental entry points are not exported.
  • The compiler's sidecar manifest version is bumped to 10.

Version-dependent behavior (audit of Node's module_api_version checks). Node gives each module its own napi_env; Perry shares one per agent. Each host-initiated entry into addon code now runs with its module marked active: the initializer, function callbacks, async-work completion, TSFN call_js/finalize, and object/instance-data finalizers. Each record captures the module when it is created.

Node behavior What this PR does
Per-module file URL Attributed per module, so node_api_get_module_file_name answers per addon
CallbackIntoModule uncaught-exception policy Previously an exception left pending by a completion, TSFN callback or finalizer stayed pending and surfaced from an unrelated later call. Now async-work completion and finalizers report it as an uncaught exception for every version (Node's <true>). TSFN callbacks report it for version ≥10; modules below 10 get DEP0168 and the exception is dropped (Node's <false>). During shutdown it is dropped.
napi_create_reference accepts any value type (v10 rule) Unchanged: Perry already did this for every module
NAPI_PREAMBLE returns napi_cannot_run_js when JS can't run Not modeled: Perry has no "cannot call into JS" state. Documented.
napi_get_value_string_* No version gate in Node 26
Finalizers Only gated on NAPI_VERSION_EXPERIMENTAL, which is rejected

docs/src/internals/node-api-host.md now documents the version-10 support level, module attribution, and the deviations above.

napi_typeof

  • Classifies through classify_value_typeof, now pub(crate). It keeps only the null and external arms that Node-API reports itself.
  • napi_create_int32/uint32 create plain doubles.
  • The number getters return napi_number_expected for a class ref.
  • js_object_coerce returns a class ref unchanged, so napi_coerce_to_object and Object(C) return the class instead of a boxed id.

Per-call overhead (second commit), which pays for the classifier and more:

  • Env::set_status rebuilt the napi_ok message CString on every successful call, one allocation per call. It now reuses the stored message while status and message are unchanged.
  • The trampoline sets module attribution inside the with_env_mut borrows it already takes.

Tests

Runtime unit tests (node_api_host/v10_tests.rs, plus one in tests.rs):

  • napi_typeof for every value kind: undefined, null, boolean, number, string, symbol, bigint, object, array, external, native function, closure, bound function, INT32 class ref, class object.
  • napi_create_int32/uint32 stay numbers when a class id with the same value is registered; getters reject class refs; ToObject returns the class.
  • Version rules: none→8, 1→8, 9, 10; 0, 11 and experimental are rejected.
  • node_api_symbol_for and the syntax-error pair.
  • Property keys, and external strings: the finalizer runs once, the copy survives the addon clobbering its buffer, and a failed call leaves ownership with the addon.
  • node_api_create_buffer_from_arraybuffer: shared storage, ERR_OUT_OF_RANGE including offset overflow, napi_invalid_arg.
  • Module file name across nested attribution.
  • Callback exception policy for v10 and v8 TSFNs, finalizer, shutdown, and async-work completion.
  • Last-error bookkeeping.

Proof the tests can fail. With the old napi_typeof ladder restored and exception settling disabled, 4 of the 9 v10 tests fail with the expected messages: left: Number, right: Function, NumberExpected, and 0 uncaught errors.

e2e gate (crates/perry/tests/node_api_host_e2e.rs node_api_10_addons_match_node):

  • fixtures/node_api_host/addon_v10.c is built three times: as a version 10 addon, a version 8 addon and a version 11 addon. It declares its own prototypes, so no Node headers are needed.
  • addon_v10_main.js exercises typeof for 17 value kinds including 4 class shapes, int32 creation, napi_new_instance, every v9/v10 entry point, per-module file names, and async-work/TSFN exception delivery for both versions (uncaughtException and DEP0168).
  • Perry's stdout must equal addon_v10_expected.txt byte for byte; the transcript is Node 26.5.1's output. When node provides Node-API ≥10, Node must reproduce the transcript too. The version 11 addon must be rejected.
  • Baseline 7661bc0 fails before any output: undefined symbol: node_api_throw_syntax_error. With the fix, Perry's output is identical to Node's.

A gap test was not used: this needs a compiled native addon plus node_modules, which the gap harness cannot provide.

Validation

Check Result
RUST_TEST_THREADS=1 cargo test --release -p perry-runtime --features node-api-host --lib node_api_host 28 passed
RUST_TEST_THREADS=1 cargo test --release -p perry-runtime --tests --no-fail-fast (default features) lib 3966 passed, 1 failed; android_tls_pool 1 passed. The failure is gc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds, which is pre-existing: its assertion is #[cfg(debug_assertions)], so it cannot pass under --release, and this PR does not touch it.
cargo test --release -p perry --test node_api_host_e2e 5 passed, 1 failed. The failure, real_node_api_addon_resolves_from_host_and_authenticates_sidecar, is a host-executable size-budget assert (614,400 B) that this PR does not touch; it is already over budget on baseline 7661bc0 (measured delta 2,125,592 B there vs 2,133,784 B on this branch, +8 KB from the new v9/v10 entry points — both far past the 614,400 B budget).
./scripts/run_lint_gates.sh (full, incl. compile tier) 82/83 gates passed. The one red, "Public benchmark evidence freshness", is pre-existing/known-red on main (unrelated to this change; left as-is per repo policy).
Gap suite (PERRY_SKIP_BUILD=1 ./scripts/run_gap_tests.sh) GAP_EXIT=0; 813 passed / 6 known failures — identical to the 7661bc0 baseline run, no new failures.

Performance

Instruction counts are perf stat -e instructions:u, 3 runs each, 7661bc0 vs this PR, both in default auto-optimize mode on perrybuilder. The addon runs napi_typeof 100k times in a C loop, called 10× from JS: 1M napi_typeof calls per row. This is the issue's repro scaled into a loop. The "JS→native" row is 1M JS calls into an addon function, which exercises the callback trampoline.

Workload Baseline This PR Δ
napi_typeof(class) ×1M 484.6–487.6M 414.5M −15%
napi_typeof({}) ×1M 594.6–597.6M 509.5M −14%
napi_typeof(5) ×1M 473.6–476.6M 372.5–375.5M −21%
napi_typeof("text") ×1M 490.6–493.6M 329.0–330.5M −33%
napi_typeof(closure) ×1M 629.6M 434.5–437.5M −31%
napi_typeof(napi_create_int32(3)) ×1M 471.9–476.4M 372.5–379.4M −20%
JS→native call ×1M 7570–7572M 7116–7126M −6%
startup only 26.39–26.42M 26.39–26.41M 0

For context, on a loaded shared host:

  • Node 26.5.1 wall: 0.04–0.13 s for each typeof row and 0.06–0.07 s for the call loop.
  • Perry wall: 0.04–0.07 s for the typeof rows. The call loop takes ~1.5 s in both arms: the per-call trampoline costs ~7k instructions before and after this PR. That gap to Node predates this PR and is not addressed here.

In the baseline arm, += on the napi_create_int32 results concatenated function source text; that is the ambiguity this PR removes.

Package checks (informational)

Both run in default auto-optimize mode against the npm prebuilds (linux-x64, no build from source).

Not verified

  • macOS and Windows. The new e2e test is #[cfg(unix)], and only Linux x64 was run.
  • napi_cannot_run_js during environment teardown is not modeled (documented).
  • Instance data remains shared by all addons in an agent (pre-existing, documented).

Fixes #10456
Fixes #10461

Summary by CodeRabbit

  • New Features
    • Added Node-API version 10 support for native addons.
    • Added property-key creation, symbol lookup, syntax-error handling, external strings, module filename lookup, and shared ArrayBuffer-backed Buffer creation.
    • Improved addon compatibility with version-aware loading and callback behavior.
  • Bug Fixes
    • Corrected type detection for classes, functions, and numeric values.
    • Improved error reporting and exception handling across asynchronous callbacks and finalizers.
  • Documentation
    • Updated Node-API host documentation for version 10 capabilities and compatibility rules.

Ralph Küpper added 2 commits September 17, 2026 12:42
… as functions in napi_typeof

The Node-API host rejected every addon declaring Node-API 9 or 10 (argon2 0.45, better-sqlite3 13, sharp 0.35) and exported none of the version 9/10 entry points. It now implements the stable surface through version 10: node_api_symbol_for, node_api_create_syntax_error/node_api_throw_syntax_error, node_api_get_module_file_name, node_api_create_property_key_*, node_api_create_external_string_* and node_api_create_buffer_from_arraybuffer. Node's per-module facts (declared version, module file URL) are attributed to the addon whose code runs, and exceptions left pending by async-work completions, TSFN callbacks and finalizers follow Node's uncaught-exception policy instead of leaking into the next call.

napi_typeof kept its own tag ladder, which read an INT32-tagged class ref as a number and a class object as an object. It now classifies through the typeof operator's classifier, napi_create_int32/uint32 produce plain doubles so a small integer can no longer read as a class, the number getters reject class refs, and ToObject returns a class ref unchanged.
Every successful Node-API call rebuilt its napi_ok error-info message as a fresh CString, one allocation per call. The status bookkeeping now reuses the stored message while the status and message are unchanged. The native-callback trampoline switches the module attribution inside the environment borrows it already takes instead of borrowing twice more.
@proggeramlug proggeramlug added the package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings label Sep 18, 2026
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 78f164c9-04aa-4ab8-ac8c-f1d1bd616ce1

📥 Commits

Reviewing files that changed from the base of the PR and between c8cf450 and 2d7a597.

📒 Files selected for processing (22)
  • changelog.d/10569-napi-v10-typeof.md
  • crates/perry-runtime/src/builtins/arithmetic.rs
  • crates/perry-runtime/src/node_api_host/async_work.rs
  • crates/perry-runtime/src/node_api_host/buffers.rs
  • crates/perry-runtime/src/node_api_host/functions.rs
  • crates/perry-runtime/src/node_api_host/loader.rs
  • crates/perry-runtime/src/node_api_host/metadata.rs
  • crates/perry-runtime/src/node_api_host/mod.rs
  • crates/perry-runtime/src/node_api_host/modules.rs
  • crates/perry-runtime/src/node_api_host/symbols.txt
  • crates/perry-runtime/src/node_api_host/tests.rs
  • crates/perry-runtime/src/node_api_host/tsfn.rs
  • crates/perry-runtime/src/node_api_host/v10_tests.rs
  • crates/perry-runtime/src/node_api_host/values.rs
  • crates/perry-runtime/src/object/alloc.rs
  • crates/perry/src/commands/compile/link/mod.rs
  • crates/perry/src/commands/compile/native_addon_sidecar.rs
  • crates/perry/tests/fixtures/node_api_host/addon_v10.c
  • crates/perry/tests/fixtures/node_api_host/addon_v10_expected.txt
  • crates/perry/tests/fixtures/node_api_host/addon_v10_main.js
  • crates/perry/tests/node_api_host_e2e.rs
  • docs/src/internals/node-api-host.md

Included review availability: Your plan provides up to 8 included reviews per hour; 4 remain after this review.


📝 Walkthrough

Walkthrough

The Node-API host now supports version 10, Node-API 9/10 entry points, per-addon module attribution, module-specific callback exception handling, corrected value classification, and expanded runtime and end-to-end validation.

Changes

Node-API host versioning and module registration

Layer / File(s) Summary
Module registration and version handling
crates/perry-runtime/src/node_api_host/..., crates/perry/src/commands/compile/...
The host resolves addon versions through version 10, registers canonical module URLs, tracks active modules, exposes module filenames, reuses unchanged error messages, and updates the exported symbol inventory and sidecar manifest.
Value classification and Node-API entry points
crates/perry-runtime/src/builtins/arithmetic.rs, crates/perry-runtime/src/node_api_host/{values.rs,buffers.rs}, crates/perry-runtime/src/object/alloc.rs
napi_typeof uses the engine classifier. Integer values avoid class-reference encoding. Class references remain functions during object coercion. Node-API 9/10 APIs for symbols, syntax errors, property keys, external strings, and shared buffer views are added.
Module-aware callbacks and exception settlement
crates/perry-runtime/src/node_api_host/{functions.rs,async_work.rs,metadata.rs,tsfn.rs,mod.rs}
Native callbacks, async work, thread-safe functions, and finalizers retain their originating module. Callback execution restores module context and applies the module-specific exception policy.
Node-API 9/10 validation and documentation
crates/perry-runtime/src/node_api_host/{tests.rs,v10_tests.rs}, crates/perry/tests/fixtures/node_api_host/*, crates/perry/tests/node_api_host_e2e.rs, docs/src/internals/node-api-host.md, changelog.d/10569-napi-v10-typeof.md
Tests and fixtures cover version rules, value types, new entry points, module filenames, shared buffers, status tracking, and callback exceptions. The end-to-end test compares Perry with Node when Node-API 10 is available and rejects version 11 addons.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant Addon
  participant NodeAPIHost
  participant ModuleRecord
  participant Callback
  Addon->>NodeAPIHost: load and register addon
  NodeAPIHost->>ModuleRecord: record API version and file URL
  NodeAPIHost->>Addon: run registration with active module
  Addon->>NodeAPIHost: create callback or async operation
  NodeAPIHost->>Callback: invoke under originating module
  Callback-->>NodeAPIHost: return or raise exception
  NodeAPIHost->>ModuleRecord: apply module-specific exception policy
Loading

Merge Risk: ⚪ Minimal · up to 2d7a5

The Node-API version 10 changes appear merge-ready, with no actionable current-head risk identified.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.02% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 113 functions across 18 files. (4 skipped… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the two primary changes: Node-API 9/10 addon support and correct class reporting in napi_typeof.
Description check ✅ Passed The description is detailed and covers the changes, root causes, implementation, tests, validation results, performance, limitations, and related issues. It does not use every template heading, but it…
Linked Issues check ✅ Passed The PR meets the coding requirements for #10456 and #10461. It raises the host and sidecar API version to 10, accepts versions 9 and 10, rejects unsupported versions, and exports the required Node-API…
Out of Scope Changes check ✅ Passed The changes remain within the linked issue scope. The new Node-API functions, module tracking, callback trampoline updates, status-message optimization, documentation, fixtures, and tests support vers…
Full details: Docstring Coverage

Explanation

Docstring coverage is 46.02% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 113 functions across 18 files. (4 skipped: 4 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

On the red build-and-freshness (docs gettext catalogs): this is not fixable from this PR, and it does not block — the required context is pr-gate, and docs.yml has not run on main since 2026-06-25.

I tried regenerating the catalogs locally (./docs/i18n.sh extract && ./docs/i18n.sh sync): the result is 11 files changed, 92112 insertions(+), 63180 deletions(-), of which only 3,487 added #: lines mention this PR's new page. The rest is re-wrapping churn from a gettext version mismatch — CI's gettext wraps the same unchanged obsolete strings at a different column than whatever generated the committed catalogs. Committing that would be a 155k-line unreviewable diff that CI would still disagree with.

Filed as #10573 (pin the generator version, regenerate once, consider --add-location=file).

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #10610 (v0.5.1594). All source commits preserve authorship; merged main matches the validated train exactly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings

Projects

None yet

1 participant