Skip to content

fix(runtime): split function-metadata registration into copying and borrowing entry points (#9188) - #9705

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/9188-static-fn-metadata
Closed

fix(runtime): split function-metadata registration into copying and borrowing entry points (#9188)#9705
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/9188-static-fn-metadata

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Summary

#9188 asked for a contract decision, not an optimisation: js_register_function_name / js_register_function_source could stop copying their bytes if the registries held (ptr, len) instead, but that turns "outlives the call" into "outlives the process" on two #[no_mangle] pub extern "C" symbols reachable from separately-loaded provider images and from FFI.

By the time this was picked up, the copy had already been removed — by tightening those published contracts in place. This PR implements option 2 from the issue (its own recommendation) and puts the contract back where it was, without giving up the win:

entry point contract who calls it
js_register_function_name / js_register_function_source bytes need only outlive the call — the registry copies them everyone who is not codegen: the runtime's own global-helper installation (parseInt, isNaN, …), provider images, FFI
js_register_function_name_static / js_register_function_source_static bytes must outlive the process — the registry stores the borrowed slice codegen only, from __perry_init_strings_<prefix>, where they are @.str.N private unnamed_addr constant globals

All the volume is on the borrowing side — 72,713 registrations on the compiled claude-code TUI, 5.1 MB of names and 23.8 MB of source text — so the startup copy stays gone from the path that had it, and no published contract is tightened underneath a caller.

Storage

Borrowed and owned bytes live in separate maps rather than one map of an enum: an enum value would add 8 bytes to every one of the ~60,000 borrowed entries to carry the handful of owned ones, which measured as a net loss (+0.31 MB) even though it removed 1.8 MB of copies. The name registry already had that shape (a small overrides map for names register_function_name_if_absent infers at run time); source text gains the matching pair. Owned entries win on read — an owned entry can only come from an explicit runtime registration, which is the more specific statement about that function — and the two locks are never held simultaneously, so there is no acquisition order to get wrong.

The registries move out of formatting.rs, which was 26 lines under the 2,000-line cap, into a new builtins/fn_metadata.rs.

The tests are sabotage tests

copying_name_entry_point_owns_its_bytes and copying_source_entry_point_owns_its_bytes register from a heap buffer, overwrite that buffer in place while it is still alive, and assert the registry still returns what was registered. Deleting the copy makes them fail deterministically — verified by rewiring both copying entry points to borrow, which fails 3 of the 6 — instead of turning into latent UB in a provider image, which is the failure this split exists to prevent. The other three pin the two-map read rules, including that Error.stack's staleness check counts both maps.

codegen/emission_order_tests.rs's IR-text matchers were moved to the emitted spelling: left on the old name they would have matched nothing and passed vacuously. Both new symbols are added to scripts/check_runtime_symbols.sh, so a runtime archive predating the split fails there instead of at link time on a build worker.

Not done here: the issue's option 3 (have codegen skip source registration entirely when a whole-program analysis proves Function.prototype.toString is unreachable). The dynamic-access analysis is the hard part and is a separate change.

Testing

  • cargo test -p perry-runtime --lib — 3,075 passed (RUST_TEST_THREADS=1)
  • cargo test -p perry-codegen --lib — 1,400 passed; cargo test -p perry-hir --lib — 380 passed
  • scripts/run_lint_gates.sh — all 62 script gates pass; cargo clippy --workspace clean; cargo check --workspace --all-targets under -D warnings clean (the Linux-only pthread_getattr_np redeclaration warnings reproduce on a clean origin/main checkout and are not from this change)
  • Gap/parity A/B against origin/main over 110 function-metadata-relevant tests (function, tostring, inspect, closure, stack, _name, fn_), same host and same Node: identical verdicts on both arms
  • End-to-end probe: console.log(fn), fn.name, class-method names, fn.toString() for both a declaration and an arrow, and computed-key name inference — all match Node

No version bump.

Closes #9188

https://claude.ai/code/session_01KL1tsB4oYnxRzF533NzHJF

…orrowing entry points (PerryTS#9188)

Registering function metadata runs once per function a bundle CONTAINS —
72,713 of them on the compiled claude-code TUI — so what one call costs is a
startup cost every program pays whether or not it ever reads a name. The copy
had already been removed by storing `(ptr, len)` and borrowing the program
image, which is sound only if the bytes outlive the PROCESS. That is strictly
stronger than the "outlives the call" these entry points published, and
`js_register_function_name` / `js_register_function_source` are `#[no_mangle]
pub extern "C"` symbols reachable from separately-loaded provider images and
from FFI, so it is not a promise that can be imposed on callers that already
exist.

PerryTS#9188 was filed to make that a deliberate decision rather than a side effect of
a perf commit. This is option 2 from the issue — split the entry points instead
of retightening the contract:

  * `js_register_function_name` / `js_register_function_source` are back to
    their original contract: the bytes need only outlive the call, because the
    registry copies them. Every caller that is not codegen uses these.
  * `js_register_function_name_static` / `js_register_function_source_static`
    require process lifetime and store the borrowed slice. Codegen emits these,
    and only these, from `__perry_init_strings_<prefix>`, where the bytes are
    `@.str.N` `private unnamed_addr constant` globals in the image.

All the volume is on the borrowing side, so the startup copy stays gone from
the path that had it, and no published contract was tightened underneath a
caller.

Borrowed and owned bytes live in separate maps rather than one map of an enum:
an enum value would add 8 bytes to every one of the ~60,000 borrowed entries to
carry the handful of owned ones, which measured as a net loss (+0.31 MB). Owned
entries take precedence on read, and the two locks are never held at the same
time, so there is no acquisition order to get wrong.

The registries move out of `formatting.rs` (26 lines under the 2,000-line cap)
into a new `builtins/fn_metadata.rs`.

The two copy tests are sabotage tests: they register from a heap buffer,
overwrite it in place while it is still alive, and assert the registry still
returns what was registered. Rewiring the copying entry points to borrow fails
3 of the 6 deterministically, instead of producing latent UB in a provider
image. `codegen/emission_order_tests.rs`'s IR-text matchers were updated to the
emitted spelling — left on the old name they would have matched nothing and
passed vacuously. Both new symbols are added to `check_runtime_symbols.sh`, so
a runtime archive predating the split fails there rather than at link time on a
build worker.

Claude-Session: https://claude.ai/code/session_01KL1tsB4oYnxRzF533NzHJF
@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 9 minutes.

Check out review usage here.

View limit details

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

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 1696106d-487c-4f6f-81a0-747a2b5c3611

📥 Commits

Reviewing files that changed from the base of the PR and between 75b886a and 773f8f8.

📒 Files selected for processing (13)
  • changelog.d/9705-fn-metadata-static-entry-points.md
  • crates/perry-codegen/src/codegen/artifact_display_names.rs
  • crates/perry-codegen/src/codegen/artifacts.rs
  • crates/perry-codegen/src/codegen/emission_order_tests.rs
  • crates/perry-codegen/src/codegen/string_pool.rs
  • crates/perry-codegen/src/runtime_decls/mod.rs
  • crates/perry-hir/src/ir/module.rs
  • crates/perry-hir/src/stable_hash/module.rs
  • crates/perry-runtime/src/builtins/fn_metadata.rs
  • crates/perry-runtime/src/builtins/formatting.rs
  • crates/perry-runtime/src/builtins/mod.rs
  • crates/perry-runtime/src/error_stack_frames.rs
  • scripts/check_runtime_symbols.sh

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
proggeramlug force-pushed the fix/9188-static-fn-metadata branch from cf0fd4d to 773f8f8 Compare September 4, 2026 09:08
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Holding this one — I think the _static borrow is unsound for plugin dylibs, which are also codegen output.

The contract table reads _static as "codegen only, from __perry_init_strings_<prefix>", treating codegen output as process-lifetime. But perry compiles TypeScript to a dylib plugin as well as an executable (crates/perry/src/commands/compile/types.rs:158 — "Output type: executable (default), dylib (shared library plugin)"), and crates/perry-codegen/src/codegen/entry.rs:21 emits the perry_plugin_abi_version / plugin_activate shim for that dylib's entry module. Those images are unloaded at runtime: perry_plugin_unload (crates/perry-runtime/src/plugin.rs:791) ends in close_library(handle)dlclose.

The emission in string_pool.rs:463 is unconditional — string_pool.rs has no mention of dylib, plugin, or an output kind, so a plugin dylib's __perry_init_strings_ calls the borrowing spelling exactly like an executable's. Its @.str.N globals live in the plugin's rodata, which is unmapped by that dlclose.

The registries have no way to drop those entries: perry_plugin_unload calls reg.remove_plugin_registrations(plugin_id), which clears plugin hook registrations, and fn_metadata.rs exposes no prune/remove/clear/unregister. So after an unload the maps retain (ptr, len) pairs into unmapped memory, and the next fn.name, Function.prototype.toString(), or error stack frame that resolves one of those addresses reads it. Being address-keyed, a later image mapped over the same range would collide rather than fault, which is the worse version.

This is the exact hazard the PR is written to prevent — "outlives the call" vs "outlives the process" — with image and process being the same lifetime for an executable but not for a plugin.

Two shapes of fix, your call:

  1. Gate the emission on output kind — executables emit _static, dylib/plugin output keeps the copying spelling. Keeps the whole win where the volume is (the cc TUI is an executable) and needs no new lifetime rules. string_pool.rs would need the output kind plumbed to it, which it currently doesn't have.
  2. Prune on unload — give the registries a remove-by-address-range and call it from perry_plugin_unload before close_library. More moving parts, and it has to run before the dlclose, not after.

I'd lean (1). Everything else in the PR looks right to me — restoring the published contract with a second symbol instead of tightening the existing one in place is the correct call, the separate borrowed/owned maps are well argued with the +0.31 MB measurement, and adding both spellings to check_runtime_symbols.sh is a good catch for the archive-skew case. Happy to take it as soon as the plugin path is covered.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via merge train #9723, together with a follow-up fix for the plugin-dylib lifetime issue noted above (emit_string_pool now picks the spelling from output_type). Thanks!

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.

Function name/source registration copies every string at startup — removing the copy needs a contract decision

1 participant