Skip to content

Realm production A2b: one declaration per native namespace (owners, names, Node member order); module vtable is a shape fact - #13967

Draft
proggeramlug wants to merge 42 commits into
mainfrom
wrt3p-a2b
Draft

proggeramlug wants to merge 42 commits into
mainfrom
wrt3p-a2b

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Realm production A2b: one declaration per native namespace (owner, names, members in Node's order); the module vtable is a shape fact

Draft. Lands together with PR B (owner ruling 11-04): this PR opens as a draft and goes ready only with PR B. The startup residue below is removed by PR B; it is not accepted on its own.

Follows A2a (#13793: Set bucket contract, array storage, realm-diff gate in CI). Design: WRT A2-DESIGN §2-§7, rulings §9 (Q6-Q10, each open to the owner's veto).

What changed, by item

Item 0: the restricted realm reach unites the program's own names with the gate input. A top program's scan was closed to the whole catalog before the test-only PERRY_REALM_REACH_NAMES input ran, so its own names were lost and the R-restricted dumper aborted (runtime layout EVENT_EMITTER_PROTOTYPE is born, but the program's realm does not reach it). The compiler keeps the pre-closure names (own), R = file names ∪ own names; the gate asserts the seed is exactly that union and that the restricted dump equals the reference. The gate runs in CI.

Item 1: every native birth declares its owner; the producer fills none in. The by-name owner fallback is deleted. realm_owners::every_native_cell_of_the_runtime_has_exactly_one_owner fails by cell name on a missing or misnamed owner. Manifest delta: 58 cells gain the owner field and nothing else. Owner truth test (runtime, cfg(test)): the real access path of every declared name yields a value whose module is the declared owner.

Item 2: one object, one declaration. A native birth lists its other names (node:X.default and %NativeNamespace:X% are one cell); the uses are spelled as the declaration.

Item 3: a native namespace is declared as an item beside its module (crate::realm_decl! { namespace NM_X = "x" { ... } }, namespaces/<module>.rs). The vtable static carries the keys; Object.keys reads them.

Item 4: all namespaces moved. 45 namespaces with hand key tables, the 11 zero-member namespaces (Q7), and the 26 live key-table arms (Q10) plus 3 two-name splits are item declarations. The hand key tables, the 89-arm native_module_enumerable_keys match and the inline key lists at the native birth are deleted. The two dead arms (console.default, http.default) are gone with them.

Q8, the module vtable as a shape fact (option a). A namespace is born into its vtable's branded root shape (perry-abi native_vtable_brand, one band); readers take the vtable off the receiver's shape; the global NATIVE_MODULE_VTABLE_PTR and the name-to-vtable match are deleted. Codegen passes the vtable symbol at every static site (_vt entries); the by-name externs nothing emits are deleted with their anchors and wasm rows. The one dynamic path (require(expr), getBuiltinModule(id)): codegen emits a constant (name, vtable) array @perry_native_modules only in a program with a dynamic require, installed by js_nm_set_module_table. IR test: no by-name birth and no array without a dynamic require. Owner veto point: this array is the one new emitted table of the PR (it replaces main's always-linked match); PR B turns it into a lookup over the image's native-module record.

Item 5: member order, fixed forward. The declaration is the one list, so the order is fixed in the declaration.

  • 51 tabled cells: declared order == table order on main for all 51 (no namespace had two orders on main, so "which of the two main orders Node matched" is empty: Node matched neither in every fixed cell; perry's tables were alphabetical or hand order).
  • 16 declaration fixes to Node v26 order (first mismatch index on main, then equal): assert/strict (0), child_process (0), constants (0), dgram (0), fs (47), http (0), http2 (0), https (1), net (0), repl (0), util as node:sys.default (0), tls (0), url (6), worker_threads (0), process with process.namespace/process.default (0), readline/promises (0, at its submodule birth). assert/strict's CJS value iterated a second hand list; it now iterates the declaration. Test: test-files/test_gap_native_member_order.ts == node; sabotage (dgram swapped back) red.
  • Q7, the 13 __module__ leaks: Object.keys was ["__module__"] on 13 cells (11 declarations). Each now declares the members perry serves, in Node order (lists taken by reading each get_own_field match): crypto.constants (54 members, two names), crypto.subtle (0), crypto.webcrypto (0), fs.constants (56), os.constants (5), readline (7), stream/promises (2), string_decoder (1), v8.promiseHooks (5, two names), v8.startupSnapshot (4, two names), streams (0). Test every_declared_namespace_key_is_served (over-declaration is red).
  • The default member of node:stream, node:events and node:module is the CJS value (Stream, EventEmitter, Module), as in Node.
  • crypto.Certificate: a real prototype data property (constructor and the three SPKAC methods), statics in Node order. Test test_gap_crypto_certificate_prototype == node.

Item 6: the agreement check is an identity (A2-DESIGN 6.4). One member list per namespace means declared order and observed order are one list. The full dumper writes one @order line per static import's module value and per sub-namespace; the gate compares the shared members, in order, with Node's Object.keys over the same access path (realm-dumps/native-order.node.json, Node v26.12.1, regenerated only with --regenerate-node). Result: 54 cells agree, 2 have no Node value and are listed by name (node:streams, node:test), 9 disagree and are listed exactly in ORDER_DISAGREES (a cell that starts or stops disagreeing is red). None of the 9 is one of A2's namespace declarations: function-valued modules with own statics (assert, events, module), submodule export objects (diagnostics_channel, fs/promises, stream/consumers, stream/web, test/reporters), and the util object served for sys. Sabotage: dgram reversed in the Node file, gate red naming node:dgram.

Q6 identity record (no cache added). The dump records, per declaration, whether every access path yields one object; the gate asserts the minting set equals exactly these 17 cells (PR B empties the list and deletes the mechanism): console, %NativeNamespace:{buffer, crypto, domain, readline, string_decoder, tls, tty, v8, worker_threads, zlib}%, node:buffer.constants, node:crypto.constants, node:http2.constants, node:perf_hooks.constants, node:v8.promiseHooks, node:v8.startupSnapshot.

Gates (head vs main ced2298)

gate result
realm-diff, both modes equal: hello 634, lad-04 324, lad-07 371, node-imports 1015, full 3117 cells
identity record 25 declarations, 17 minting = the asserted set (PR B empties it)
member order vs Node v26.12.1 54 cells agree, 9 ORDER_DISAGREES (listed in the gate), no Node value for 2
order sabotage (dgram reversed in the Node file) red, names node:dgram
realm_diff_gate_tests.py 22/22 ok
realm_* migration python tests, wgraph gate all rc=0, PASS
manifest tests 94/0
owners sabotage (a native cell declares owner planted instead of perf_hooks) red by name: %NativeNamespace:perf_hooks%: native cell declares owner planted but is named for perf_hooks; restored: green
HIR tests 64 result lines, 0 failed
codegen --features target-wasi 0 failed
runtime DEBUG full 6491 pass, 4 fail: 1 fixed in this PR (deferred builtin import test), net_transport sabotage passes alone (suite flake), 2 global_tests fail on main too
stdlib 312 pass, 2 fail (handle_lifecycle_tests), both fail on main too
gap FULL tier (2535 tests) head 2434 PASS / 94 MISMATCH / 7 COMPILE_FAIL; main 2431 / 94 / 7 / 3 NO_FILE (the 3 new tests); 0 realm-reach aborts on either arm
main PASS -> head not PASS one: test_gap_fetch_streaming_clone (Date.now()-before < 180, a timing assertion). Three runs each, alone: head 3/3 PASS, main 3/3 PASS.
-D warnings perry-runtime lib the same 4 errors on both arms (stack_maps.rs unused doc comment, messaging.rs unnecessary unsafe, runtime_layouts.rs layout_id never used, plus the crate summary): main's
rustfmt 33 files with diffs on head, 50 on main; no file with a diff on head only

Lint gates (scripts/run_lint_gates.sh; each failing lint compared by content, head vs main, sorted failure lines)

lint head main note
shape descriptor census ok ok the first head run was red: set_native_namespace_keys named a parameter keys_array, a census-tracked raw member word. The parameter is key_list; the baseline is unchanged.
native export collisions (x2) same failure js_sqlite_stmt_columns not recorded head first failed earlier, on wrapper macro native_submodule_exports invokes an export macro: realm_decl!'s namespace arm now exports the perry_nm_vt_* family and the scanner took every realm_decl! use for an export. The scanner now treats a keyword-picked export arm as exporting only for invocations that start with its keyword (self-test added). Head now fails on the same sqlite row as main (116 recorded collisions on both, no new one).
runtime ABI table ok stale head regenerated with --emit-wasm-abi: the _vt entry rows and js_nm_set_module_table are this PR's; six more rows (js_ext_zlib_subclass_init, js_fs_read_callback_short, js_perry_net_link_adopt_descriptor, js_set_native_subclass_init, perry_ffi_gc_* x2) were already missing on main
gc runtime root holders (x2) same failure same head had one extra row, the test-only ADD_BRAND_TRANSITIONS thread_local; it is a brand-keyed atomic now
raw-handle debt same failure (1718 sites) same (1718) head had native_module.rs at 5 against a ceiling of 4; set_native_namespace_keys returns the receiver through with_mut_ptr
file size same offenders same state.rs (was ours) is back under 2000; rustfmt-only churn in dialect/mod.rs undone; fs/mod.rs is 2102 against 2101 on main (the mod namespace; line; main's offender)
reference-test audit same same dyn_index.rs 9 vs 8 on both
per-test global sinks same same identical failure list
SSO unbox inventory ok FAIL main's
release pipeline, Windows xwin (no perl), PyYAML pip install, -D warnings workspace same same environment or main's dead code (above)

Startup (callgrind Ir, head vs main ced2298, pinned; irdiff measured at the owners/lint commits, codegen unchanged since)

witness main Ir head Ir delta
hello 818,545 818,545 0
lad_01_arith 873,001 873,001 0
lad_04_classes 15,133,319 15,133,660 +341
lad_07_json 15,072,460 15,072,772 +312
commander_help 18,801,909 18,805,438 +3,529
commander 19,008,370 19,011,525 +3,155
fastify_inject 150,074,320 150,090,723 +16,403

Attribution (per function, call counts from the callgrind edges): the namespace birth into the branded root shape (set_native_namespace_keys N calls replaces js_object_set_keys + publish + live-rep + tombstone, same N, net cheaper); +1 root-shape mint per distinct vtable (shape_descriptor_mint_fresh, publish_dynamic, family_append_fresh: +14 calls in fastify, +3 in lad_07); BrandList::insert + fold_brands per birth (55 fastify, 20 lad_07); the vtable argument per call (export_begin +4.9/call, bound_native_callable_export_value +14/call, createRequire +3/call); shape_descriptor_intern +0.19/call over 13.6k calls (larger shape table); ensure_function_prototype_object +65/call x16 (not root-caused). Removed by PR B (branded root shapes in the realm image): this residue is not accepted on its own, and the PR does not leave draft before PR B.

Linked hello (head vs main, llvm-nm -S): functions +4086 B, data +4608 B. The data is 64 new perry_nm_vt_NM_* module vtable statics x 72 B (the vtable as a shape fact). Functions: set_native_namespace_keys +4241 replaces native_module_enumerable_keys -4101; create_native_module_namespace +1355 replaces js_create_native_module_namespace -1295; js_object_set_keys -1120; BrandList::insert +517, add_brand -651; assert prototype attach +1888 against assert_cjs_export_value -1479; js_class_static_method_call +1047; namespace_vtable/object_native_vtable/namespace_has_enumerable_key about +1040; OnceLock<Vec<&[u8]>> init -1902. Nothing here is A2b code that hello should leave out: hello links the whole runtime today (WRT DCE and PR B remove the unreached births).

Failures that are main's

  • Runtime DEBUG: global_tests::global_object_is_born_on_its_static_layout_and_population_keeps_it and global_object_keys_are_atoms_and_a_miss_proves_absence fail on main ced2298 too (MessageEvent is not an image atom).
  • stdlib handle_lifecycle_tests::native_property_bag_follows_a_reachable_reclaimable_payload and native_property_bag_to_handle_cycle_dies_with_its_last_js_owner fail on main too (receipts: t-std-main.log, same 2 names).
  • The 94 gap MISMATCH and 7 COMPILE_FAIL are the same set on main (gap-main.tsv).
  • Lint failures listed in the lint table as "same" fail identically on main; sso_unbox_inventory fails on main only.
  • -D warnings on perry-runtime: 4 errors on both arms, identical text.

Open questions

  1. Coordinator ruling 11-04: node:<m> cells keep their owner from their name (the name is the declaration); only receivers (%NativeNamespace:x%) declare one, and a receiver without one is an error by name.
  2. Q9 (kept, the owner's call): Object.keys(performance) is 14 perf_hooks keys in perry and [] in Node (Node's are prototype getters).
  3. The six CJS-default modules follow WRT's ruling (the default member is the CJS value).
  4. wasm / Mach-O / COFF: the link-order ties recorded in A1 apply unchanged; this PR adds no section.
  5. The 9 ORDER_DISAGREES cells are export-order fixes outside A2's declarations; each is a fix-forward candidate that leaves the list when fixed.
  6. The startup residue is removed by PR B (branded root shapes in the realm image); this PR does not land without it.

Ralph Küpper and others added 30 commits November 4, 2026 05:26
…ate input; the gate asserts the seed and runs in CI
realm_decl! gains the item form `namespace SYM = "owner" { names = [...]; members = { key; key = (value); } }`.
It expands to the module's vtable static: the shared NATIVE_MODULE_VTABLE_BASE plus the module_keys the item
lists, in Node's order. The fs namespace moves to fs/namespace.rs; FS_NAMESPACE_EXPORT_KEYS is gone and
Object.keys reads NM_FS.module_keys.

The producer reads the item as source text and births its cell through the native namespace birth, which
names the item births with the entry `namespaces = native_namespaces`. The cell records the item's source,
line and vtable (crate::fs::namespace::NM_FS). An item without that one birth, a duplicate owner, or a
member value that is not one paren group fails by name.

Test 6.3, generated next to the owner truth test: the vtable keys are the declaration's own key order, and
Object.keys of the namespace the owner yields is exactly those keys.
…spaces/<module>.rs, http2 in node_http2_constants.rs); their hand key tables and arms are gone
…e members perry serves (Q7: Object.keys no longer leaks __module__); their inline lists leave the native birth

crypto.constants (54), crypto.subtle (0, extends SubtleCrypto.prototype), crypto.webcrypto (0, extends Crypto.prototype), fs.constants (56), os.constants (5), readline (7), stream/promises (2), string_decoder (1), v8.promiseHooks (5), v8.startupSnapshot (4), streams (0): Node v26.12.1 order, keys perry does not serve left out. Test every_declared_namespace_key_is_served.
… and 3 two-name splits leave their hand tables

Each Node module whose enumerable keys were a hand table behind a
`native_module_enumerable_keys` arm is now an item-level
`realm_decl! { namespace ... }` beside its module, and replaces its
births-only entry in the native birth block: dgram, async_hooks, sea,
module, path (+ posix, win32), dns (+ promises), child_process, cluster,
stream, process (namespace, default), querystring, punycode (+ default,
ucs2), timers (+ promises), os, url, util, events, readline/promises,
wasi, inspector, inspector/promises, repl. 20 hand tables are deleted.

The legacy util members refer to node:sys.<k>; events carries the data
values of node:events; punycode gets its bound methods and version as nodes.

Deferred (non-Node module declarations): sqlite, sqlite.constants, bun,
bun:ffi, bun:jsc, ffi, node-pty keep their arms and tables.
…option a, stage S1)

perry-abi native_vtable_brand defines the brand band 1<<60 once (producer and
reader). The namespace birth brands its shape with the module vtable, so each
module's namespace has its own shape and the existing transitions carry the
vtable to successor shapes. Readers take the vtable off the receiver's shape;
the global NATIVE_MODULE_VTABLE_PTR is gone. Keys live on the vtable
(ModuleKeys head+tail, no OnceLock). Runtime instance objects carry the
unkeyed vtable. The name -> vtable match still picks the vtable at the by-name
birth; S2 deletes it.

Tests: own shape per module, twin births share it, unmatched name is unkeyed,
define/delete, preventExtensions/seal/freeze and the sealed `module`
namespace keep the vtable, the brand is not a key.
…e; the default namespace vtable lives on the base vtable; codegen passes vtables (Q8 S2 runtime and codegen)
…value, payload prototypes and payload families (Q8 S2 step 2)
…ry their vtable; IR test: no by-name birth without a dynamic require (Q8 S2)
…ule_namespace, js_native_module_esm_export_value, js_native_module_named_esm_export_value) with their anchors and wasm rows (Q8 S2 Q1)
… vtable base link, one clause sets both links (Q8 S2 Q2)
…_vt); the deleted extern's name no longer stands for it
…PROCESS (main added the call after the by-name birth was deleted)
…main's gate; references regenerated (names from this head's R, cells from the restricted dumper source); the dumper skips the identity block when its module import is bound out of R
…rocess namespace birth changed); thread-exit custody entries for the module row table (NATIVE_MODULE_ROWS/ROW_COUNT replace NATIVE_MODULE_VTABLE_PTR)
A birth publishes its first keyed shape through shapes::birth_publish_branded_keys: the shape is the vtable's root shape (keys plus brand), so no brand transition runs and no unbranded shape is published first. The shared publishes (mod.rs, field_ops.rs, field_get_set.rs) are main's code byte for byte; the brand is applied only by the birth entry.
… is the CJS value (Stream, EventEmitter, Module)

import d from "node:stream" gives the module.exports value, so the declaration refs its cell rather than the ESM namespace of the module (which pulled ReadableStream and WritableStream into node-imports R). Gate references regenerated (node-imports loses 7 names); gap test test_gap_node_default_import_is_cjs_value checks default import, namespace .default and the named export agree, as in node 26.8.1.
The NoBaseNamespace import the namespace macro emits is allowed to be unused where a vtable has its own base; dead module bindings, two needless unsafe blocks and the dead CRYPTO_NAMESPACE_KEYS table are removed; rustfmt on the files whose main version is formatted.
…rocess.default, constants.default, dgram.default, http.default, https.default, net.default, process (and process.namespace, process.default), repl.default, sys.default, tls.default, url.default, worker_threads.default

The declaration is the one list of each namespace (the vtable key table and Object.keys derive from it): shared members are placed in Node v26 insertion order, members only perry has keep their place against their neighbours.
…r (Node order) instead of a second hand list
…he three SPKAC methods), statics in Node order

ownKeys listed prototype while the descriptor was undefined. The function stays a plain function cell (no constructor kind), the prototype is born with the function like X509Certificate.prototype; test_gap_crypto_certificate_prototype compares typeof, ownKeys, the prototype descriptor and its members with node.
…e; gap test test_gap_native_member_order (16 cells, shared keys, node oracle)
…ble segment; a code-size difference past a page moved the read-only segment start)
…ssageEvent global and dropped the Object.prototype names from Object; the old-to-new change equals main's own 6e5c6e1-to-ced229822d change except on the cells this branch changes)
…one member list per namespace, the gate compares it with Node's Object.keys per cell (native-order.node.json, Node v26.12.1), cells with no Node value listed, the disagreeing set equals ORDER_DISAGREES
Ralph Küpper added 12 commits November 4, 2026 06:42
…table) rows a program with a dynamic import carries (test-only with_test_module_rows installs and restores them)
…no inner block); string_decoder drops the unused module name
…an error by name (the producer fills none in); the 8 receivers declare their owner
…ble through one local use (state.rs back under the file-size limit)
… (the shape census tracks the word keys_array as a raw member; the census baseline is unchanged)
…mespace) exports only for its own invocations; the wrapper check and the other arms skip it (self-test added)
…lated to A2b; both files were already over the size limit on main)
…c (a thread_local is a GC-holder census row)
…ugh with_mut_ptr (raw-handle debt ceiling for native_module.rs stays at 4)
… js_nm_set_module_table are A2b rows; 6 more rows were missing on main already
…-export line in fs/mod.rs, which is over the size limit on main)
@coderabbitai

coderabbitai Bot commented Nov 4, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

This branch has not been deployed

No deployments
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.

1 participant