From 83c701e73cd7b503f16ed38132065f680788a358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 09:21:23 +0200 Subject: [PATCH] diag(hir): report every native-instance tag at the two entry points that create them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PERRY_NATIVEINST_DIAG=1` prints one line per native-instance registration: [nativeinst] REGISTER push_module name="O" -> child_process::Instance `register_native_instance` and `push_module_native_instance` are the only two entry points through which a native-instance tag can come into existence, so a diagnostic on them cannot miss a tag. That placement is the point of the change: an earlier attempt at the same question instrumented four plausible construction sites out of the 165 that build `Expr::NativeMethodCall`, printed zero, and the zero was uninterpretable. What it is for (#9847). The tag table is keyed by identifier TEXT with module-wide scope. On a minified bundle that compiles as one module the same short name is routinely claimed by several unrelated native classes, and every method call on any local with that name is then lowered as a native-instance call of whichever class won. On claude-code's `cli_2.1.112.js` this report prints 795 registrations whose most-registered identifiers are Y(71), z(65), K(65), _(65), A(54), O(52), w(37), q(35) — every one a single letter — with `O` registered as `stream::Instance`, `child_process::Instance`, `transform_stream::TransformStream` and `readable_stream::ReadableStream` at once. Reading that took one 30-second compile; deriving it from source took a day of hypotheses, four of which were wrong. `PERRY_NATIVEINST_DIAG` is excluded from the build-level cache for the same reason `PERRY_OPT_REPORT` is: a cached build reuses the finished binary and never lowers HIR, so the report would come up empty — and empty is indistinguishable from "no tag was ever registered", which is precisely the reading this diagnostic exists to make impossible. Off, the cost is one relaxed atomic load per registration and nothing else. --- changelog.d/9847-nativeinst-registry-diag.md | 16 +++++++++ crates/perry-hir/src/lower/context.rs | 35 +++++++++++++++++++ .../perry/src/commands/compile/build_cache.rs | 8 +++++ 3 files changed, 59 insertions(+) create mode 100644 changelog.d/9847-nativeinst-registry-diag.md diff --git a/changelog.d/9847-nativeinst-registry-diag.md b/changelog.d/9847-nativeinst-registry-diag.md new file mode 100644 index 0000000000..43102427a8 --- /dev/null +++ b/changelog.d/9847-nativeinst-registry-diag.md @@ -0,0 +1,16 @@ +`PERRY_NATIVEINST_DIAG=1` reports every native-instance tag as it is created. + +`register_native_instance` and `push_module_native_instance` are the only two +entry points through which such a tag can come into existence, so a diagnostic +on them cannot miss one the way a diagnostic on guessed construction sites can +— which is why it is placed there. One line per registration: + +``` +[nativeinst] REGISTER push_module name="O" -> child_process::Instance +``` + +The env var is excluded from the build-level cache, because a cached build +reuses the finished binary and never lowers HIR, so the report would print +nothing — and nothing is indistinguishable from "no tag was ever registered". + +Off, the cost is one relaxed atomic load per registration. diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 338b27193f..0f392b3e71 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -1461,6 +1461,7 @@ impl LoweringContext { module_name: String, class_name: String, ) -> bool { + nativeinst_registry_diag("register", &local_name, &module_name, &class_name); // #5137: if the user opted this package into `perry.compilePackages`, // its real npm source is being compiled and the binding resolves to // the compiled-from-source class. Registering a native instance here @@ -1679,6 +1680,7 @@ impl LoweringContext { /// scans these in reverse (last-match-wins), so the index stores the LAST /// pushed entry per name (overwrite). pub(crate) fn push_module_native_instance(&mut self, entry: (String, String, String)) { + nativeinst_registry_diag("push_module", &entry.0, &entry.1, &entry.2); let idx = self.module_native_instances.len(); self.module_native_instances_index .insert(entry.0.clone(), idx); @@ -1909,3 +1911,36 @@ pub(crate) fn perry_ui_factory_returns_handle(name: &str) -> bool { || perry_dispatch::perry_ui_lookup(name) .is_some_and(|row| row.ret == perry_dispatch::ReturnKind::Widget) } + +/// #9847: report every native-instance tag as it is created. +/// +/// `register_native_instance` and `push_module_native_instance` are the only +/// two entry points through which a native-instance tag can come into +/// existence, so a diagnostic on *them* cannot miss a tag the way one on +/// guessed construction sites can — which is the whole reason this exists. +/// +/// What it prints, one line per registration: +/// +/// ```text +/// [nativeinst] REGISTER push_module name="O" -> child_process::Instance +/// ``` +/// +/// The tag table is keyed by identifier TEXT with module-wide scope, so on a +/// minified single-module bundle the same short name is routinely claimed by +/// several unrelated native classes and every method call on any local with +/// that name is lowered as a native-instance call of whichever won. This +/// report is what makes that visible: on `cli_2.1.112.js` it prints 795 lines +/// whose most-registered identifiers are `Y`(71), `z`(65), `K`(65), `_`(65), +/// `A`(54), `O`(52), `w`(37), `q`(35) — every one a single letter. +/// +/// Enable with `PERRY_NATIVEINST_DIAG=1`. Off, this is one relaxed atomic load +/// per registration and nothing else. +pub(crate) fn nativeinst_registry_diag(kind: &str, name: &str, module: &str, class: &str) { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + let on = *ON.get_or_init( + || matches!(std::env::var("PERRY_NATIVEINST_DIAG"), Ok(v) if !v.is_empty() && v != "0"), + ); + if on { + eprintln!("[nativeinst] REGISTER {kind} name={name:?} -> {module}::{class}"); + } +} diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index ae988212c7..40e5a425f3 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -841,6 +841,14 @@ fn eligibility(args: &CompileArgs, project_root: &Path) -> Result<(), String> { if std::env::var("PERRY_OUTLINE_ENTRY_REPORT").is_ok() { return Err("outline-entry-report".to_string()); } + // #9847: same reasoning as `opt-report` above. A cached build reuses the + // finished binary and never lowers HIR, so the native-instance report + // would print nothing — and nothing is indistinguishable from "no tag was + // ever registered", which is the reading this diagnostic exists to make + // impossible. + if std::env::var("PERRY_NATIVEINST_DIAG").is_ok() { + return Err("nativeinst-diag".to_string()); + } if args.verify_native_regions || args.emit_attest || args.emit_sandbox { return Err("sidecar-or-verify".to_string()); }