From 04d545e4097a27727f11a859e9eb46f3e2bbb940 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 21 Sep 2026 16:22:33 +0000 Subject: [PATCH 1/2] feat(runtime): perry/tui handles are ordinary objects (#10821) Every value `perry/tui` handed TypeScript was a small registry integer wearing `POINTER_TAG` -- a number pretending to be a pointer. Three registries minted those ids and three more kinds were plain constants, so SIX id spaces shared one encoding and they collided: useApp() -> 1 Text("hi") -> 1 useRef(x), first -> 1 useStdout() -> 2 Box() -> 2 useRef(y), second -> 2 useFocusManager() -> 3 Spacer() -> 3 state(0), first -> 0 <- POINTER_TAG | 0, a null pointer wearing the pointer tag `useApp() === Text("hi")` was `true`. A `Map` or `Set` keyed on two different handles kept one entry. A `WeakMap` entry stored under a widget was readable through the App handle. And the first `state(0)` of a program was a tagged null -- the exact shape the honest-tag invariant exists to forbid. None of it was reachable through a type error, because the encoding carries no provenance: at run time the values are indistinguishable. Each kind is now an ORDINARY object: `GC_TYPE_OBJECT` with a real ShapeId, a class id from the web-builtin block (`0xFFFF_240B..0x2410`) and ZERO own keys. `typeof` is `"object"`, `Object.keys` is `[]`, `JSON.stringify` is `{}` where it was `null`, and two handles are two values. * The registry ids are UNCHANGED and stay the module's internal currency. The widget tree, the Taffy layout pass, the paint pass and the hook slots all still speak ids; only the value crossing the `#[no_mangle]` boundary changed, through two helpers (`widget_object` out, `widget_id` in). A raw argument is not a GC root, so every consumer resolves at entry, before anything that can allocate and move it. * `useApp()` / `useStdout()` / `useFocusManager()` answer the SAME object on every call -- ink's do, and perry's did too while they were constants -- so they are per-realm singletons in rooted slots rather than re-minted per call. That is the resource->object mapping at singleton scale. * `useRef` is likewise stable across renders, so the HOOK SLOT owns its handle object. That makes it a GC pointer in a side table with two scanners over it; both now go through one `visit_hook_slot_roots` whose `match` destructures every field, so a forgotten edge is a compile error rather than a scavenge crash. * `state.get()/.set()`, `ref.get()/.set()`, `app.exit()/.waitUntilExit()`, `stdout.write()/.columns()/.rows()` and `focusManager.focusNext()/ .focusPrevious()/.focus(id)` are real methods on a per-kind prototype as well as the statically lowered `class_filter` rows they already were. Before this they existed ONLY as static lowerings, so a handle reached through an untyped value answered `undefined` for every one of them. The prototypes are lazy per-realm singletons built in the runtime (the timer family's shape), not `populate_builtin_prototype_methods` entries, so unlike #10831 there is no `global-*` feature to make load-bearing -- `perry/tui` has none. * A foreign receiver is answered leniently with `undefined`, not thrown at. `perry/tui` is not WebIDL and has no node equivalent to copy a brand policy from, and this is the choice that cannot turn a working program into a throwing one. It is never READ as an id of the wrong kind: that is what the class-id brand prevents, and with six overlapping id spaces it had to. Deleted by the representation: `tui::is_known_handle` and the three `contains_handle` probes it unioned. They answered "is this integer one of our registries' ids?" by taking three mutexes, and could not answer correctly because the spaces overlap. A class-id load answers it with one load and no ambiguity. Also here, because the third family is where it stopped being avoidable: the class ids move into `perry-runtime/src/native_class_ids.rs`. The worker-transfer guard had grown into a range spelled `TEXT_ENCODER_CLASS_ID..=IMMEDIATE_CLASS_ID` INSIDE `text.rs` -- a check about every family living in one family's file, widened by hand per landing. The new module owns the whole `0xFFFF_24xx` block, each family aliases its own id from it, and a `const fn` assertion (not a `debug_assert`, which is free in release) fails the BUILD if two families ever claim one id or if an id wanders toward the `ShapeId` window -- #10824 was a shipped aliasing bug. Gate A: the `tui` arm of `receiver_repr_family_fixtures_move_constructed_and_observed_old` is inverted from "observed_old moves" to "stays 0", and its `observe_pointer` arm is deleted. Note what the pre-migration fixture had to do: retry when the constructed handle came back 0, because the first `state(0)` was a tagged null. Gate B: a producer-side assertion that each kind's constructor result is not in the handle band, carries zero own keys and resolves back to its id -- which is what covers the `class_filter`-lowered reads gate A cannot see. Three tests named the old representation and are re-baselined, all stated in the PR: `alloc_returns_sequential_handles` and `state_slots_survive_a_foreign_clear` asserted the handle WAS the slot index (`h0 == 0`, `h_next == h + 1`) and now assert that of the slot the handle carries, and `out_of_range_handle_returns_undefined` is renamed for what it now proves -- an arbitrary integer can no longer address a live slot at all. The emitted small-handle guards and every `addr_class` band predicate stay exactly as they are. They come out only after the last family has moved. --- changelog.d/honest-handle-tag-tui.md | 44 ++ crates/perry-runtime/src/event_target.rs | 8 +- .../src/hot_diag/receiver_repr.rs | 28 +- crates/perry-runtime/src/lib.rs | 3 + crates/perry-runtime/src/native_class_ids.rs | 162 +++++ crates/perry-runtime/src/object/mod.rs | 6 + crates/perry-runtime/src/text.rs | 12 +- crates/perry-runtime/src/thread.rs | 17 +- crates/perry-runtime/src/timer.rs | 2 +- .../perry-runtime/src/timer/handle_object.rs | 4 +- .../perry-runtime/src/timer/tests_inline.rs | 2 +- crates/perry-runtime/src/tui/ffi.rs | 98 ++- crates/perry-runtime/src/tui/handle_object.rs | 586 ++++++++++++++++++ crates/perry-runtime/src/tui/hooks.rs | 277 ++++++--- crates/perry-runtime/src/tui/mod.rs | 14 +- crates/perry-runtime/src/tui/run.rs | 8 +- crates/perry-runtime/src/tui/state.rs | 95 ++- crates/perry-runtime/src/tui/tree.rs | 3 - crates/perry-runtime/src/url/abort.rs | 4 +- scripts/gc_runtime_root_holders.json | 40 ++ 20 files changed, 1226 insertions(+), 187 deletions(-) create mode 100644 changelog.d/honest-handle-tag-tui.md create mode 100644 crates/perry-runtime/src/native_class_ids.rs create mode 100644 crates/perry-runtime/src/tui/handle_object.rs diff --git a/changelog.d/honest-handle-tag-tui.md b/changelog.d/honest-handle-tag-tui.md new file mode 100644 index 0000000000..7ce7177b6a --- /dev/null +++ b/changelog.d/honest-handle-tag-tui.md @@ -0,0 +1,44 @@ +`perry/tui` now hands TypeScript real objects instead of small registry +integers. Every value the module returned — a widget from `Text` / `Box` / +`Table` / `Tabs` / …, a `state(initial)` container, a `useRef` box, and the +`useApp()` / `useStdout()` / `useFocusManager()` singletons — used to be an id +NaN-boxed with `POINTER_TAG`, a number pretending to be a pointer. Three +registries minted those ids and three more were plain constants, so SIX id +spaces shared one encoding and they collided: + + useApp() -> 1 Text("hi") -> 1 useRef(x), first -> 1 + useStdout() -> 2 Box() -> 2 useRef(y), second -> 2 + useFocusManager() -> 3 Spacer() -> 3 + state(0), first -> 0 <- POINTER_TAG | 0, a null pointer wearing + the pointer tag + +`useApp() === Text("hi")` was therefore `true`, a `Map` or `Set` keyed on two +different handles kept one entry, a `WeakMap` entry stored under a widget was +readable through the App handle, and the first `state(0)` of a program was a +tagged null. None of it was reachable through a type error: the values are +indistinguishable at run time, because the encoding carries no provenance. + +Each kind is now a `GC_TYPE_OBJECT` with its own class id, a real ShapeId and +ZERO own keys, so `typeof` is `"object"`, `Object.keys` is `[]`, +`JSON.stringify` is `{}` (it was `null`), spread and `Object.assign` copy +nothing, and two handles are two values. `useApp()`, `useStdout()` and +`useFocusManager()` still answer the SAME object on every call — ink's do, and +perry's did too while they were constants — so they are per-realm singletons in +rooted slots rather than re-minted per call. `useRef` is likewise stable across +renders: the hook slot owns its handle object. + +`state.get()` / `.set(v)`, `ref.get()` / `.set(v)`, `app.exit()` / +`.waitUntilExit()`, `stdout.write()` / `.columns()` / `.rows()` and +`focusManager.focusNext()` / `.focusPrevious()` / `.focus(id)` are now real +methods on a per-kind prototype as well as the statically lowered +`class_filter` rows they already were. Before this they existed ONLY as static +lowerings, so a handle reached through an untyped value (`const s: any = state(0)`) +answered `undefined` for every one of them. + +The registry ids are unchanged and stay the module's internal currency: the +widget tree, the Taffy layout pass, the paint pass and the hook slots all still +speak ids, and only the value that crosses the FFI boundary changed. A handle +of one kind can no longer address another kind's registry entry, which the +overlapping id spaces previously allowed — `tui::is_known_handle` and the three +`contains_handle` probes it unioned are deleted, because a class-id load answers +the same question without asking three mutexes. diff --git a/crates/perry-runtime/src/event_target.rs b/crates/perry-runtime/src/event_target.rs index 4238375e38..5c6623f27b 100644 --- a/crates/perry-runtime/src/event_target.rs +++ b/crates/perry-runtime/src/event_target.rs @@ -14,16 +14,16 @@ use crate::{ use std::collections::HashSet; use std::sync::{Mutex, OnceLock}; -pub const CLASS_ID_EVENT: u32 = 0xFFFF_2403; -pub const CLASS_ID_CUSTOM_EVENT: u32 = 0xFFFF_2404; -pub const CLASS_ID_DOM_EXCEPTION: u32 = 0xFFFF_2405; +pub const CLASS_ID_EVENT: u32 = crate::native_class_ids::EVENT; +pub const CLASS_ID_CUSTOM_EVENT: u32 = crate::native_class_ids::CUSTOM_EVENT; +pub const CLASS_ID_DOM_EXCEPTION: u32 = crate::native_class_ids::DOM_EXCEPTION; /// `EventTarget` base class. Stamped on `new EventTarget()` instances and used /// as the PARENT class id of a user `class X extends EventTarget` (wired by /// `js_register_class_parent_dynamic` via `global_builtin_constructor_class_id`). /// Walking to it through the class chain is what lets a subclass instance be /// recognized as an event target (#6301). Keep in sync with the reserved id in /// perry-codegen/src/expr/instance_misc1.rs. -pub const CLASS_ID_EVENT_TARGET: u32 = 0xFFFF_2406; +pub const CLASS_ID_EVENT_TARGET: u32 = crate::native_class_ids::EVENT_TARGET; const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; diff --git a/crates/perry-runtime/src/hot_diag/receiver_repr.rs b/crates/perry-runtime/src/hot_diag/receiver_repr.rs index 5ee63216d2..cd978e85bb 100644 --- a/crates/perry-runtime/src/hot_diag/receiver_repr.rs +++ b/crates/perry-runtime/src/hot_diag/receiver_repr.rs @@ -206,10 +206,13 @@ fn observe_pointer(addr: usize) { // receiver can be a small band id any more and this family can never be // marked old again. The fixture below asserts `observed_old == 0` for it; // that inversion is the per-family record that the migration landed. + // #340/#341 GATE A: `tui` has migrated, so its arm is gone from here too. + // `tui::is_known_handle` survives for the ledger's own question ("does any + // small id still reach a funnel?") but is no longer consulted on this + // path: a tui handle is a heap object, and asking three registries whether + // an arbitrary heap address is one of their ids took three mutexes to + // answer "no". // Each remaining family deletes its arm here as it moves. - if crate::tui::is_known_handle(addr as i64) { - mark_old(ReceiverReprFamily::Tui); - } if crate::async_hooks::is_async_hook_handle(addr as i64) { mark_old(ReceiverReprFamily::AsyncHook); } @@ -375,9 +378,10 @@ mod tests { /// It is a gate only from HERE, because the fixture calls the funnels /// directly. A compiled program's `observed_old` proves nothing for this /// family — a statically lowered `d.encoding` (codegen's - /// `Expr::TextDecoderEncoding`) never reaches an instrumented funnel, so - /// the counter read 0 before the migration too. Gate B (the producer-side - /// band assertion in `text.rs`'s own tests) covers those reads. + /// `Expr::TextDecoderEncoding`) or a `class_filter`-lowered `state.get()` + /// never reaches an instrumented funnel, so the counter read 0 before the + /// migration too. Gate B (the producer-side band assertion in the + /// family's own tests) covers those reads. fn assert_fixture_migrated(family: ReceiverReprFamily, construct: impl FnOnce() -> usize) { receiver_repr_test_reset(); receiver_repr_test_arm(true); @@ -436,12 +440,12 @@ mod tests { assert_fixture_migrated(ReceiverReprFamily::Text, || { crate::text::js_text_encoder_new() as usize }); - assert_fixture(ReceiverReprFamily::Tui, || { - let mut handle = crate::tui::state::js_perry_tui_state_alloc(0.0); - if handle == 0 { - handle = crate::tui::state::js_perry_tui_state_alloc(0.0); - } - (handle as usize, false) + // #340/#341: `tui` is migrated — gate A, inverted (see `text`). + // Note what the pre-migration fixture had to do: retry when the handle + // came back 0, because the FIRST `state(0)` of a program was slot 0 + // and `POINTER_TAG | 0` is a tagged null. It cannot be 0 now. + assert_fixture_migrated(ReceiverReprFamily::Tui, || { + crate::tui::state::js_perry_tui_state_alloc(0.0) as usize }); assert_fixture(ReceiverReprFamily::AsyncHook, || { let options = crate::object::js_object_alloc(0, 0); diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 25abe9c1b9..d6e2452f8f 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -281,6 +281,9 @@ pub mod tty; /// always available; no separate cargo dep, no async-runtime, no /// per-program link flag. pub mod tui; +// #340/#341: the class ids of every runtime class whose instances are +// ordinary objects. Declared before the families that alias it. +pub(crate) mod native_class_ids; /// HarmonyOS perry/ui FFI no-op stubs (#395). Auto-generated by /// build.rs from perry-dispatch tables. Compiled only when `ohos-napi` /// is on so the platform UI crates' definitions own the symbols on diff --git a/crates/perry-runtime/src/native_class_ids.rs b/crates/perry-runtime/src/native_class_ids.rs new file mode 100644 index 0000000000..3a8f74bc34 --- /dev/null +++ b/crates/perry-runtime/src/native_class_ids.rs @@ -0,0 +1,162 @@ +//! The class ids of runtime classes whose instances are ORDINARY objects +//! (#340/#341) — one place that says which id belongs to which family. +//! +//! Honest tags gives every native-backed value a `GC_TYPE_OBJECT` carrying a +//! family class id, and those ids were being minted family by family as bare +//! constants in the family's own module. There is no central allocator for +//! class ids in the tree, per-module class-id collisions are a known open +//! problem (#10824: a class id must also never alias a live `ShapeId`), and +//! the worker-transfer guard had grown into a range spelled +//! `TEXT_ENCODER_CLASS_ID..=IMMEDIATE_CLASS_ID` **inside `text.rs`** — a check +//! about every family, living in one family's file, widened by hand each time +//! a family landed. +//! +//! This module owns the `0xFFFF_24xx` web-builtin block instead. A family +//! declares its own alias from here (so its call sites are unchanged), the +//! range is closed by construction, and the const assertions below fail the +//! build rather than a test if two families ever name one id or if an id +//! wanders into the `ShapeId` window. +//! +//! **Allocating an id: take the next value, add it to `ALL` and to the +//! `is_native_backed_class_id` range if the family owns native state.** Do not +//! reuse a retired id: a stale compiled artifact naming it would brand the +//! wrong family. + +/// Web-builtin block start. `ShapeId`s live in `[0x8000_0000, 0xC000_0000)` +/// (`object/shapes.rs`), so this block is far above them; the assertion at the +/// bottom of the file pins that rather than trusting the comment (#10824). +pub(crate) const WEB_BUILTIN_BLOCK_START: u32 = 0xFFFF_2401; + +// --- Pre-existing, declared elsewhere and aliased here so the block is one +// --- list. These six are ordinary objects already, but their state is own +// --- fields rather than prototype accessors (#10823-shaped), so they are not +// --- yet `is_native_backed_class_id`. +pub(crate) const ABORT_CONTROLLER: u32 = 0xFFFF_2401; +pub(crate) const ABORT_SIGNAL: u32 = 0xFFFF_2402; +pub(crate) const EVENT: u32 = 0xFFFF_2403; +pub(crate) const CUSTOM_EVENT: u32 = 0xFFFF_2404; +pub(crate) const DOM_EXCEPTION: u32 = 0xFFFF_2405; +pub(crate) const EVENT_TARGET: u32 = 0xFFFF_2406; + +// --- Migrated families. Each carries native state in `ObjectMeta.native_state` +// --- and is therefore refused by the worker-transfer guard. +pub(crate) const TEXT_ENCODER: u32 = 0xFFFF_2407; +pub(crate) const TEXT_DECODER: u32 = 0xFFFF_2408; +pub(crate) const TIMEOUT: u32 = 0xFFFF_2409; +pub(crate) const IMMEDIATE: u32 = 0xFFFF_240A; +pub(crate) const TUI_WIDGET: u32 = 0xFFFF_240B; +pub(crate) const TUI_STATE: u32 = 0xFFFF_240C; +pub(crate) const TUI_REF_BOX: u32 = 0xFFFF_240D; +pub(crate) const TUI_APP: u32 = 0xFFFF_240E; +pub(crate) const TUI_STDOUT: u32 = 0xFFFF_240F; +pub(crate) const TUI_FOCUS_MANAGER: u32 = 0xFFFF_2410; + +/// The first id in the native-state range and the last one, inclusive. Every +/// family between them carries a `native_state` word the far side of a +/// `postMessage` could not reconstruct. +const NATIVE_BACKED_FIRST: u32 = TEXT_ENCODER; +const NATIVE_BACKED_LAST: u32 = TUI_FOCUS_MANAGER; + +/// Class ids whose instances are ordinary objects carrying native state that +/// cannot cross a thread boundary (#340/#341). +/// +/// A contiguous range, so a family joins it by taking the next id rather than +/// by editing the transfer path. `thread.rs` calls this in its +/// `GC_TYPE_OBJECT` arm: an ordinary object no longer hits the "kinds 13-16 +/// are native handles" rejection, so without this a `postMessage`d handle +/// would arrive as a plain `{}` with no native state instead of the named +/// `TypeError` #6185 made these surface. +pub(crate) fn is_native_backed_class_id(class_id: u32) -> bool { + (NATIVE_BACKED_FIRST..=NATIVE_BACKED_LAST).contains(&class_id) +} + +/// Every id this module hands out, newest last. Used by the assertions below +/// and by the uniqueness test. +const ALL: &[u32] = &[ + ABORT_CONTROLLER, + ABORT_SIGNAL, + EVENT, + CUSTOM_EVENT, + DOM_EXCEPTION, + EVENT_TARGET, + TEXT_ENCODER, + TEXT_DECODER, + TIMEOUT, + IMMEDIATE, + TUI_WIDGET, + TUI_STATE, + TUI_REF_BOX, + TUI_APP, + TUI_STDOUT, + TUI_FOCUS_MANAGER, +]; + +/// Strictly ascending ⟹ no two families share an id, and the block stays +/// dense so `is_native_backed_class_id` can remain one range compare. A +/// `const fn` loop rather than a test: a duplicated id must not be able to +/// reach a build at all, and a `debug_assert` would enforce nothing in release +/// (#10824 was a SHIPPED aliasing bug). +const fn strictly_ascending(ids: &[u32]) -> bool { + let mut i = 1; + while i < ids.len() { + if ids[i - 1] >= ids[i] { + return false; + } + i += 1; + } + true +} + +const _: () = assert!(strictly_ascending(ALL), "two families claim one class id"); +const _: () = assert!(ALL[0] == WEB_BUILTIN_BLOCK_START); +// A class id must never alias a live ShapeId: the shape store mints into +// `[0x8000_0000, 0xC000_0000)` and a collision would make a shape compare +// answer for a family brand. +const _: () = assert!(NATIVE_BACKED_FIRST >= 0xC000_0000); +const _: () = assert!(NATIVE_BACKED_LAST == ALL[ALL.len() - 1]); + +#[cfg(test)] +mod tests { + use super::*; + + /// Every family that owns a `native_state` word is inside the transfer + /// guard's range, and the classes that do NOT own one are outside it. The + /// second half is what a new family gets wrong: taking the next id makes + /// it non-transferable automatically, which is right, but an id added + /// BELOW the range would silently ship a family that deep-copies into a + /// worker as an empty object. + #[test] + fn the_transfer_guard_covers_exactly_the_migrated_families() { + for id in [ + TEXT_ENCODER, + TEXT_DECODER, + TIMEOUT, + IMMEDIATE, + TUI_WIDGET, + TUI_STATE, + TUI_REF_BOX, + TUI_APP, + TUI_STDOUT, + TUI_FOCUS_MANAGER, + ] { + assert!( + is_native_backed_class_id(id), + "{id:#x} carries native state but crosses a thread boundary" + ); + } + for id in [ + ABORT_CONTROLLER, + ABORT_SIGNAL, + EVENT, + CUSTOM_EVENT, + DOM_EXCEPTION, + EVENT_TARGET, + 0, + 1, + 0x8000_0000, + ] { + assert!(!is_native_backed_class_id(id), "{id:#x} is not migrated"); + } + } + +} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index e157b5f730..944cd73d50 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1339,6 +1339,12 @@ pub fn scan_object_cache_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<' // and be rewritten when they move — the same contract as the iterator // tower above. crate::timer::scan_timer_prototype_roots_mut(visitor); + // #340/#341: the five `perry/tui` prototypes and the three singleton + // handles (`useApp` / `useStdout` / `useFocusManager`). The singletons are + // a resource -> object mapping, not just a prototype: `useApp()` must be + // the SAME object on every call, so the object lives here rather than + // being re-minted. + crate::tui::handle_object::scan_tui_handle_roots_mut(visitor); #[cfg(feature = "regex-engine")] regex_proto_thunks::scan_canonical_test_site_roots_mut(visitor); } diff --git a/crates/perry-runtime/src/text.rs b/crates/perry-runtime/src/text.rs index 9f8acd779b..a120b6e255 100644 --- a/crates/perry-runtime/src/text.rs +++ b/crates/perry-runtime/src/text.rs @@ -85,8 +85,8 @@ struct DecoderState { /// Class ids in the web-builtin block (`0xFFFF_24xx`); `0x2401..=0x2406` are /// AbortController/AbortSignal/Event/CustomEvent/DOMException/EventTarget. -pub(crate) const TEXT_ENCODER_CLASS_ID: u32 = 0xFFFF_2407; -pub(crate) const TEXT_DECODER_CLASS_ID: u32 = 0xFFFF_2408; +pub(crate) const TEXT_ENCODER_CLASS_ID: u32 = crate::native_class_ids::TEXT_ENCODER; +pub(crate) const TEXT_DECODER_CLASS_ID: u32 = crate::native_class_ids::TEXT_DECODER; const STATE_PRESENT: u64 = 1; const STATE_FATAL: u64 = 1 << 1; @@ -790,14 +790,6 @@ static KEEP_TEXT_DECODER_IGNORE_BOM: extern "C" fn(f64) -> f64 = js_text_decoder // does, instead of silently decoding as utf-8. // --------------------------------------------------------------------------- -/// Class ids whose instances are ordinary objects carrying native state that -/// cannot cross a thread boundary (#340/#341). A contiguous range so the -/// remaining handle families join it without touching the transfer path again -/// — `timer.rs` adds `Timeout`/`Immediate` at `0x2409/A` by extending the end. -pub(crate) fn is_native_backed_class_id(class_id: u32) -> bool { - (TEXT_ENCODER_CLASS_ID..=crate::timer::IMMEDIATE_CLASS_ID).contains(&class_id) -} - #[cfg(feature = "global-text")] fn require_text_brand(value: f64, class_id: u32, message: &[u8]) { if text_native_state(value, class_id).is_none() { diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index 07348cc79e..5165b37d4c 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -446,14 +446,17 @@ pub unsafe fn serialize_nanbox_for_thread(bits: u64) -> SerializedValue { if fs_thread_codec().is_some_and(|codec| (codec.is_filehandle)(value)) { return SerializedValue::DetachedFileHandle; } - // #340/#341: a native-backed builtin (TextEncoder/TextDecoder - // today) is an ORDINARY object now, so the GC kind no longer - // rejects it the way kinds 13-16 do below. Refuse it by class - // id instead — deep-copying one would hand the other thread a - // plain `{}` with no native state, which is exactly the silent - // shape #6185 made these surface a named TypeError for. + // #340/#341: a native-backed builtin (TextEncoder/TextDecoder, + // Timeout/Immediate, the six `perry/tui` handle kinds) is an + // ORDINARY object now, so the GC kind no longer rejects it the + // way kinds 13-16 do below. Refuse it by class id instead — + // deep-copying one would hand the other thread a plain `{}` + // with no native state, which is exactly the silent shape + // #6185 made these surface a named TypeError for. The id range + // is owned by `native_class_ids`, so a family joins this guard + // by taking the next id rather than by editing this file. let class_id = (*(raw_ptr as *const crate::object::ObjectHeader)).class_id; - if crate::text::is_native_backed_class_id(class_id) { + if crate::native_class_ids::is_native_backed_class_id(class_id) { return SerializedValue::Unsupported("native handle"); } return serialize_object(raw_ptr as *const crate::object::ObjectHeader); diff --git a/crates/perry-runtime/src/timer.rs b/crates/perry-runtime/src/timer.rs index d7d16a7dfe..a3a7fc17fb 100644 --- a/crates/perry-runtime/src/timer.rs +++ b/crates/perry-runtime/src/timer.rs @@ -409,7 +409,7 @@ use ownership::{has_refed_callback_timer, has_refed_interval_timer, has_refed_pr pub(crate) use ownership::{purge_agent_timers, timer_phase_work_pending}; pub(crate) use gc_scan::{new_timer_root_scan_state, scan_timer_roots_mut_step}; -pub(crate) use handle_object::{scan_timer_prototype_roots_mut, IMMEDIATE_CLASS_ID}; +pub(crate) use handle_object::scan_timer_prototype_roots_mut; // `crate::timer::`-qualified only from unit tests (`timer/tests_inline.rs`, // `gc/tests/handle_bound_method_name.rs`, `timer/ref_states.rs`'s test module); // an unconditional `pub(crate) use` would be an unused import in a lib build diff --git a/crates/perry-runtime/src/timer/handle_object.rs b/crates/perry-runtime/src/timer/handle_object.rs index 96c4067c10..b6960475cb 100644 --- a/crates/perry-runtime/src/timer/handle_object.rs +++ b/crates/perry-runtime/src/timer/handle_object.rs @@ -39,8 +39,8 @@ use super::*; /// Class ids in the web-builtin block. `0x2401..=0x2406` are /// AbortController/AbortSignal/Event/CustomEvent/DOMException/EventTarget and /// `0x2407/8` are TextEncoder/TextDecoder. -pub(crate) const TIMEOUT_CLASS_ID: u32 = 0xFFFF_2409; -pub(crate) const IMMEDIATE_CLASS_ID: u32 = 0xFFFF_240A; +pub(crate) const TIMEOUT_CLASS_ID: u32 = crate::native_class_ids::TIMEOUT; +pub(crate) const IMMEDIATE_CLASS_ID: u32 = crate::native_class_ids::IMMEDIATE; const TIMER_STATE_PRESENT: u64 = 1; const TIMER_STATE_IMMEDIATE: u64 = 1 << 1; diff --git a/crates/perry-runtime/src/timer/tests_inline.rs b/crates/perry-runtime/src/timer/tests_inline.rs index c423d00c2f..995ad238ab 100644 --- a/crates/perry-runtime/src/timer/tests_inline.rs +++ b/crates/perry-runtime/src/timer/tests_inline.rs @@ -443,7 +443,7 @@ mod honest_tag_tests { ), ( js_set_immediate_callback(0), - crate::timer::IMMEDIATE_CLASS_ID, + crate::native_class_ids::IMMEDIATE, true, ), ] { diff --git a/crates/perry-runtime/src/tui/ffi.rs b/crates/perry-runtime/src/tui/ffi.rs index 12fdf74b2b..ed3b0b022c 100644 --- a/crates/perry-runtime/src/tui/ffi.rs +++ b/crates/perry-runtime/src/tui/ffi.rs @@ -9,8 +9,38 @@ use super::cell::Grid; use super::color::{parse_color, Color}; use super::render; use super::style::{Edges, Length}; +use super::handle_object::{tui_handle_id, tui_object, TuiKind}; use super::tree::{box_add_child, register, Node}; +// --------------------------------------------------------------------------- +// Honest tags (#340/#341): the widget handle that crosses into JS. +// +// A widget used to leave this file as its raw tree id NaN-boxed with +// `POINTER_TAG` — a small integer wearing the pointer tag, in an id space +// shared with `useApp()`'s 1, `useStdout()`'s 2 and `useRef`'s 1, so +// `Text("hi") === useApp()` was `true`. It is now an ORDINARY object carrying +// the tree id in `ObjectMeta.native_state`. +// +// The tree, the Taffy layout pass and the paint pass are untouched: they still +// speak ids. Only the two directions across the `#[no_mangle]` boundary change, +// through the two helpers below. A raw argument is NOT a GC root, so a +// consumer resolves at entry, before anything that can allocate and move it. +// --------------------------------------------------------------------------- + +/// Wrap a tree id on its way out to JS. +fn widget_object(id: i64) -> i64 { + tui_object(TuiKind::Widget, id) +} + +/// Resolve a widget handle back to its tree id, or 0 for anything that is not +/// one. 0 is already this module's "no such node" id, so a foreign receiver +/// no-ops exactly as an unknown handle always did — and, unlike before, a +/// handle of another tui kind (whose id space overlaps) cannot address a real +/// node. +fn widget_id(raw: i64) -> i64 { + tui_handle_id(raw, TuiKind::Widget).unwrap_or(0) +} + /// Singleton grid — sized to the current terminal at first render. static GRID: OnceLock> = OnceLock::new(); @@ -48,12 +78,12 @@ fn current_term_size() -> (u16, u16) { #[no_mangle] pub extern "C" fn js_perry_tui_text(content_ptr: *const StringHeader) -> i64 { let content = unsafe { read_string(content_ptr) }; - register(Node::Text { + widget_object(register(Node::Text { content, fg: Color::Default, bg: Color::Default, style: super::cell::Style::default(), - }) + })) } /// `Text(content, { fg, bg, bold, italic, underline, reverse })` — same as @@ -73,12 +103,12 @@ pub extern "C" fn js_perry_tui_text_styled( let fg = parse_color(&unsafe { read_string(fg_ptr) }); let bg = parse_color(&unsafe { read_string(bg_ptr) }); let bits = style_bits.max(0.0) as u8; - register(Node::Text { + widget_object(register(Node::Text { content, fg, bg, style: super::cell::Style(bits), - }) + })) } /// `Box()` — empty container. Children are added via @@ -88,17 +118,20 @@ pub extern "C" fn js_perry_tui_text_styled( /// `Box({ flexDirection: "row" }, [children])`. #[no_mangle] pub extern "C" fn js_perry_tui_box() -> i64 { - register(Node::Box { + widget_object(register(Node::Box { children: Vec::new(), fg: Color::Default, bg: Color::Default, style: super::style::BoxStyle::default(), - }) + })) } /// Mutate a Box's style. Wraps `tree::with_node_mut` so the per-FFI /// boilerplate stays small. Silently no-ops on non-Box handles. fn with_box_style_mut(handle: i64, f: impl FnOnce(&mut super::style::BoxStyle)) { + // The single funnel for all fourteen `boxSet*` FFI rows, so the handle + // resolves once here rather than in each of them. + let handle = widget_id(handle); super::tree::with_node_mut(handle, |n| { if let Node::Box { style, .. } = n { f(style); @@ -124,15 +157,16 @@ pub extern "C" fn js_perry_tui_box_add_children_array(parent: i64, children_arra if children_array == 0 { return f64::from_bits(TAG_UNDEFINED); } + let parent = widget_id(parent); let len = crate::array::js_array_get_length(children_array); for i in 0..len { let child_f64 = crate::array::js_array_get_element_f64(children_array, i); - // Children are NaN-boxed POINTER widget handles. Unbox by - // stripping the high 16 bits of the NaN-box tag to recover - // the raw i64 widget handle. (Same pattern run.rs uses to - // extract a Widget handle from the component's return.) - let bits = child_f64.to_bits(); - let child_handle = (bits & 0x0000_FFFF_FFFF_FFFF) as i64; + // Children are NaN-boxed widget handle OBJECTS (#340/#341). Resolving + // through the brand rather than by masking off the tag is what keeps a + // non-widget element — a number, a string, another tui kind — from + // addressing a real tree node: the low 48 bits of ANY pointer-tagged + // value used to be accepted as a handle. + let child_handle = super::handle_object::tui_widget_id_from_bits(child_f64.to_bits()); if child_handle != 0 { super::tree::box_add_child(parent, child_handle); } @@ -280,12 +314,12 @@ pub extern "C" fn js_perry_tui_box_set_flex_basis_pct(handle: i64, pct: f64) -> pub extern "C" fn js_perry_tui_spacer() -> i64 { let mut s = super::style::BoxStyle::default(); s.flex_grow = 1; - super::tree::register(Node::Box { + widget_object(super::tree::register(Node::Box { children: Vec::new(), fg: Color::Default, bg: Color::Default, style: s, - }) + })) } /// `ProgressBar(value, max, width)` — renders `[==== ]`-style filled @@ -310,12 +344,12 @@ pub extern "C" fn js_perry_tui_progress_bar(value: f64, max: f64, width: f64) -> s.push(' '); } s.push(']'); - super::tree::register(Node::Text { + widget_object(super::tree::register(Node::Text { content: s, fg: Color::Default, bg: Color::Default, style: super::cell::Style::default(), - }) + })) } // --------------------------------------------------------------------------- @@ -335,12 +369,12 @@ pub extern "C" fn js_perry_tui_spinner(frame: f64) -> i64 { const CHARS: [char; 4] = ['-', '\\', '|', '/']; let idx = (frame.max(0.0) as usize) % CHARS.len(); let s = CHARS[idx].to_string(); - super::tree::register(Node::Text { + widget_object(super::tree::register(Node::Text { content: s, fg: Color::Default, bg: Color::Default, style: super::cell::Style::default(), - }) + })) } /// `Input(value)` — single-line text input renderer. The widget shows @@ -352,12 +386,12 @@ pub extern "C" fn js_perry_tui_spinner(frame: f64) -> i64 { pub extern "C" fn js_perry_tui_input(value_ptr: *const StringHeader) -> i64 { let value = unsafe { read_string(value_ptr) }; let display = format!("{}_", value); - super::tree::register(Node::Text { + widget_object(super::tree::register(Node::Text { content: display, fg: Color::Default, bg: Color::Default, style: super::cell::Style::default(), - }) + })) } /// `Input(value, cursor)` — single-line text input with the cursor at @@ -423,7 +457,7 @@ pub extern "C" fn js_perry_tui_input_at(value_ptr: *const StringHeader, cursor: } } - parent + widget_object(parent) } /// Read items from a JS array of strings into an owned `Vec`. @@ -486,7 +520,7 @@ pub extern "C" fn js_perry_tui_list(items_ptr: i64, selected: f64) -> i64 { }); super::tree::box_add_child(parent, child); } - parent + widget_object(parent) } /// `Select(items, selected)` — alias for `List` with an enforced @@ -520,13 +554,13 @@ pub extern "C" fn js_perry_tui_text_area(value_ptr: *const StringHeader) -> i64 }); super::tree::box_add_child(parent, child); } - parent + widget_object(parent) } /// Append a child to a Box. Both args are unboxed POINTER handles. #[no_mangle] pub extern "C" fn js_perry_tui_box_add_child(parent: i64, child: i64) -> f64 { - box_add_child(parent, child); + box_add_child(widget_id(parent), widget_id(child)); f64::from_bits(0x7FFC_0000_0000_0001) // TAG_UNDEFINED } @@ -596,12 +630,12 @@ pub extern "C" fn js_perry_tui_animated_spinner(interval_ms: f64, frames_ptr: i6 DEFAULT_SPINNER_FRAMES.to_vec() }; let idx = ((process_elapsed_ms() / interval) as usize) % frames.len(); - super::tree::register(Node::Text { + widget_object(super::tree::register(Node::Text { content: frames[idx].to_string(), fg: Color::Default, bg: Color::Default, style: super::cell::Style::default(), - }) + })) } // --------------------------------------------------------------------------- @@ -663,10 +697,9 @@ fn read_handle_array(handles_ptr: i64) -> Vec { let mut out = Vec::with_capacity(len as usize); for i in 0..len { let v = js_array_get_f64_unchecked(arr, i); - // Widget handles are NaN-boxed POINTER values — extract the - // low 48 bits as a raw handle. - let h = (v.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; - out.push(h); + // Widget handles are NaN-boxed handle OBJECTS (#340/#341); resolve + // through the brand, not by masking the tag off an arbitrary value. + out.push(super::handle_object::tui_widget_id_from_bits(v.to_bits())); } out } @@ -762,7 +795,7 @@ pub extern "C" fn js_perry_tui_table(headers_ptr: i64, rows_ptr: i64, selected: super::tree::box_add_child(parent, row_widget); } - parent + widget_object(parent) } /// `Tabs({ tabs, active, body })` — render a horizontal tab bar @@ -820,7 +853,7 @@ pub extern "C" fn js_perry_tui_tabs(tabs_ptr: i64, active: f64, body_ptr: i64) - super::tree::box_add_child(outer, *body); } - outer + widget_object(outer) } // --------------------------------------------------------------------------- @@ -831,6 +864,7 @@ pub extern "C" fn js_perry_tui_tabs(tabs_ptr: i64, active: f64, body_ptr: i64) - /// the Taffy layout pass before paint so flexbox styles take effect. #[no_mangle] pub extern "C" fn js_perry_tui_render(root: i64) -> f64 { + let root = widget_id(root); let (w, h) = current_term_size(); let mut g = grid().lock().unwrap(); g.resize(w, h); diff --git a/crates/perry-runtime/src/tui/handle_object.rs b/crates/perry-runtime/src/tui/handle_object.rs new file mode 100644 index 0000000000..23916a404e --- /dev/null +++ b/crates/perry-runtime/src/tui/handle_object.rs @@ -0,0 +1,586 @@ +//! The JS-visible `perry/tui` handle objects (#340/#341). +//! +//! Everything `perry/tui` hands back to TypeScript — a widget from `Text` / +//! `Box` / `Table` / …, a `state(0)` container, a `useRef` box, and the +//! `useApp` / `useStdout` / `useFocusManager` singletons — used to be a small +//! registry integer NaN-boxed with `POINTER_TAG`. Six independent id spaces +//! shared that one encoding and every one of them counts from a small +//! constant, so the values COLLIDED: +//! +//! ```text +//! useApp() -> 1 Text("hi") -> 1 useRef(x) (first) -> 1 +//! useStdout() -> 2 Box() -> 2 useRef(y) (second)-> 2 +//! useFocusManager() -> 3 Spacer() -> 3 +//! state(0) (first) -> 0 <- POINTER_TAG | 0: a null pointer wearing the +//! pointer tag +//! ``` +//! +//! So `useApp() === Text("hi")` was `true`, a `Map` keyed on two different +//! handles collapsed to one entry, and the first `state(0)` of a program was +//! literally a tagged null. None of that is reachable through a type error — +//! the values are indistinguishable at run time, because the encoding carries +//! no provenance. +//! +//! A tui handle is now an ORDINARY object: `GC_TYPE_OBJECT` with a real +//! ShapeId, a per-kind class id in the web-builtin block and (for the kinds +//! that have a method surface) a per-kind prototype. The registry id rides in +//! `ObjectMeta.native_state`, so the id stays the module's internal currency — +//! the widget tree, the layout pass, the paint pass and the hook slots all +//! still speak ids — and only the value that crosses into JS changes. +//! +//! The boundary is exactly the `#[no_mangle]` FFI surface: a producer wraps an +//! id on its way out, a consumer resolves an object back to an id on the way +//! in, and nothing between them changes. A raw argument is not a GC root, so +//! every consumer resolves at entry, before anything that can allocate. +//! +//! State word: bit 0 present, bits 8.. the registry id. The KIND is not in the +//! word — it is the class id on the object header, which is also what brands a +//! prototype method against a foreign receiver. + +use std::sync::atomic::{AtomicI64, Ordering}; + +/// Class ids in the web-builtin block (`0xFFFF_24xx`), allocated by +/// [`crate::native_class_ids`]. `0x2401..=0x2406` are +/// AbortController/AbortSignal/Event/CustomEvent/DOMException/EventTarget, +/// `0x2407/8` TextEncoder/TextDecoder, `0x2409/A` Timeout/Immediate. +pub(crate) const WIDGET_CLASS_ID: u32 = crate::native_class_ids::TUI_WIDGET; +pub(crate) const STATE_CLASS_ID: u32 = crate::native_class_ids::TUI_STATE; +pub(crate) const REF_BOX_CLASS_ID: u32 = crate::native_class_ids::TUI_REF_BOX; +pub(crate) const APP_CLASS_ID: u32 = crate::native_class_ids::TUI_APP; +pub(crate) const STDOUT_CLASS_ID: u32 = crate::native_class_ids::TUI_STDOUT; +pub(crate) const FOCUS_MANAGER_CLASS_ID: u32 = crate::native_class_ids::TUI_FOCUS_MANAGER; + +const TUI_STATE_PRESENT: u64 = 1; +const TUI_STATE_ID_SHIFT: u32 = 8; + +/// The JS-visible kinds of `perry/tui` handle. One class id each, so a +/// prototype method can refuse a receiver from another kind instead of +/// reading its id as if it were one of its own — the six id spaces overlap, +/// so without the brand `state.get.call(someWidget)` would read a real state +/// slot. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum TuiKind { + /// `Text` / `Box` / `Spacer` / … — a node in the widget tree. + Widget, + /// `state(initial)` — a reactive slot with `.get()` / `.set(v)`. + State, + /// `useRef(initial)` — a hook slot with `.get()` / `.set(v)`. + RefBox, + /// `useApp()` — process-wide singleton. + App, + /// `useStdout()` — process-wide singleton. + Stdout, + /// `useFocusManager()` — process-wide singleton. + FocusManager, +} + +impl TuiKind { + pub(crate) fn class_id(self) -> u32 { + match self { + TuiKind::Widget => WIDGET_CLASS_ID, + TuiKind::State => STATE_CLASS_ID, + TuiKind::RefBox => REF_BOX_CLASS_ID, + TuiKind::App => APP_CLASS_ID, + TuiKind::Stdout => STDOUT_CLASS_ID, + TuiKind::FocusManager => FOCUS_MANAGER_CLASS_ID, + } + } + + fn from_class_id(class_id: u32) -> Option { + Some(match class_id { + WIDGET_CLASS_ID => TuiKind::Widget, + STATE_CLASS_ID => TuiKind::State, + REF_BOX_CLASS_ID => TuiKind::RefBox, + APP_CLASS_ID => TuiKind::App, + STDOUT_CLASS_ID => TuiKind::Stdout, + FOCUS_MANAGER_CLASS_ID => TuiKind::FocusManager, + _ => return None, + }) + } +} + +crate::perry_thread_local! { + static STATE_PROTOTYPE_SLOT: AtomicI64 = const { AtomicI64::new(0) }; + static REF_BOX_PROTOTYPE_SLOT: AtomicI64 = const { AtomicI64::new(0) }; + static APP_PROTOTYPE_SLOT: AtomicI64 = const { AtomicI64::new(0) }; + static STDOUT_PROTOTYPE_SLOT: AtomicI64 = const { AtomicI64::new(0) }; + static FOCUS_MANAGER_PROTOTYPE_SLOT: AtomicI64 = const { AtomicI64::new(0) }; + static APP_SINGLETON_SLOT: AtomicI64 = const { AtomicI64::new(0) }; + static STDOUT_SINGLETON_SLOT: AtomicI64 = const { AtomicI64::new(0) }; + static FOCUS_MANAGER_SINGLETON_SLOT: AtomicI64 = const { AtomicI64::new(0) }; +} + +/// Per-kind prototype singletons, one per realm. Every handle of that kind +/// points its `[[Prototype]]` here, so they must outlive every handle — the +/// same rooting contract as the `%IteratorPrototype%` tower and the +/// `Timeout`/`Immediate` prototypes, and scanned from the same place +/// (`object::scan_object_cache_roots_mut`). +pub(crate) static STATE_PROTOTYPE_PTR: crate::object::RealmAtomicI64 = + crate::object::RealmAtomicI64::new(&STATE_PROTOTYPE_SLOT); +pub(crate) static REF_BOX_PROTOTYPE_PTR: crate::object::RealmAtomicI64 = + crate::object::RealmAtomicI64::new(&REF_BOX_PROTOTYPE_SLOT); +pub(crate) static APP_PROTOTYPE_PTR: crate::object::RealmAtomicI64 = + crate::object::RealmAtomicI64::new(&APP_PROTOTYPE_SLOT); +pub(crate) static STDOUT_PROTOTYPE_PTR: crate::object::RealmAtomicI64 = + crate::object::RealmAtomicI64::new(&STDOUT_PROTOTYPE_SLOT); +pub(crate) static FOCUS_MANAGER_PROTOTYPE_PTR: crate::object::RealmAtomicI64 = + crate::object::RealmAtomicI64::new(&FOCUS_MANAGER_PROTOTYPE_SLOT); + +/// The three singleton handles. `useApp()` returned the same id on every call +/// so that reference semantics stayed stable across renders (ink's `useApp()` +/// does the same); with objects, "the same id" has to become "the same +/// object" or `useApp() === useApp()` would break. This is the resource -> +/// object mapping at singleton scale. +pub(crate) static APP_SINGLETON_PTR: crate::object::RealmAtomicI64 = + crate::object::RealmAtomicI64::new(&APP_SINGLETON_SLOT); +pub(crate) static STDOUT_SINGLETON_PTR: crate::object::RealmAtomicI64 = + crate::object::RealmAtomicI64::new(&STDOUT_SINGLETON_SLOT); +pub(crate) static FOCUS_MANAGER_SINGLETON_PTR: crate::object::RealmAtomicI64 = + crate::object::RealmAtomicI64::new(&FOCUS_MANAGER_SINGLETON_SLOT); + +/// GC roots for the prototypes and the three singletons. Called from +/// `object::scan_object_cache_roots_mut`, beside the timer prototypes. +pub(crate) fn scan_tui_handle_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + for slot in [ + &STATE_PROTOTYPE_PTR, + &REF_BOX_PROTOTYPE_PTR, + &APP_PROTOTYPE_PTR, + &STDOUT_PROTOTYPE_PTR, + &FOCUS_MANAGER_PROTOTYPE_PTR, + &APP_SINGLETON_PTR, + &STDOUT_SINGLETON_PTR, + &FOCUS_MANAGER_SINGLETON_PTR, + ] { + slot.with_slot(|slot| { + visitor.visit_atomic_i64_slot(slot, Ordering::Acquire, Ordering::Release); + }); + } +} + +fn state_word(id: i64) -> u64 { + TUI_STATE_PRESENT | ((id as u64) << TUI_STATE_ID_SHIFT) +} + +/// `(kind, id)` for a tui handle reached by its UNBOXED payload, or `None` for +/// anything else. `raw` is what codegen passes a native: the receiver and every +/// `NA_PTR` argument arrive already stripped of the NaN-box tag. +pub(crate) fn tui_handle_parts_raw(raw: i64) -> Option<(TuiKind, i64)> { + if raw <= 0 { + return None; + } + let addr = raw as usize; + let header = unsafe { crate::value::addr_class::try_read_gc_header(addr)? }; + if header.obj_type != crate::gc::GC_TYPE_OBJECT { + return None; + } + let obj = addr as *mut crate::object::ObjectHeader; + unsafe { + let kind = TuiKind::from_class_id((*obj).class_id)?; + let meta = (*obj).meta; + if meta.is_null() { + return None; + } + let word = (*meta).native_state; + if word & TUI_STATE_PRESENT == 0 { + return None; + } + Some((kind, (word >> TUI_STATE_ID_SHIFT) as i64)) + } +} + +/// The registry id behind a tui handle of the expected kind, or `None`. +/// +/// The kind check is load-bearing rather than defensive: the six id spaces +/// overlap (widget 1, app 1 and the first `useRef` are all id 1), so a handle +/// of the wrong kind would resolve to a real, live entry of this one. +pub(crate) fn tui_handle_id(raw: i64, kind: TuiKind) -> Option { + match tui_handle_parts_raw(raw) { + Some((k, id)) if k == kind => Some(id), + _ => None, + } +} + +/// The widget id behind a NaN-boxed JS value. Used where a widget handle +/// arrives as a boxed `f64` rather than as a native argument: elements of the +/// children array, and the value a `run()` component returns. +pub(crate) fn tui_widget_id_from_bits(bits: u64) -> i64 { + let raw = (bits & crate::value::POINTER_MASK) as i64; + tui_handle_id(raw, TuiKind::Widget).unwrap_or(0) +} + +/// `(kind, id)` for a NaN-boxed JS value. The prototype thunks resolve their +/// receiver through this, since `js_implicit_this_get` hands back a boxed +/// value. +fn tui_handle_parts_value(value: f64) -> Option<(TuiKind, i64)> { + let bits = value.to_bits(); + if (bits & crate::value::TAG_MASK) != crate::value::POINTER_TAG { + return None; + } + tui_handle_parts_raw((bits & crate::value::POINTER_MASK) as i64) +} + +/// The receiver of a prototype method, as an id of the expected kind. +/// +/// `perry/tui` is not a WebIDL surface and has no node equivalent to copy a +/// brand-check policy from, so a foreign receiver is answered leniently with +/// `undefined` rather than thrown at — the same choice node makes for the +/// timer methods, and the one that cannot turn a working program into a +/// throwing one. It is never read as an id of this kind: that is what the +/// class-id brand prevents. +fn receiver_id(kind: TuiKind) -> Option { + let this = crate::object::js_implicit_this_get(); + match tui_handle_parts_value(this) { + Some((k, id)) if k == kind => Some(id), + _ => None, + } +} + +const UNDEFINED: u64 = crate::value::TAG_UNDEFINED; + +extern "C" fn state_proto_get_thunk(_c: *const crate::closure::ClosureHeader) -> f64 { + match receiver_id(TuiKind::State) { + Some(id) => super::state::state_get_by_id(id), + None => f64::from_bits(UNDEFINED), + } +} + +extern "C" fn state_proto_set_thunk( + _c: *const crate::closure::ClosureHeader, + value: f64, +) -> f64 { + if let Some(id) = receiver_id(TuiKind::State) { + super::state::state_set_by_id(id, value); + } + f64::from_bits(UNDEFINED) +} + +extern "C" fn ref_proto_get_thunk(_c: *const crate::closure::ClosureHeader) -> f64 { + match receiver_id(TuiKind::RefBox) { + Some(id) => super::hooks::ref_get_by_id(id), + None => f64::from_bits(UNDEFINED), + } +} + +extern "C" fn ref_proto_set_thunk(_c: *const crate::closure::ClosureHeader, value: f64) -> f64 { + if let Some(id) = receiver_id(TuiKind::RefBox) { + super::hooks::ref_set_by_id(id, value); + } + f64::from_bits(UNDEFINED) +} + +extern "C" fn app_proto_exit_thunk(_c: *const crate::closure::ClosureHeader) -> f64 { + if receiver_id(TuiKind::App).is_some() { + super::input::EXIT_FLAG.store(true, Ordering::Release); + } + f64::from_bits(UNDEFINED) +} + +extern "C" fn app_proto_wait_until_exit_thunk(_c: *const crate::closure::ClosureHeader) -> f64 { + if receiver_id(TuiKind::App).is_some() { + super::hooks::wait_until_exit_blocking(); + } + f64::from_bits(UNDEFINED) +} + +extern "C" fn stdout_proto_write_thunk( + _c: *const crate::closure::ClosureHeader, + value: f64, +) -> f64 { + if receiver_id(TuiKind::Stdout).is_some() { + let s = crate::value::js_jsvalue_to_string_coerce(value); + super::hooks::stdout_write_string_ptr(s); + } + f64::from_bits(UNDEFINED) +} + +extern "C" fn stdout_proto_columns_thunk(_c: *const crate::closure::ClosureHeader) -> f64 { + match receiver_id(TuiKind::Stdout) { + Some(_) => super::hooks::js_perry_tui_stdout_columns(0), + None => f64::from_bits(UNDEFINED), + } +} + +extern "C" fn stdout_proto_rows_thunk(_c: *const crate::closure::ClosureHeader) -> f64 { + match receiver_id(TuiKind::Stdout) { + Some(_) => super::hooks::js_perry_tui_stdout_rows(0), + None => f64::from_bits(UNDEFINED), + } +} + +extern "C" fn focus_proto_next_thunk(_c: *const crate::closure::ClosureHeader) -> f64 { + if receiver_id(TuiKind::FocusManager).is_some() { + super::hooks::js_perry_tui_focus_next(); + } + f64::from_bits(UNDEFINED) +} + +extern "C" fn focus_proto_previous_thunk(_c: *const crate::closure::ClosureHeader) -> f64 { + if receiver_id(TuiKind::FocusManager).is_some() { + super::hooks::js_perry_tui_focus_previous(); + } + f64::from_bits(UNDEFINED) +} + +extern "C" fn focus_proto_focus_thunk(_c: *const crate::closure::ClosureHeader, id: f64) -> f64 { + if receiver_id(TuiKind::FocusManager).is_some() { + super::hooks::js_perry_tui_focus(id); + } + f64::from_bits(UNDEFINED) +} + +/// Build a kind's prototype into its rooted slot. Idempotent; lazy, because a +/// program that never touches `perry/tui` must not pay for any of it. +/// +/// `TuiKind::Widget` has no method surface — a widget is an opaque handle you +/// pass to `render` or to `Box` — so it links no prototype and inherits +/// `Object.prototype` like any other bare object. +fn build_prototype(kind: TuiKind) -> *mut crate::object::ObjectHeader { + let Some(slot) = prototype_slot(kind) else { + return std::ptr::null_mut(); + }; + let existing = slot.load(Ordering::Acquire); + if existing != 0 { + return existing as *mut crate::object::ObjectHeader; + } + // Raw locals stay stable across the allocating installs below, exactly as + // the iterator tower and the timer prototypes do (#7251). + let _no_move = crate::gc::GcSuppressScope::new(); + let proto = crate::object::js_object_alloc(0, 0); + if proto.is_null() { + return std::ptr::null_mut(); + } + let methods: &[(&str, *const u8, u32)] = match kind { + TuiKind::State => &[ + ("get", state_proto_get_thunk as *const u8, 0), + ("set", state_proto_set_thunk as *const u8, 1), + ], + TuiKind::RefBox => &[ + ("get", ref_proto_get_thunk as *const u8, 0), + ("set", ref_proto_set_thunk as *const u8, 1), + ], + TuiKind::App => &[ + ("exit", app_proto_exit_thunk as *const u8, 0), + ( + "waitUntilExit", + app_proto_wait_until_exit_thunk as *const u8, + 0, + ), + ], + TuiKind::Stdout => &[ + ("write", stdout_proto_write_thunk as *const u8, 1), + ("columns", stdout_proto_columns_thunk as *const u8, 0), + ("rows", stdout_proto_rows_thunk as *const u8, 0), + ], + TuiKind::FocusManager => &[ + ("focusNext", focus_proto_next_thunk as *const u8, 0), + ("focusPrevious", focus_proto_previous_thunk as *const u8, 0), + ("focus", focus_proto_focus_thunk as *const u8, 1), + ], + TuiKind::Widget => &[], + }; + for (name, ptr, arity) in methods { + crate::object::install_proto_method(proto, name, *ptr, *arity); + } + slot.store(proto as i64, Ordering::Release); + proto +} + +fn prototype_slot(kind: TuiKind) -> Option<&'static crate::object::RealmAtomicI64> { + Some(match kind { + TuiKind::State => &STATE_PROTOTYPE_PTR, + TuiKind::RefBox => &REF_BOX_PROTOTYPE_PTR, + TuiKind::App => &APP_PROTOTYPE_PTR, + TuiKind::Stdout => &STDOUT_PROTOTYPE_PTR, + TuiKind::FocusManager => &FOCUS_MANAGER_PROTOTYPE_PTR, + TuiKind::Widget => return None, + }) +} + +/// Wrap a registry id in the JS-visible handle object. The id itself stays the +/// module's internal currency — the tree, the layout pass and the hook slots +/// all keep speaking ids — so this is called only at the `#[no_mangle]` FFI +/// boundary, on the way out. +pub(crate) fn tui_object(kind: TuiKind, id: i64) -> i64 { + let obj = crate::object::js_object_alloc(kind.class_id(), 0); + if obj.is_null() { + return 0; + } + // Building the prototype allocates (lazily, on the first handle of a + // program) and `GC_TYPE_OBJECT` is movable, so the instance is re-read + // through its handle after each allocating step. + let scope = crate::gc::RuntimeHandleScope::new(); + let handle = scope.root_raw_mut_ptr(obj); + let proto = build_prototype(kind); + if !proto.is_null() { + handle.with_mut_ptr::(|obj| { + crate::object::prototype_chain::object_link_class_default_prototype( + obj as usize, + crate::value::js_nanbox_pointer(proto as i64).to_bits(), + ); + }); + } + handle.with_mut_ptr::(|obj| unsafe { + let meta = crate::object::object_meta_ensure(obj); + // A handle whose state word never landed would resolve to `None` and + // every method on it would answer `undefined` -- silently, and only in + // a low-memory run. The meta has to exist for the handle to mean + // anything. + debug_assert!(!meta.is_null(), "a tui handle must carry its meta"); + if !meta.is_null() { + (*meta).native_state = state_word(id); + } + }); + handle.with_mut_ptr::(|obj| obj as i64) +} + +/// The process-wide singleton handle for `useApp` / `useStdout` / +/// `useFocusManager`, minted once per realm. +/// +/// `useApp() === useApp()` is `true` today because both calls answer the same +/// id, and ink's `useApp()` is likewise stable across renders. Objects only +/// keep that property if the SAME object comes back, so the singleton lives in +/// a rooted slot rather than being re-minted per call. +pub(crate) fn tui_singleton(kind: TuiKind, id: i64) -> i64 { + let slot = match kind { + TuiKind::App => &APP_SINGLETON_PTR, + TuiKind::Stdout => &STDOUT_SINGLETON_PTR, + TuiKind::FocusManager => &FOCUS_MANAGER_SINGLETON_PTR, + _ => return tui_object(kind, id), + }; + let existing = slot.load(Ordering::Acquire); + if existing != 0 { + return existing; + } + let obj = tui_object(kind, id); + if obj != 0 { + slot.store(obj, Ordering::Release); + } + obj +} + +#[cfg(test)] +mod tests { + use super::*; + + /// GATE B, and the invariant the whole migration is for: the value JS + /// receives is a real heap object with the kind's class id, ABOVE the + /// small-handle band, carrying zero own keys. The band assertion is what + /// covers statically lowered reads -- `state.get()` lowers through a + /// `class_filter` row and never reaches an instrumented funnel, so the + /// receiver-repr ledger (gate A) cannot see it. + #[test] + fn a_tui_handle_is_an_ordinary_object_outside_the_handle_band() { + let cases = [ + (super::super::ffi::js_perry_tui_box(), TuiKind::Widget), + ( + super::super::state::js_perry_tui_state_alloc(0.0), + TuiKind::State, + ), + (super::super::hooks::js_perry_tui_use_app(), TuiKind::App), + ( + super::super::hooks::js_perry_tui_use_stdout(), + TuiKind::Stdout, + ), + ( + super::super::hooks::js_perry_tui_use_focus_manager(), + TuiKind::FocusManager, + ), + ]; + for (raw, kind) in cases { + let addr = raw as usize; + assert!( + !crate::value::addr_class::is_handle_band(addr), + "gate B: {kind:?} handed back a small band id ({addr:#x})" + ); + let header = unsafe { crate::value::addr_class::try_read_gc_header(addr) } + .expect("a tui handle carries a GcHeader"); + assert_eq!(header.obj_type, crate::gc::GC_TYPE_OBJECT); + let obj = addr as *mut crate::object::ObjectHeader; + assert_eq!(unsafe { (*obj).class_id }, kind.class_id()); + let keys = unsafe { crate::object::object_keys_array(obj) }; + let key_count = if keys.is_null() { + 0 + } else { + unsafe { (*keys).length } + }; + assert_eq!(key_count, 0, "a {kind:?} handle must have no own keys"); + assert!( + tui_handle_id(raw, kind).is_some(), + "a {kind:?} handle must resolve back to its id" + ); + } + } + + /// The six id spaces overlap, so the brand is what keeps them apart. Two + /// handles of DIFFERENT kinds that carry the SAME id must not resolve + /// through each other -- before this change they were literally the same + /// value. + #[test] + fn a_handle_of_another_kind_never_resolves_as_this_one() { + let widget = super::super::ffi::js_perry_tui_box(); + let app = super::super::hooks::js_perry_tui_use_app(); + assert_ne!(widget, app, "two kinds must be two objects"); + assert!(tui_handle_id(widget, TuiKind::App).is_none()); + assert!(tui_handle_id(app, TuiKind::Widget).is_none()); + // A plain object, a non-pointer value and 0 are all refused. + let plain = crate::object::js_object_alloc(0, 0) as i64; + assert!(tui_handle_parts_raw(plain).is_none()); + assert!(tui_handle_parts_raw(0).is_none()); + assert!(tui_handle_parts_raw(7).is_none()); + } + + /// `useApp()` / `useStdout()` / `useFocusManager()` are stable across + /// calls -- ink's are, and they were trivially stable before this change + /// because they were constants. A per-call mint would break + /// `useApp() === useApp()` without breaking any method. + #[test] + fn the_singletons_are_one_object_per_realm() { + assert_eq!( + super::super::hooks::js_perry_tui_use_app(), + super::super::hooks::js_perry_tui_use_app() + ); + assert_eq!( + super::super::hooks::js_perry_tui_use_stdout(), + super::super::hooks::js_perry_tui_use_stdout() + ); + assert_eq!( + super::super::hooks::js_perry_tui_use_focus_manager(), + super::super::hooks::js_perry_tui_use_focus_manager() + ); + // ... and the three singletons are three DIFFERENT objects, where the + // old encoding made them ids 1, 2 and 3 in one space shared with every + // widget. + let a = super::super::hooks::js_perry_tui_use_app(); + let s = super::super::hooks::js_perry_tui_use_stdout(); + let f = super::super::hooks::js_perry_tui_use_focus_manager(); + assert_ne!(a, s); + assert_ne!(s, f); + assert_ne!(a, f); + } + + /// Two widgets are two objects. The old encoding made `Text("a")` and + /// `Text("b")` ids 1 and 2 -- distinct -- but made `useApp()` and the + /// first widget BOTH id 1, and this is the property that has to hold for + /// `Map` / `Set` / `WeakMap` keys to work at all. + #[test] + fn distinct_widgets_are_distinct_objects() { + let a = super::super::ffi::js_perry_tui_box(); + let b = super::super::ffi::js_perry_tui_box(); + assert_ne!(a, b); + let ida = tui_handle_id(a, TuiKind::Widget).unwrap(); + let idb = tui_handle_id(b, TuiKind::Widget).unwrap(); + assert_ne!(ida, idb, "two widgets must be two tree entries"); + } + + /// The first `state(0)` of a program used to be `POINTER_TAG | 0` -- a + /// null pointer wearing the pointer tag, which is the exact shape the + /// invariant exists to forbid. Slot 0 is still a legal slot id; it is the + /// `present` bit that distinguishes "slot 0" from "no state". + #[test] + fn state_slot_zero_is_a_real_object_not_a_tagged_null() { + let first = super::super::state::js_perry_tui_state_alloc(0.0); + assert_ne!(first, 0, "a state handle must never be a tagged null"); + let id = tui_handle_id(first, TuiKind::State).expect("state handle resolves"); + assert!(id >= 0); + } +} diff --git a/crates/perry-runtime/src/tui/hooks.rs b/crates/perry-runtime/src/tui/hooks.rs index 8e660ac249..459c1b8a2b 100644 --- a/crates/perry-runtime/src/tui/hooks.rs +++ b/crates/perry-runtime/src/tui/hooks.rs @@ -63,7 +63,17 @@ enum HookSlot { }, /// `useRef(initial)` — mutable cell. Same storage as State but a /// distinct kind so a rule-of-hooks mismatch can be detected. - Ref { value_bits: u64 }, + /// + /// `handle_bits` is the NaN-boxed `RefBox` handle OBJECT for this slot, + /// or 0 before the first `useRef` at this index (#340/#341). React's + /// `useRef` is stable across renders and perry's was too — trivially, + /// because the handle was the slot index + 1 — so the object has to be + /// stable as well, which means the slot owns it rather than each call + /// minting one. That makes it a GC pointer living in a side table, and + /// [`visit_hook_slot_roots`] is the single funnel that roots it: both + /// scanners over `SLOTS` go through that one function so a forgotten one + /// is a compile error, not a scavenge crash. + Ref { value_bits: u64, handle_bits: u64 }, /// `useFocus({autoFocus, isActive})` — registers this slot as a /// focus candidate. Stores its assigned focus-order ID so the /// FocusManager's Tab cycle can route correctly across renders. @@ -72,9 +82,6 @@ enum HookSlot { static SLOTS: Mutex> = Mutex::new(Vec::new()); -pub(crate) fn contains_handle(handle: i64) -> bool { - handle > 0 && (handle as usize) <= crate::gc::lock_gc_root_registry(&SLOTS).len() -} /// Per-frame hook index, reset by the run loop before each component call. static NEXT_HOOK_IDX: AtomicUsize = AtomicUsize::new(0); @@ -103,27 +110,54 @@ pub fn scan_hook_slot_roots(mark: &mut dyn FnMut(f64)) { pub fn scan_hook_slot_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { let mut s = crate::gc::lock_gc_root_registry(&SLOTS); for slot in s.iter_mut() { - match slot { - HookSlot::State { value_bits } => { - visitor.visit_nanbox_u64_slot(value_bits); - } - HookSlot::Memo { - value_bits, - computed, - .. - } => { - if *computed { - visitor.visit_nanbox_u64_slot(value_bits); - } - } - HookSlot::Ref { value_bits } => { + visit_hook_slot_roots(visitor, slot); + } +} + +/// Every GC edge one hook slot owns, in ONE place. +/// +/// There are two scanners over `SLOTS` — this whole-table one and the +/// budgeted `scan_hook_slot_roots_mut_step` — and #340/#341 added a second +/// pointer to `Ref` (its handle object). A pointer visited by one scanner and +/// not the other is an unrooted GC address that a moving collection rewrites +/// in one path and not the other; this campaign already had a store-only +/// mirror SIGSEGV 3/3 on a scavenge with every perf gate green. Routing both +/// scanners through this function makes a forgotten edge a compile error +/// (the `match` is exhaustive and destructures every field) rather than a +/// crash under load. +fn visit_hook_slot_roots(visitor: &mut crate::gc::RuntimeRootVisitor<'_>, slot: &mut HookSlot) { + match slot { + HookSlot::State { value_bits } => { + visitor.visit_nanbox_u64_slot(value_bits); + } + HookSlot::Memo { + value_bits, + computed, + last_deps_hash: _, + } => { + if *computed { visitor.visit_nanbox_u64_slot(value_bits); } - // TODO: when useEffect cleanup-on-dep-change wiring lands, - // emit `cleanup` here too — it'll hold a NaN-boxed POINTER - // to a Perry closure that the GC otherwise can't see. - HookSlot::Effect { .. } | HookSlot::Focus { .. } => {} } + HookSlot::Ref { + value_bits, + handle_bits, + } => { + visitor.visit_nanbox_u64_slot(value_bits); + visitor.visit_nanbox_u64_slot(handle_bits); + } + // TODO: when useEffect cleanup-on-dep-change wiring lands, + // emit `cleanup` here too — it'll hold a NaN-boxed POINTER + // to a Perry closure that the GC otherwise can't see. + HookSlot::Effect { + last_deps_hash: _, + ran_once: _, + cleanup: _, + } + | HookSlot::Focus { + focus_id: _, + is_active: _, + } => {} } } @@ -146,24 +180,7 @@ pub(crate) fn scan_hook_slot_roots_mut_step( .expect("tui hook root scanner state type"); let mut slots = crate::gc::lock_gc_root_registry(&SLOTS); while *remaining > 0 && state.index < slots.len() { - match &mut slots[state.index] { - HookSlot::State { value_bits } => { - visitor.visit_nanbox_u64_slot(value_bits); - } - HookSlot::Memo { - value_bits, - computed, - .. - } => { - if *computed { - visitor.visit_nanbox_u64_slot(value_bits); - } - } - HookSlot::Ref { value_bits } => { - visitor.visit_nanbox_u64_slot(value_bits); - } - HookSlot::Effect { .. } | HookSlot::Focus { .. } => {} - } + visit_hook_slot_roots(visitor, &mut slots[state.index]); state.index += 1; *remaining -= 1; } @@ -180,7 +197,10 @@ pub(crate) fn test_seed_hook_slot_roots(value_bits: u64) { value_bits, computed: true, }); - slots.push(HookSlot::Ref { value_bits }); + slots.push(HookSlot::Ref { + value_bits, + handle_bits: 0, + }); NEXT_HOOK_IDX.store(0, Ordering::Release); } @@ -196,7 +216,7 @@ pub(crate) fn test_hook_slot_roots() -> (u64, u64, u64) { _ => 0, }; let reference = match slots.get(2) { - Some(HookSlot::Ref { value_bits }) => *value_bits, + Some(HookSlot::Ref { value_bits, .. }) => *value_bits, _ => 0, }; (state, memo, reference) @@ -532,42 +552,77 @@ pub extern "C" fn js_perry_tui_use_memo(fn_closure: i64, deps_array: i64) -> f64 /// do NOT flip STATE_DIRTY, so .set() doesn't trigger a re-render /// (matches React). /// -/// The handle is the slot index + 1 (so the encoding is never 0, -/// which the dispatch layer treats as a null pointer). The dispatch -/// table NR_PTR-wraps the i64 with POINTER_TAG; receiver-method -/// dispatch unboxes it back to an i64. We subtract 1 in `ref_get` / -/// `ref_set` to recover the slot index. +/// The internal id is the slot index + 1 (so it is never 0, which the +/// dispatch layer treats as a null pointer). Since #340/#341 that id no +/// longer crosses into JS: the handle is an OBJECT carrying the id in its +/// `ObjectMeta.native_state`, and the SLOT owns that object so the second +/// render's `useRef` at the same index hands back the same one — React's +/// `useRef` is stable across renders, and it used to be stable here only +/// because the id was a pure function of the index. #[no_mangle] pub extern "C" fn js_perry_tui_use_ref(initial: f64) -> i64 { let idx = next_idx(); - let mut s = crate::gc::lock_gc_root_registry(&SLOTS); - while s.len() <= idx { - s.push(HookSlot::Ref { - value_bits: initial.to_bits(), - }); - } - if !matches!(s[idx], HookSlot::Ref { .. }) { - s[idx] = HookSlot::Ref { - value_bits: initial.to_bits(), - }; - } - if crate::hot_diag::receiver_repr_on() { - crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Tui); + let id = { + let mut s = crate::gc::lock_gc_root_registry(&SLOTS); + while s.len() <= idx { + s.push(HookSlot::Ref { + value_bits: initial.to_bits(), + handle_bits: 0, + }); + } + if !matches!(s[idx], HookSlot::Ref { .. }) { + s[idx] = HookSlot::Ref { + value_bits: initial.to_bits(), + handle_bits: 0, + }; + } + if let HookSlot::Ref { handle_bits, .. } = &s[idx] { + // The slot already owns its handle: hand back the SAME object. + if *handle_bits != 0 { + return (*handle_bits & crate::value::POINTER_MASK) as i64; + } + } + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed( + crate::hot_diag::ReceiverReprFamily::Tui, + ); + } + (idx as i64) + 1 + }; + // Minted with the registry lock RELEASED: `tui_object` allocates, an + // allocation can collect, and a collection scans `SLOTS` through + // `scan_hook_slot_roots_mut` — which takes this same lock. + let handle = super::handle_object::tui_object(super::handle_object::TuiKind::RefBox, id); + if handle != 0 { + let mut s = crate::gc::lock_gc_root_registry(&SLOTS); + if let Some(HookSlot::Ref { handle_bits, .. }) = s.get_mut(idx) { + // GC_STORE_AUDIT(ROOT): rooted by visit_hook_slot_roots, which both + // SLOTS scanners call. + *handle_bits = crate::value::js_nanbox_pointer(handle).to_bits(); + } } - (idx as i64) + 1 + handle } -/// `ref.get()` — read the slot's stored value. `handle` is the -/// NaN-unboxed i64 receiver (slot index + 1). +/// `ref.get()` — read the slot's stored value. `handle` is the unboxed +/// receiver payload: the handle OBJECT's address since #340/#341, resolved to +/// a slot id at entry before anything that could allocate. #[no_mangle] pub extern "C" fn js_perry_tui_ref_get(handle: i64) -> f64 { - if handle <= 0 { + match super::handle_object::tui_handle_id(handle, super::handle_object::TuiKind::RefBox) { + Some(id) => ref_get_by_id(id), + None => f64::from_bits(TAG_UNDEFINED), + } +} + +pub(super) fn ref_get_by_id(id: i64) -> f64 { + if id <= 0 { return f64::from_bits(TAG_UNDEFINED); } - let idx = (handle - 1) as usize; + let idx = (id - 1) as usize; let s = crate::gc::lock_gc_root_registry(&SLOTS); match s.get(idx) { - Some(HookSlot::Ref { value_bits }) => f64::from_bits(*value_bits), + Some(HookSlot::Ref { value_bits, .. }) => f64::from_bits(*value_bits), _ => f64::from_bits(TAG_UNDEFINED), } } @@ -575,35 +630,53 @@ pub extern "C" fn js_perry_tui_ref_get(handle: i64) -> f64 { /// `ref.set(v)` — write the slot. Does NOT flip STATE_DIRTY. #[no_mangle] pub extern "C" fn js_perry_tui_ref_set(handle: i64, value: f64) -> f64 { - if handle <= 0 { - return f64::from_bits(TAG_UNDEFINED); + if let Some(id) = + super::handle_object::tui_handle_id(handle, super::handle_object::TuiKind::RefBox) + { + ref_set_by_id(id, value); + } + f64::from_bits(TAG_UNDEFINED) +} + +pub(super) fn ref_set_by_id(id: i64, value: f64) { + if id <= 0 { + return; } - let idx = (handle - 1) as usize; + let idx = (id - 1) as usize; let mut s = crate::gc::lock_gc_root_registry(&SLOTS); - if let Some(HookSlot::Ref { value_bits }) = s.get_mut(idx) { + if let Some(HookSlot::Ref { value_bits, .. }) = s.get_mut(idx) { *value_bits = value.to_bits(); } - f64::from_bits(TAG_UNDEFINED) } // --------------------------------------------------------------------------- // useApp — singleton handle with .exit() / .waitUntilExit() methods. // --------------------------------------------------------------------------- -/// Singleton App handle value (slot 0 of an "app singleton" namespace). -/// Returning the same handle on every call keeps reference semantics -/// stable across renders — ink's useApp() also returns a stable object. +/// The App singleton's internal id. It is no longer what JS receives +/// (#340/#341) — `useApp()` hands back the realm's App OBJECT, which carries +/// this id — but the id is still what the singleton slot is minted from. +/// +/// This constant is also the clearest statement of the bug the migration +/// fixes: `APP_HANDLE` is 1, `STDOUT_HANDLE` is 2, `FOCUS_MANAGER_HANDLE` is +/// 3, the widget tree counts from 1 and `useRef` counts from 1 — six id +/// spaces in one encoding, so `useApp() === Text("hi")` was `true` and a +/// `Map` keyed on both kept one entry. const APP_HANDLE: i64 = 1; -/// `useApp()` — returns an App handle whose `.exit()` and +/// `useApp()` — returns the App handle object whose `.exit()` and /// `.waitUntilExit()` methods dispatch through perry-codegen's -/// class_filter: Some("App") rows. +/// class_filter: Some("TuiApp") rows, or through `TuiApp.prototype` when the +/// compiler cannot see the receiver's class. +/// +/// The SAME object every time: ink's `useApp()` is stable across renders and +/// perry's was too, trivially, while the handle was a constant. #[no_mangle] pub extern "C" fn js_perry_tui_use_app() -> i64 { if crate::hot_diag::receiver_repr_on() { crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Tui); } - APP_HANDLE + super::handle_object::tui_singleton(super::handle_object::TuiKind::App, APP_HANDLE) } /// `app.exit()` — flips the run-loop's EXIT_FLAG. Receiver argument is @@ -622,11 +695,18 @@ pub extern "C" fn js_perry_tui_app_exit(_handle: i64) -> f64 { /// typically don't need waitUntilExit() outside an effect. #[no_mangle] pub extern "C" fn js_perry_tui_app_wait_until_exit(_handle: i64) -> f64 { + wait_until_exit_blocking(); + f64::from_bits(TAG_UNDEFINED) +} + +/// The blocking wait itself, without the FFI receiver. Shared by the FFI +/// entry point above, the receiver-free `js_perry_tui_wait_until_exit`, and +/// `TuiApp.prototype.waitUntilExit`. +pub(super) fn wait_until_exit_blocking() { use std::time::Duration; while !super::input::EXIT_FLAG.load(Ordering::Acquire) { std::thread::sleep(Duration::from_millis(50)); } - f64::from_bits(TAG_UNDEFINED) } // --------------------------------------------------------------------------- @@ -643,7 +723,7 @@ pub extern "C" fn js_perry_tui_use_stdout() -> i64 { if crate::hot_diag::receiver_repr_on() { crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Tui); } - STDOUT_HANDLE + super::handle_object::tui_singleton(super::handle_object::TuiKind::Stdout, STDOUT_HANDLE) } /// `stdout.write(s)` — write the string to stdout raw. Used as the @@ -654,19 +734,28 @@ pub extern "C" fn js_perry_tui_stdout_write( _handle: i64, s_ptr: *const crate::string::StringHeader, ) -> f64 { + stdout_write_string_ptr(s_ptr as *mut crate::string::StringHeader); + f64::from_bits(TAG_UNDEFINED) +} + +/// The write itself, shared by the FFI entry point and +/// `TuiStdout.prototype.write` (which coerces its argument to a string +/// first, because a prototype method receives a JS value where the +/// statically lowered call receives an already-resolved `StringHeader`). +pub(super) fn stdout_write_string_ptr(s_ptr: *mut crate::string::StringHeader) { use std::io::Write; - if !s_ptr.is_null() { - let s = unsafe { - let len = (*s_ptr).byte_len as usize; - let data = (s_ptr as *const u8).add(std::mem::size_of::()); - std::slice::from_raw_parts(data, len) - }; - let stdout = std::io::stdout(); - let mut h = stdout.lock(); - let _ = h.write_all(s); - let _ = h.flush(); + if s_ptr.is_null() { + return; } - f64::from_bits(TAG_UNDEFINED) + let s = unsafe { + let len = (*s_ptr).byte_len as usize; + let data = (s_ptr as *const u8).add(std::mem::size_of::()); + std::slice::from_raw_parts(data, len) + }; + let stdout = std::io::stdout(); + let mut h = stdout.lock(); + let _ = h.write_all(s); + let _ = h.flush(); } /// `stdout.columns()` — current terminal column count. Used by ink @@ -829,7 +918,10 @@ pub extern "C" fn js_perry_tui_use_focus_manager() -> i64 { if crate::hot_diag::receiver_repr_on() { crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Tui); } - FOCUS_MANAGER_HANDLE + super::handle_object::tui_singleton( + super::handle_object::TuiKind::FocusManager, + FOCUS_MANAGER_HANDLE, + ) } #[no_mangle] @@ -857,7 +949,8 @@ pub extern "C" fn js_perry_tui_focus_manager_focus(_handle: i64, id: f64) -> f64 /// `app.waitUntilExit()` minus the receiver arg. #[no_mangle] pub extern "C" fn js_perry_tui_wait_until_exit() -> f64 { - js_perry_tui_app_wait_until_exit(APP_HANDLE) + wait_until_exit_blocking(); + f64::from_bits(TAG_UNDEFINED) } // --------------------------------------------------------------------------- diff --git a/crates/perry-runtime/src/tui/mod.rs b/crates/perry-runtime/src/tui/mod.rs index 963a233728..83bdcf5afe 100644 --- a/crates/perry-runtime/src/tui/mod.rs +++ b/crates/perry-runtime/src/tui/mod.rs @@ -35,6 +35,7 @@ pub mod cell; pub mod color; pub mod ffi; +pub(crate) mod handle_object; pub mod hooks; pub mod input; pub mod layout; @@ -44,8 +45,11 @@ pub mod state; pub mod style; pub mod tree; -pub(crate) fn is_known_handle(handle: i64) -> bool { - tree::contains_handle(handle) - || state::contains_handle(handle) - || hooks::contains_handle(handle) -} +// #340/#341 deleted `is_known_handle` from here. It answered "is this integer +// one of our three registries' ids?" for the receiver-repr ledger, by asking +// all three under their mutexes — and it could not answer correctly, because +// `tree` counts from 1, `state` from 0 and `hooks` from 1, so one integer was +// simultaneously a live widget, a live state slot and a live ref. A tui handle +// is a heap object now; the brand on its header answers the same question with +// one load and no ambiguity (`tui::handle_object::tui_handle_parts_raw`), and +// the three `contains_handle` probes went with it. diff --git a/crates/perry-runtime/src/tui/run.rs b/crates/perry-runtime/src/tui/run.rs index d3f7ec5ede..1a5efcc52d 100644 --- a/crates/perry-runtime/src/tui/run.rs +++ b/crates/perry-runtime/src/tui/run.rs @@ -63,8 +63,12 @@ pub extern "C" fn js_perry_tui_run(component: i64) -> f64 { // Call the component to get a fresh widget tree. let widget_v = js_closure_call0(component_closure); - // Unbox the POINTER tag → raw handle (low 48 bits). - let widget_handle = (widget_v.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + // #340/#341: the component returns a widget handle OBJECT, so resolve + // it through the brand instead of masking the tag off whatever came + // back. A component that returns a number or a string now paints + // nothing (tree id 0, "no such node") rather than addressing whichever + // tree node its low 48 bits happened to name. + let widget_handle = super::handle_object::tui_widget_id_from_bits(widget_v.to_bits()); // Paint the tree into the back buffer + flush. super::ffi::paint_root_for_run(widget_handle); diff --git a/crates/perry-runtime/src/tui/state.rs b/crates/perry-runtime/src/tui/state.rs index 9cf3a3e563..12d6b77e61 100644 --- a/crates/perry-runtime/src/tui/state.rs +++ b/crates/perry-runtime/src/tui/state.rs @@ -95,9 +95,23 @@ pub(crate) fn scan_state_slot_roots_mut_step( } /// Allocate a fresh state slot with the given initial value (NaN-boxed -/// JSValue bits). Returns the slot index as the handle. +/// JSValue bits). Returns the JS-visible handle OBJECT (#340/#341); the slot +/// index stays this module's internal currency and rides in the object's +/// `ObjectMeta.native_state`. +/// +/// The slot index is what used to cross into JS, and the FIRST one is `0`, so +/// `state(0)` handed back `POINTER_TAG | 0` — a null pointer wearing the +/// pointer tag, the exact shape the honest-tag invariant exists to forbid. #[no_mangle] pub extern "C" fn js_perry_tui_state_alloc(initial: f64) -> i64 { + let id = alloc_state_slot(initial); + super::handle_object::tui_object(super::handle_object::TuiKind::State, id) +} + +/// Mint the slot and return its index. Split out of the FFI entry point so +/// tests can drive the table without going through the handle object, and so +/// the registry lock is released before `tui_object` allocates. +fn alloc_state_slot(initial: f64) -> i64 { let mut s = crate::gc::lock_gc_root_registry(&SLOTS); let h = s.len() as i64; s.push(initial.to_bits()); @@ -107,16 +121,28 @@ pub extern "C" fn js_perry_tui_state_alloc(initial: f64) -> i64 { h } -pub(crate) fn contains_handle(handle: i64) -> bool { - handle >= 0 && (handle as usize) < crate::gc::lock_gc_root_registry(&SLOTS).len() -} - /// Read a state slot. Returns the stored NaN-boxed value. Out-of-range /// handles return undefined. +/// +/// `handle` is the unboxed receiver payload codegen passes for a +/// `class_filter: Some("State")` row — the handle OBJECT's address since +/// #340/#341 — and it is resolved to a slot index at entry, before anything +/// that could allocate and move it. A receiver of another kind (the six tui id +/// spaces overlap) resolves to `None` rather than to a live slot of this one. #[no_mangle] pub extern "C" fn js_perry_tui_state_get(handle: i64) -> f64 { + match super::handle_object::tui_handle_id(handle, super::handle_object::TuiKind::State) { + Some(id) => state_get_by_id(id), + None => f64::from_bits(0x7FFC_0000_0000_0001), // TAG_UNDEFINED + } +} + +pub(super) fn state_get_by_id(id: i64) -> f64 { + if id < 0 { + return f64::from_bits(0x7FFC_0000_0000_0001); + } let s = crate::gc::lock_gc_root_registry(&SLOTS); - match s.get(handle as usize) { + match s.get(id as usize) { Some(bits) => f64::from_bits(*bits), None => f64::from_bits(0x7FFC_0000_0000_0001), // TAG_UNDEFINED } @@ -127,15 +153,25 @@ pub extern "C" fn js_perry_tui_state_get(handle: i64) -> f64 { /// handles silently no-op. #[no_mangle] pub extern "C" fn js_perry_tui_state_set(handle: i64, value: f64) -> f64 { + if let Some(id) = super::handle_object::tui_handle_id(handle, super::handle_object::TuiKind::State) + { + state_set_by_id(id, value); + } + f64::from_bits(0x7FFC_0000_0000_0001) +} + +pub(super) fn state_set_by_id(id: i64, value: f64) { + if id < 0 { + return; + } let mut s = crate::gc::lock_gc_root_registry(&SLOTS); - if let Some(slot) = s.get_mut(handle as usize) { + if let Some(slot) = s.get_mut(id as usize) { let new_bits = value.to_bits(); if *slot != new_bits { *slot = new_bits; STATE_DIRTY.store(true, Ordering::Release); } } - f64::from_bits(0x7FFC_0000_0000_0001) } #[cfg(test)] @@ -168,15 +204,36 @@ mod tests { STATE_DIRTY.store(false, Ordering::Release); } + /// #340/#341 re-baselined: the SLOT INDEX is still allocated + /// sequentially, but it is no longer what crosses into JS — the handle is + /// an object now, so the assertion moved onto the index it carries. + /// (Before: `h0 == 0`, which also meant `state(0)` handed JS + /// `POINTER_TAG | 0`, a tagged null.) #[test] fn alloc_returns_sequential_handles() { reset(); let h0 = js_perry_tui_state_alloc(0.0); let h1 = js_perry_tui_state_alloc(1.0); let h2 = js_perry_tui_state_alloc(2.0); - assert_eq!(h0, 0); - assert_eq!(h1, 1); - assert_eq!(h2, 2); + assert_eq!(slot_of(h0), 0); + assert_eq!(slot_of(h1), 1); + assert_eq!(slot_of(h2), 2); + // Three distinct handles, which the pre-#340 encoding could not give + // for the first one: `POINTER_TAG | 0` is indistinguishable from a + // null pointer. + assert_ne!(h0, 0); + assert_ne!(h0, h1); + assert_ne!(h1, h2); + } + + /// The slot index behind a handle object, for the tests that are about + /// the slot table rather than about the handle. + fn slot_of(handle: i64) -> i64 { + super::super::handle_object::tui_handle_id( + handle, + super::super::handle_object::TuiKind::State, + ) + .expect("a state handle resolves to its slot") } #[test] @@ -214,11 +271,21 @@ mod tests { assert!(!STATE_DIRTY.load(Ordering::Acquire)); } + /// #340/#341: `9_999` is no longer an out-of-range SLOT, it is not a + /// handle at all — the brand refuses it before the slot table is reached. + /// Both the old and the new representation answer `undefined`, but for + /// different reasons, and the new one is the stronger property: an + /// arbitrary integer can no longer address a live slot. #[test] - fn out_of_range_handle_returns_undefined() { + fn a_value_that_is_not_a_state_handle_returns_undefined() { reset(); + let _live = js_perry_tui_state_alloc(1.0); let v = js_perry_tui_state_get(9_999); assert_eq!(v.to_bits(), 0x7FFC_0000_0000_0001); + // Slot 0 exists and holds 1.0; the old encoding would have read it + // through any receiver whose payload was 0. + let v0 = js_perry_tui_state_get(0); + assert_eq!(v0.to_bits(), 0x7FFC_0000_0000_0001); } /// #7680: plants the #7672 shape directly — allocate a slot on THIS @@ -255,8 +322,8 @@ mod tests { ); let h_next = js_perry_tui_state_alloc(1.0); assert_eq!( - h_next, - h + 1, + slot_of(h_next), + slot_of(h) + 1, "this thread's slot count must not have been reset by the foreign clear" ); reset(); diff --git a/crates/perry-runtime/src/tui/tree.rs b/crates/perry-runtime/src/tui/tree.rs index 2d71c0ad9d..eb383d8ec9 100644 --- a/crates/perry-runtime/src/tui/tree.rs +++ b/crates/perry-runtime/src/tui/tree.rs @@ -65,9 +65,6 @@ pub fn lookup(handle: i64) -> Option { .find_map(|(h, n)| if *h == handle { Some(n.clone()) } else { None }) } -pub(crate) fn contains_handle(handle: i64) -> bool { - REGISTRY.lock().unwrap().iter().any(|(h, _)| *h == handle) -} /// Append a child handle to a Box node. No-op if the handle isn't a /// Box (silently ignored — matches the "we accept anything, you check diff --git a/crates/perry-runtime/src/url/abort.rs b/crates/perry-runtime/src/url/abort.rs index b02e628603..15b010ae3e 100644 --- a/crates/perry-runtime/src/url/abort.rs +++ b/crates/perry-runtime/src/url/abort.rs @@ -10,8 +10,8 @@ use super::*; /// Field 0: signal (object-ptr NaN-boxed) /// Field 1: aborted flag (NaN-boxed bool) /// Field 2: abort method (closure) -pub(crate) const ABORT_CONTROLLER_CLASS_ID: u32 = 0xFFFF_2401; -pub(crate) const ABORT_SIGNAL_CLASS_ID: u32 = 0xFFFF_2402; +pub(crate) const ABORT_CONTROLLER_CLASS_ID: u32 = crate::native_class_ids::ABORT_CONTROLLER; +pub(crate) const ABORT_SIGNAL_CLASS_ID: u32 = crate::native_class_ids::ABORT_SIGNAL; const ABORT_CONTROLLER_FIELD_COUNT: u32 = 3; const ABORT_SIGNAL_FIELD: u32 = 0; const ABORT_ABORTED_FIELD: u32 = 1; diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index b20b707225..dfffb6155f 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -1141,6 +1141,46 @@ "verdict": "test_only", "why": "AtomicI64 holding a scheduled mock timer's id (an i64 returned by schedule_mock_callback_timer, never a GC heap pointer) so the timer's own extern \"C\" callback can look itself up in the ref-state registry mid-dispatch. Declared under #[cfg(test)] only (tests_inline.rs, mock_dispatch_own_pin_tests), never live in a shipped binary." }, + { + "file": "crates/perry-runtime/src/tui/handle_object.rs", + "name": "STATE_PROTOTYPE_SLOT", + "scanner": "scan_object_cache_roots_mut" + }, + { + "file": "crates/perry-runtime/src/tui/handle_object.rs", + "name": "REF_BOX_PROTOTYPE_SLOT", + "scanner": "scan_object_cache_roots_mut" + }, + { + "file": "crates/perry-runtime/src/tui/handle_object.rs", + "name": "APP_PROTOTYPE_SLOT", + "scanner": "scan_object_cache_roots_mut" + }, + { + "file": "crates/perry-runtime/src/tui/handle_object.rs", + "name": "STDOUT_PROTOTYPE_SLOT", + "scanner": "scan_object_cache_roots_mut" + }, + { + "file": "crates/perry-runtime/src/tui/handle_object.rs", + "name": "FOCUS_MANAGER_PROTOTYPE_SLOT", + "scanner": "scan_object_cache_roots_mut" + }, + { + "file": "crates/perry-runtime/src/tui/handle_object.rs", + "name": "APP_SINGLETON_SLOT", + "scanner": "scan_object_cache_roots_mut" + }, + { + "file": "crates/perry-runtime/src/tui/handle_object.rs", + "name": "STDOUT_SINGLETON_SLOT", + "scanner": "scan_object_cache_roots_mut" + }, + { + "file": "crates/perry-runtime/src/tui/handle_object.rs", + "name": "FOCUS_MANAGER_SINGLETON_SLOT", + "scanner": "scan_object_cache_roots_mut" + }, { "file": "crates/perry-runtime/src/weakref/test_support.rs", "name": "DELIVERED", From 716b45c97a9ab862744d7a3746c2cc9510804f49 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 21 Sep 2026 16:38:28 +0000 Subject: [PATCH 2/2] test(runtime): compiled-program identity tests for the perry/tui family; root-holder verdicts The three tests compile and run real TS programs, which is the only way to reach the statically lowered class_filter rows and the emitted IC-miss edge -- every by-name unit test in #10831 passed while the compiled program answered undefined, and this family lowers even more of its surface statically. The eight new GC root-holder entries take the RESEARCHED-verdict form (covered_elsewhere + the scanner chain) rather than the frontier ratchet, and the two Timeout/Immediate prototype slots move with them. The frontier means ENUMERATED BUT SCANNED BY NOTHING; these are scanned by object::scan_object_cache_roots_mut, so recording them as debt understated the gate own coverage by ten holders. --- crates/perry/tests/tui_handle_identity.rs | 176 ++++++++++++++++++++++ scripts/gc_runtime_root_holders.json | 68 ++++++--- 2 files changed, 220 insertions(+), 24 deletions(-) create mode 100644 crates/perry/tests/tui_handle_identity.rs diff --git a/crates/perry/tests/tui_handle_identity.rs b/crates/perry/tests/tui_handle_identity.rs new file mode 100644 index 0000000000..c371ccb791 --- /dev/null +++ b/crates/perry/tests/tui_handle_identity.rs @@ -0,0 +1,176 @@ +//! #10821 row 3 -- object identity and surface for `perry/tui` handles. +//! +//! Every value the module handed TypeScript was a small registry integer under +//! `POINTER_TAG`, and SIX id spaces shared that encoding: three registries +//! (widget tree, state slots, hook slots) plus three constants +//! (`useApp()` = 1, `useStdout()` = 2, `useFocusManager()` = 3). The widget +//! tree and `useRef` both count from 1, so `useApp() === Text("hi")` was +//! `true` and a `Set` of two widgets and the App handle had SIZE 2. The first +//! `state(0)` of a program was slot 0, i.e. `POINTER_TAG | 0` -- a null +//! pointer wearing the pointer tag. +//! +//! These tests are the identity contract every migrated family must satisfy, +//! written against BEHAVIOUR rather than representation: two distinct +//! resources are `!==`, the same resource reached twice is `===`, both hold as +//! `Map` / `Set` keys, and all of it survives a collection -- a widget is a +//! movable heap object now, so a probe that only checked values would not +//! cover the axis that changed. +//! +//! `perry/tui` has no node equivalent, so the parity bar here is the ORDINARY +//! OBJECT surface node gives every one of its own native classes: `typeof` +//! `"object"`, `Object.keys` `[]`, `JSON.stringify` `{}` (it was `null`). + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &std::path::Path, source: &str) -> String { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// The collision, and the object surface that removes it. Measured on the +/// pre-change binary (v0.5.1631), every one of these lines read the other way: +/// `app-eq-widget true`, `set-size 2`, and `{}` was `null`. +#[test] +fn tui_handles_of_different_kinds_are_different_objects() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +import { Text, state, useApp, useStdout, useFocusManager } from "perry/tui"; +const a: any = Text("alpha"); +const b: any = Text("beta"); +const app: any = useApp(); +const so: any = useStdout(); +const fm: any = useFocusManager(); +const s: any = state(0); +console.log("typeof", typeof a, typeof app, typeof s); +console.log("collide", app === a, app === so, so === fm, s === a); +console.log("stable", useApp() === app, useStdout() === so, a === a); +console.log("distinct", a === b); +const set = new Set([a, b, app, so, fm, s]); +console.log("setsize", set.size); +const m = new Map([[a, "one"], [b, "two"]]); +console.log("map", m.size, m.get(a), m.get(b)); +console.log("surface", JSON.stringify(a), JSON.stringify(s), JSON.stringify(app)); +console.log("keys", JSON.stringify(Object.keys(a)), JSON.stringify(Object.getOwnPropertyNames(a))); +"#, + ); + assert_eq!( + stdout, + "typeof object object object\n\ + collide false false false false\n\ + stable true true true\n\ + distinct false\n\ + setsize 6\n\ + map 2 one two\n\ + surface {} {} {}\n\ + keys [] []\n" + ); +} + +/// Identity and state must survive a collection: a handle is a movable +/// `GC_TYPE_OBJECT` now, held from a `Map` key, from a hook slot and from a +/// realm singleton slot. A probe that only checked `s.get()` would pass a +/// change that left the singleton slot or the `Map` key unrewritten. +#[test] +fn tui_handles_survive_a_collection() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +import { Text, state, useApp } from "perry/tui"; +const a: any = Text("alpha"); +const b: any = Text("beta"); +const app: any = useApp(); +const s = state(0); +s.set(7); +const m = new Map([[a, "one"], [b, "two"]]); +let sink: any[] = []; +for (let i = 0; i < 200000; i++) { sink.push({ i: i, j: i + 1 }); } +sink = []; +console.log("state", s.get()); +console.log("map", m.get(a), m.get(b), m.size); +console.log("identity", a === a, a === b, useApp() === app); +console.log("surface", typeof a, JSON.stringify(a)); +"#, + ); + assert_eq!( + stdout, + "state 7\n\ + map one two 2\n\ + identity true false true\n\ + surface object {}\n" + ); +} + +/// The whole method surface used to exist ONLY as `class_filter` lowerings, so +/// a handle reached through an untyped value answered `undefined` for every +/// one of them -- measured on v0.5.1631, all four of these read `undefined` +/// and `typeof sAny.get === "function"` was `false`. They are prototype +/// methods now, so the ordinary read path finds them. +#[test] +fn tui_handle_methods_resolve_through_the_prototype() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +import { state, useApp, useStdout, useFocusManager } from "perry/tui"; +const s: any = state(1); +const app: any = useApp(); +const so: any = useStdout(); +const fm: any = useFocusManager(); +console.log("types", typeof s.get, typeof s.set, typeof app.exit, typeof so.columns, typeof fm.focusNext); +// A dynamic read of a method value, then a call through it (the #8133 shape). +const g: any = s.get; +console.log("read-then-call", typeof g === "function"); +s.set(42); +console.log("dynamic-set-then-get", s.get()); +console.log("columns-positive", so.columns() > 0, so.rows() > 0); +// A foreign receiver is answered leniently, never read as an id of this kind: +// perry/tui is not WebIDL and node has no equivalent to copy a policy from. +console.log("foreign", s.get.call({}), app.exit.call({})); +"#, + ); + assert_eq!( + stdout, + "types function function function function function\n\ + read-then-call true\n\ + dynamic-set-then-get 42\n\ + columns-positive true true\n\ + foreign undefined undefined\n" + ); +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index dfffb6155f..507ecb35d9 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -1135,51 +1135,81 @@ "verdict": "not_a_gc_pointer", "why": "In-flight job counter for perry/thread." }, - { - "file": "crates/perry-runtime/src/timer/tests_inline.rs", - "name": "SELF_ID", - "verdict": "test_only", - "why": "AtomicI64 holding a scheduled mock timer's id (an i64 returned by schedule_mock_callback_timer, never a GC heap pointer) so the timer's own extern \"C\" callback can look itself up in the ref-state registry mid-dispatch. Declared under #[cfg(test)] only (tests_inline.rs, mock_dispatch_own_pin_tests), never live in a shipped binary." - }, { "file": "crates/perry-runtime/src/tui/handle_object.rs", "name": "STATE_PROTOTYPE_SLOT", - "scanner": "scan_object_cache_roots_mut" + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut -> tui::handle_object::scan_tui_handle_roots_mut", + "why": "#340/#341 per-realm `perry/tui` prototype singleton. Real GC address, visited by `tui::handle_object::scan_tui_handle_roots_mut`, which `object::scan_object_cache_roots_mut` calls beside the iterator tower and the timer prototypes; that function is a registered scanner, so the slot is both kept alive and rewritten when the prototype moves. It reads as uncovered here only because the registered scanner is in another file." }, { "file": "crates/perry-runtime/src/tui/handle_object.rs", "name": "REF_BOX_PROTOTYPE_SLOT", - "scanner": "scan_object_cache_roots_mut" + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut -> tui::handle_object::scan_tui_handle_roots_mut", + "why": "#340/#341 per-realm `perry/tui` prototype singleton. Real GC address, visited by `tui::handle_object::scan_tui_handle_roots_mut`, which `object::scan_object_cache_roots_mut` calls beside the iterator tower and the timer prototypes; that function is a registered scanner, so the slot is both kept alive and rewritten when the prototype moves. It reads as uncovered here only because the registered scanner is in another file." }, { "file": "crates/perry-runtime/src/tui/handle_object.rs", "name": "APP_PROTOTYPE_SLOT", - "scanner": "scan_object_cache_roots_mut" + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut -> tui::handle_object::scan_tui_handle_roots_mut", + "why": "#340/#341 per-realm `perry/tui` prototype singleton. Real GC address, visited by `tui::handle_object::scan_tui_handle_roots_mut`, which `object::scan_object_cache_roots_mut` calls beside the iterator tower and the timer prototypes; that function is a registered scanner, so the slot is both kept alive and rewritten when the prototype moves. It reads as uncovered here only because the registered scanner is in another file." }, { "file": "crates/perry-runtime/src/tui/handle_object.rs", "name": "STDOUT_PROTOTYPE_SLOT", - "scanner": "scan_object_cache_roots_mut" + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut -> tui::handle_object::scan_tui_handle_roots_mut", + "why": "#340/#341 per-realm `perry/tui` prototype singleton. Real GC address, visited by `tui::handle_object::scan_tui_handle_roots_mut`, which `object::scan_object_cache_roots_mut` calls beside the iterator tower and the timer prototypes; that function is a registered scanner, so the slot is both kept alive and rewritten when the prototype moves. It reads as uncovered here only because the registered scanner is in another file." }, { "file": "crates/perry-runtime/src/tui/handle_object.rs", "name": "FOCUS_MANAGER_PROTOTYPE_SLOT", - "scanner": "scan_object_cache_roots_mut" + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut -> tui::handle_object::scan_tui_handle_roots_mut", + "why": "#340/#341 per-realm `perry/tui` prototype singleton. Real GC address, visited by `tui::handle_object::scan_tui_handle_roots_mut`, which `object::scan_object_cache_roots_mut` calls beside the iterator tower and the timer prototypes; that function is a registered scanner, so the slot is both kept alive and rewritten when the prototype moves. It reads as uncovered here only because the registered scanner is in another file." }, { "file": "crates/perry-runtime/src/tui/handle_object.rs", "name": "APP_SINGLETON_SLOT", - "scanner": "scan_object_cache_roots_mut" + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut -> tui::handle_object::scan_tui_handle_roots_mut", + "why": "#340/#341 per-realm `perry/tui` singleton HANDLE (`useApp()` / `useStdout()` / `useFocusManager()` must answer the same object on every call, so the object lives here rather than being re-minted). Real GC address, visited by `tui::handle_object::scan_tui_handle_roots_mut` from the registered `object::scan_object_cache_roots_mut`. Uncovered here only because that scanner is in another file." }, { "file": "crates/perry-runtime/src/tui/handle_object.rs", "name": "STDOUT_SINGLETON_SLOT", - "scanner": "scan_object_cache_roots_mut" + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut -> tui::handle_object::scan_tui_handle_roots_mut", + "why": "#340/#341 per-realm `perry/tui` singleton HANDLE (`useApp()` / `useStdout()` / `useFocusManager()` must answer the same object on every call, so the object lives here rather than being re-minted). Real GC address, visited by `tui::handle_object::scan_tui_handle_roots_mut` from the registered `object::scan_object_cache_roots_mut`. Uncovered here only because that scanner is in another file." }, { "file": "crates/perry-runtime/src/tui/handle_object.rs", "name": "FOCUS_MANAGER_SINGLETON_SLOT", - "scanner": "scan_object_cache_roots_mut" + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut -> tui::handle_object::scan_tui_handle_roots_mut", + "why": "#340/#341 per-realm `perry/tui` singleton HANDLE (`useApp()` / `useStdout()` / `useFocusManager()` must answer the same object on every call, so the object lives here rather than being re-minted). Real GC address, visited by `tui::handle_object::scan_tui_handle_roots_mut` from the registered `object::scan_object_cache_roots_mut`. Uncovered here only because that scanner is in another file." + }, + { + "file": "crates/perry-runtime/src/timer/handle_object.rs", + "name": "TIMEOUT_PROTOTYPE_SLOT", + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut -> timer::handle_object::scan_timer_prototype_roots_mut", + "why": "#340/#341 per-realm `Timeout` / `Immediate` prototype singleton. Real GC address, visited by `timer::handle_object::scan_timer_prototype_roots_mut`, which the registered `object::scan_object_cache_roots_mut` calls. Moved out of the `frontier` list by #10821 row 3: the frontier means ENUMERATED BUT SCANNED BY NOTHING, and these are scanned, so recording them as debt understated the gate's own coverage." + }, + { + "file": "crates/perry-runtime/src/timer/handle_object.rs", + "name": "IMMEDIATE_PROTOTYPE_SLOT", + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut -> timer::handle_object::scan_timer_prototype_roots_mut", + "why": "#340/#341 per-realm `Timeout` / `Immediate` prototype singleton. Real GC address, visited by `timer::handle_object::scan_timer_prototype_roots_mut`, which the registered `object::scan_object_cache_roots_mut` calls. Moved out of the `frontier` list by #10821 row 3: the frontier means ENUMERATED BUT SCANNED BY NOTHING, and these are scanned, so recording them as debt understated the gate's own coverage." + }, + { + "file": "crates/perry-runtime/src/timer/tests_inline.rs", + "name": "SELF_ID", + "verdict": "test_only", + "why": "AtomicI64 holding a scheduled mock timer's id (an i64 returned by schedule_mock_callback_timer, never a GC heap pointer) so the timer's own extern \"C\" callback can look itself up in the ref-state registry mid-dispatch. Declared under #[cfg(test)] only (tests_inline.rs, mock_dispatch_own_pin_tests), never live in a shipped binary." }, { "file": "crates/perry-runtime/src/weakref/test_support.rs", @@ -4026,16 +4056,6 @@ "file": "crates/perry-runtime/src/timer.rs", "name": "TIMER_CALLBACK_DISPATCH_DEPTH" }, - { - "file": "crates/perry-runtime/src/timer/handle_object.rs", - "name": "TIMEOUT_PROTOTYPE_SLOT", - "scanner": "scan_object_cache_roots_mut" - }, - { - "file": "crates/perry-runtime/src/timer/handle_object.rs", - "name": "IMMEDIATE_PROTOTYPE_SLOT", - "scanner": "scan_object_cache_roots_mut" - }, { "file": "crates/perry-runtime/src/tls_hot.rs", "name": "AFTER_PROBE"