diff --git a/CLAUDE.md b/CLAUDE.md index 556a7f4334..c34e12624e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1633 +**Current Version:** 0.5.1634 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index e66c97644c..b755855258 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5532,7 +5532,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "anyhow", "base64 0.22.1", @@ -5596,7 +5596,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "perry-dispatch", "serde", @@ -5604,7 +5604,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "cc", "libc", @@ -5613,7 +5613,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "aho-corasick", "anyhow", @@ -5630,7 +5630,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "anyhow", "perry-hir", @@ -5638,7 +5638,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "anyhow", "perry-hir", @@ -5646,7 +5646,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "anyhow", "perry-dispatch", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "anyhow", "perry-hir", @@ -5663,7 +5663,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "anyhow", "base64 0.22.1", @@ -5675,7 +5675,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "anyhow", "perry-hir", @@ -5683,7 +5683,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "async-trait", "clap", @@ -5707,14 +5707,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "serde", "serde_json", @@ -5722,7 +5722,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1633" +version = "0.5.1634" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5733,7 +5733,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "anyhow", "clap", @@ -5748,7 +5748,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "block2", "objc2", @@ -5758,7 +5758,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "argon2", "perry-ffi", @@ -5767,7 +5767,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "bcrypt", "perry-ffi", @@ -5775,7 +5775,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "perry-ffi", "rusqlite", @@ -5783,7 +5783,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "perry-ffi", "scraper", @@ -5791,7 +5791,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "perry-ffi", "rust_decimal", @@ -5799,7 +5799,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5807,7 +5807,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "perry-ffi", "perry-runtime", @@ -5815,7 +5815,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "bytes", "lazy_static", @@ -5828,7 +5828,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "base64 0.22.1", "bytes", @@ -5860,7 +5860,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "lazy_static", "perry-ffi", @@ -5870,7 +5870,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "bson", "futures-util", @@ -5882,7 +5882,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "bytes", "perry-ffi", @@ -5897,7 +5897,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "lettre", "perry-ffi", @@ -5907,7 +5907,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "notify", "perry-ffi", @@ -5919,7 +5919,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "perry-ffi", "printpdf", @@ -5927,7 +5927,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "fast_image_resize", "image", @@ -5938,7 +5938,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "lazy_static", "perry-ffi", @@ -5947,7 +5947,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "anyhow", "perry-ffi", @@ -5967,7 +5967,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "perry-ffi", "perry-runtime", @@ -5976,7 +5976,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "futures-util", "lazy_static", @@ -5989,7 +5989,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "brotli", "flate2", @@ -5999,7 +5999,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6009,7 +6009,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "anyhow", "perry-api-manifest", @@ -6029,11 +6029,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1633" +version = "0.5.1634" [[package]] name = "perry-parser" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "anyhow", "perry-diagnostics", @@ -6046,7 +6046,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "perex", "regex", @@ -6054,7 +6054,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "ahash", "base64 0.22.1", @@ -6112,14 +6112,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6202,21 +6202,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "dirs", "perry-ffi", @@ -6226,7 +6226,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "base64 0.22.1", "jni", @@ -6241,7 +6241,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "rand 0.10.2", "serde", @@ -6251,7 +6251,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "base64 0.22.1", "block2", @@ -6291,7 +6291,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "base64 0.22.1", "block2", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1633" +version = "0.5.1634" [[package]] name = "perry-ui-test" @@ -6319,11 +6319,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1633" +version = "0.5.1634" [[package]] name = "perry-ui-tvos" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "base64 0.22.1", "block2", @@ -6340,7 +6340,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "base64 0.22.1", "block2", @@ -6357,7 +6357,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "block2", "libc", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "base64 0.22.1", "libc", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "base64 0.22.1", "libc", @@ -6403,7 +6403,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "anyhow", "base64 0.22.1", @@ -6418,7 +6418,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1633" +version = "0.5.1634" dependencies = [ "wasmi", ] @@ -7426,9 +7426,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.44" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", diff --git a/Cargo.toml b/Cargo.toml index 0ae30ad39d..26f843b3dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1633" +version = "0.5.1634" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10913-stdin-reader-restart-race.md b/changelog.d/10913-stdin-reader-restart-race.md new file mode 100644 index 0000000000..78e777013b --- /dev/null +++ b/changelog.d/10913-stdin-reader-restart-race.md @@ -0,0 +1,23 @@ +Fixed `for await (const chunk of process.stdin)` on a pipe stalling forever +part-way through the input (#10895). + +The async iterator pauses its source after every delivered chunk and resumes +it on the next pull. On `process.stdin`, `pause()` latches `STDIN_DETACHED` — +the fd-0 reader thread exits when it sees the latch at the top of its loop — +and `resume()` clears the latch and respawns the reader unless +`STDIN_READER_STARTED` says one is still running. The reader's stop decision +and its `STARTED` reset were two separate steps, so a `resume()` that landed +between them found `STARTED` still true, spawned nothing, and the old reader +then left: fd 0 had no reader while every liveness view still reported an +open, flowing stdin, and the process idled forever with input unread. One roll +per delivered chunk: near-certain at 4 MiB through a 16 KiB macOS pipe, 1 in +350 at 16 MiB on Linux. Introduced by the unified fd-0 reader (2026-09-04); +the published 0.5.1520 predates it. + +The reader's check-and-clear and the restart CAS are now atomic with respect +to each other under one lifecycle lock (never held across `read()`); the +detach exit releases the reader slot itself and disarms the drop guard. + +Tests: `crates/perry/tests/issue_10895_stdin_pipe_stall.rs` (8 MiB in 256-byte +writes, 12 rounds, red on unpatched main) and `reader_lifecycle_tests` in +`os_process_streams.rs`, which replays the interleaving deterministically. diff --git a/changelog.d/10915-honest-handle-tag-tui.md b/changelog.d/10915-honest-handle-tag-tui.md new file mode 100644 index 0000000000..7ce7177b6a --- /dev/null +++ b/changelog.d/10915-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/changelog.d/10916-resizable-arraybuffer.md b/changelog.d/10916-resizable-arraybuffer.md new file mode 100644 index 0000000000..04f02040be --- /dev/null +++ b/changelog.d/10916-resizable-arraybuffer.md @@ -0,0 +1,9 @@ +Implemented resizable `ArrayBuffer` (ES2024): `new ArrayBuffer(length, { maxByteLength })`, `ArrayBuffer.prototype.resize`, real `resizable` / `maxByteLength` getters, length-tracking and fixed-length views, and `transfer()` preserving resizability (#10873). + +`new ArrayBuffer(len, { maxByteLength })` silently returned a fixed-length buffer: the `"ArrayBuffer"` arm in `perry-codegen`'s `lower_call/builtin.rs` lowered only the first argument (the options bag was never evaluated), `ArrayBuffer.prototype.resize` did not exist, and `get_field_by_name_tail.rs` hard-coded `resizable` to `false`. A program using one — guest271314's TypeScript Native Messaging host keeps a single `new ArrayBuffer(0, { maxByteLength: 64 MiB })` and `resize()`s it per message — died on its first `.resize()` with `TypeError: (Buffer).resize is not a function`. + +Storage model (`perry-runtime/src/buffer/resizable.rs`): buffer bytes live inline after the `BufferHeader` and every view aliases its backing by raw address, so a resize must never move the payload. A resizable buffer reserves `maxByteLength` once (its `capacity`) and `resize()` only rewrites `length`. Construction clears the initial `length` bytes only; a per-buffer `dirty_end` boundary records where the reserved tail is known-zero, so a grow clears exactly what may be dirty — `new ArrayBuffer(0, { maxByteLength: 64 MiB })` reserves address space, not resident memory, and `resize(64 MiB)` into never-touched or released pages costs 0.04 ms (a first cut that memset the range cost 224 ms). A shrink of at least 64 KiB hands the dropped pages back to the OS with the same `madvise` detach uses, so RSS follows `byteLength`, not the high-water mark. + +Views are re-lengthed eagerly on every `resize` — the way detach zeroes them — so every fast tier that reads a view's length is unchanged: a view constructed without an explicit length (and a `subarray()` without `end` of one) tracks `byteLength`; a fixed-length view reads as length 0 / byteOffset 0 while it no longer fits and comes back when the buffer regrows; an out-of-bounds `DataView` throws `TypeError` from its accessors and `byteLength`. `transfer()` keeps a buffer resizable (and `RangeError`s past `maxByteLength`), `transferToFixedLength()` drops it. `resize` / `transfer` / `transferToFixedLength` and the `resizable` / `maxByteLength` / `detached` accessors are installed on `ArrayBuffer.prototype`; the dynamic constructor path (`class_registry/construct.rs`) passes the options too. Every probe added to a shared path is gated on one `RegistryLatch` load, and the per-access `ViewInfo` / `ViewMeta` copies stay two words; measured `instructions:u` on typed-array, view, DataView and ArrayBuffer micro-rows are within ±0.6% of `main`. + +Verified: `test-files/test_gap_10873_resizable_arraybuffer.ts` byte-identical against node 26.5.1 (fails on unpatched `main` at its first line), also under `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_VERIFY_EVACUATION=1` (1572 forced copying minors); eleven unit tests in `buffer/resizable_tests.rs`; the test262 `resizable-arraybuffer` built-ins subset goes from 33 to 156 of 408 passing. Not covered yet: growable `SharedArrayBuffer`, and `%TypedArray%.prototype` method semantics for a receiver that shrinks mid-iteration (the bulk of the remaining test262 cases; `test-compat/test262/features-applicable.txt` says so). diff --git a/changelog.d/10919-shape-identity-key-order.md b/changelog.d/10919-shape-identity-key-order.md new file mode 100644 index 0000000000..b477c9ea38 --- /dev/null +++ b/changelog.d/10919-shape-identity-key-order.md @@ -0,0 +1,14 @@ +Added parity coverage pinning property **key order** across every way an object +reaches a layout (#10868 stage 0). + +Key order is observable through `Object.keys`, `JSON.stringify`, `for…in` and +spread, and Perry reaches a given layout by several different routes — literal, +incremental assignment, `Object.assign`, spread, delete-and-re-add, integer-like +keys, and the transitions between them. Nothing pinned that the routes agree +with each other *or* with the specification's insertion/integer-index ordering, +so a layout change could silently reorder one route only. + +Test-only: a parity fixture that drives each route to the same key set and +compares the observed order byte-for-byte against the Node oracle. It is stage 0 +of #10868 — it locks current behaviour in place before the layout work that +follows can move it. diff --git a/changelog.d/10921-add-tree-leaf-after-conversion.md b/changelog.d/10921-add-tree-leaf-after-conversion.md new file mode 100644 index 0000000000..c809ec3849 --- /dev/null +++ b/changelog.d/10921-add-tree-leaf-after-conversion.md @@ -0,0 +1,46 @@ +Fixed `a + b + c` reading a leaf *after* a conversion the specification orders +before it (#10904). + +`lower_guarded_numeric_add` fuses a whole `+` tree into one shared guard, which +evaluates every leaf before any addition. That is faithful only when the +specification also finishes every evaluation before the first conversion. For +`Add(L, R)` the spec evaluates `L`, evaluates `R`, and only then `ToPrimitive`s +both — so when `L` is itself an `Add`, its conversions run *before* `R` is +evaluated, and a user `valueOf`/`toString` inside `L` can change what a leaf in +`R` reads: + +```js +const O = { a: null, b: 1, c: 7 }; +O.a = { valueOf() { O.c = 100; return 1; } }; +O.a + O.b + O.c; // parses as (O.a + O.b) + O.c — node 102, perry 9 +``` + +The rule is exact rather than a leaf-count approximation, and narrower than +"decline every left-leaning chain": `Add(L, R)` is faithful iff `L` and `R` are, +and, when `L` is an `Add`, **every leaf of `R` is evaluation-invariant**. A +property read is not invariant (a `valueOf` can assign `O.c`); a literal is, and +so is a `LocalGet` whose storage only this activation writes — outside +`boxed_vars` (captured and assigned anywhere, a parameter a sloppy mapped +`arguments` aliases, a TDZ box), not a module global, not a POD record. No new +analysis: `boxed_vars` already means "some other code can write it". Declining +every left-leaning chain instead cost `x + y + z` over plain locals — one of the +commonest expressions in JavaScript — for no correctness gain (lane 13's +`read4_stmt` column was +91..+99). + +Soundness of the exemption: the cold arm (`rebuild_add_tree(fast = false)`) +already performs the conversions in spec order over the lowered values. The only +thing #10904 broke was *reading* a leaf before an earlier conversion could run, +and for an invariant leaf the read time is unobservable. + +The check lives at the top of the fold, not at one call site. +`lower_guarded_numeric_add` is reached from two places, and gating only the +dynamic entry left the same stale read alive on the declared-number entry: with +`a: number[]` and an object in `a[0]` whose `valueOf` assigns `a[2]`, +`a[0] + a[1] + a[2]` printed 6 where node prints 103 — on the fix branch and on +main. A declined tree now lowers node by node through the spec helper from +either entry, and `dynamic_add_tree_benefits_shared_guard` returns to its +main-branch form: it answers whether the fold is *worth* it, not whether it is +*correct*. + +The dominant accumulator shape keeps its guard: `sum += row.x + row.y` parses as +`sum + (row.x + row.y)`, which is right-leaning and faithful. diff --git a/changelog.d/10923-stdout-write-bytes.md b/changelog.d/10923-stdout-write-bytes.md new file mode 100644 index 0000000000..49aefd1ddc --- /dev/null +++ b/changelog.d/10923-stdout-write-bytes.md @@ -0,0 +1,44 @@ +`process.stdout.write(chunk[, encoding])` / `process.stderr.write(...)` now put +the chunk's **bytes** on the fd (#10903). + +Both stubs started from `js_jsvalue_to_string(chunk)` — the chunk's display text +— and never read `encoding`. A `Buffer` / `Uint8Array` was UTF-8 *decoded* and +the text written, so every byte that is not valid UTF-8 reached the fd as +`EF BF BD`: a 4-byte frame length of 200 (`C8 00 00 00`) was enough to corrupt +a binary protocol (found with a Native Messaging host). Any other `TypedArray` +went out as its `join(",")` text (`new Uint16Array([0x6968, 0x0a21])` printed +`26984,2593`), a `DataView` as `[object DataView]`, and +`write("6865780a", "hex")` wrote eight characters instead of four bytes. The +write itself used `Stdout::write_all`, which stops at the first `EAGAIN` after +an unknown prefix, and the error was discarded — on a non-blocking fd 1 an +8 MiB chunk delivered 131,072 bytes, exit code 0. + +Now, as in Node: a `Buffer` / any `TypedArray` / `DataView` is written byte for +byte, exactly the window the view covers (`subarray`, `new T(ab, off, n)`, +`DataView(ab, off, n)`); a string is encoded with `encoding` (`latin1`, +`binary`, `ascii`, `hex`, `base64`, `base64url`, `ucs2`, `utf16le`; default +`utf8`), using `Buffer.from`'s encoder; a binary chunk ignores `encoding`; a +view whose ArrayBuffer was `transfer()`red away throws a `TypeError`. The new +`write_all_fd` completes partial writes, retries `EINTR`, waits out `EAGAIN` +with `POLLOUT`, returns `EPIPE` instead of spinning, and caps a request at +1 GiB; Rust's stdout handle is flushed first and its lock held across the +write, so `console.log` and `write` stay in program order. + +The common path got cheaper: a utf8 string chunk is written from its own +payload instead of a `to_vec()` copy — `instructions:u` per call −182 (−5.2%) +for a string write, −23,924 (−86.7%) for a 49-byte `Uint8Array` write, and +`console.log` (untouched, the control) +7 (+0.2%). + +Unchanged on purpose: a value that is neither a string nor a binary chunk +(`42`, `null`, an `ArrayBuffer`) and an unknown encoding name keep perry's +leniency; Node throws `ERR_INVALID_ARG_TYPE` / `ERR_STREAM_NULL_VALUES` / +`ERR_UNKNOWN_ENCODING` there. + +Tests: `crates/perry/tests/issue_10903_stdout_write_bytes.rs` compares both fds +byte for byte with what Node 26.5.1 writes for +`test-files/test_gap_10903_stdout_write_bytes.ts` — chunk kinds and windows, +encodings, callbacks, `console.log` interleaving, Native Messaging framing, +5 MiB / 16 / 32 / 64 MiB single writes, 20,000 small writes, detached views, +and the large cases again over a non-blocking socket with a slow reader. The +same fixture, undriven, is a text parity case. Nine unit tests in +`os_process_stream_write_tests.rs` cover the conversion and `write_all_fd`. diff --git a/changelog.d/10924-honest-handle-tag-null-stub.md b/changelog.d/10924-honest-handle-tag-null-stub.md new file mode 100644 index 0000000000..7f490bc54c --- /dev/null +++ b/changelog.d/10924-honest-handle-tag-null-stub.md @@ -0,0 +1,16 @@ +The value perry hands back when an import or a method dispatch has nowhere to go +(the "unresolved-namespace stub") is now a real empty object. It used to be the +address of a `.rodata` byte array laid out like an object header but with no GC +header in front of it, so every type probe read whatever bytes the linker had +placed before it. In a v0.5.1631 build those bytes were the tail of a string +literal, and the stub reported itself as a heap kind that does not exist: +`JSON.stringify` of it answered `""` and `String()` of it threw `TypeError: +Cannot convert object to primitive value`. Both now answer as `{}` does +(`"{}"`, `"[object Object]"`), and the answers no longer depend on how the +binary happened to be linked (#10917). + +One behaviour change follows from the stub now being the empty object it always +claimed to be: calling a method on it (`stub.raw()`) throws `TypeError: raw is +not a function`, exactly as it does on any `{}` and as node does. It used to +return the stub again, but only because the fake header routed the call into a +fallback arm; perry had already stopped doing that for real empty objects. diff --git a/changelog.d/10929-nbc-after-ptr-shape-proofs.md b/changelog.d/10929-nbc-after-ptr-shape-proofs.md new file mode 100644 index 0000000000..88ba728bf0 --- /dev/null +++ b/changelog.d/10929-nbc-after-ptr-shape-proofs.md @@ -0,0 +1,24 @@ +Fixed numeric provenance being computed before the `Ptr` receiver proofs +it depends on (refs #10777). + +`collectors/hir_facts.rs` computed `number_by_construction_locals` before +`collect_shape_proven_ptr_locals`. For `h = h + o.a` that asks "is `h` +Number-producing?" before `o`'s receiver proof exists, and +`expr_numeric_by_construction`'s `PropertyGet` arm is gated on the receiver +being a tracked member — so the function-scope entry point passed +`empty_members`/`empty_fields` hardcoded (`ptr_shape_numeric.rs`) and that arm +could never fire. The accumulator was therefore never admitted, however +completely the receiver's shape was proven: on a fixture whose opt report says +`Ptr 1 selected / 1 CONSUMED`, the `+` routing decision still reported +`both_numeric=false => GUARDED`. + +The computation now runs after the receiver proofs, and the two hardcoded-empty +parameters become real. No new admission arm, no new provenance class, no new +fact. The shape inputs are the **intersection** of the proven receivers' numeric +field sets — the arm consumes one set and does not re-check which receiver a +property belongs to, so the set must be numeric on every admitted receiver; a +union would be a wrong answer, not a weaker one. + +Default OFF behind `PERRY_L14_NBC_ORDER=1` and keyed into the object cache; with +it off the inputs are empty and the fixpoint computes exactly what it computed +before, so the reorder is a no-op. diff --git a/changelog.d/10930-l14-nbc-order-build-cache.md b/changelog.d/10930-l14-nbc-order-build-cache.md new file mode 100644 index 0000000000..c8cb0ffaba --- /dev/null +++ b/changelog.d/10930-l14-nbc-order-build-cache.md @@ -0,0 +1,16 @@ +Registered `PERRY_L14_NBC_ORDER` as a build-cache input. + +#10929 added the knob and keyed it into the **object** cache +(`commands/compile/object_cache.rs`) but not the **build** cache, so +`codegen_env_vars_are_build_cache_inputs` — #6394's rule, that every codegen +switch must key the build cache or carry a written exclusion — failed: + +``` +these codegen env vars key neither the build cache nor an exclusion +(#6394's rule): ["PERRY_L14_NBC_ORDER"]. +``` + +It belongs in `BUILD_CACHE_ENV_VARS`, not the exclusions: on, an accumulator +written `h = h + o.a` is admitted and the `+` routes to `INLINE_FADD`; off, the +shape inputs are empty and it stays guarded. The two settings emit different +code, so an object built under one must not be served to a build of the other. diff --git a/changelog.d/10930-rustls-advisory.md b/changelog.d/10930-rustls-advisory.md new file mode 100644 index 0000000000..6186875037 --- /dev/null +++ b/changelog.d/10930-rustls-advisory.md @@ -0,0 +1,10 @@ +Bumped `rustls` 0.23.44 → 0.23.45, clearing **RUSTSEC-2026-0285** (#10791). + +TLS 1.3 handshake messages were accepted across encryption-level boundaries +(medium, 5.3). `cargo audit` has failed on it since the advisory was published; +it is the one unignored finding in the workspace, and `security-audit` runs on +every PR that touches a lockfile — so every merge train hit it. + +A single-crate lock bump, no manifest change and no cascade ("Locking 1 package +to highest compatible version"). 0.23.45 cleared the repo's 7-day +`SOAK_DAYS` window on 2026-09-21, which is what #10791 was waiting for. diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index aa2ba7e257..75723fbeae 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -601,20 +601,6 @@ pub(crate) fn collect_type_facts( // is a consequence of the range proof, not an additional assumption. integer_locals.extend(loop_bounded_i32_locals.iter().copied()); let not_bigint_locals = not_bigint.into_locals(); - // #8105: locals that hold a JS Number by construction. Computed here, not - // inside the `Ptr` pass, so the fact does not vanish under - // `PERRY_PTR_SHAPE_LOCALS=0` — `is_numeric_expr` is not a repsel consumer. - let number_by_construction_locals = super::collect_number_by_construction_locals( - stmts, - params, - boxed_vars, - module_globals, - binding_types, - spec_ta_lens, - spec_numeric_params, - ¬_bigint_locals, - module_global_proven_types, - ); let (mut array_facts, effect_facts, materialization_hazards) = collect_array_facts(stmts, params, module_globals, binding_types); // #7469: at-allocation all-pointer element-layout declaration candidates. @@ -736,6 +722,38 @@ pub(crate) fn collect_type_facts( spec_numeric_params, ); array_facts.exact_numeric_element_fields = exact_numeric_element_fields; + + // #8105 / #10777: locals that hold a JS Number by construction. + // + // MOVED here from before `collect_shape_proven_ptr_locals`. The old + // position asked "is `h` Number-producing?" for `h = h + o.a` BEFORE `o`'s + // receiver proof existed, so `expr_numeric_by_construction`'s `PropertyGet` + // arm — gated on a tracked member — could never fire, and the accumulator + // was never admitted however completely `o`'s shape was proven. A probe on + // the `+` routing decision reported `left=LocalGet(num=false) + // right=PropertyGet(num=true)`: the slot was proven and the local was not. + // + // The move is a pure reordering — nothing between the two positions + // consumes this fact and the computation has no side effects. The comment + // it replaces claimed the early position kept the fact alive under + // `PERRY_PTR_SHAPE_LOCALS=0`; that still holds, because an empty + // `shape_proven_ptr_locals` yields empty inputs below and the fixpoint then + // computes exactly what it computed before. + let (nbc_shape_members, nbc_shape_numeric_fields) = + super::number_by_construction::shape_numeric_inputs(&shape_proven_ptr_locals); + let number_by_construction_locals = super::collect_number_by_construction_locals( + stmts, + params, + boxed_vars, + module_globals, + binding_types, + spec_ta_lens, + spec_numeric_params, + ¬_bigint_locals, + module_global_proven_types, + &nbc_shape_members, + &nbc_shape_numeric_fields, + ); let guarded_argument_route_locals = if module_dispatch.has_argument_shape_routes() { super::ptr_shape::collect_guarded_argument_route_locals( stmts, diff --git a/crates/perry-codegen/src/collectors/number_by_construction.rs b/crates/perry-codegen/src/collectors/number_by_construction.rs index fd1a6de595..ad5651d9c4 100644 --- a/crates/perry-codegen/src/collectors/number_by_construction.rs +++ b/crates/perry-codegen/src/collectors/number_by_construction.rs @@ -112,6 +112,11 @@ pub(crate) fn collect_number_by_construction_locals( spec_numeric_params: &HashSet, not_bigint_locals: &HashSet, module_global_proven_types: &HashMap, + // #10777: shape-proven receivers and the property names numeric on all of + // them, from `collect_shape_proven_ptr_locals`. Empty reproduces the + // pre-fix behaviour exactly. + shape_members: &HashSet, + shape_numeric_fields: &HashSet, ) -> HashSet { if !enabled() { return HashSet::new(); @@ -155,6 +160,8 @@ pub(crate) fn collect_number_by_construction_locals( not_bigint_locals, &HashMap::new(), &numeric_ta_views, + shape_members, + shape_numeric_fields, ); numeric.extend(collect_number_at_read_after_undefined( stmts, @@ -648,6 +655,8 @@ mod tests { &HashSet::new(), &HashMap::new(), ta_views, + &HashSet::new(), + &HashSet::new(), ) } @@ -743,3 +752,65 @@ mod tests { assert!(!numeric.contains(&acc)); } } + +// ── #10777: shape inputs for the function-scope walk ────────────────────── + +/// `PERRY_L14_NBC_ORDER` gate. **Default OFF.** When off this returns empty +/// sets, the fixpoint sees exactly what it saw before, and every emitted byte +/// is identical to the pre-fix build — the reorder in `hir_facts.rs` is pure, +/// so the knob gates the INPUTS, not the position. Keyed into the object cache +/// so a warm cache cannot serve the other arm's object. +pub(crate) fn nbc_order_enabled() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + matches!( + std::env::var("PERRY_L14_NBC_ORDER").as_deref(), + Ok("1") | Ok("on") | Ok("true") + ) + }) +} + +/// Turn the receiver proofs into the `(members, numeric_fields)` pair the +/// function-scope fixpoint needs. +/// +/// ## Why an INTERSECTION, and why that is sound +/// +/// `expr_numeric_by_construction` takes ONE `numeric_fields` set for ONE +/// receiver class, because its other caller proves one receiver at a time. A +/// function-scope walk may see several shape-proven receivers of different +/// classes, and the arm it feeds asks only "is `members.contains(recv)` and +/// `numeric_fields.contains(prop)`" — it does not re-check which receiver the +/// property belongs to. +/// +/// So the set passed must be numeric on **every** admitted receiver, which is +/// the intersection: if `prop` is numeric on all of them, it is numeric on +/// whichever one the expression names. Under-approximates when receivers +/// disagree; exact for a single shape-proven receiver. +/// +/// A union would be a WRONG ANSWER, not a weaker one: `a` numeric on `C` and +/// not on `D` would license a bare `fadd` on `D.a`. +pub(crate) fn shape_numeric_inputs( + shape_proven: &HashMap, +) -> (HashSet, HashSet) { + if !nbc_order_enabled() || shape_proven.is_empty() { + return (HashSet::new(), HashSet::new()); + } + let mut members: HashSet = HashSet::new(); + let mut fields: Option> = None; + for (id, fact) in shape_proven { + members.insert(*id); + fields = Some(match fields { + None => fact.numeric_fields.clone(), + Some(acc) => acc + .intersection(&fact.numeric_fields) + .cloned() + .collect::>(), + }); + } + let fields = fields.unwrap_or_default(); + if fields.is_empty() { + return (HashSet::new(), HashSet::new()); + } + (members, fields) +} diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index 13b64f34e3..dda88b6e52 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -416,6 +416,11 @@ fn collect_shape_proven_ptr_locals_impl( // not the local rooting proof; it has no specialized `TaPtr` context, so // no view binding is spec-proven here. &HashSet::new(), + // #10777: the per-receiver proof supplies its own `members` / + // `numeric_fields`; this locals fixpoint feeds it, so it must stay + // empty here or the two would be mutually recursive. + &HashSet::new(), + &HashSet::new(), ); // A spec entry has validated these parameters before entering this body. // Unlike a TypeScript annotation, that is runtime evidence, so derived diff --git a/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs index 82c52de4c0..31cb262fb3 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs @@ -787,6 +787,8 @@ fn numeric_locals_of(stmts: &[Stmt]) -> HashSet { &HashSet::new(), &HashMap::new(), &HashSet::new(), + &HashSet::new(), + &HashSet::new(), ) } @@ -945,6 +947,8 @@ fn non_numeric_writes_and_bindings_are_excluded() { &HashSet::new(), &HashMap::new(), &HashSet::new(), + &HashSet::new(), + &HashSet::new(), ) .contains(&7), "a boxed local's write set is not this region's to enumerate" @@ -980,6 +984,8 @@ fn update_value_resolves_via_not_bigint() { ¬_bigint, &HashMap::new(), &HashSet::new(), + &HashSet::new(), + &HashSet::new(), ); assert!(with_fact.contains(&21)); let without_fact = numeric::collect_numeric_by_construction_locals( @@ -989,6 +995,8 @@ fn update_value_resolves_via_not_bigint() { &HashSet::new(), &HashMap::new(), &HashSet::new(), + &HashSet::new(), + &HashSet::new(), ); assert!( !without_fact.contains(&22), diff --git a/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs index a29c049847..1e3ef0550f 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs @@ -440,6 +440,14 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( // #8619: view bindings proven to hold a numeric-kind typed array (spec-ABI // `TaPtr` params). Empty for the `Ptr` type-analysis caller. numeric_ta_views: &HashSet, + // #10777: shape-proven receivers visible to THIS walk, and the property + // names numeric on all of them. Both were hardcoded empty here, so + // `expr_numeric_by_construction`'s `PropertyGet` arm — gated on + // `members.contains(id)` — could never fire for a function-scope walk. An + // accumulator written `h = h + o.a` was therefore never admitted, however + // completely `o`'s shape was proven. Empty for every pre-existing caller. + shape_members: &HashSet, + shape_numeric_fields: &HashSet, ) -> HashSet { // ONE write walker for both fixpoints (`collect_not_bigint_locals` and // this one) — see its doc for why sharing is load-bearing. `None` = a @@ -460,8 +468,8 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( stable_local_inits.entry(id).or_insert(Some(*init)); } } - let empty_members: HashSet = HashSet::new(); - let empty_fields: HashSet = HashSet::new(); + let empty_members: HashSet = shape_members.clone(); + let empty_fields: HashSet = shape_numeric_fields.clone(); let mut numeric: HashSet = let_bound .into_iter() .filter(|id| !boxed_vars.contains(id) && !module_globals.contains_key(id)) diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index ca1a5d34be..fd5d16655d 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -185,6 +185,29 @@ fn lower_checked_i32_modulo(ctx: &mut FnCtx<'_>, left: &str, right: &str) -> Str /// precheck or a `js_typed_feedback_class_field_get_guard` call for its shape /// check regardless. Proven raw-f64 tiers need no guard at all. fn lower_guarded_numeric_add(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { + // #10904: this fold reads every leaf before the first addition converts + // anything, so it declines a tree whose specification order converts + // first and then reads a leaf that conversion could change. The decision + // lives HERE, not at a call site, because two entries reach the fold and + // both can hold such a tree: the fully dynamic tree, and the tree whose + // numeric proof only an annotation carries (`a: number[]`, then + // `a[0] + a[1] + a[2]` with an object in `a[0]`). A declined tree lowers + // node by node, which evaluates and converts in source order. + if let Expr::Binary { + op: BinaryOp::Add, + left, + right, + } = expr + { + if !add_tree_evaluates_before_it_converts(ctx, expr) { + return lower_rooted_dynamic_binary( + ctx, + "js_dynamic_string_or_number_add", + left, + right, + ); + } + } let mut leaves = Vec::new(); add_tree_leaves(expr, &mut leaves); let needs_test: Vec = leaves @@ -445,6 +468,14 @@ fn add_tree_leaves<'a>(expr: &'a Expr, out: &mut Vec<&'a Expr>) { /// is often a captured local plus fields read from interface-shaped objects, /// so every leaf is `Any` to codegen. The guard itself is the runtime proof; /// its cold arm preserves the original tree and exact dynamic `+` semantics. +/// +/// This predicate is about COST only. Whether the fold is FAITHFUL to a tree +/// (#10904) is decided inside `lower_guarded_numeric_add`, which every entry +/// reaches; see `add_tree_evaluates_before_it_converts`. A left-leaning chain +/// of property reads (`h += o.a + o.b + o.c`) is declined there and pays the +/// per-node helper the fold exists to avoid — the regression #10904 declares. +/// Correct and slower beats fast and wrong; step 4b's region guard (#10884) is +/// what buys it back. fn dynamic_add_tree_benefits_shared_guard(expr: &Expr) -> bool { if matches!( std::env::var("PERRY_DYNAMIC_ADD_PAIR_GUARD").as_deref(), @@ -459,6 +490,115 @@ fn dynamic_add_tree_benefits_shared_guard(expr: &Expr) -> bool { leaves.len() >= 2 } +/// #10904: may every leaf of this `+` tree be evaluated before any addition? +/// +/// The fold evaluates all leaves up front. For `Add(L, R)` the specification +/// evaluates `L`, evaluates `R`, and only then `ToPrimitive`s both — so when +/// `L` is itself an `Add`, **`L`'s conversions run before any leaf of `R` is +/// evaluated**, and a user `valueOf`/`toString` running in them can change +/// what such a leaf reads. Measured before the fix: with +/// `O.a = { valueOf() { O.c = 100; return 1 } }`, `O.a + O.b + O.c` (which +/// parses as `(O.a + O.b) + O.c`) returned **9** where node returns **102**. +/// +/// The fold departs from the specification only in WHEN it reads such a +/// leaf. The conversions themselves still run in specification order: the +/// cold arm (`rebuild_add_tree(.., fast = false)`) calls the spec-`+` helper +/// node for node over the lowered values. So a tree is faithful exactly when +/// every leaf the specification reads after an earlier conversion is one +/// whose read time cannot be observed (`add_leaf_is_evaluation_invariant`). +/// By induction: +/// +/// `Add(L, R)` is faithful iff `L` and `R` are, and, when `L` is an `Add`, +/// every leaf of `R` is evaluation-invariant. +/// +/// * `h + (a + b)` — faithful whatever the leaves are, because no leaf +/// follows a conversion. This is the accumulator shape the fold exists for +/// (`sum += row.x + row.y` parses as `sum + (row.x + row.y)`). +/// * `(o.a + o.b) + o.c` — NOT faithful: `o.c` follows the first conversion, +/// and a property read sees whatever that conversion did. +/// * `(r0 + r1) + r2` over `const` locals — faithful: no code a conversion +/// runs can write `r2`'s storage, and reading it has no effect. +/// +/// The cold arm cannot rescue an unfaithful tree, because it adds the values +/// the fold ALREADY read. Making it re-read the late leaves would run a getter +/// or a call twice; that is legal only once something has proven the leaves +/// effect-free, which is what a region guard establishes (#10884). +fn add_tree_evaluates_before_it_converts(ctx: &FnCtx<'_>, expr: &Expr) -> bool { + let Expr::Binary { + op: BinaryOp::Add, + left, + right, + } = expr + else { + return true; + }; + let left_converts = matches!( + left.as_ref(), + Expr::Binary { + op: BinaryOp::Add, + .. + } + ); + if left_converts { + let mut late = Vec::new(); + add_tree_leaves(right, &mut late); + if !late + .into_iter() + .all(|leaf| add_leaf_is_evaluation_invariant(ctx, leaf)) + { + return false; + } + } + add_tree_evaluates_before_it_converts(ctx, right) + && add_tree_evaluates_before_it_converts(ctx, left) +} + +/// #10904: may this `+` leaf be read before an earlier conversion instead of +/// after it? +/// +/// Yes when its value is the same whenever it is read and reading it has no +/// effect: then the user code a conversion runs can neither change what the +/// leaf yields nor observe that it was read early. +/// +/// * Literals. +/// * A local whose storage no other code can write. `boxed_vars` is exactly +/// the set of locals that some other code CAN write: a local captured by a +/// closure and assigned anywhere, a parameter that a sloppy-mode mapped +/// `arguments` object aliases (`add_arguments_mapped_boxes`), and a +/// TDZ-seeded binding, whose read can throw. Outside it a local is a stack +/// slot that only this activation writes, or a capture slot written once +/// when the closure is built — so `let` and `var` qualify too, not just +/// `const`. +/// +/// Not invariant: +/// +/// * a module-level binding. Its storage is a module global that any function +/// in the module can assign without capturing it +/// (`let z = 1; function f() { z = 100 }`), so `boxed_vars` says nothing +/// about it, and codegen carries no module-wide "never reassigned" fact at +/// this point. Declining costs the old per-node lowering, never a wrong +/// answer; +/// * a POD-record local, whose read materializes an object; +/// * everything else, property and element reads (a getter, a Proxy, or the +/// very mutation #10904 is about) and calls included. +fn add_leaf_is_evaluation_invariant(ctx: &FnCtx<'_>, leaf: &Expr) -> bool { + match leaf { + Expr::Undefined + | Expr::Null + | Expr::Bool(_) + | Expr::Number(_) + | Expr::Integer(_) + | Expr::String(_) + | Expr::WtfString(_) => true, + Expr::LocalGet(id) => { + !ctx.boxed_vars.contains(id) + && !ctx.module_globals.contains_key(id) + && !ctx.pod_records.contains_key(id) + } + _ => false, + } +} + /// Rebuild the `+` tree over already-lowered leaf values, node for node, so the /// original associativity survives. `fast` picks the inline `fadd`; otherwise /// every node goes through the spec-`+` helper. diff --git a/crates/perry-codegen/src/expr/dynamic_add_tree_tests.rs b/crates/perry-codegen/src/expr/dynamic_add_tree_tests.rs index 92eb30c573..b54ed515e0 100644 --- a/crates/perry-codegen/src/expr/dynamic_add_tree_tests.rs +++ b/crates/perry-codegen/src/expr/dynamic_add_tree_tests.rs @@ -1,7 +1,7 @@ //! IR coverage for the shared numeric guard on fully dynamic `+` trees. use perry_hir::types::Type; -use perry_hir::{BinaryOp, Expr, Stmt}; +use perry_hir::{ArgumentsObjectMeta, BinaryOp, Expr, Function, Module, Param, Stmt}; use crate::temp_root_coverage::main_ir_for as ir_for; @@ -156,3 +156,324 @@ fn dynamic_arithmetic_results_are_guarded_before_add() { ); } } + +// ---- #10904: which leaves the fold may read before an earlier conversion ---- +// +// `(x + y) + z` converts `x` and `y` before the specification evaluates `z`. +// The fold reads `z` first, which is unobservable only when nothing that +// conversion can run is able to change `z`. Each fixture below is a +// left-leaning chain; what varies is the LATE leaf. The discriminator is the +// `fadd` count: a fused chain of N leaves has N - 1 of them on its fast arm, +// while a declined root lowers node by node, keeping only the inner pair's +// fused `fadd`. + +const P_A: u32 = 11; +const P_B: u32 = 12; +const P_C: u32 = 13; +const P_D: u32 = 14; +const Z: u32 = 15; +const G: u32 = 16; +const ARGS: u32 = 17; + +fn param(id: u32, name: &str) -> Param { + Param { + id, + name: name.into(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn function(name: &str, params: Vec, body: Vec) -> Function { + Function { + id: 1, + name: name.into(), + type_params: Vec::new(), + params, + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +/// The `define` block of `f`, compiled as a module-level function so its +/// locals are stack slots and not module globals. +fn function_ir(f: Function) -> String { + let mut module = Module::new("add_chain_order.ts"); + let needle = format!("__{}(", f.name); + module.functions.push(f); + let ir = String::from_utf8( + crate::compile_module(&module, crate::temp_root_coverage::entry_opts()) + .unwrap_or_else(|e| panic!("codegen failed: {e}")), + ) + .expect("LLVM IR should be UTF-8"); + ir.split("\ndefine ") + .find(|block| block.lines().next().is_some_and(|l| l.contains(&needle))) + .unwrap_or_else(|| panic!("no define for {needle}:\n{ir}")) + .to_string() +} + +fn left_chain(leaves: Vec) -> Expr { + let mut it = leaves.into_iter(); + let first = it.next().expect("a chain has leaves"); + it.fold(first, add) +} + +fn fadds(ir: &str) -> usize { + ir.matches("fadd double").count() +} + +fn params_abc() -> Vec { + vec![param(P_A, "a"), param(P_B, "b"), param(P_C, "c")] +} + +#[test] +fn left_chain_over_parameters_keeps_one_shared_guard() { + // function f(a, b, c, d) { return a + b + c + d }: every late leaf is a + // parameter nothing else can write, so the fold is unobservable. + let chain = left_chain(vec![ + Expr::LocalGet(P_A), + Expr::LocalGet(P_B), + Expr::LocalGet(P_C), + Expr::LocalGet(P_D), + ]); + let mut params = params_abc(); + params.push(param(P_D, "d")); + let ir = function_ir(function( + "params_chain", + params, + vec![Stmt::Return(Some(chain))], + )); + assert_eq!( + ir.matches("\nguarded_add.numeric.").count(), + 1, + "one shared guard for the whole chain:\n{ir}" + ); + assert_eq!( + fadds(&ir), + 3, + "the fast arm keeps all three additions:\n{ir}" + ); +} + +#[test] +fn left_chain_over_an_assigned_uncaptured_let_keeps_the_fold() { + // function f(a, b) { let z = 1; z = 2; return a + b + z }: `let`, and + // assigned, but only by this activation. + let body = vec![ + Stmt::Let { + id: Z, + name: "z".into(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Integer(1)), + }, + Stmt::Expr(Expr::LocalSet(Z, Box::new(Expr::Integer(2)))), + Stmt::Return(Some(left_chain(vec![ + Expr::LocalGet(P_A), + Expr::LocalGet(P_B), + Expr::LocalGet(Z), + ]))), + ]; + let ir = function_ir(function( + "uncaptured_let", + vec![param(P_A, "a"), param(P_B, "b")], + body, + )); + assert_eq!(fadds(&ir), 2, "the chain stays fused:\n{ir}"); +} + +#[test] +fn left_chain_declines_when_a_late_leaf_is_a_property_read() { + // function f(a, b, o) { return a + b + o.x }: `a`'s valueOf can assign o.x. + let chain = left_chain(vec![ + Expr::LocalGet(P_A), + Expr::LocalGet(P_B), + Expr::PropertyGet { + object: Box::new(Expr::LocalGet(P_C)), + property: "x".into(), + byte_offset: 0, + }, + ]); + let ir = function_ir(function( + "property_late", + params_abc(), + vec![Stmt::Return(Some(chain))], + )); + assert_eq!( + fadds(&ir), + 1, + "only the inner `a + b` may fuse; the root converts before o.x:\n{ir}" + ); +} + +#[test] +fn left_chain_declines_when_a_late_leaf_is_captured_and_assigned() { + // function f(a, b) { let z = 1; const g = () => { z = 100 }; return a + b + z } + // `a`'s valueOf can call g. This is the must-fail control for the + // local exemption: `z` is a local, but not one only this frame writes. + let body = vec![ + Stmt::Let { + id: Z, + name: "z".into(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Integer(1)), + }, + Stmt::Let { + id: G, + name: "g".into(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Closure { + func_id: 2, + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Expr(Expr::LocalSet(Z, Box::new(Expr::Integer(100))))], + captures: vec![Z], + mutable_captures: vec![Z], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: true, + }), + }, + Stmt::Return(Some(left_chain(vec![ + Expr::LocalGet(P_A), + Expr::LocalGet(P_B), + Expr::LocalGet(Z), + ]))), + ]; + let ir = function_ir(function( + "captured_late", + vec![param(P_A, "a"), param(P_B, "b")], + body, + )); + assert!( + ir.contains("call i64 @js_box_alloc_bits("), + "premise: z is a boxed cell\n{ir}" + ); + assert_eq!(fadds(&ir), 1, "the root must not read z early:\n{ir}"); +} + +#[test] +fn left_chain_declines_when_a_late_leaf_is_a_mapped_arguments_parameter() { + // Sloppy function f(a, b, c) { return a + b + c } with `arguments` mapped + // onto c: a valueOf holding the Arguments object can assign c through it. + let mut arguments = param(ARGS, "arguments"); + arguments.arguments_object = Some(ArgumentsObjectMeta { + strict: false, + simple_parameters: true, + mapped_parameter_ids: vec![(2, P_C)], + restricted_callee: false, + }); + let mut params = params_abc(); + params.push(arguments); + let mut f = function( + "mapped_late", + params, + vec![Stmt::Return(Some(left_chain(vec![ + Expr::LocalGet(P_A), + Expr::LocalGet(P_B), + Expr::LocalGet(P_C), + ])))], + ); + f.is_strict = false; + let ir = function_ir(f); + assert_eq!(fadds(&ir), 1, "the root must not read c early:\n{ir}"); +} + +#[test] +fn left_chain_over_top_level_slots_keeps_the_fold() { + // Top-level `let a, b, c; a + b + c` with NO function referencing them. + // perry mints a module global only for a binding some function or closure + // references, so these stay slots in `main`: only `main` can write `c`, + // and reading it early is unobservable. + let mut body = dynamic_locals(); + body.push(result(left_chain(vec![ + Expr::LocalGet(A), + Expr::LocalGet(B), + Expr::LocalGet(C), + ]))); + let ir = ir_for("top_level_slots", body); + assert!( + !ir.contains("@perry_global_"), + "premise: a, b and c are slots in main\n{ir}" + ); + assert_eq!(fadds(&ir), 2, "the chain stays fused:\n{ir}"); +} + +#[test] +fn left_chain_declines_when_a_late_leaf_is_a_module_global() { + // Top-level `let a, b, c; function bump() { c = 100 } a + b + c`. `bump` + // assigns the module global `c` without capturing it, so capture analysis + // says nothing about it, and `a`'s valueOf can call `bump`. + let mut body = dynamic_locals(); + body.push(result(left_chain(vec![ + Expr::LocalGet(A), + Expr::LocalGet(B), + Expr::LocalGet(C), + ]))); + let mut module = crate::temp_root_coverage::module_with_init("module_global_late", body); + module.functions.push(function( + "bump", + Vec::new(), + vec![ + Stmt::Expr(Expr::LocalSet(C, Box::new(Expr::Integer(100)))), + Stmt::Return(Some(Expr::Undefined)), + ], + )); + let ir = String::from_utf8( + crate::compile_module(&module, crate::temp_root_coverage::entry_opts()) + .unwrap_or_else(|e| panic!("codegen failed: {e}")), + ) + .expect("LLVM IR should be UTF-8"); + let main = crate::testing::root_slots::function_slice(&ir, "main"); + assert!( + main.contains("@perry_global_"), + "premise: c is a module global\n{main}" + ); + assert_eq!(fadds(main), 1, "the root must not read c early:\n{main}"); +} + +#[test] +fn declared_number_left_chain_declines_when_a_late_leaf_is_an_element_read() { + // function f(a: number[]) { return a[0] + a[1] + a[2] }. The element type + // is only declared, so the tree is `both_numeric` and reaches the fold + // through the declared-only entry, not the dynamic one. `a[0]` can still + // hold an object whose valueOf assigns `a[2]`: on the unfixed compiler + // this printed 6 where node prints 103. + let mut arr = param(P_A, "a"); + arr.ty = Type::Array(Box::new(Type::Number)); + let elem = |i: i64| Expr::IndexGet { + object: Box::new(Expr::LocalGet(P_A)), + index: Box::new(Expr::Integer(i)), + }; + let ir = function_ir(function( + "declared_elements", + vec![arr], + vec![Stmt::Return(Some(left_chain(vec![ + elem(0), + elem(1), + elem(2), + ])))], + )); + assert!( + ir.contains("\nguarded_add.numeric."), + "premise: the declared-number chain reaches the fold\n{ir}" + ); + assert_eq!(fadds(&ir), 1, "the root must not read a[2] early:\n{ir}"); +} diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index 2547843883..13f60e8fd5 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -247,6 +247,31 @@ pub(super) fn lower_builtin_new<'a>( // Uint8Array — i.e. ArrayBuffers — are aliased rather than // copied). SharedArrayBuffer uses the same storage allocation with a // separate runtime registry so util.types can distinguish it. + // #10873: `new ArrayBuffer(length, { maxByteLength })`. The options bag + // used to be dropped here — never even evaluated — so a resizable + // buffer silently came back fixed-length. The runtime reads + // `maxByteLength` AFTER `ToIndex(length)`, per spec, so both operands + // go over raw. `length` can be an object (its `valueOf` runs in the + // runtime), and lowering the options literal allocates: root it. + "ArrayBuffer" if args.len() >= 2 => { + let size_collects = rooting::any_operand_may_collect(ctx, args[1..].iter()); + let size_idx = group.lower(ctx, &args[0], size_collects)?; + let options_idx = adopt_optional_arg(ctx, args, 1, group)?; + for arg in args.iter().skip(2) { + let _ = lower_expr(ctx, arg)?; + } + let size_box = group.reread(ctx, size_idx)?; + let options_box = match options_idx { + Some(i) => group.reread(ctx, i)?, + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; + let handle = ctx.block().call( + I64, + "js_array_buffer_new_with_options", + &[(DOUBLE, &size_box), (DOUBLE, &options_box)], + ); + Ok(Some(nanbox_pointer_inline(ctx.block(), &handle))) + } "ArrayBuffer" | "SharedArrayBuffer" => { let size_box = if !args.is_empty() { lower_expr(ctx, &args[0])? diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index 9ecb421fd7..04f4885ba6 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -681,6 +681,8 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { module.declare_function("js_array_buffer_new", I64, &[I32]); module.declare_function("js_shared_array_buffer_new", I64, &[I32]); module.declare_function("js_array_buffer_new_value", I64, &[DOUBLE]); + // #10873: `new ArrayBuffer(length, { maxByteLength })`. + module.declare_function("js_array_buffer_new_with_options", I64, &[DOUBLE, DOUBLE]); module.declare_function("js_shared_array_buffer_new_value", I64, &[DOUBLE]); // JSON full-featured stringify/parse (replacer + indent + reviver). module.declare_function("js_json_stringify_full", I64, &[DOUBLE, DOUBLE, DOUBLE]); diff --git a/crates/perry-runtime/src/buffer/dataview.rs b/crates/perry-runtime/src/buffer/dataview.rs index 6cbda5bef6..958a9db5cb 100644 --- a/crates/perry-runtime/src/buffer/dataview.rs +++ b/crates/perry-runtime/src/buffer/dataview.rs @@ -104,6 +104,13 @@ fn throw_dataview_oob() -> ! { super::numeric::throw_dataview_offset_out_of_bounds() } +/// A DataView whose resizable buffer shrank past it (ES2024 IsViewOutOfBounds). +fn throw_dataview_out_of_bounds_view() -> ! { + crate::collection_iter::throw_type_error( + "Cannot perform DataView operation on an out-of-bounds view", + ) +} + fn throw_dataview_detached() -> ! { crate::collection_iter::throw_type_error( "Cannot perform DataView access on a detached ArrayBuffer", @@ -178,6 +185,12 @@ unsafe fn read_bytes(buf: *const BufferHeader, offset: i64) -> [ } let len = (*buf).length as i64; if offset + (N as i64) > len { + // Failure path only: a view its resizable buffer shrank past has a + // zeroed length, and the spec's answer for it is a TypeError + // (IsViewOutOfBounds), not the ordinary RangeError (#10873). + if len == 0 && super::view::is_out_of_bounds_view(buf as usize) { + throw_dataview_out_of_bounds_view(); + } throw_dataview_oob(); } let base = super::view::resolve_data_ptr(buf).add(offset as usize); @@ -199,6 +212,9 @@ unsafe fn write_bytes(buf: *mut BufferHeader, offset: i64, bytes: &[u8]) { if len == 0 && super::detach::is_detached_buffer(super::view::backing_of(buf as usize)) { throw_dataview_detached(); } + if len == 0 && super::view::is_out_of_bounds_view(buf as usize) { + throw_dataview_out_of_bounds_view(); + } if offset + (bytes.len() as i64) > len { throw_dataview_oob(); } diff --git a/crates/perry-runtime/src/buffer/detach.rs b/crates/perry-runtime/src/buffer/detach.rs index dc66e708f7..39c47fbc07 100644 --- a/crates/perry-runtime/src/buffer/detach.rs +++ b/crates/perry-runtime/src/buffer/detach.rs @@ -96,7 +96,7 @@ pub fn detach_array_buffer(addr: usize) { /// affected. Failure is harmless (the advice is best-effort), so the return /// value is ignored. #[cfg(unix)] -fn decommit_payload_pages(data: *mut u8, capacity: usize) { +pub(super) fn decommit_payload_pages(data: *mut u8, capacity: usize) { let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; if page <= 0 { return; @@ -129,7 +129,45 @@ fn decommit_payload_pages(data: *mut u8, capacity: usize) { } #[cfg(not(unix))] -fn decommit_payload_pages(_data: *mut u8, _capacity: usize) {} +pub(super) fn decommit_payload_pages(_data: *mut u8, _capacity: usize) {} + +/// Release `[data, data + len)` AND report whether every byte of it is now +/// guaranteed to read as zero (#10873: what lets a resizable buffer regrow into +/// the range without clearing — i.e. without touching — it). +/// +/// Linux only: `MADV_DONTNEED` on private anonymous memory is specified to +/// zero-fill on the next touch, so the whole pages go back to the OS and only +/// the two partial edge pages (< 2 pages) are cleared by hand. macOS's +/// `MADV_FREE_REUSABLE` makes no such promise (a page not yet reclaimed keeps +/// its bytes), so there this only releases and answers `false`. +#[cfg(all(unix, not(target_os = "macos")))] +pub(super) fn decommit_payload_pages_zeroed(data: *mut u8, len: usize) -> bool { + let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + if page <= 0 { + return false; + } + let page = page as usize; + let begin = data as usize; + let start = begin.wrapping_add(page - 1) & !(page - 1); + let end = (begin + len) & !(page - 1); + if end <= start { + return false; + } + unsafe { + if libc::madvise(start as *mut libc::c_void, end - start, libc::MADV_DONTNEED) != 0 { + return false; + } + std::ptr::write_bytes(data, 0, start - begin); + std::ptr::write_bytes(end as *mut u8, 0, begin + len - end); + } + true +} + +#[cfg(not(all(unix, not(target_os = "macos"))))] +pub(super) fn decommit_payload_pages_zeroed(data: *mut u8, len: usize) -> bool { + decommit_payload_pages(data, len); + false +} fn throw_type_error(message: &str) -> ! { let msg = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); @@ -137,12 +175,15 @@ fn throw_type_error(message: &str) -> ! { crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) } -/// `ArrayBuffer.prototype.transfer(newLength?)` and `transferToFixedLength`. -/// Perry has no resizable ArrayBuffers, so both produce a fixed-length result -/// and are identical: allocate a zero-filled buffer of `newLength` (default: -/// the current byteLength), copy `min(oldLength, newLength)` bytes, detach the -/// source, and return the new buffer. -pub(crate) fn array_buffer_transfer(addr: usize, args: &[f64]) -> f64 { +/// `ArrayBuffer.prototype.transfer(newLength?)` and `transferToFixedLength` +/// (ES2024 ArrayBufferCopyAndDetach): allocate a zero-filled buffer of +/// `newLength` (default: the current byteLength), copy +/// `min(oldLength, newLength)` bytes, detach the source, and return the new +/// buffer. `transfer` preserves resizability — a resizable source yields a +/// resizable result with the same `maxByteLength` (and a `newLength` past it is +/// a RangeError) — while `transferToFixedLength` always yields a fixed-length +/// one. Over a fixed-length source the two are identical. +pub(crate) fn array_buffer_transfer(addr: usize, args: &[f64], preserve_resizability: bool) -> f64 { // ES2024 ArrayBufferCopyAndDetach ordering: ToIndex(newLength) runs FIRST // — it can execute user code (`valueOf`) that detaches this very buffer — // and IsDetachedBuffer is checked after, so a mid-coercion detach is @@ -159,8 +200,26 @@ pub(crate) fn array_buffer_transfer(addr: usize, args: &[f64]) -> f64 { let src = addr as *mut BufferHeader; let old_len = unsafe { (*src).length } as i32; let new_len = requested_len.unwrap_or(old_len); - let dst = super::from::zeroed_array_buffer_storage(new_len); - mark_as_array_buffer(dst as usize); + let preserved_max = if preserve_resizability { + super::resizable_max_byte_length(addr) + } else { + None + }; + let dst = match preserved_max { + Some(max) => { + if new_len as i64 > max as i64 { + crate::typedarray::throw_range_error(b"Invalid array buffer length"); + } + // Allocates: re-read nothing from `src` across this call other + // than through its (non-moving, old-arena) address. + super::resizable::alloc_resizable_array_buffer(new_len, max as i32) + } + None => { + let dst = super::from::zeroed_array_buffer_storage(new_len); + mark_as_array_buffer(dst as usize); + dst + } + }; let copy_len = old_len.min(new_len); if copy_len > 0 { unsafe { diff --git a/crates/perry-runtime/src/buffer/from.rs b/crates/perry-runtime/src/buffer/from.rs index 50725ce64d..443032812f 100644 --- a/crates/perry-runtime/src/buffer/from.rs +++ b/crates/perry-runtime/src/buffer/from.rs @@ -600,6 +600,9 @@ pub extern "C" fn js_uint8array_new(val: f64) -> *mut BufferHeader { let view = js_buffer_slice(src, 0, len); mark_as_uint8array(view as usize); set_buffer_ab_alias(view as usize, resolve_buffer_ab_alias(raw)); + // No explicit length: over a resizable ArrayBuffer the + // view's length follows `byteLength` (#10873). + super::view::mark_length_tracking(view as usize); return view; } } @@ -723,6 +726,9 @@ pub extern "C" fn js_uint8array_view( let view = js_buffer_slice(src, start, end); mark_as_uint8array(view as usize); set_buffer_ab_alias(view as usize, resolve_buffer_ab_alias(raw)); + if requested.is_none() { + super::view::mark_length_tracking(view as usize); + } view } } @@ -941,6 +947,9 @@ pub extern "C" fn js_data_view_new(value: f64, offset_value: f64, length_value: let start = offset as u32; let len = view_len as u32; let view = super::view::alloc_data_view(src, start, len); + if length_jv.is_undefined() { + super::view::mark_length_tracking(view as usize); + } mark_as_data_view(view as usize); set_buffer_ab_alias(view as usize, resolve_buffer_ab_alias(addr)); f64::from_bits(crate::value::JSValue::pointer(view as *mut u8).bits()) diff --git a/crates/perry-runtime/src/buffer/header.rs b/crates/perry-runtime/src/buffer/header.rs index 74fc149fc2..d85a080b96 100644 --- a/crates/perry-runtime/src/buffer/header.rs +++ b/crates/perry-runtime/src/buffer/header.rs @@ -142,6 +142,14 @@ crate::perry_thread_local! { /// backing store. Track constructor-created views so util.types can /// distinguish the ArrayBufferView predicate from TypedArray predicates. static DATA_VIEW_REGISTRY: RefCell> = RefCell::new(new_ptr_hash_set()); + /// #10873: `ArrayBuffer addr -> maxByteLength` for RESIZABLE buffers. + /// Presence IS the `[[ArrayBufferMaxByteLength]]` internal slot. The same + /// population and lifetime as the identity sets above: a plain + /// address-keyed attribute of a non-moving buffer, never dereferenced and + /// never a root, pruned in `finalize_collected_dead_buffer` (the #6080 ABA + /// class). The resize logic lives in `buffer::resizable`. + static RESIZABLE_BUFFER_MAX: RefCell> = + RefCell::new(new_ptr_hash_map()); /// Issue #1225: ArrayBuffer-identity alias map for Buffers produced by /// copy paths like `Buffer.from(buf)`. Node-compatible semantics: the /// new Buffer's `.buffer` returns the same ArrayBuffer object as the @@ -316,6 +324,9 @@ pub(crate) fn note_buffer_like_registered(addr: usize) { static ARRAY_BUFFER_EVER_MARKED: RegistryLatch = RegistryLatch::new(); static SHARED_ARRAY_BUFFER_EVER_MARKED: RegistryLatch = RegistryLatch::new(); static DATA_VIEW_EVER_MARKED: RegistryLatch = RegistryLatch::new(); +/// #10873: armed by the first resizable ArrayBuffer. Every probe the feature +/// adds to a shared path answers from this one load in a program without one. +static RESIZABLE_BUFFER_EVER_MARKED: RegistryLatch = RegistryLatch::new(); static UINT8ARRAY_EVER_MARKED: RegistryLatch = RegistryLatch::new(); /// Smallest and largest address ever marked as a `new Uint8Array(...)` @@ -379,6 +390,61 @@ pub fn is_array_buffer(addr: usize) -> bool { ARRAY_BUFFER_REGISTRY.with(|r| r.borrow().contains(&addr)) } +/// Per-buffer state of a resizable ArrayBuffer (#10873). Plain integers. +#[derive(Copy, Clone, Debug)] +pub(crate) struct ResizableInfo { + /// `[[ArrayBufferMaxByteLength]]` — also the payload's reserved capacity. + pub max_byte_length: u32, + /// Every payload byte at or past this offset is known to read as zero, so + /// a grow only has to clear `[old byteLength, dirty_end)`. Never below the + /// current `byteLength`. See `buffer::resizable`. + pub dirty_end: u32, +} + +/// Record `addr` as a resizable ArrayBuffer. +pub(crate) fn mark_as_resizable_buffer(addr: usize, info: ResizableInfo) { + // Arm before the insert — see `crate::registry_latch`. + RESIZABLE_BUFFER_EVER_MARKED.arm(); + RESIZABLE_BUFFER_MAX.with(|r| { + r.borrow_mut().insert(addr, info); + }); +} + +/// The resizable state of `addr`, or `None` for a fixed-length buffer. +#[inline] +pub(crate) fn resizable_info(addr: usize) -> Option { + if RESIZABLE_BUFFER_EVER_MARKED.is_idle() { + return None; + } + RESIZABLE_BUFFER_MAX.with(|r| r.borrow().get(&addr).copied()) +} + +/// Move a resizable buffer's known-zero boundary. A no-op for any other address. +pub(crate) fn set_resizable_dirty_end(addr: usize, dirty_end: u32) { + RESIZABLE_BUFFER_MAX.with(|r| { + if let Some(info) = r.borrow_mut().get_mut(&addr) { + info.dirty_end = dirty_end; + } + }); +} + +/// True once any resizable ArrayBuffer has existed in this process. +#[inline] +pub(crate) fn any_resizable_buffer() -> bool { + RESIZABLE_BUFFER_EVER_MARKED.is_armed() +} + +/// `[[ArrayBufferMaxByteLength]]`, or `None` for a fixed-length buffer. +#[inline] +pub fn resizable_max_byte_length(addr: usize) -> Option { + resizable_info(addr).map(|info| info.max_byte_length) +} + +#[cfg(test)] +pub(crate) fn test_resizable_registry_len() -> usize { + RESIZABLE_BUFFER_MAX.with(|r| r.borrow().len()) +} + pub fn mark_as_shared_array_buffer(addr: usize) { SHARED_ARRAY_BUFFER_EVER_MARKED.arm(); SHARED_ARRAY_BUFFER_REGISTRY.with(|r| { @@ -1213,6 +1279,12 @@ pub(crate) fn finalize_collected_dead_buffer(addr: usize) { DATA_VIEW_REGISTRY.with(|r| { r.borrow_mut().remove(&addr); }); + // #10873: a recycled address must not inherit resizability. + if RESIZABLE_BUFFER_EVER_MARKED.is_armed() { + RESIZABLE_BUFFER_MAX.with(|r| { + r.borrow_mut().remove(&addr); + }); + } BUFFER_AB_ALIAS.with(|r| { r.borrow_mut().remove(&addr); }); diff --git a/crates/perry-runtime/src/buffer/mod.rs b/crates/perry-runtime/src/buffer/mod.rs index a2b8fe0f1c..16d7c07b2b 100644 --- a/crates/perry-runtime/src/buffer/mod.rs +++ b/crates/perry-runtime/src/buffer/mod.rs @@ -30,6 +30,10 @@ mod mutate; mod numeric; mod own_props; mod query; +mod resizable; +/// #10873: resizable ArrayBuffer storage model + view relength. +#[cfg(test)] +mod resizable_tests; mod transcode; mod u8_codec; pub mod validate; @@ -91,6 +95,16 @@ pub use own_props::{ buffer_own_prop_names, buffer_own_props_possible, buffer_read_own_prop, buffer_set_own_prop, clear_buffer_own_props, scan_buffer_own_props_roots_mut, }; +// ---- Re-exports: resizable ArrayBuffer (#10873) ---- +pub use header::resizable_max_byte_length; +pub(crate) use header::{ + any_resizable_buffer, mark_as_resizable_buffer, resizable_info, set_resizable_dirty_end, + ResizableInfo, +}; +pub(crate) use resizable::{array_buffer_resize, view_length_after_resize}; +pub use resizable::{ + is_out_of_bounds_data_view, is_resizable_buffer, js_array_buffer_new_with_options, +}; // ---- Re-exports: #8149 integer-indexed-exotic discrimination ---- // `ArrayBuffer` / `SharedArrayBuffer` / `DataView` share `BufferHeader` and the @@ -101,6 +115,7 @@ pub use exotic_view::{ }; // ---- Re-exports: Buffer.from / alloc / concat (FFI) ---- +pub(crate) use from::buffer_string_bytes_for_encoding; pub use from::{ js_array_buffer_new, js_array_buffer_new_value, js_buffer_alloc, js_buffer_alloc_fill_value, js_buffer_alloc_unsafe, js_buffer_concat, js_buffer_concat_with_length, js_buffer_fill, diff --git a/crates/perry-runtime/src/buffer/resizable.rs b/crates/perry-runtime/src/buffer/resizable.rs new file mode 100644 index 0000000000..538b2c8e3b --- /dev/null +++ b/crates/perry-runtime/src/buffer/resizable.rs @@ -0,0 +1,241 @@ +//! Resizable `ArrayBuffer` (ES2024): `new ArrayBuffer(len, { maxByteLength })`, +//! `ArrayBuffer.prototype.resize`, and the `resizable` / `maxByteLength` +//! getters (#10873). +//! +//! # Storage model +//! +//! Buffer bytes live INLINE after the `BufferHeader`, and every view aliases +//! its backing by RAW ADDRESS (`view::ViewInfo`, `typedarray_view::ViewMeta`, +//! the DataView's cached data pointer). A resize therefore must never move the +//! payload. So a resizable buffer reserves `maxByteLength` bytes ONCE — its +//! `BufferHeader::capacity` — and `resize(n)` only rewrites +//! `BufferHeader::length`. Nothing is reallocated, and no address a view holds +//! can go stale. +//! +//! Bytes past `length` are never observable: a grow clears what it exposes. It +//! clears only what might be dirty, though — each buffer carries a `dirty_end` +//! boundary past which every byte is known to read as zero (`ResizableInfo`). +//! Construction touches only the initial `length` bytes, so a +//! `new ArrayBuffer(0, { maxByteLength: 64 MiB })` reserves address space, not +//! resident memory. A large shrink hands the dropped pages back to the OS (the +//! same `madvise` detach uses), so RSS follows `byteLength`, not the high-water +//! mark — and on Linux, where `MADV_DONTNEED` zero-fills on the next touch, it +//! also moves `dirty_end` back down, so regrowing costs nothing until the pages +//! are actually written. (macOS gives no such guarantee; there a regrow clears.) +//! +//! # Views +//! +//! A view's observable length is a function of its buffer's current +//! `byteLength` (ES2024 `IsTypedArrayOutOfBounds` / `TypedArrayLength`): +//! a *length-tracking* view (constructed without an explicit length) spans to +//! the end of the buffer, a *fixed-length* view keeps its length while it fits +//! and reads as length 0 / byteOffset 0 while it does not — and comes back when +//! the buffer grows again. Rather than teach every length read about that, +//! `resize` recomputes the header `length` of every registered view EAGERLY, +//! the way `detach` zeroes them. Every fast tier that reads a view's length +//! keeps working unchanged, and a program with no resizable buffer pays nothing: +//! the only probes on shared paths are gated on `header::any_resizable_buffer`. +//! +//! Codegen's inline element tiers are unaffected by construction: a resizable +//! buffer's bytes are only ever reachable through a VIEW, and every inline tier +//! already declines views (`u8_inline_cache` admits non-view buffers only; +//! the typed-array tiers require `PERRY_TA_VIEW_GUARD == 0`). + +use super::*; + +/// A dropped tail at least this large is handed back to the OS on shrink. +/// Below it the `madvise` syscall costs more than the pages are worth. +const DECOMMIT_MIN_BYTES: usize = 64 * 1024; + +/// `ArrayBuffer.prototype.resizable`. +#[inline] +pub fn is_resizable_buffer(addr: usize) -> bool { + resizable_max_byte_length(addr).is_some() +} + +fn throw_type_error(message: &str) -> ! { + crate::collection_iter::throw_type_error(message) +} + +/// A plain V8-style `RangeError` — node attaches no `code` to these. +fn throw_invalid_length(message: &str) -> ! { + crate::typedarray::throw_range_error(message.as_bytes()) +} + +/// AllocateArrayBuffer with a `maxByteLength`: reserve `max` bytes, expose +/// `len`. The caller has already established `len <= max`. +pub(crate) fn alloc_resizable_array_buffer(len: i32, max: i32) -> *mut BufferHeader { + let len = len.max(0) as u32; + let max = (max.max(0) as u32).max(len); + let buf = buffer_alloc(max); + let dirty_end = unsafe { + (*buf).length = len; + let data = buffer_data_mut(buf); + // Only the visible prefix needs zeroing: `resize` clears whatever a + // later grow exposes, so the reserved tail is never observable. + if len > 0 { + std::ptr::write_bytes(data, 0, len as usize); + } + // The old arena can hand back a recycled hole, so the reserved tail is + // not known-zero yet. For a large reservation make it so up front — + // releasing pages that were never touched is nearly free — so growing + // into it never has to clear (touch) it. + let tail = (max - len) as usize; + if tail >= DECOMMIT_MIN_BYTES + && super::detach::decommit_payload_pages_zeroed(data.add(len as usize), tail) + { + len + } else { + max + } + }; + mark_as_array_buffer(buf as usize); + mark_as_resizable_buffer( + buf as usize, + ResizableInfo { + max_byte_length: max, + dirty_end, + }, + ); + buf +} + +/// GetArrayBufferMaxByteLengthOption(options): `None` unless `options` is an +/// object whose `maxByteLength` is not `undefined`. The `Get` and the `ToIndex` +/// both run user code, in that order. +fn max_byte_length_option(options: f64) -> Option { + if !crate::value::JSValue::from_bits(options.to_bits()).is_pointer() { + return None; + } + // Interning the key allocates, and `options` is an ordinary (movable) + // object: hold it in a handle and re-read it after the allocation. + let scope = crate::gc::RuntimeHandleScope::new(); + let options = scope.root_nanbox_f64(options); + let key = crate::string::js_string_from_bytes(b"maxByteLength".as_ptr(), 13); + let obj = crate::value::js_nanbox_get_pointer(options.get_nanbox_f64()) as usize; + if obj == 0 { + return None; + } + let value = + crate::object::js_object_get_field_by_name(obj as *const crate::object::ObjectHeader, key); + if value.is_undefined() { + return None; + } + Some(super::from::array_buffer_to_index(f64::from_bits( + value.bits(), + ))) +} + +/// `new ArrayBuffer(length, options)`. Spec order: `ToIndex(length)`, then the +/// `maxByteLength` option read + `ToIndex`, then the `length > max` RangeError. +#[no_mangle] +pub extern "C" fn js_array_buffer_new_with_options( + size_value: f64, + options: f64, +) -> *mut BufferHeader { + // `ToIndex(length)` can run a user `valueOf`, i.e. collect: root the + // options bag across it. + let scope = crate::gc::RuntimeHandleScope::new(); + let options = scope.root_nanbox_f64(options); + let len = super::from::array_buffer_to_index(size_value); + match max_byte_length_option(options.get_nanbox_f64()) { + None => super::from::js_array_buffer_new(len), + Some(max) => { + if len > max { + throw_invalid_length("Invalid array buffer max length"); + } + alloc_resizable_array_buffer(len, max) + } + } +} + +/// `ArrayBuffer.prototype.resize(newLength)`. +pub(crate) fn array_buffer_resize(addr: usize, args: &[f64]) -> f64 { + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + // RequireInternalSlot(O, [[ArrayBufferMaxByteLength]]) precedes ToIndex. + let Some(info) = resizable_info(addr) else { + throw_type_error("Method ArrayBuffer.prototype.resize called on incompatible receiver"); + }; + let max = info.max_byte_length; + // ToIndex can run user code that detaches this very buffer; the detached + // check reads post-coercion state. + let new_len = super::from::array_buffer_to_index(args.first().copied().unwrap_or(undefined)); + if super::detach::is_detached_buffer(addr) { + throw_type_error("Cannot perform ArrayBuffer.prototype.resize on a detached ArrayBuffer"); + } + let new_len = new_len as u32; + if new_len > max { + throw_invalid_length("ArrayBuffer.prototype.resize: Invalid length parameter"); + } + let buf = addr as *mut BufferHeader; + let old_len = unsafe { (*buf).length }; + if new_len == old_len { + return undefined; + } + // `dirty_end`: every byte at or past it already reads as zero. A grow + // clears only what lies below it; a large shrink releases the dropped + // pages and, where the OS then guarantees zero-fill, pulls it back down. + let mut dirty_end = info.dirty_end.max(old_len); + unsafe { + let data = buffer_data_mut(buf); + if new_len > old_len { + let clear_to = new_len.min(dirty_end); + if clear_to > old_len { + std::ptr::write_bytes(data.add(old_len as usize), 0, (clear_to - old_len) as usize); + } + dirty_end = dirty_end.max(new_len); + } + (*buf).length = new_len; + if new_len < old_len && (old_len - new_len) as usize >= DECOMMIT_MIN_BYTES { + let zeroed = super::detach::decommit_payload_pages_zeroed( + data.add(new_len as usize), + (old_len - new_len) as usize, + ); + // Only when nothing dirty lies beyond the range just released. + if zeroed && dirty_end <= old_len { + dirty_end = new_len; + } + } + } + if dirty_end != info.dirty_end { + set_resizable_dirty_end(addr, dirty_end); + } + super::view::relength_views_of_resized_backing(addr, new_len); + crate::typedarray_view::relength_views_of_resized_backing(addr, new_len); + undefined +} + +/// The observable length (in elements) of a view over a resizable buffer whose +/// byteLength is now `buffer_len`, or `None` when the view is out of bounds. +/// +/// `fixed_len` is the construction-time element count of a fixed-length view +/// and is ignored for a length-tracking one. +#[inline] +pub(crate) fn view_length_after_resize( + buffer_len: u32, + byte_offset: u32, + elem_size: u32, + length_tracking: bool, + fixed_len: u32, +) -> Option { + let elem_size = elem_size.max(1) as u64; + let (buffer_len, byte_offset) = (buffer_len as u64, byte_offset as u64); + if byte_offset > buffer_len { + return None; + } + if length_tracking { + return Some(((buffer_len - byte_offset) / elem_size) as u32); + } + if byte_offset + fixed_len as u64 * elem_size > buffer_len { + return None; + } + Some(fixed_len) +} + +/// A DataView whose resizable buffer shrank past it. Its `byteLength` / +/// `byteOffset` getters and every accessor throw a TypeError, where a typed +/// array in the same state merely reads as empty. +#[inline] +pub fn is_out_of_bounds_data_view(addr: usize) -> bool { + any_resizable_buffer() && is_data_view(addr) && super::view::is_out_of_bounds_view(addr) +} diff --git a/crates/perry-runtime/src/buffer/resizable_tests.rs b/crates/perry-runtime/src/buffer/resizable_tests.rs new file mode 100644 index 0000000000..8611e7e7ae --- /dev/null +++ b/crates/perry-runtime/src/buffer/resizable_tests.rs @@ -0,0 +1,280 @@ +//! #10873: resizable ArrayBuffer — storage model, view relength, and the +//! "costs nothing when unused" latch. + +use super::*; + +fn boxed(buf: *const BufferHeader) -> f64 { + f64::from_bits(crate::value::JSValue::pointer(buf as *const u8).bits()) +} + +fn undefined() -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +fn unbox(value: f64) -> *mut BufferHeader { + crate::value::JSValue::from_bits(value.to_bits()).as_pointer::() + as *mut BufferHeader +} + +fn resize(buf: *mut BufferHeader, len: u32) { + resizable::array_buffer_resize(buf as usize, &[len as f64]); +} + +#[test] +fn resize_never_moves_the_payload_and_reserves_max_once() { + let buf = resizable::alloc_resizable_array_buffer(4, 4096); + let data = buffer_data(buf); + unsafe { + assert_eq!((*buf).length, 4); + assert_eq!((*buf).capacity, 4096, "capacity IS the reservation"); + } + assert_eq!(resizable_max_byte_length(buf as usize), Some(4096)); + assert!(is_resizable_buffer(buf as usize)); + assert!(is_array_buffer(buf as usize)); + for len in [4096u32, 0, 17, 4096, 1] { + resize(buf, len); + unsafe { + assert_eq!((*buf).length, len); + assert_eq!((*buf).capacity, 4096); + } + assert_eq!(buffer_data(buf), data, "views alias this address raw"); + } +} + +#[test] +fn a_grow_zero_fills_exactly_what_it_exposes() { + let buf = resizable::alloc_resizable_array_buffer(8, 64); + let data = buffer_data_mut(buf); + unsafe { + // Dirty the whole reservation behind the API's back: a regrow must not + // let any of it through. + std::ptr::write_bytes(data, 0xAB, 64); + } + resize(buf, 4); + resize(buf, 32); + let bytes = unsafe { std::slice::from_raw_parts(data, 32) }; + assert_eq!(&bytes[..4], &[0xAB; 4], "surviving prefix is untouched"); + assert!( + bytes[4..].iter().all(|&b| b == 0), + "exposed range is zeroed" + ); +} + +#[test] +fn a_large_shrink_and_regrow_never_leaks_old_bytes() { + // Large enough to take the decommit path (and, on Linux, the "skip the + // clear, the OS zero-fills" path): dirty every byte, drop it all, regrow. + const MAX: u32 = 4 * 1024 * 1024; + let buf = resizable::alloc_resizable_array_buffer(0, MAX as i32); + let data = buffer_data_mut(buf); + for round in 0..3u8 { + resize(buf, MAX); + let bytes = unsafe { std::slice::from_raw_parts_mut(data, MAX as usize) }; + assert!( + bytes.iter().all(|&b| b == 0), + "round {round}: regrown bytes" + ); + bytes.fill(0xC0 + round); + // An unaligned, partial shrink: the edge pages are cleared by hand. + resize(buf, 4097); + resize(buf, MAX); + let bytes = unsafe { std::slice::from_raw_parts_mut(data, MAX as usize) }; + assert!(bytes[..4097].iter().all(|&b| b == 0xC0 + round)); + assert!(bytes[4097..].iter().all(|&b| b == 0), "round {round}: tail"); + bytes.fill(0xEE); + resize(buf, 0); + } + // A small shrink (below the decommit threshold) keeps the dirty boundary: + // the regrow must clear by hand. + resize(buf, 1024); + unsafe { std::slice::from_raw_parts_mut(data, 1024) }.fill(0x55); + resize(buf, 16); + resize(buf, 1024); + let bytes = unsafe { std::slice::from_raw_parts(data, 1024) }; + assert!(bytes[..16].iter().all(|&b| b == 0x55)); + assert!(bytes[16..].iter().all(|&b| b == 0)); +} + +#[test] +fn fixed_length_buffers_are_not_resizable() { + let plain = js_array_buffer_new(8); + assert!(!is_resizable_buffer(plain as usize)); + assert_eq!(resizable_max_byte_length(plain as usize), None); + // An options bag with no usable `maxByteLength` is the fixed-length path. + let via_options = js_array_buffer_new_with_options(8.0, undefined()); + assert!(!is_resizable_buffer(via_options as usize)); + unsafe { + assert_eq!((*via_options).length, 8); + } +} + +#[test] +fn byte_views_track_go_out_of_bounds_and_come_back() { + let buf = resizable::alloc_resizable_array_buffer(8, 32); + let whole = js_uint8array_new(boxed(buf)); + let fixed = js_uint8array_view(boxed(buf), 4.0, 4.0); + let tail = js_uint8array_view(boxed(buf), 6.0, undefined()); + let len = |v: *mut BufferHeader| unsafe { (*v).length }; + assert_eq!((len(whole), len(fixed), len(tail)), (8, 4, 2)); + assert!(view::is_length_tracking(whole as usize)); + assert!(!view::is_length_tracking(fixed as usize)); + assert!(view::is_length_tracking(tail as usize)); + + resize(buf, 6); + assert_eq!((len(whole), len(fixed), len(tail)), (6, 0, 0)); + assert!(view::is_out_of_bounds_view(fixed as usize)); + assert!( + !view::is_out_of_bounds_view(tail as usize), + "offset == byteLength is an empty in-bounds view" + ); + assert_eq!(view::byte_offset_of(fixed as usize), 0); + assert_eq!(view::byte_offset_of(tail as usize), 6); + + resize(buf, 5); + assert!(view::is_out_of_bounds_view(tail as usize)); + assert_eq!(view::byte_offset_of(tail as usize), 0); + + resize(buf, 32); + assert_eq!((len(whole), len(fixed), len(tail)), (32, 4, 26)); + assert!(!view::is_out_of_bounds_view(fixed as usize)); + assert_eq!(view::byte_offset_of(fixed as usize), 4); + assert_eq!(view::byte_offset_of(tail as usize), 6); +} + +#[test] +fn a_view_over_a_fixed_buffer_is_never_marked_tracking() { + // Arm the latch first so the probes below are really answered by the + // tables, not by the idle fast path. + let _armed = resizable::alloc_resizable_array_buffer(0, 8); + let plain = js_array_buffer_new(16); + let view = js_uint8array_new(boxed(plain)); + assert!(!view::is_length_tracking(view as usize)); + assert!(!view::is_out_of_bounds_view(view as usize)); + // Relength on a non-resizable backing is never requested; if it were it + // would treat the view as fixed-length and keep it. + view::relength_views_of_resized_backing(plain as usize, 16); + assert_eq!(unsafe { (*view).length }, 16); +} + +#[test] +fn typed_array_views_floor_track_and_survive_growth_past_their_birth_length() { + use crate::typedarray::{js_typed_array_get, js_typed_array_length, js_typed_array_set}; + let buf = resizable::alloc_resizable_array_buffer(8, 64); + let tracking = crate::typedarray_view::js_typed_array_view( + crate::typedarray::KIND_INT32 as i32, + boxed(buf), + undefined(), + undefined(), + ); + let fixed = crate::typedarray_view::js_typed_array_view( + crate::typedarray::KIND_FLOAT64 as i32, + boxed(buf), + 0.0, + 1.0, + ); + assert_eq!(js_typed_array_length(tracking), 2); + assert_eq!(js_typed_array_length(fixed), 1); + + // 18 bytes = 4 whole int32 + 2 stray bytes: a tracking view floors. + resize(buf, 18); + assert_eq!(js_typed_array_length(tracking), 4); + // Element 3 lies beyond the 2 elements the header was allocated with; the + // write must land in the BACKING (which reserves max), not the header. + js_typed_array_set(tracking, 3, 123456.0); + assert_eq!(js_typed_array_get(tracking, 3), 123456.0); + let raw = unsafe { std::slice::from_raw_parts(buffer_data(buf).add(12), 4) }; + assert_eq!(i32::from_le_bytes(raw.try_into().unwrap()), 123456); + + resize(buf, 4); + assert_eq!(js_typed_array_length(tracking), 1); + assert_eq!(js_typed_array_length(fixed), 0, "8-byte view over 4 bytes"); + assert_eq!(crate::typedarray_view::js_typed_array_byte_offset(fixed), 0); + + resize(buf, 16); + assert_eq!(js_typed_array_length(tracking), 4); + assert_eq!(js_typed_array_length(fixed), 1); + assert_eq!( + js_typed_array_get(tracking, 3), + 0.0, + "regrown bytes are zero" + ); +} + +#[test] +fn data_views_track_and_report_out_of_bounds() { + let buf = resizable::alloc_resizable_array_buffer(8, 16); + let tracking = unbox(js_data_view_new(boxed(buf), undefined(), undefined())); + let fixed = unbox(js_data_view_new(boxed(buf), 4.0, 4.0)); + resize(buf, 16); + unsafe { + assert_eq!(((*tracking).length, (*fixed).length), (16, 4)); + } + resize(buf, 6); + unsafe { + assert_eq!(((*tracking).length, (*fixed).length), (6, 0)); + } + assert!(is_out_of_bounds_data_view(fixed as usize)); + assert!(!is_out_of_bounds_data_view(tracking as usize)); + resize(buf, 8); + assert!(!is_out_of_bounds_data_view(fixed as usize)); + unsafe { + assert_eq!((*fixed).length, 4); + } +} + +#[test] +fn transfer_preserves_resizability_and_to_fixed_length_drops_it() { + let buf = resizable::alloc_resizable_array_buffer(4, 8); + unsafe { + std::ptr::copy_nonoverlapping([9u8, 8, 7, 6].as_ptr(), buffer_data_mut(buf), 4); + } + let moved = unbox(array_buffer_transfer(buf as usize, &[], true)); + assert!(is_detached_buffer(buf as usize)); + assert_eq!(resizable_max_byte_length(moved as usize), Some(8)); + unsafe { + assert_eq!((*moved).length, 4); + assert_eq!( + std::slice::from_raw_parts(buffer_data(moved), 4), + &[9, 8, 7, 6] + ); + } + let pinned = unbox(array_buffer_transfer(moved as usize, &[], false)); + assert!(!is_resizable_buffer(pinned as usize)); + unsafe { + assert_eq!((*pinned).length, 4); + } +} + +#[test] +fn a_dead_buffers_resizable_mark_is_pruned() { + let buf = resizable::alloc_resizable_array_buffer(1, 2); + let before = header::test_resizable_registry_len(); + assert!(is_resizable_buffer(buf as usize)); + header::finalize_collected_dead_buffer(buf as usize); + assert!( + !is_resizable_buffer(buf as usize), + "a recycled address must not inherit resizability (#6080 ABA class)" + ); + assert_eq!(header::test_resizable_registry_len(), before - 1); +} + +#[test] +fn view_length_after_resize_matches_the_spec_table() { + let f = resizable::view_length_after_resize; + // length-tracking + assert_eq!(f(16, 0, 1, true, 0), Some(16)); + assert_eq!( + f(16, 16, 1, true, 0), + Some(0), + "offset == length is in bounds" + ); + assert_eq!(f(16, 17, 1, true, 0), None); + assert_eq!(f(18, 0, 4, true, 0), Some(4), "floors to whole elements"); + assert_eq!(f(18, 4, 8, true, 0), Some(1)); + // fixed-length + assert_eq!(f(16, 4, 4, false, 3), Some(3)); + assert_eq!(f(15, 4, 4, false, 3), None, "one byte short"); + assert_eq!(f(0, 0, 1, false, 0), Some(0)); + // no u32 overflow at the top of the range + assert_eq!(f(u32::MAX, u32::MAX - 8, 8, false, 2), None); +} diff --git a/crates/perry-runtime/src/buffer/view.rs b/crates/perry-runtime/src/buffer/view.rs index 8879d12469..fef0c64b6a 100644 --- a/crates/perry-runtime/src/buffer/view.rs +++ b/crates/perry-runtime/src/buffer/view.rs @@ -15,10 +15,29 @@ pub(crate) struct ViewInfo { pub offset: u32, } +/// What the registry stores per view: the hot `ViewInfo` (what every byte +/// access copies out through `lookup`) plus the resizable-backing bookkeeping +/// (#10873), which only `resize` and the reflective getters read. Kept apart so +/// the per-access copy stays the two words it always was. +#[derive(Debug)] +struct ViewRecord { + info: ViewInfo, + /// Construction-time byte length. Only consulted when the backing is a + /// resizable ArrayBuffer: a fixed-length view reads as length 0 while it + /// does not fit, and gets THIS length back when the buffer grows. + fixed_len: u32, + /// Constructed without an explicit length over a resizable ArrayBuffer, so + /// its length follows the buffer's `byteLength`. + length_tracking: bool, + /// ES2024 IsTypedArrayOutOfBounds / IsViewOutOfBounds, as of the last + /// resize. Always false over a fixed-length backing. + out_of_bounds: bool, +} + crate::perry_thread_local! { // Boxed records keep GC slot addresses stable across registry growth. // Keys and backing addresses are non-moving GC_TYPE_BUFFER objects. - static VIEW_REGISTRY: RefCell>> = + static VIEW_REGISTRY: RefCell>> = RefCell::new(new_ptr_hash_map()); // Weak reverse index, used for detach and death pruning, never for writes. static BACKING_TO_VIEWS: RefCell>> = @@ -27,7 +46,7 @@ crate::perry_thread_local! { #[inline] pub(crate) fn lookup(view_ptr: usize) -> Option { - VIEW_REGISTRY.with(|r| r.borrow().get(&view_ptr).map(|info| **info)) + VIEW_REGISTRY.with(|r| r.borrow().get(&view_ptr).map(|rec| rec.info)) } #[inline] @@ -39,7 +58,9 @@ pub(crate) fn backing_of(buf_ptr: usize) -> usize { pub(crate) fn byte_offset_of(buf_ptr: usize) -> u32 { lookup(buf_ptr) .map(|v| { - if super::detach::is_detached_buffer(v.backing) { + // A view that no longer fits its resized buffer reports byteOffset + // 0, like a view over a detached one. + if super::detach::is_detached_buffer(v.backing) || is_out_of_bounds_view(buf_ptr) { 0 } else { v.offset @@ -48,6 +69,79 @@ pub(crate) fn byte_offset_of(buf_ptr: usize) -> u32 { .unwrap_or(0) } +/// True when `view_ptr` is a view whose resizable backing has shrunk past it. +/// `resize` has already zeroed its header length; this is what tells a +/// DataView accessor to raise the spec's TypeError rather than a RangeError. +#[inline] +pub(crate) fn is_out_of_bounds_view(view_ptr: usize) -> bool { + super::any_resizable_buffer() + && VIEW_REGISTRY.with(|r| { + r.borrow() + .get(&view_ptr) + .is_some_and(|rec| rec.out_of_bounds) + }) +} + +/// Mark a just-constructed view as length-tracking (`new Uint8Array(rab)`, +/// `new DataView(rab, off)`). A no-op over a fixed-length backing. +pub(crate) fn mark_length_tracking(view_ptr: usize) { + if !super::any_resizable_buffer() { + return; + } + VIEW_REGISTRY.with(|r| { + if let Some(rec) = r.borrow_mut().get_mut(&view_ptr) { + if super::resizable::is_resizable_buffer(rec.info.backing) { + rec.length_tracking = true; + } + } + }); +} + +#[inline] +pub(crate) fn is_length_tracking(view_ptr: usize) -> bool { + super::any_resizable_buffer() + && VIEW_REGISTRY.with(|r| { + r.borrow() + .get(&view_ptr) + .is_some_and(|rec| rec.length_tracking) + }) +} + +/// `ArrayBuffer.prototype.resize` support: recompute the header length of every +/// Buffer-shaped view (Uint8Array / Buffer / DataView — byte-granular) over +/// `backing`, whose byteLength is now `buffer_len`. Eager, like detach zeroing +/// them, so every reader of a view's length stays oblivious to resizing. +pub(crate) fn relength_views_of_resized_backing(backing: usize, buffer_len: u32) { + let views: Vec = BACKING_TO_VIEWS.with(|m| { + m.borrow() + .get(&backing) + .map(|views| views.iter().copied().collect()) + .unwrap_or_default() + }); + if views.is_empty() { + return; + } + VIEW_REGISTRY.with(|r| { + let mut r = r.borrow_mut(); + for view in views { + let Some(rec) = r.get_mut(&view) else { + continue; + }; + let len = super::resizable::view_length_after_resize( + buffer_len, + rec.info.offset, + 1, + rec.length_tracking, + rec.fixed_len, + ); + rec.out_of_bounds = len.is_none(); + unsafe { + (*(view as *mut BufferHeader)).length = len.unwrap_or(0); + } + } + }); +} + /// `buf_ptr` must be a live buffer. All runtime/native span consumers use the /// same canonical bytes as indexed reads and writes. pub(crate) unsafe fn resolve_data_ptr(buf_ptr: *const BufferHeader) -> *const u8 { @@ -69,7 +163,7 @@ pub(crate) fn for_each_view(backing_ptr: usize, mut f /// O(1) per dead view, with no tombstones. If the backing is finalized first, /// remove its dead children in one pass; total sweep work stays linear. pub(crate) fn remove_entries_for_dead_buffer(addr: usize) { - let info = VIEW_REGISTRY.with(|r| r.borrow_mut().remove(&addr)); + let info = VIEW_REGISTRY.with(|r| r.borrow_mut().remove(&addr).map(|rec| rec.info)); BACKING_TO_VIEWS.with(|m| { let mut m = m.borrow_mut(); if let Some(info) = info { @@ -155,14 +249,21 @@ fn register(view_ptr: usize, backing_ptr: usize, offset: u32) { .map(|parent| (parent.backing, parent.offset + offset)) .unwrap_or((backing_ptr, offset)); super::header::u8_inline_cache_invalidate(view_ptr); - let mut info = Box::new(ViewInfo { backing, offset }); + // Every caller sets the view's header length before registering it. + let fixed_len = unsafe { (*(view_ptr as *const BufferHeader)).length }; + let mut rec = Box::new(ViewRecord { + info: ViewInfo { backing, offset }, + fixed_len, + length_tracking: false, + out_of_bounds: false, + }); crate::gc::runtime_write_barrier_external_slot( view_ptr, - &mut info.backing as *mut usize as usize, + &mut rec.info.backing as *mut usize as usize, backing as u64, ); VIEW_REGISTRY.with(|r| { - r.borrow_mut().insert(view_ptr, info); + r.borrow_mut().insert(view_ptr, rec); }); BACKING_TO_VIEWS.with(|m| { m.borrow_mut() @@ -177,8 +278,8 @@ fn register(view_ptr: usize, backing_ptr: usize, offset: u32) { /// visible to the collector's rewrite and verification walks. pub(crate) fn visit_backing_slot(addr: usize, mut visit: impl FnMut(*mut u64)) { VIEW_REGISTRY.with(|r| { - if let Some(info) = r.borrow_mut().get_mut(&addr) { - visit(&mut info.backing as *mut usize as *mut u64); + if let Some(rec) = r.borrow_mut().get_mut(&addr) { + visit(&mut rec.info.backing as *mut usize as *mut u64); } }); } 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..554e836eb2 100644 --- a/crates/perry-runtime/src/hot_diag/receiver_repr.rs +++ b/crates/perry-runtime/src/hot_diag/receiver_repr.rs @@ -206,19 +206,22 @@ 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); } if crate::async_hooks::is_async_resource_handle(addr as i64) { mark_old(ReceiverReprFamily::AsyncResource); } - if crate::object::is_null_stub_address(addr) { - mark_old(ReceiverReprFamily::NullStub); - } + // #340/#341 GATE A: `null_stub` has migrated to an ordinary object, so + // its arm is gone from here, and `is_null_stub_address` with it: it could + // only ever have answered for a `.data` static no longer handed to JS. if crate::shared_sab::is_shared_sab(addr) { mark_old(ReceiverReprFamily::Sab); } @@ -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); @@ -386,6 +390,22 @@ mod tests { !crate::value::addr_class::is_handle_band(value), "{family:?} producer still returns a small band id ({value:#x})" ); + // The band check above covers only ONE of the two dishonest classes + // (plan section 1.1): small registry ids. The other class is a + // pointer-tagged address with NO `GcHeader` -- the `.data` null stub, + // a `Box`-allocated SymbolHeader, a SAB or external buffer backing -- + // and it is NOT in the band, so for those families the band check + // alone cannot fail (measured: #10821 row 4's gate A stayed green with + // the stub sabotaged back to a header-less block). What every migrated + // family's value has in common is that the ALLOCATOR owns it: + // `try_read_tracked_gc_header` proves ownership rather than trusting + // `addr - 8`, so it refuses both old classes and accepts exactly the + // ordinary object the migration produces. + assert!( + unsafe { crate::value::addr_class::try_read_tracked_gc_header(value) }.is_some(), + "{family:?} producer returned {value:#x}, which is not an allocator-owned GC \ + cell -- the header-less class of the old representation" + ); receiver_repr_note_decoded_pointer(value); let (constructed, observed, wrapped) = receiver_repr_test_snapshot(family); assert!( @@ -436,12 +456,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); @@ -477,17 +497,20 @@ mod tests { assert_fixture(ReceiverReprFamily::Sab, || { (crate::shared_sab::alloc_shared_sab(1) as usize, false) }); - assert_fixture(ReceiverReprFamily::NullStub, || { - ( - crate::object::js_unresolved_namespace_stub().to_bits() as usize, - true, - ) + // #340/#341: `null_stub` is migrated — gate A, inverted (see `text`). + assert_fixture_migrated(ReceiverReprFamily::NullStub, || { + (crate::object::js_unresolved_namespace_stub().to_bits() & crate::value::POINTER_MASK) + as usize }); let line = render(); assert!(line.starts_with("[receiver-repr-diag] constructed common=0")); assert!(line.contains("null_stub=1; observed_old")); - assert!(line.contains("null_stub=1; observed_wrapped")); + // #340/#341 row 4: the rendered sink line is the last place gate A is + // visible. `null_stub` is the final bucket of the `observed_old` + // section, so this segment IS its observed_old count, and it must read + // 0 now that the stub is an ordinary object (it read 1 before). + assert!(line.contains("null_stub=0; observed_wrapped")); assert!(line.ends_with("bare_managed=0; invalid_pointer_zero=0; direct_mismatch=0\n")); receiver_repr_test_arm(false); } 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..d60c068239 --- /dev/null +++ b/crates/perry-runtime/src/native_class_ids.rs @@ -0,0 +1,161 @@ +//! 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/buffer_dispatch.rs b/crates/perry-runtime/src/object/buffer_dispatch.rs index 0e5a90280c..cf55641015 100644 --- a/crates/perry-runtime/src/object/buffer_dispatch.rs +++ b/crates/perry-runtime/src/object/buffer_dispatch.rs @@ -110,6 +110,19 @@ macro_rules! buffer_method_names { }; } +/// ES2024 `ArrayBuffer.prototype` methods that exist ONLY on an ArrayBuffer — +/// `typeof u8.resize` must stay `"undefined"`, so they are kept out of the +/// shared [`buffer_method_name_static`] table that every Buffer-shaped receiver +/// consults. Same `'static`-name contract as that table. +pub fn array_buffer_only_method_name_static(name: &str) -> Option<&'static str> { + match name { + "resize" => Some("resize"), + "transfer" => Some("transfer"), + "transferToFixedLength" => Some("transferToFixedLength"), + _ => None, + } +} + buffer_method_names!( "toString", "inspect", @@ -556,7 +569,17 @@ pub unsafe fn dispatch_buffer_method( && !crate::buffer::is_shared_array_buffer(addr) && !crate::buffer::is_data_view(addr) => { - crate::buffer::array_buffer_transfer(addr, args) + crate::buffer::array_buffer_transfer(addr, args, method_name == "transfer") + } + // ES2024 `ArrayBuffer.prototype.resize` (#10873). Same receiver scope + // as `transfer`; a fixed-length ArrayBuffer reaches the helper too and + // gets the spec's TypeError rather than "resize is not a function". + "resize" + if crate::buffer::is_array_buffer(addr) + && !crate::buffer::is_shared_array_buffer(addr) + && !crate::buffer::is_data_view(addr) => + { + crate::buffer::array_buffer_resize(addr, args) } "slice" | "subarray" => { let source_is_array_buffer = crate::buffer::is_array_buffer(addr); @@ -632,6 +655,17 @@ pub unsafe fn dispatch_buffer_method( crate::buffer::js_buffer_slice(buf, start, end) } }); + // #10873 (ES2024 %TypedArray%.prototype.subarray): a subarray of a + // length-tracking view taken WITHOUT an `end` is itself + // length-tracking; with an `end` it is fixed-length (the default + // every view over a resizable buffer is registered with). + if method_name == "subarray" + && !source_is_any_array_buffer + && (args.len() < 2 || JSValue::from_bits(args[1].to_bits()).is_undefined()) + && crate::buffer::view::is_length_tracking(addr) + { + crate::buffer::view::mark_length_tracking(result as usize); + } // #2877: `ArrayBuffer.prototype.slice` returns a NEW ArrayBuffer // (a copy), so mark the result so `ArrayBuffer.isView(slice)` is // false and a subsequent `new Uint8Array(slice)` aliases it. diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index 9e3700dbb9..a3065ae5fb 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -935,7 +935,13 @@ pub unsafe extern "C-unwind" fn js_new_function_construct( let buf = if name == "SharedArrayBuffer" { crate::buffer::js_shared_array_buffer_new_value(size) } else { - crate::buffer::js_array_buffer_new_value(size) + // #10873: the `{ maxByteLength }` options bag, exactly as + // the static lowering passes it. + let options = args + .get(1) + .copied() + .unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)); + crate::buffer::js_array_buffer_new_with_options(size, options) }; return crate::value::js_nanbox_pointer(buf as i64); } diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index f8be43bad5..81ffd75b4f 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -1024,8 +1024,7 @@ pub(crate) fn get_field_by_name_past_inherited_cache( let key_len = (*key).byte_len as usize; let key_bytes = std::slice::from_raw_parts(key_ptr, key_len); if key_bytes == b"constructor" { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return JSValue::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return JSValue::from_bits(crate::object::null_stub_value().to_bits()); } if let Some(dispatch) = handle_property_dispatch() { let bits = dispatch(raw as i64, key_ptr, key_len); diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index 436c0db52d..7327252226 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -78,9 +78,7 @@ pub(crate) fn get_field_by_name_object_tail( return value; } } - let null_obj_ptr = - &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return JSValue::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return JSValue::from_bits(crate::object::null_stub_value().to_bits()); } } if let Some(dispatch) = handle_property_dispatch() { @@ -246,19 +244,48 @@ pub(crate) fn get_field_by_name_object_tail( && !crate::buffer::is_non_indexed_buffer_view(obj as usize)) { let b = obj as *const crate::buffer::BufferHeader; - return JSValue::number(crate::buffer::js_buffer_length(b) as f64); + let len = crate::buffer::js_buffer_length(b); + // #10873: a DataView its resizable buffer shrank past has no + // byteLength — the getter throws (ES2024 IsViewOutOfBounds). + // A typed array in the same state just reads 0. Only a + // zero-length read can be one, so this costs nothing else. + if len == 0 && crate::buffer::is_out_of_bounds_data_view(obj as usize) { + crate::collection_iter::throw_type_error( + "Cannot perform DataView.prototype.byteLength on an out-of-bounds view", + ); + } + return JSValue::number(len as f64); } // An own property on the Buffer shadows the same-named prototype // method; both reads live in `buffer_own_prop`. if let Some(v) = super::buffer_own_prop::buffer_own_prop_or_method(obj, key_bytes) { return v; } - // ArrayBuffer.prototype `resizable` / `maxByteLength` getters. - // Perry has no resizable ArrayBuffers, so `resizable` is always - // false and `maxByteLength` equals `byteLength`. These live only - // on ArrayBuffer (not DataView/SharedArrayBuffer/typed arrays), - // which return `undefined` for them in Node — so scope to a - // plain registered ArrayBuffer. + // `ab.resize` / `ab.transfer` read as VALUES (`typeof ab.resize`, + // `const r = ab.resize`): bound methods, on ArrayBuffer only — + // a Uint8Array / DataView / SharedArrayBuffer has none of them. + if crate::buffer::is_array_buffer(obj as usize) + && !crate::buffer::is_data_view(obj as usize) + && !crate::buffer::is_shared_array_buffer(obj as usize) + { + if let Some(method) = std::str::from_utf8(key_bytes).ok().and_then( + crate::object::buffer_dispatch::array_buffer_only_method_name_static, + ) { + let bound = crate::object::js_class_method_bind( + crate::value::js_nanbox_pointer(obj as i64), + method.as_ptr(), + method.len(), + ); + return JSValue::from_bits(bound.to_bits()); + } + } + // ArrayBuffer.prototype `resizable` / `maxByteLength` getters + // (#10873). A fixed-length buffer answers `false` and its + // `byteLength`; a resizable one answers `true` and the + // `maxByteLength` it was constructed with (0 once detached). + // These live only on ArrayBuffer (not DataView / + // SharedArrayBuffer / typed arrays), which return `undefined` + // for them in Node — so scope to a plain registered ArrayBuffer. if (key_bytes == b"resizable" || key_bytes == b"maxByteLength" || key_bytes == b"detached") @@ -267,7 +294,13 @@ pub(crate) fn get_field_by_name_object_tail( && !crate::buffer::is_shared_array_buffer(obj as usize) { if key_bytes == b"resizable" { - return JSValue::bool(false); + return JSValue::bool(crate::buffer::is_resizable_buffer(obj as usize)); + } + if key_bytes == b"maxByteLength" { + if let Some(max) = crate::buffer::resizable_max_byte_length(obj as usize) { + let detached = crate::buffer::is_detached_buffer(obj as usize); + return JSValue::number(if detached { 0.0 } else { max as f64 }); + } } // `detached` (ES2024) — true after a successful // `transfer`/`transferToFixedLength`/structuredClone diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index e48f1b0acc..d8d5e7b568 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -995,8 +995,7 @@ pub(super) fn get_field_ic_miss_impl( return bits; } } - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return crate::object::null_stub_value(); } } if let Some(dispatch) = handle_property_dispatch() { diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 1f539ec64c..c00374783a 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -159,7 +159,10 @@ pub(crate) use proto_methods::{ install_error_prototype_data_properties, populate_builtin_prototype_methods, }; pub(crate) use typed_array::{ - array_buffer_byte_length_getter_thunk, array_buffer_is_view_thunk, array_buffer_slice_thunk, + array_buffer_byte_length_getter_thunk, array_buffer_detached_getter_thunk, + array_buffer_is_view_thunk, array_buffer_max_byte_length_getter_thunk, + array_buffer_resizable_getter_thunk, array_buffer_resize_thunk, array_buffer_slice_thunk, + array_buffer_transfer_thunk, array_buffer_transfer_to_fixed_length_thunk, ensure_typed_array_intrinsic, install_function_has_instance_symbol, shared_array_buffer_byte_length_getter_thunk, shared_array_buffer_slice_thunk, typed_array_constructor_this_kind, typed_array_intrinsic_proto_ptr, diff --git a/crates/perry-runtime/src/object/global_this/proto_methods.rs b/crates/perry-runtime/src/object/global_this/proto_methods.rs index 6afa2968a5..c9dd6c3d82 100644 --- a/crates/perry-runtime/src/object/global_this/proto_methods.rs +++ b/crates/perry-runtime/src/object/global_this/proto_methods.rs @@ -461,18 +461,52 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: } "ArrayBuffer" => { install_proto_method(proto_obj, "slice", array_buffer_slice_thunk as *const u8, 2); - unsafe { - crate::closure::js_register_closure_arity( - array_buffer_byte_length_getter_thunk as *const u8, - 0, - ); - let getter = crate::closure::js_closure_alloc( + // ES2024 (#10873): `resize`, `transfer`, `transferToFixedLength`. + install_proto_method( + proto_obj, + "resize", + array_buffer_resize_thunk as *const u8, + 1, + ); + // The thunks take the optional `newLength` (call arity 1); the + // spec `.length` of both is 0. + let transfers: [(&str, *const u8); 2] = [ + ("transfer", array_buffer_transfer_thunk as *const u8), + ( + "transferToFixedLength", + array_buffer_transfer_to_fixed_length_thunk as *const u8, + ), + ]; + for (name, thunk) in transfers { + let installed = install_proto_method(proto_obj, name, thunk, 1); + let closure = crate::value::js_nanbox_get_pointer(installed) as usize; + if closure != 0 { + super::super::native_module::set_builtin_closure_length(closure, 0); + } + } + let getters: [(&str, *const u8); 4] = [ + ( + "byteLength", array_buffer_byte_length_getter_thunk as *const u8, - 0, - ); - if !getter.is_null() { - let getter_bits = crate::value::js_nanbox_pointer(getter as i64).to_bits(); - install_builtin_getter(proto_obj, "byteLength", getter_bits); + ), + ( + "resizable", + array_buffer_resizable_getter_thunk as *const u8, + ), + ( + "maxByteLength", + array_buffer_max_byte_length_getter_thunk as *const u8, + ), + ("detached", array_buffer_detached_getter_thunk as *const u8), + ]; + for (name, thunk) in getters { + unsafe { + crate::closure::js_register_closure_arity(thunk, 0); + let getter = crate::closure::js_closure_alloc(thunk, 0); + if !getter.is_null() { + let getter_bits = crate::value::js_nanbox_pointer(getter as i64).to_bits(); + install_builtin_getter(proto_obj, name, getter_bits); + } } } install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); diff --git a/crates/perry-runtime/src/object/global_this/typed_array.rs b/crates/perry-runtime/src/object/global_this/typed_array.rs index 8a30f927d4..e32f351575 100644 --- a/crates/perry-runtime/src/object/global_this/typed_array.rs +++ b/crates/perry-runtime/src/object/global_this/typed_array.rs @@ -111,6 +111,102 @@ pub(crate) extern "C" fn array_buffer_slice_thunk( } } +/// `ArrayBuffer.prototype.{resize, transfer, transferToFixedLength}` reached +/// reflectively (`ArrayBuffer.prototype.resize.call(ab, n)`). Instances +/// dispatch through `buffer_dispatch` directly; these only brand-check the +/// receiver and forward to the same arm, so the two cannot disagree (#10873). +fn array_buffer_method_via_dispatch(method: &'static str, arg: f64, brand_error: &[u8]) -> f64 { + match array_buffer_receiver_addr() { + Some(addr) + if !crate::buffer::is_shared_array_buffer(addr) + && !crate::buffer::is_data_view(addr) => + unsafe { + let args = [arg]; + super::super::buffer_dispatch::dispatch_buffer_method(addr, method, args.as_ptr(), 1) + }, + _ => super::super::object_ops::throw_object_type_error(brand_error), + } +} + +pub(crate) extern "C" fn array_buffer_resize_thunk( + _closure: *const crate::closure::ClosureHeader, + new_length: f64, +) -> f64 { + array_buffer_method_via_dispatch( + "resize", + new_length, + b"Method ArrayBuffer.prototype.resize called on incompatible receiver", + ) +} + +pub(crate) extern "C" fn array_buffer_transfer_thunk( + _closure: *const crate::closure::ClosureHeader, + new_length: f64, +) -> f64 { + array_buffer_method_via_dispatch( + "transfer", + new_length, + b"Method ArrayBuffer.prototype.transfer called on incompatible receiver", + ) +} + +pub(crate) extern "C" fn array_buffer_transfer_to_fixed_length_thunk( + _closure: *const crate::closure::ClosureHeader, + new_length: f64, +) -> f64 { + array_buffer_method_via_dispatch( + "transferToFixedLength", + new_length, + b"Method ArrayBuffer.prototype.transferToFixedLength called on incompatible receiver", + ) +} + +/// Shared body of the reflectable `resizable` / `maxByteLength` / `detached` +/// accessors on `ArrayBuffer.prototype`. Instance reads are answered by +/// `get_field_by_name_tail`; this is the `Object.getOwnPropertyDescriptor( +/// ArrayBuffer.prototype, "resizable").get.call(ab)` route. +fn array_buffer_flag_getter(key: &[u8]) -> f64 { + let Some(addr) = array_buffer_receiver_addr() else { + super::super::object_ops::throw_object_type_error( + b"Method ArrayBuffer.prototype getter called on incompatible receiver", + ) + }; + let value = match key { + b"resizable" => JSValue::bool(crate::buffer::is_resizable_buffer(addr)), + b"detached" => JSValue::bool(crate::buffer::is_detached_buffer(addr)), + _ => { + let buf = addr as *const crate::buffer::BufferHeader; + let max = if crate::buffer::is_detached_buffer(addr) { + 0.0 + } else { + crate::buffer::resizable_max_byte_length(addr) + .map(|max| max as f64) + .unwrap_or_else(|| crate::buffer::js_buffer_length(buf) as f64) + }; + JSValue::number(max) + } + }; + f64::from_bits(value.bits()) +} + +pub(crate) extern "C" fn array_buffer_resizable_getter_thunk( + _closure: *const crate::closure::ClosureHeader, +) -> f64 { + array_buffer_flag_getter(b"resizable") +} + +pub(crate) extern "C" fn array_buffer_max_byte_length_getter_thunk( + _closure: *const crate::closure::ClosureHeader, +) -> f64 { + array_buffer_flag_getter(b"maxByteLength") +} + +pub(crate) extern "C" fn array_buffer_detached_getter_thunk( + _closure: *const crate::closure::ClosureHeader, +) -> f64 { + array_buffer_flag_getter(b"detached") +} + pub(crate) unsafe fn validate_array_buffer_species_constructor(addr: usize) { let scope = crate::gc::RuntimeHandleScope::new(); let receiver = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(addr as i64)); diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index e741f221ca..5f5e4302ea 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -123,7 +123,7 @@ pub(crate) use live_slots::set_object_live_slot_count; pub use live_slots::{ js_object_live_slot_count, object_live_slot_count, perry_object_header_abi_revision, }; -pub(crate) use null_stub::{is_null_stub_address, NullObjectBytes, NULL_OBJECT_BYTES}; +pub(crate) use null_stub::null_stub_value; pub use null_stub::{js_unresolved_default_call, js_unresolved_namespace_stub}; #[cfg(test)] pub(crate) use side_table_roots::test_transition_cache_insert; @@ -1378,6 +1378,16 @@ 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); + // #340/#341 row 4: the unresolved-namespace stub. It was a `.data` + // static with no `GcHeader`; it is an ordinary object now, so the slot + // holding it is a real GC root that a moving collection must rewrite. + null_stub::scan_null_stub_roots_mut(visitor); #[cfg(feature = "regex-engine")] regex_proto_thunks::scan_canonical_test_site_roots_mut(visitor); } diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index c640618750..2504c109d3 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1388,8 +1388,7 @@ pub unsafe extern "C-unwind" fn js_native_call_method( method_name, "empty object", ); - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return crate::object::null_stub_value(); } }; @@ -2092,8 +2091,7 @@ pub unsafe extern "C-unwind" fn js_native_call_method( IMPLICIT_THIS.with(|c| c.set(prev_this_h.get_nanbox_u64())); return result; } - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return crate::object::null_stub_value(); } if let Some(r) = crate::builtins::try_console_instance_method_dispatch( @@ -2162,17 +2160,25 @@ pub unsafe extern "C-unwind" fn js_native_call_method( // numeric arithmetic on bit patterns. Truly garbage pointers // benefit too — chained calls hit a stable null stub instead // of mysterious numeric values. - if !is_valid_obj_ptr(obj as *const u8) { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); - } - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + // + // #340/#341 row 4 collapsed an `is_valid_obj_ptr(obj)` branch that + // used to sit here: BOTH of its arms already returned the stub, so + // it could not change the answer -- a test that cannot fail. Its + // premise is gone too. It was written when the stub was a `.data` + // static, deliberately OUTSIDE the macOS heap window + // (`HEAP_MIN == 0x200_0000_0000`) that `is_valid_obj_ptr` requires, + // so a re-entrant `stub.raw().all(...)` reached this arm with + // `gc_type` read out of whatever bytes preceded the static. The + // stub is a real `GC_TYPE_OBJECT` now, so that re-entry takes the + // ordinary-object path below, finds a zero-key shape, matches no + // method and reaches the same catch-all at the end of this + // function. Same answer, decided by the object model rather than by + // the linker's layout. + return crate::object::null_stub_value(); } let Some(descriptor) = crate::object::shapes::object_shape_descriptor(obj) else { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return crate::object::null_stub_value(); }; let keys = descriptor.keys as usize as *mut ArrayHeader; @@ -2180,8 +2186,7 @@ pub unsafe extern "C-unwind" fn js_native_call_method( // Validate keys_array pointer before dereferencing let keys_ptr = keys as usize; if (keys_ptr as u64) >> 48 != 0 || keys_ptr < 0x10000 { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return crate::object::null_stub_value(); } // Issue #62 phase B: removed macOS "ASCII-like pointer" heuristic — // mimalloc + arena strings produce valid heap pointers with bytes @@ -2193,8 +2198,7 @@ pub unsafe extern "C-unwind" fn js_native_call_method( let key_count = descriptor.logical_key_count as usize; // Sanity check key_count if key_count > 65536 { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return crate::object::null_stub_value(); } // Compare method_name bytes directly against each stored key // instead of allocating a transient StringHeader via diff --git a/crates/perry-runtime/src/object/null_stub.rs b/crates/perry-runtime/src/object/null_stub.rs index ce8ef3476c..f548be7aae 100644 --- a/crates/perry-runtime/src/object/null_stub.rs +++ b/crates/perry-runtime/src/object/null_stub.rs @@ -1,28 +1,89 @@ -//! The unresolved-module namespace stub — a static, GcHeader-less "empty -//! object" handed to user code when a module import or a method dispatch has -//! nowhere to go. +//! The unresolved-module namespace stub — the "empty object" handed to user +//! code when a module import or a method dispatch has nowhere to go. //! //! Split out of `object/mod.rs` (2000-line cap) by #8113. +//! +//! # Honest tags (#340/#341, #10821 row 4) +//! +//! This used to be a `static NullObjectBytes` — a `.data` byte array laid out +//! like an `ObjectHeader`, whose ADDRESS was handed to JS under `POINTER_TAG`. +//! It looked like an object to everything that reads an `ObjectHeader`, and it +//! is not one: **it has no `GcHeader`**. `addr_class::try_read_gc_header` +//! accepts any heap-plausible address and returns `&*((addr - 8) as *const +//! GcHeader)`, so every brand probe on this value read whatever `.data` bytes +//! happened to precede the static and dispatched on them as an `obj_type`. +//! That is the same hazard `native_call_method.rs` already documents for a +//! `Box`-allocated `SymbolHeader`, and it is what the honest-tag invariant — +//! a `POINTER_TAG` value is always a dereferenceable GC cell — exists to +//! forbid. It worked only because the preceding bytes happened to be benign. +//! +//! The stub is now an ordinary `GC_TYPE_OBJECT` with class id 0 and zero own +//! keys: exactly what `{}` allocates, so `typeof`, `Object.keys`, +//! `JSON.stringify` and property reads are unchanged, and the header at +//! `addr - 8` is real. +//! +//! It stays ONE object per realm, because that is what it was: every stub was +//! the same static address, so every stub was `===` every other. Per realm +//! rather than per process because a GC object belongs to the thread whose +//! arena allocated it — a static was shared across threads, which a heap +//! object must not be. -/// Static "null object" used as a safe return value when the depth guard triggers. -/// Instead of returning undefined (which callers may dereference as a null pointer), -/// we return a pointer to this valid-but-empty object so downstream code doesn't crash. -/// -/// Uses a raw byte array with matching layout to avoid Sync issues with raw pointers. +use std::sync::atomic::{AtomicI64, Ordering}; + +crate::perry_thread_local! { + static NULL_STUB_SLOT: AtomicI64 = const { AtomicI64::new(0) }; +} + +/// The realm's stub object. A GC pointer in a static, so it is scanned from +/// `object::scan_object_cache_roots_mut` beside the iterator tower — both to +/// keep it alive and to rewrite the slot when a moving collection relocates +/// it. Without the rewrite every later stub would be a stale address, which is +/// strictly worse than the static it replaces. +pub(crate) static NULL_STUB_PTR: crate::object::RealmAtomicI64 = + crate::object::RealmAtomicI64::new(&NULL_STUB_SLOT); + +/// GC root for the stub singleton. +pub(crate) fn scan_null_stub_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + NULL_STUB_PTR.with_slot(|slot| { + visitor.visit_atomic_i64_slot(slot, Ordering::Acquire, Ordering::Release); + }); +} + +/// The realm's unresolved-namespace stub, allocating it on first use. /// -/// #8047: mirrors the 16-byte header on both LP64 and ILP32. The trailing zero -/// word is `meta` on LP64 and `{alignment padding, meta}` on ILP32. -#[repr(C, align(8))] -pub(crate) struct NullObjectBytes { - class_id: u32, // 0 - parent_class_id: u32, // 0 (never a ShapeId: the stub has no descriptor) - meta_and_padding: u64, // 0 +/// Lazy, so a program that never hits an unresolved import pays nothing — and +/// every one of the eleven call sites returns this value immediately, with no +/// raw receiver pointer live across it, which is what makes allocating from +/// inside the property-read funnels safe here. +pub(crate) fn null_stub_object() -> *mut super::ObjectHeader { + let existing = NULL_STUB_PTR.load(Ordering::Acquire); + if existing != 0 { + return existing as *mut super::ObjectHeader; + } + // Class id 0 and zero keys: an ordinary `{}`. Deliberately NOT a family + // class id — the stub carries no native state, so it must stay an + // ordinary object that a worker can deep-copy like any other `{}`. + let obj = super::js_object_alloc(0, 0); + if obj.is_null() { + return std::ptr::null_mut(); + } + NULL_STUB_PTR.store(obj as i64, Ordering::Release); + obj } -// Safety: this is a read-only zero-initialized struct with no interior mutability -unsafe impl Sync for NullObjectBytes {} -const _: () = - assert!(std::mem::size_of::() == std::mem::size_of::()); +/// The stub as a JS value. The single funnel every fallback returns through. +/// +/// Answers `undefined` if the allocation fails, which is the honest behaviour +/// under memory exhaustion: a caller then sees "cannot read property of +/// undefined" rather than dereferencing a null pointer, and the static this +/// replaces could not report failure at all. +pub(crate) fn null_stub_value() -> f64 { + let obj = null_stub_object(); + if obj.is_null() { + return f64::from_bits(0x7FFC_0000_0000_0001); // TAG_UNDEFINED + } + f64::from_bits(crate::JSValue::pointer(obj as *mut u8).bits()) +} /// Issue #629: namespace imports for unresolved modules /// (`import * as fsp from "node:fs/promises"` when the module isn't @@ -35,13 +96,12 @@ const _: () = /// undefined via the existing object-field path. #[no_mangle] pub extern "C" fn js_unresolved_namespace_stub() -> f64 { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; if crate::hot_diag::receiver_repr_on() { crate::hot_diag::receiver_repr_note_constructed( crate::hot_diag::ReceiverReprFamily::NullStub, ); } - f64::from_bits(crate::JSValue::pointer(null_obj_ptr).bits()) + null_stub_value() } /// Issue #692: default-import calls against unresolved modules @@ -69,13 +129,58 @@ pub extern "C" fn js_unresolved_default_call() -> f64 { f64::from_bits(0x7FFC_0000_0000_0001) // TAG_UNDEFINED } -pub(crate) static NULL_OBJECT_BYTES: NullObjectBytes = NullObjectBytes { - class_id: 0, - parent_class_id: 0, - meta_and_padding: 0, -}; +// #340/#341 row 4 deleted `is_null_stub_address`. Its only production caller +// was the receiver-repr ledger's `observe_pointer` arm, which asked whether a +// decoded pointer was the `.data` static — gate A inverts that arm away, and a +// heap object needs no address-equality probe to be recognised. + +#[cfg(test)] +mod tests { + use super::*; -#[inline] -pub(crate) fn is_null_stub_address(addr: usize) -> bool { - addr == &NULL_OBJECT_BYTES as *const NullObjectBytes as usize + /// GATE B for this family, and the invariant it exists for: the value JS + /// receives is a real heap object with a `GcHeader`, not a `.data` static + /// whose `addr - 8` is whatever the linker put there. + #[test] + fn the_stub_is_an_ordinary_object_with_a_real_header() { + let value = js_unresolved_namespace_stub(); + let bits = value.to_bits(); + assert_eq!(bits & crate::value::TAG_MASK, crate::value::POINTER_TAG); + let addr = (bits & crate::value::POINTER_MASK) as usize; + assert!( + !crate::value::addr_class::is_handle_band(addr), + "gate B: the stub is in the small-handle band ({addr:#x})" + ); + let header = unsafe { crate::value::addr_class::try_read_gc_header(addr) } + .expect("the stub carries a GcHeader"); + assert_eq!(header.obj_type, crate::gc::GC_TYPE_OBJECT); + let obj = addr as *mut super::super::ObjectHeader; + assert_eq!( + unsafe { (*obj).class_id }, + 0, + "an ordinary object, not a family" + ); + 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, "the stub must have no own keys"); + assert_eq!( + NULL_STUB_PTR.load(Ordering::Acquire) as usize, + addr, + "the realm slot holds it" + ); + } + + /// One object per realm, as the static was: every stub was the same + /// address, so every stub was `===` every other, and a program that + /// compares two unresolved namespaces must keep seeing that. + #[test] + fn every_stub_in_a_realm_is_the_same_object() { + let a = js_unresolved_namespace_stub(); + let b = js_unresolved_namespace_stub(); + assert_eq!(a.to_bits(), b.to_bits()); + } } diff --git a/crates/perry-runtime/src/os.rs b/crates/perry-runtime/src/os.rs index 2c8d90a7d7..90edc67238 100644 --- a/crates/perry-runtime/src/os.rs +++ b/crates/perry-runtime/src/os.rs @@ -837,6 +837,8 @@ mod signal; pub(crate) use signal::ignore_sigpipe_at_startup; pub use signal::{js_process_kill, js_util_convert_process_signal_to_exit_code}; +#[path = "os_process_stream_write.rs"] +mod process_stream_write; #[path = "os_process_streams.rs"] mod process_streams; pub(crate) use process_streams::process_stdin_needs_pump; diff --git a/crates/perry-runtime/src/os_process_stream_write.rs b/crates/perry-runtime/src/os_process_stream_write.rs new file mode 100644 index 0000000000..8064d908f5 --- /dev/null +++ b/crates/perry-runtime/src/os_process_stream_write.rs @@ -0,0 +1,238 @@ +//! What `process.stdout.write(chunk[, encoding])` / `process.stderr.write(...)` +//! put on the fd, and how they put it there. +//! +//! #10903. Both stubs used to start from `js_jsvalue_to_string(chunk)`, i.e. +//! the chunk's *display text*, and ignored `encoding`. Three things followed: +//! +//! * a `Buffer` / `Uint8Array` chunk was UTF-8 **decoded** (`Buffer#toString`) +//! and the text written, so every byte that is not valid UTF-8 reached the +//! fd as `EF BF BD` — a 4-byte frame length of 200 (`C8 00 00 00`) was +//! already enough to corrupt a binary protocol; +//! * any other `TypedArray` was written as its `join(",")` text +//! (`new Uint16Array([0x6968])` printed `26984`) and a `DataView` as +//! `[object DataView]`; +//! * `write("6869", "hex")` wrote the four characters, not the two bytes. +//! +//! Node's contract (`writable.write(chunk[, encoding][, callback])`): a binary +//! chunk — `Buffer`, any `TypedArray`, `DataView` — is written byte for byte, +//! exactly the window the view covers; a string chunk is encoded with +//! `encoding` (default `utf8`). +//! +//! The write itself also changed. `std::io::Stdout::write_all` gives up on the +//! first `EAGAIN`, after an unknown prefix has already gone out, and the stubs +//! discarded that error — so on a non-blocking fd 1 (a pipe or tty whose open +//! file description someone put in `O_NONBLOCK`; the flag is shared by every +//! process holding the description) a large chunk was silently truncated. +//! [`write_all_fd`] owns the partial-write loop instead and waits for +//! `POLLOUT`. + +use crate::value::JSValue; + +/// The window a binary write chunk covers, or `None` when `chunk` is not one. +/// +/// `ArrayBuffer` / `SharedArrayBuffer` share the `BufferHeader` shape but are +/// NOT chunks — Node rejects them — so they stay on the display-text path. +/// +/// The pointer borrows the live allocation: it is valid until the next +/// collection. The callers below neither allocate on the JS heap nor reach a +/// safepoint between taking it and finishing the write. +fn binary_chunk_span(chunk: f64) -> Option<(*const u8, usize)> { + if !JSValue::from_bits(chunk.to_bits()).is_pointer() { + return None; + } + // Everything below keys registries by address; nothing dereferences + // `addr` unless a registry has vouched for it. + let addr = (chunk.to_bits() & crate::value::POINTER_MASK) as usize; + if crate::buffer::is_any_array_buffer(addr) { + return None; + } + let mut len = 0_u32; + // SAFETY: `len` is a valid out-pointer. This is the shared native-span + // accessor: it resolves a registered view (subarray / `new T(ab, off, n)` / + // DataView) to its backing window rather than to the view header. + let data = unsafe { crate::buffer::js_value_buffer_or_typedarray_data(chunk, &mut len) }; + if !data.is_null() && len != 0 { + return Some((data, len as usize)); + } + // `(null, 0)` is both "not a binary chunk" and "an empty one" (a + // zero-length or detached view). Only the second is a chunk. + let is_view = crate::buffer::is_registered_buffer(addr) + || crate::typedarray::lookup_typed_array_kind(addr).is_some(); + if !is_view { + return None; + } + // Node re-wraps every view that is not already a `Buffer` + // (`new FastBuffer(chunk.buffer, chunk.byteOffset, chunk.byteLength)`), + // and that construction throws once the ArrayBuffer has been transferred + // away. A detached `Buffer` is written as it is: empty. Only reachable + // for an empty view, so a live chunk never pays for the lookup. + if !crate::buffer::is_node_buffer(addr) && view_backing_is_detached(addr) { + crate::typedarray::throw_type_error(b"Cannot perform Construct on a detached ArrayBuffer"); + } + Some((std::ptr::NonNull::::dangling().as_ptr() as *const u8, 0)) +} + +/// Whether the `ArrayBuffer` a view aliases has been detached (`transfer()`). +fn view_backing_is_detached(addr: usize) -> bool { + let backing = match crate::typedarray_view::view_meta_of(addr) { + Some(meta) => meta.backing, + None => crate::buffer::view::backing_of(addr), + }; + crate::buffer::is_detached_buffer(backing) +} + +/// The `Buffer.from(string, encoding)` tag for a `write` encoding argument. +/// Anything that is not a string — `undefined`, or the completion callback in +/// `write(chunk, cb)` — is the default, `utf8` (tag 0). +fn string_encoding_tag(encoding: f64) -> i32 { + if JSValue::from_bits(encoding.to_bits()).is_any_string() { + crate::buffer::js_encoding_tag_from_value(encoding) + } else { + 0 + } +} + +/// Run `f` over exactly the bytes `write(chunk, encoding)` must put on the fd. +/// +/// The common case — a string chunk with no encoding — borrows the string's +/// own payload: no allocation and no copy (the old conversion `to_vec()`'d +/// every chunk). A binary chunk is borrowed the same way. Only a non-UTF-8 +/// string encoding, which has to transcode, builds a buffer. +/// +/// A value that is neither a string nor a binary chunk keeps perry's existing +/// leniency and is written as its display text; Node throws +/// `ERR_INVALID_ARG_TYPE` there. That is deliberately out of scope here. +pub(super) fn with_write_bytes(chunk: f64, encoding: f64, f: impl FnOnce(&[u8]) -> R) -> R { + let value = JSValue::from_bits(chunk.to_bits()); + if value.is_any_string() { + let mut scratch = [0_u8; crate::value::SHORT_STRING_MAX_LEN]; + if let Some((ptr, len)) = crate::string::str_bytes_from_jsvalue(chunk, &mut scratch) { + let text: &[u8] = if ptr.is_null() || len == 0 { + &[] + } else { + // SAFETY: the accessor reports `len` readable bytes at `ptr` + // (the heap payload, or `scratch` for an inline short string). + unsafe { std::slice::from_raw_parts(ptr, len as usize) } + }; + return match string_encoding_tag(encoding) { + 0 => f(text), + tag => f(&crate::buffer::buffer_string_bytes_for_encoding(text, tag)), + }; + } + } else if let Some((data, len)) = binary_chunk_span(chunk) { + // SAFETY: see `binary_chunk_span` — `len` readable bytes, stable for + // the duration of `f`, which does not touch the JS heap. + return f(unsafe { std::slice::from_raw_parts(data, len) }); + } + let s_ptr = crate::value::js_jsvalue_to_string(chunk); + if s_ptr.is_null() { + return f(&[]); + } + // SAFETY: a non-null `StringHeader` is followed by `byte_len` payload bytes. + let text = unsafe { + let data = (s_ptr as *const u8).add(std::mem::size_of::()); + std::slice::from_raw_parts(data, (*s_ptr).byte_len as usize) + }; + f(text) +} + +/// Largest single `write(2)` request. macOS rejects a count above `INT_MAX` +/// with `EINVAL`, and Linux transfers at most `0x7FFF_F000` per call anyway. +#[cfg(unix)] +const MAX_WRITE_REQUEST: usize = 0x4000_0000; + +/// Block until `fd` accepts more bytes. Errors are not inspected: whatever +/// woke the poll, the caller's next `write` reports the real condition. +#[cfg(unix)] +fn wait_writable(fd: i32) { + let mut poll_fd = libc::pollfd { + fd, + events: libc::POLLOUT, + revents: 0, + }; + // SAFETY: one valid `pollfd`, count 1, no timeout. + unsafe { libc::poll(&mut poll_fd, 1, -1) }; +} + +/// Write all of `bytes` to `fd`, completing partial writes and waiting out +/// `EAGAIN` on a non-blocking descriptor instead of dropping the remainder. +#[cfg(unix)] +pub(super) fn write_all_fd(fd: i32, mut bytes: &[u8]) -> std::io::Result<()> { + while !bytes.is_empty() { + let request = bytes.len().min(MAX_WRITE_REQUEST); + // SAFETY: `bytes` is a live slice of at least `request` bytes. + let written = unsafe { libc::write(fd, bytes.as_ptr().cast(), request) }; + if written > 0 { + bytes = &bytes[written as usize..]; + continue; + } + if written == 0 { + return Err(std::io::ErrorKind::WriteZero.into()); + } + let err = std::io::Error::last_os_error(); + match err.kind() { + std::io::ErrorKind::Interrupted => {} + std::io::ErrorKind::WouldBlock => wait_writable(fd), + _ => return Err(err), + } + } + Ok(()) +} + +/// Drain Rust's own buffer for the stream (`console.log` prints through it), +/// so bytes written straight to the fd afterwards cannot overtake it. +#[cfg(unix)] +fn flush_completely(handle: &mut impl std::io::Write, fd: i32) -> bool { + loop { + match handle.flush() { + Ok(()) => return true, + // `BufWriter` keeps the unwritten tail on error, so retrying the + // flush resumes exactly where the fd stopped accepting bytes. + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => wait_writable(fd), + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return false, + } + } +} + +/// Put `bytes` on fd 1. Holds Rust's stdout lock for the whole write so a +/// chunk is never interleaved with a concurrent `console.log`, and drains +/// that handle's buffer first so the two funnels stay in program order. +pub(super) fn write_stdout(bytes: &[u8]) { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + #[cfg(unix)] + { + if flush_completely(&mut handle, libc::STDOUT_FILENO) { + let _ = write_all_fd(libc::STDOUT_FILENO, bytes); + } + } + #[cfg(not(unix))] + { + use std::io::Write; + let _ = handle.write_all(bytes); + let _ = handle.flush(); + } +} + +/// Put `bytes` on fd 2. Same contract as [`write_stdout`]. +pub(super) fn write_stderr(bytes: &[u8]) { + let stderr = std::io::stderr(); + let mut handle = stderr.lock(); + #[cfg(unix)] + { + if flush_completely(&mut handle, libc::STDERR_FILENO) { + let _ = write_all_fd(libc::STDERR_FILENO, bytes); + } + } + #[cfg(not(unix))] + { + use std::io::Write; + let _ = handle.write_all(bytes); + let _ = handle.flush(); + } +} + +#[cfg(test)] +#[path = "os_process_stream_write_tests.rs"] +mod tests; diff --git a/crates/perry-runtime/src/os_process_stream_write_tests.rs b/crates/perry-runtime/src/os_process_stream_write_tests.rs new file mode 100644 index 0000000000..e248104027 --- /dev/null +++ b/crates/perry-runtime/src/os_process_stream_write_tests.rs @@ -0,0 +1,226 @@ +//! #10903: the bytes `process.stdout.write` / `process.stderr.write` produce, +//! and the fd writer that delivers them. + +use super::*; +use crate::buffer::{ + buffer_data_mut, js_array_buffer_new, js_buffer_alloc, js_buffer_slice, js_data_view_new, + js_uint8array_alloc, BufferHeader, +}; + +const UNDEFINED: f64 = f64::from_bits(crate::value::TAG_UNDEFINED); + +fn pointer_value(ptr: *const T) -> f64 { + f64::from_bits(JSValue::pointer(ptr as *const u8).bits()) +} + +fn heap_string(text: &[u8]) -> f64 { + let header = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + f64::from_bits(JSValue::string_ptr(header).bits()) +} + +fn fill(buf: *mut BufferHeader, bytes: &[u8]) { + // GC_STORE_AUDIT(POINTER_FREE): raw test bytes into a byte payload. + unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), buffer_data_mut(buf), bytes.len()) }; +} + +fn written(chunk: f64, encoding: f64) -> Vec { + with_write_bytes(chunk, encoding, <[u8]>::to_vec) +} + +/// The exact bytes that tripped the Native Messaging host: a little-endian +/// frame length of 1,048,567. Decoded as UTF-8 they are two U+FFFD and `0F 00`. +const FRAME_LENGTH: [u8; 4] = [0xf7, 0xff, 0x0f, 0x00]; + +#[test] +fn a_uint8array_and_a_buffer_are_written_byte_for_byte() { + let u8a = js_uint8array_alloc(4); + fill(u8a, &FRAME_LENGTH); + assert_eq!(written(pointer_value(u8a), UNDEFINED), FRAME_LENGTH); + + let buf = js_buffer_alloc(4, 0); + fill(buf, &FRAME_LENGTH); + assert_eq!(written(pointer_value(buf), UNDEFINED), FRAME_LENGTH); + // An encoding argument names how to encode a STRING; a binary chunk + // ignores it. + assert_eq!( + written(pointer_value(buf), heap_string(b"hex")), + FRAME_LENGTH + ); +} + +#[test] +fn a_view_writes_only_its_own_window() { + let source = js_uint8array_alloc(8); + fill(source, &[0x01, 0x80, 0x81, 0x82, 0x05, 0x06, 0x07, 0x08]); + let scope = crate::gc::RuntimeHandleScope::new(); + let _source = scope.root_raw_mut_ptr(source); + let window = js_buffer_slice(source, 1, 4); + assert_eq!( + written(pointer_value(window), UNDEFINED), + [0x80, 0x81, 0x82] + ); + + let backing = js_array_buffer_new(8); + fill(backing, &[0x10, 0x90, 0x91, 0x13, 0x14, 0x15, 0x16, 0x17]); + let _backing = scope.root_raw_mut_ptr(backing); + let data_view = js_data_view_new(pointer_value(backing), 1.0, 2.0); + assert_eq!(written(data_view, UNDEFINED), [0x90, 0x91]); +} + +#[test] +fn a_wider_typed_array_is_written_as_its_raw_element_bytes() { + let ta = crate::typedarray::js_typed_array_new_empty(crate::typedarray::KIND_UINT16 as i32, 2); + crate::typedarray::js_typed_array_set(ta, 0, 0xfffe as f64); + crate::typedarray::js_typed_array_set(ta, 1, 0x0a80 as f64); + // Node does not convert elements: it writes the view's bytes as stored. + let expected: Vec = [0xfffe_u16, 0x0a80] + .iter() + .flat_map(|unit| unit.to_ne_bytes()) + .collect(); + assert_eq!(written(pointer_value(ta), UNDEFINED), expected); +} + +#[test] +fn an_empty_view_is_an_empty_chunk_and_an_arraybuffer_is_not_a_chunk() { + let empty = js_uint8array_alloc(0); + assert_eq!( + binary_chunk_span(pointer_value(empty)).map(|s| s.1), + Some(0) + ); + assert!(written(pointer_value(empty), UNDEFINED).is_empty()); + + // Node rejects an ArrayBuffer chunk; perry keeps writing its display text + // rather than its bytes, so it must not be classified as binary. + let array_buffer = js_array_buffer_new(4); + assert!(binary_chunk_span(pointer_value(array_buffer)).is_none()); + assert!(binary_chunk_span(UNDEFINED).is_none()); + assert!(binary_chunk_span(42.0).is_none()); +} + +#[test] +fn a_string_is_utf8_unless_an_encoding_is_named() { + let text = "h\u{e9}llo"; + let heap = heap_string(text.as_bytes()); + assert_eq!(written(heap, UNDEFINED), text.as_bytes()); + assert_eq!(written(heap, heap_string(b"utf8")), text.as_bytes()); + assert_eq!(written(heap, heap_string(b"UTF-8")), text.as_bytes()); + // `write(chunk, callback)`: a non-string second argument is not an encoding. + assert_eq!(written(heap, 1.0), text.as_bytes()); + + let latin1 = [b'h', 0xe9, b'l', b'l', b'o']; + assert_eq!(written(heap, heap_string(b"latin1")), latin1); + assert_eq!(written(heap, heap_string(b"binary")), latin1); + assert_eq!(written(heap, heap_string(b"ascii")), latin1); + assert_eq!( + written(heap_string(b"hi"), heap_string(b"ucs2")), + [b'h', 0, b'i', 0] + ); + assert_eq!( + written(heap_string(b"f7ff0f00"), heap_string(b"hex")), + FRAME_LENGTH + ); + assert_eq!( + written(heap_string(b"9/8PAA=="), heap_string(b"base64")), + FRAME_LENGTH + ); + assert_eq!( + written(heap_string(b"9_8PAA"), heap_string(b"base64url")), + FRAME_LENGTH + ); +} + +#[test] +fn an_inline_short_string_takes_the_same_path() { + let short = JSValue::try_short_string(b"f7ff").expect("fits inline"); + let short = f64::from_bits(short.bits()); + assert_eq!(written(short, UNDEFINED), b"f7ff"); + assert_eq!(written(short, heap_string(b"hex")), [0xf7, 0xff]); +} + +#[test] +fn a_non_chunk_value_keeps_its_display_text() { + assert_eq!(written(42.0, UNDEFINED), b"42"); + assert_eq!(written(UNDEFINED, UNDEFINED), b"undefined"); + // The encoding applies to string chunks only. + assert_eq!(written(42.0, heap_string(b"hex")), b"42"); +} + +#[cfg(unix)] +fn pattern(len: usize) -> Vec { + (0..len) + .map(|i| (i.wrapping_mul(31) ^ (i >> 8)) as u8) + .collect() +} + +/// A non-blocking pipe holds 16–64 KiB. A 3 MiB chunk therefore hits `EAGAIN` +/// dozens of times while the reader dawdles; `Stdout::write_all` would have +/// returned that error after an unknown prefix and the stub dropped it. +#[cfg(unix)] +#[test] +fn write_all_fd_completes_a_large_chunk_on_a_non_blocking_pipe() { + use std::io::Read; + use std::os::fd::FromRawFd; + + let mut fds = [0_i32; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + let (read_fd, write_fd) = (fds[0], fds[1]); + unsafe { + let flags = libc::fcntl(write_fd, libc::F_GETFL); + assert_eq!( + libc::fcntl(write_fd, libc::F_SETFL, flags | libc::O_NONBLOCK), + 0 + ); + } + // Precondition the test depends on: a bare write really does fill the pipe + // and report EAGAIN, i.e. the loop below has something to survive. + let probe = vec![0_u8; 1 << 20]; + let first = unsafe { libc::write(write_fd, probe.as_ptr().cast(), probe.len()) }; + assert!(first > 0 && (first as usize) < probe.len()); + let again = unsafe { libc::write(write_fd, probe.as_ptr().cast(), probe.len()) }; + assert_eq!(again, -1); + assert_eq!( + std::io::Error::last_os_error().kind(), + std::io::ErrorKind::WouldBlock + ); + + let prefix = first as usize; + let reader = std::thread::spawn(move || { + let mut file = unsafe { std::fs::File::from_raw_fd(read_fd) }; + let mut received = Vec::new(); + let mut chunk = [0_u8; 8192]; + loop { + std::thread::sleep(std::time::Duration::from_micros(200)); + match file.read(&mut chunk) { + Ok(0) => break, + Ok(n) => received.extend_from_slice(&chunk[..n]), + Err(err) => panic!("reader: {err}"), + } + } + received + }); + + let payload = pattern(3 << 20); + write_all_fd(write_fd, &payload).expect("write_all_fd"); + unsafe { libc::close(write_fd) }; + let received = reader.join().expect("reader thread"); + assert_eq!(received.len(), prefix + payload.len()); + assert!(received[..prefix].iter().all(|b| *b == 0)); + assert!( + received[prefix..] == payload[..], + "payload reordered or lost" + ); +} + +#[cfg(unix)] +#[test] +fn write_all_fd_reports_a_closed_reader_instead_of_spinning() { + let mut fds = [0_i32; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + unsafe { libc::close(fds[0]) }; + // SIGPIPE is ignored by the runtime at startup; make the test independent + // of who ran first. + unsafe { libc::signal(libc::SIGPIPE, libc::SIG_IGN) }; + let err = write_all_fd(fds[1], b"x").expect_err("EPIPE"); + assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe); + unsafe { libc::close(fds[1]) }; +} diff --git a/crates/perry-runtime/src/os_process_streams.rs b/crates/perry-runtime/src/os_process_streams.rs index e6e585f8e5..d474f3d497 100644 --- a/crates/perry-runtime/src/os_process_streams.rs +++ b/crates/perry-runtime/src/os_process_streams.rs @@ -1,23 +1,7 @@ +use super::process_stream_write::{with_write_bytes, write_stderr, write_stdout}; use crate::string::StringHeader; use std::cell::RefCell; -/// Coerce a NaN-boxed JSValue to its display bytes, suitable for raw -/// stream writes. Used by `process.stdout.write` / `process.stderr.write`. -/// Mirrors Node's behavior: numbers/booleans/null/undefined coerce to -/// their string form; strings pass through verbatim. -fn jsvalue_to_write_bytes(value: f64) -> Vec { - let s_ptr = crate::value::js_jsvalue_to_string(value); - if s_ptr.is_null() { - return Vec::new(); - } - unsafe { - let header = &*s_ptr; - let len = header.byte_len as usize; - let data = (s_ptr as *const u8).add(std::mem::size_of::()); - std::slice::from_raw_parts(data, len).to_vec() - } -} - /// Node's `stream.write(chunk[, encoding][, callback])` passes an optional /// completion callback as the last argument (whichever of the two trailing args /// is a function). Node invokes it — asynchronously, never synchronously — once @@ -60,21 +44,17 @@ fn callable_closure_ptr(value: f64) -> usize { 0 } -/// `write` impl for process.stdout. Writes the value's display bytes to fd 1 -/// without appending a newline, matching Node.js semantics, then fires the -/// optional completion callback (see [`schedule_write_callback`]). +/// `write` impl for process.stdout. Puts the chunk's bytes on fd 1 — a binary +/// chunk verbatim, a string encoded per `arg2` when that is an encoding name +/// (#10903, see [`with_write_bytes`]) — without appending a newline, then +/// fires the optional completion callback (see [`schedule_write_callback`]). extern "C" fn process_stdout_write_stub( _closure: *const crate::closure::ClosureHeader, chunk: f64, arg2: f64, arg3: f64, ) -> f64 { - use std::io::Write; - let bytes = jsvalue_to_write_bytes(chunk); - let stdout = std::io::stdout(); - let mut handle = stdout.lock(); - let _ = handle.write_all(&bytes); - let _ = handle.flush(); + with_write_bytes(chunk, arg2, write_stdout); schedule_write_callback(arg2, arg3); f64::from_bits(crate::value::TAG_TRUE) } @@ -86,12 +66,7 @@ extern "C" fn process_stderr_write_stub( arg2: f64, arg3: f64, ) -> f64 { - use std::io::Write; - let bytes = jsvalue_to_write_bytes(chunk); - let stderr = std::io::stderr(); - let mut handle = stderr.lock(); - let _ = handle.write_all(&bytes); - let _ = handle.flush(); + with_write_bytes(chunk, arg2, write_stderr); schedule_write_callback(arg2, arg3); f64::from_bits(crate::value::TAG_TRUE) } @@ -349,25 +324,101 @@ pub extern "C" fn js_register_stdin_reader_consumer( } } -fn ensure_stdin_reader() { +/// Serializes the reader's decision to STOP with every request to (re)START it +/// (#10895). +/// +/// `pause()` sets `STDIN_DETACHED`; the reader notices at the top of its loop +/// and exits, and `resume()` clears the latch and calls `ensure_stdin_reader`, +/// which spawns a reader only when `STDIN_READER_STARTED` is false. Those two +/// flags used to be read and written independently, so this interleaving lost +/// the restart for good: +/// +/// reader: sees `STDIN_DETACHED == true`, decides to exit +/// main: `resume()` → `STDIN_DETACHED = false`; CAS(STARTED: false→true) +/// FAILS — the dying reader has not cleared STARTED yet +/// reader: clears STARTED and is gone +/// +/// fd 0 then has no reader while every liveness view still says stdin is open +/// and flowing, so the process idles forever with input unread. The async +/// iterator pauses/resumes the source once per delivered chunk, so a piped +/// `for await (const chunk of process.stdin)` rolled this dice hundreds of +/// times per megabyte. +/// +/// Holding this lock across the reader's check-and-clear and across the +/// restart CAS makes the two atomic with respect to each other: a restart +/// request either runs entirely before the stop decision (the reader then sees +/// the cleared latch and keeps going) or entirely after it (STARTED is already +/// false, so a fresh reader is spawned). It is never held across `read()`. +static STDIN_READER_LIFECYCLE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn stdin_reader_lifecycle() -> std::sync::MutexGuard<'static, ()> { + STDIN_READER_LIFECYCLE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// The stop half of the lifecycle handshake, over an explicit slot flag so the +/// unit tests can replay interleavings without touching the process-global +/// one. `should_stop` is evaluated UNDER the lock, and a true answer releases +/// the slot in the same step. +fn reader_slot_claim_stop( + started: &std::sync::atomic::AtomicBool, + should_stop: impl FnOnce() -> bool, +) -> bool { + let _lifecycle = stdin_reader_lifecycle(); + if should_stop() { + started.store(false, std::sync::atomic::Ordering::Release); + true + } else { + false + } +} + +/// The restart half: true when the caller now owns the (single) reader slot +/// and must spawn the reader. +fn reader_slot_claim_start(started: &std::sync::atomic::AtomicBool) -> bool { use std::sync::atomic::Ordering; - // A previous reader may have exited (EOF, error, or explicit detach); its - // drop guard resets `STDIN_READER_STARTED` to false, - // so a later `resume()`/`on(...)` can spin up a fresh reader. - if STDIN_READER_STARTED + let _lifecycle = stdin_reader_lifecycle(); + started .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_ok() - { +} + +/// The reader's top-of-loop stop check. Returns true when the reader must +/// exit; STARTED has then ALREADY been cleared, under the lifecycle lock, so +/// the caller must not clear it again (a late clear would clobber the `true` +/// of a reader respawned in between and let a third one start). +fn stdin_reader_claim_stop() -> bool { + reader_slot_claim_stop(&STDIN_READER_STARTED, stdin_reader_should_stop) +} + +fn stdin_reader_claim_start() -> bool { + reader_slot_claim_start(&STDIN_READER_STARTED) +} + +fn ensure_stdin_reader() { + // A previous reader may have exited (EOF, error, or explicit detach), which + // resets `STDIN_READER_STARTED` to false so a later `resume()`/`on(...)` + // can spin up a fresh reader. The claim is atomic with a live reader's + // decision to stop (#10895). + if stdin_reader_claim_start() { std::thread::spawn(|| { use std::io::Read; - // On exit, clear STARTED so the reader can be restarted later. - struct ReaderGuard; + // On an EOF / error / panic exit, clear STARTED so the reader can + // be restarted later. The detach exit clears it itself, inside + // `stdin_reader_claim_stop`, and disarms this guard. + struct ReaderGuard { + armed: bool, + } impl Drop for ReaderGuard { fn drop(&mut self) { - STDIN_READER_STARTED.store(false, std::sync::atomic::Ordering::Release); + if self.armed { + let _lifecycle = stdin_reader_lifecycle(); + STDIN_READER_STARTED.store(false, std::sync::atomic::Ordering::Release); + } } } - let _guard = ReaderGuard; + let mut guard = ReaderGuard { armed: true }; let stdin = std::io::stdin(); let mut handle = stdin.lock(); // Read in chunks, not one byte at a time. A paste or a fast-typed @@ -387,7 +438,11 @@ fn ensure_stdin_reader() { // #9676: `stdin_reader_should_stop`, NOT `stdin_is_detached` — // an `unref()`d stdin still delivers data in Node, and reading // the liveness view here is what killed the reader for good. - if stdin_reader_should_stop() { + // #10895: the check and the STARTED reset are one step under + // the lifecycle lock, so a concurrent `resume()` can never + // find STARTED still true for a reader that is already leaving. + if stdin_reader_claim_stop() { + guard.armed = false; break; } match handle.read(&mut buf) { @@ -1266,6 +1321,88 @@ mod empty_checkpoint_tests { } } +#[cfg(test)] +mod reader_lifecycle_tests { + use super::{reader_slot_claim_start, reader_slot_claim_stop}; + use std::sync::atomic::{AtomicBool, Ordering}; + + /// #10895: replays the interleaving that stranded fd 0 without a reader — + /// the reader decides to stop, and `resume()` asks for a restart BEFORE + /// the dying reader has finished leaving. The stop decision must already + /// have released the reader slot, or the restart's claim fails and nobody + /// ever reads stdin again. + /// + /// Runs on local flags: no fd-0 reader is spawned and no process-global + /// stdin state is touched, so it cannot disturb the liveness tests. + #[test] + fn a_restart_requested_while_the_reader_is_leaving_is_not_lost() { + // A reader is running and `pause()` has latched the detach. + let started = AtomicBool::new(true); + let detached = AtomicBool::new(true); + assert!( + reader_slot_claim_stop(&started, || detached.load(Ordering::Acquire)), + "a detached reader must decide to stop" + ); + // `resume()`: clear the latch, then ask for a reader. The old reader + // has not run another instruction since its stop decision. + detached.store(false, Ordering::Release); + assert!( + reader_slot_claim_start(&started), + "restart lost: the stopping reader still held the reader slot" + ); + // The respawned reader owns the slot; a second request is a no-op. + assert!(!reader_slot_claim_start(&started)); + } + + /// The other order: `resume()` clears the latch before the reader looks. + /// The reader keeps running and no second reader may be started on fd 0. + #[test] + fn a_resume_that_beats_the_stop_check_keeps_the_one_reader() { + let started = AtomicBool::new(true); + let detached = AtomicBool::new(true); + detached.store(false, Ordering::Release); + assert!(!reader_slot_claim_start(&started)); + assert!(!reader_slot_claim_stop(&started, || detached.load(Ordering::Acquire))); + assert!(started.load(Ordering::Acquire)); + } + + /// Hammer the handshake from two threads: a "reader" that stops whenever + /// it sees the latch and a "main" that pauses/resumes. After every + /// resume the slot must be owned — by the surviving reader or by the + /// restart — never stranded. + #[test] + fn pause_resume_storm_never_strands_the_slot() { + use std::sync::Arc; + let started = Arc::new(AtomicBool::new(true)); + let detached = Arc::new(AtomicBool::new(false)); + let done = Arc::new(AtomicBool::new(false)); + let reader = { + let (started, detached, done) = (started.clone(), detached.clone(), done.clone()); + std::thread::spawn(move || { + while !done.load(Ordering::Acquire) { + // A live reader polls the latch between reads; one that + // stopped waits to be "respawned" by main's claim. + if started.load(Ordering::Acquire) { + reader_slot_claim_stop(&started, || detached.load(Ordering::Acquire)); + } + std::hint::spin_loop(); + } + }) + }; + for _ in 0..200_000 { + detached.store(true, Ordering::Release); // pause() + detached.store(false, Ordering::Release); // resume(): clear … + reader_slot_claim_start(&started); // … then ensure a reader + assert!( + started.load(Ordering::Acquire), + "resume() returned with no reader owning fd 0" + ); + } + done.store(true, Ordering::Release); + reader.join().unwrap(); + } +} + fn pump_stdin_data_chunks() { let has_bytes = STDIN_BUFFER.lock().map(|b| !b.is_empty()).unwrap_or(false); if !has_bytes { 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..fbe24c81f2 100644 --- a/crates/perry-runtime/src/tui/ffi.rs +++ b/crates/perry-runtime/src/tui/ffi.rs @@ -7,10 +7,40 @@ use crate::string::StringHeader; use super::cell::Grid; use super::color::{parse_color, Color}; +use super::handle_object::{tui_handle_id, tui_object, TuiKind}; use super::render; use super::style::{Edges, Length}; 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..e707d606e5 --- /dev/null +++ b/crates/perry-runtime/src/tui/handle_object.rs @@ -0,0 +1,583 @@ +//! 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..40309bc72d 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,26 @@ 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 +205,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 +272,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 +323,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..9703ff76dc 100644 --- a/crates/perry-runtime/src/tui/tree.rs +++ b/crates/perry-runtime/src/tui/tree.rs @@ -65,10 +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 /// at the call site" convention from the rest of Perry's FFI). diff --git a/crates/perry-runtime/src/typedarray/slice_ops.rs b/crates/perry-runtime/src/typedarray/slice_ops.rs index dc58e25c4b..0651dc7e8b 100644 --- a/crates/perry-runtime/src/typedarray/slice_ops.rs +++ b/crates/perry-runtime/src/typedarray/slice_ops.rs @@ -265,7 +265,16 @@ pub extern "C" fn js_typed_array_subarray( crate::typedarray_view::js_typed_array_byte_offset(ta) + (b as u32) * elem; let buffer_val = crate::value::js_nanbox_pointer(buffer as i64); let off_val = byte_offset as f64; - let len_val = count as f64; + // #10873 (ES2024): a subarray of a length-tracking view taken WITHOUT an + // `end` is itself length-tracking — it is constructed with no length. + let end_absent = + has_end == 0 || crate::value::JSValue::from_bits(end.to_bits()).is_undefined(); + let len_val = if end_absent && crate::typedarray_view::is_view_length_tracking(ta as usize) + { + f64::from_bits(crate::value::TAG_UNDEFINED) + } else { + count as f64 + }; match choice { species::SpeciesChoice::Default => crate::typedarray_view::js_typed_array_view( kind as i32, diff --git a/crates/perry-runtime/src/typedarray_view.rs b/crates/perry-runtime/src/typedarray_view.rs index a4f7cc10f8..e35967a075 100644 --- a/crates/perry-runtime/src/typedarray_view.rs +++ b/crates/perry-runtime/src/typedarray_view.rs @@ -109,7 +109,10 @@ pub extern "C" fn js_typed_array_view( let elem_count = match requested { None => { let remaining = total_len - offset; - if bpe > 1 && remaining % bpe != 0 { + // ES2024: the whole-multiple requirement is for a FIXED-length + // buffer only. A length-tracking view over a resizable one floors + // (its length is recomputed on every resize anyway) (#10873). + if bpe > 1 && remaining % bpe != 0 && !crate::buffer::is_resizable_buffer(addr) { throw_range_error( format!( "byte length of {} should be a multiple of {}", @@ -150,6 +153,11 @@ pub extern "C" fn js_typed_array_view( // mutations are then visible through the buffer and every sibling view, // matching Node (#4103). register_view_meta(ta, addr, offset as u32); + // No explicit length: over a resizable ArrayBuffer the view's length + // follows `byteLength` (#10873). + if requested.is_none() { + mark_view_length_tracking(ta as usize); + } ta } @@ -168,7 +176,7 @@ thread_local! { /// The backing `BufferHeader` lives for the thread's lifetime (Perry never /// `dealloc`s individual buffers — see `buffer::view`), so the raw addr is /// stable and aliasing through it is free of use-after-free. - static TYPED_ARRAY_VIEW_META: RefCell> = + static TYPED_ARRAY_VIEW_META: RefCell> = RefCell::new(crate::fast_hash::new_ptr_hash_map()); } @@ -182,6 +190,23 @@ pub(crate) struct ViewMeta { pub byte_offset: u32, } +/// What the side table stores per view: the hot `ViewMeta` (what every element +/// access copies out through `view_meta_of`) plus the resizable-backing +/// bookkeeping (#10873), which only `resize` and the reflective getters read. +/// Kept apart so the per-access copy stays the two words it always was. +struct ViewRecord { + meta: ViewMeta, + /// Construction-time element count. Only consulted when the backing is a + /// resizable ArrayBuffer: a fixed-length view reads as length 0 while it + /// does not fit, and gets THIS length back when the buffer grows. + fixed_len: u32, + /// Constructed without an explicit length over a resizable ArrayBuffer, so + /// its length follows the buffer's `byteLength`. + length_tracking: bool, + /// ES2024 IsTypedArrayOutOfBounds, as of the last resize. + out_of_bounds: bool, +} + /// #5525: process-global count of typed arrays that have a `TYPED_ARRAY_VIEW_META` /// entry (ArrayBuffer-aliasing or lazily-materialized `.buffer` views). The vast /// majority of typed arrays are owning with inline storage and never appear @@ -207,8 +232,8 @@ pub(crate) fn zero_views_of_detached_backing(backing: usize) { return; } TYPED_ARRAY_VIEW_META.with(|r| { - for (&ta, meta) in r.borrow().iter() { - if meta.backing == backing { + for (&ta, rec) in r.borrow().iter() { + if rec.meta.backing == backing { unsafe { (*(ta as *mut TypedArrayHeader)).length = 0; } @@ -217,6 +242,70 @@ pub(crate) fn zero_views_of_detached_backing(backing: usize) { }); } +/// `ArrayBuffer.prototype.resize` support: recompute the length of every typed +/// array aliasing `backing`, whose byteLength is now `buffer_len`. Eager, like +/// `zero_views_of_detached_backing`, so every reader of a typed array's length +/// stays oblivious to resizing. Growing past the construction-time element +/// count is memory-safe: a registered view's `data_ptr` resolves into the +/// backing (which reserves `maxByteLength`), never into the header's inline +/// region, and the codegen inline tiers are barred while any view exists +/// (`PERRY_TA_VIEW_GUARD`). +pub(crate) fn relength_views_of_resized_backing(backing: usize, buffer_len: u32) { + if !any_view_meta() { + return; + } + TYPED_ARRAY_VIEW_META.with(|r| { + for (&ta, rec) in r.borrow_mut().iter_mut() { + if rec.meta.backing != backing { + continue; + } + let header = ta as *mut TypedArrayHeader; + let elem = elem_size_for_kind(unsafe { (*header).kind }) as u32; + let len = crate::buffer::view_length_after_resize( + buffer_len, + rec.meta.byte_offset, + elem, + rec.length_tracking, + rec.fixed_len, + ); + rec.out_of_bounds = len.is_none(); + unsafe { + (*header).length = len.unwrap_or(0); + } + } + }); +} + +/// Mark a just-registered view as length-tracking. A no-op over a fixed-length +/// backing. +pub(crate) fn mark_view_length_tracking(ta: usize) { + if !crate::buffer::any_resizable_buffer() { + return; + } + TYPED_ARRAY_VIEW_META.with(|r| { + if let Some(rec) = r.borrow_mut().get_mut(&ta) { + if crate::buffer::is_resizable_buffer(rec.meta.backing) { + rec.length_tracking = true; + } + } + }); +} + +/// True when `ta` is a length-tracking view over a resizable ArrayBuffer. +#[inline] +pub(crate) fn is_view_length_tracking(ta: usize) -> bool { + crate::buffer::any_resizable_buffer() + && TYPED_ARRAY_VIEW_META + .with(|r| r.borrow().get(&ta).is_some_and(|rec| rec.length_tracking)) +} + +/// True when `ta` is a view its resizable ArrayBuffer has shrunk past. +#[inline] +fn is_view_out_of_bounds(ta: usize) -> bool { + crate::buffer::any_resizable_buffer() + && TYPED_ARRAY_VIEW_META.with(|r| r.borrow().get(&ta).is_some_and(|rec| rec.out_of_bounds)) +} + /// Record `ta` as aliasing `backing` at `byte_offset`. After this call /// `data_ptr(ta)` resolves into the backing store rather than `ta`'s inline /// region, so reads/writes are shared with the buffer and every other view. @@ -224,9 +313,15 @@ pub(crate) fn register_view_meta(ta: *const TypedArrayHeader, backing: usize, by TYPED_ARRAY_VIEW_META.with(|r| { let prev = r.borrow_mut().insert( ta as usize, - ViewMeta { - backing, - byte_offset, + ViewRecord { + meta: ViewMeta { + backing, + byte_offset, + }, + // Every caller registers the view at its construction length. + fixed_len: unsafe { (*ta).length }, + length_tracking: false, + out_of_bounds: false, }, ); if prev.is_none() { @@ -244,7 +339,7 @@ pub(crate) fn view_meta_of(addr: usize) -> Option { if !any_view_meta() { return None; } - TYPED_ARRAY_VIEW_META.with(|r| r.borrow().get(&addr).copied()) + TYPED_ARRAY_VIEW_META.with(|r| r.borrow().get(&addr).map(|rec| rec.meta)) } /// Data pointer for element 0 of the typed array at `addr` when it aliases an @@ -278,10 +373,10 @@ pub(crate) fn scan_typed_array_view_meta_roots_mut( return; } TYPED_ARRAY_VIEW_META.with(|r| { - for meta in r.borrow_mut().values_mut() { - let mut backing = meta.backing as *mut crate::buffer::BufferHeader; + for rec in r.borrow_mut().values_mut() { + let mut backing = rec.meta.backing as *mut crate::buffer::BufferHeader; visitor.visit_raw_mut_ptr_slot(&mut backing); - meta.backing = backing as usize; + rec.meta.backing = backing as usize; } }); } @@ -302,7 +397,16 @@ pub(crate) fn clear_view_meta(addr: usize) { /// in `TYPED_ARRAY_VIEW_META` report their real offset; everything else is 0. pub fn js_typed_array_byte_offset(ta: *const TypedArrayHeader) -> u32 { let addr = clean_ta_ptr(ta) as usize; - view_meta_of(addr).map(|m| m.byte_offset).unwrap_or(0) + // An out-of-bounds view (its resizable buffer shrank past it) reports 0. + view_meta_of(addr) + .map(|m| { + if is_view_out_of_bounds(addr) { + 0 + } else { + m.byte_offset + } + }) + .unwrap_or(0) } /// `%TypedArray%.prototype.buffer` for a registered typed array: the backing 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/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index ec86d4d650..008226b455 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -61,6 +61,12 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ // machine pipeline for bounded ISel/regalloc. That changes object bytes, // so both the build and object caches must distinguish its settings. "PERRY_LL_FAST_EMIT_MAX_INSTRS", + // #10777: gates computing numeric-by-construction provenance AFTER the + // `Ptr` receiver proofs it depends on. On, an accumulator written + // `h = h + o.a` is admitted and the `+` routes to INLINE_FADD; off, the + // shape inputs are empty and it stays GUARDED. Different emitted code, so + // an object built one way must not be served to a build of the other. + "PERRY_L14_NBC_ORDER", // #9071: gates resolving a loop-called immutable callee binding once at // body entry instead of per call — the two settings emit different call // sequences, so a cached object from one must not serve the other. diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 7d18b4fe10..b43fbf2ef1 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -1395,6 +1395,15 @@ fn compute_object_cache_key_with_env( .unwrap_or(""), ); + // #10777 — numeric-provenance fact ordering. `=1` lets the function-scope + // `number_by_construction` fixpoint see the `Ptr` receiver proofs + // computed before it, which flips `both_numeric` and with it the `+` + // lowering. Different IR, different .o bytes. + h.field( + "env_l14_nbc_order", + env_var("PERRY_L14_NBC_ORDER").as_deref().unwrap_or(""), + ); + h.finish() } diff --git a/crates/perry/tests/issue_10895_stdin_pipe_stall.rs b/crates/perry/tests/issue_10895_stdin_pipe_stall.rs new file mode 100644 index 0000000000..f3203aa0cd --- /dev/null +++ b/crates/perry/tests/issue_10895_stdin_pipe_stall.rs @@ -0,0 +1,152 @@ +//! Regression coverage for #10895: `for await (const chunk of process.stdin)` +//! on a pipe stalled forever part-way through the input. +//! +//! The async iterator pauses its source after every delivered chunk and +//! resumes it on the next pull. On `process.stdin`, `pause()` latches +//! `STDIN_DETACHED` (the fd-0 reader thread exits when it sees it) and +//! `resume()` clears the latch and respawns the reader unless one is still +//! registered. A `resume()` that landed while the old reader was on its way +//! out found it still registered, spawned nothing, and the old reader then +//! left: no reader on fd 0, every liveness view still reporting an open, +//! flowing stdin, the process idle forever with input unread. +//! +//! It is a race, so this test is statistical by nature: many small writes +//! (each pause/resume cycle is one roll) over several rounds. On an unpatched +//! build a single 8 MiB round fed in 256-byte writes stalls roughly every +//! second time on macOS and rarely on Linux; the deterministic witness for +//! the interleaving itself is `reader_lifecycle_tests` in +//! `perry-runtime/src/os_process_streams.rs`. + +#![cfg(unix)] + +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const SOURCE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../test-files/test_issue_10895_stdin_pipe_stall.ts" +)); + +const TOTAL_BYTES: usize = 8 * 1024 * 1024; +const WRITE_BYTES: usize = 256; +const ROUNDS: usize = 12; +const ROUND_DEADLINE: Duration = Duration::from_secs(60); + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile(dir: &Path) -> PathBuf { + let source = dir.join("stdin_pipe_stall.ts"); + let binary = dir.join("stdin_pipe_stall_bin"); + std::fs::write(&source, SOURCE).expect("write stdin fixture"); + let output = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&source) + .arg("-o") + .arg(&binary) + .output() + .expect("compile stdin fixture"); + assert!( + output.status.success(), + "fixture compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + binary +} + +/// One round: pipe `TOTAL_BYTES` in `WRITE_BYTES` writes, close stdin, and +/// require the child to report every byte before the deadline. +fn run_round(binary: &Path, round: usize) { + let mut child = Command::new(binary) + .env("PERRY_10895_DRIVE", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn stdin fixture"); + let mut stdin = child.stdin.take().expect("fixture stdin"); + let writer = std::thread::spawn(move || { + let piece = [1u8; WRITE_BYTES]; + let mut left = TOTAL_BYTES; + while left > 0 { + let n = left.min(WRITE_BYTES); + // A stalled child stops draining the pipe; the kill below then + // breaks this write with EPIPE. Either way the thread ends. + if stdin.write_all(&piece[..n]).is_err() { + return left; + } + left -= n; + } + drop(stdin); + 0 + }); + + let started = Instant::now(); + let status = loop { + if let Some(status) = child.try_wait().expect("poll stdin fixture") { + break Some(status); + } + if started.elapsed() > ROUND_DEADLINE { + break None; + } + std::thread::sleep(Duration::from_millis(5)); + }; + let Some(status) = status else { + let _ = child.kill(); + let _ = child.wait(); + let unwritten = writer.join().unwrap_or(TOTAL_BYTES); + panic!( + "round {round}: piped stdin stalled — the child was still alive after {:?} with \ + {unwritten} of {TOTAL_BYTES} bytes not even accepted by the pipe (#10895)", + ROUND_DEADLINE + ); + }; + assert_eq!(writer.join().expect("writer thread"), 0); + let mut stdout = String::new(); + child + .stdout + .take() + .expect("fixture stdout") + .read_to_string(&mut stdout) + .expect("read fixture stdout"); + assert!( + status.success(), + "round {round}: exit {status:?}: {stdout:?}" + ); + assert_eq!( + stdout.trim_end(), + format!("RESULT:{TOTAL_BYTES}"), + "round {round}: not every piped byte reached the iterator" + ); +} + +#[test] +fn piped_stdin_async_iteration_reads_to_eof_every_time() { + let dir = tempfile::tempdir().expect("create fixture directory"); + let binary = compile(dir.path()); + for round in 0..ROUNDS { + run_round(&binary, round); + } +} + +/// Undriven, the fixture must leave stdin alone (the parity sweep runs it +/// with whatever stdin the caller has). +#[test] +fn undriven_fixture_does_not_wait_on_stdin() { + let dir = tempfile::tempdir().expect("create fixture directory"); + let binary = compile(dir.path()); + let output = Command::new(&binary) + .stdin(Stdio::piped()) + .output() + .expect("run undriven fixture"); + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim_end(), + "RESULT:idle" + ); +} diff --git a/crates/perry/tests/issue_10903_stdout_write_bytes.rs b/crates/perry/tests/issue_10903_stdout_write_bytes.rs new file mode 100644 index 0000000000..151f7a218b --- /dev/null +++ b/crates/perry/tests/issue_10903_stdout_write_bytes.rs @@ -0,0 +1,315 @@ +//! Regression coverage for #10903: `process.stdout.write(chunk[, encoding])` / +//! `process.stderr.write(...)` must put a binary chunk (`Buffer`, any +//! `TypedArray`, `DataView`) on the fd byte for byte — exactly the view's +//! window — encode a string chunk with `encoding`, and deliver all of it even +//! when the fd is non-blocking. +//! +//! They used to start from the chunk's display text and ignore `encoding`, so a +//! 4-byte little-endian frame length such as `F7 FF 0F 00` reached the reader +//! as `EF BF BD EF BF BD 0F 00`, `new Uint16Array([0x6968])` printed `26984`, +//! `write("6869", "hex")` wrote four characters, and on a non-blocking pipe an +//! 8 MiB write stopped at the first `EAGAIN` (131,072 bytes delivered). +//! +//! The small expectations are the bytes Node 26.5.1 writes for the same fixture +//! (`PERRY_10903_CASE= node --experimental-strip-types … | xxd -p`), +//! pinned so the test needs no node on the machine. The large ones are +//! recomputed here from the fixture's `pattern()`; their SHA-256 under Node +//! was checked to match when this test was written. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const SOURCE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../test-files/test_gap_10903_stdout_write_bytes.ts" +)); + +const CHUNKS_STDOUT: &str = "f7ff0f000af7ff440ac800800ab0b10afefd800afd800a91920aa1a2a30aa2a30a\ +feff800afeff800aff800aff990a00000000000004c00a9998970a68c3a96c6c6f0a"; +const CHUNKS_STDERR: &str = "f1f20a800aff0a"; +const ENCODINGS_STDOUT: &str = "68e90a68e90a68e90a68c3a90a68c3a90af7ff0f000af7ff0f000af7ff0f000a\ +6800e9000a006800e9000a00f70af80a"; +const ENCODINGS_STDERR: &str = "ff0a800a"; +const CALLBACKS_STDOUT: &str = "e00ae10ae20a"; +const INTERLEAVE_STDOUT: &str = "6f6e650a800a74776f0a7468726565810a666f75720af50a666976650a"; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile(dir: &Path) -> PathBuf { + let source = dir.join("stdout_write_bytes.ts"); + let binary = dir.join("stdout_write_bytes_bin"); + std::fs::write(&source, SOURCE).expect("write fixture"); + let output = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&source) + .arg("-o") + .arg(&binary) + .output() + .expect("compile fixture"); + assert!( + output.status.success(), + "fixture compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + binary +} + +fn run_case(binary: &Path, case: &str) -> (Vec, Vec) { + let output = Command::new(binary) + .env("PERRY_10903_CASE", case) + .stdin(Stdio::null()) + .output() + .expect("run fixture"); + assert!(output.status.success(), "{case}: exit {:?}", output.status); + (output.stdout, output.stderr) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// The fixture's `pattern(length, mul)`. +fn pattern(length: usize, mul: usize) -> Vec { + (0..length) + .map(|i| ((i * mul + (i >> 8)) & 0xff) as u8) + .collect() +} + +fn frame(payload: &[u8]) -> Vec { + let mut out = (payload.len() as u32).to_le_bytes().to_vec(); + out.extend_from_slice(payload); + out +} + +fn expected_framing() -> Vec { + let mut out = frame(&pattern(200, 7)); + out.extend(frame(&pattern(1_048_567, 13))); + out +} + +fn expected_large() -> Vec { + let mut out = pattern(5 * 1024 * 1024 + 3, 31); + out.extend(pattern(3 * 1024 * 1024 + 1, 17)); + out.extend_from_slice(b"done\n"); + out +} + +fn expected_many() -> Vec { + let mut out = Vec::new(); + for i in 0..20_000_usize { + out.extend_from_slice(&[(i & 0xff) as u8, ((i >> 8) & 0xff) as u8, 0x80, 0x0a]); + if i % 1000 == 0 { + out.extend_from_slice(format!("k{i}\n").as_bytes()); + } + } + out +} + +/// The fixture's `huge` case: one `write()` each of 16, 32 and 64 MiB, every +/// byte `0x80 + MiB` except a leading `MiB` and a trailing `0x0A`. Checked in +/// place — a second 112 MiB buffer to compare against buys nothing. +fn huge_mismatch(got: &[u8]) -> Option { + let mut at = 0_usize; + for mib in [16_usize, 32, 64] { + let len = mib * 1024 * 1024; + let Some(region) = got.get(at..at + len) else { + return Some(format!( + "{mib} MiB chunk: only {} of {len} bytes arrived (total {})", + got.len().saturating_sub(at), + got.len() + )); + }; + let fill = 0x80 + mib as u8; + if region[0] != mib as u8 || region[len - 1] != 0x0a { + return Some(format!("{mib} MiB chunk: wrong first/last byte")); + } + if let Some(bad) = region[1..len - 1].iter().position(|b| *b != fill) { + return Some(format!( + "{mib} MiB chunk: byte {} is {:#04x}, want {fill:#04x}", + bad + 1, + region[bad + 1] + )); + } + at += len; + } + (got.len() != at).then(|| format!("{} trailing bytes", got.len() - at)) +} + +/// Where two byte strings first differ — a multi-megabyte `assert_eq!` dump +/// would bury the answer. +fn first_difference(got: &[u8], want: &[u8]) -> Option { + if got == want { + return None; + } + let at = got + .iter() + .zip(want) + .position(|(a, b)| a != b) + .unwrap_or(got.len().min(want.len())); + let window = |bytes: &[u8]| hex(&bytes[at.min(bytes.len())..(at + 16).min(bytes.len())]); + Some(format!( + "got {} bytes, want {}; first difference at {at}: got {} want {}", + got.len(), + want.len(), + window(got), + window(want) + )) +} + +/// Run `case` with a NON-BLOCKING socket as fd 1 and a reader that dawdles, so +/// the child is guaranteed to see `EAGAIN` many times inside one `write()`. +/// `O_NONBLOCK` lives on the open file description, which the child shares. +#[cfg(unix)] +fn run_case_on_non_blocking_stdout(binary: &Path, case: &str) -> Vec { + use std::io::Read; + use std::os::fd::OwnedFd; + use std::os::unix::net::UnixStream; + + let (mut ours, theirs) = UnixStream::pair().expect("socketpair"); + theirs.set_nonblocking(true).expect("O_NONBLOCK"); + // The `Command` temporary owns `theirs`; it is dropped at the end of this + // statement, leaving the child as the only writer so EOF can arrive. + let mut child = Command::new(binary) + .env("PERRY_10903_CASE", case) + .stdin(Stdio::null()) + .stdout(Stdio::from(OwnedFd::from(theirs))) + .stderr(Stdio::null()) + .spawn() + .expect("spawn fixture"); + let mut received = Vec::new(); + let mut chunk = vec![0_u8; 16 * 1024]; + loop { + if received.len() < (1 << 20) { + std::thread::sleep(std::time::Duration::from_micros(500)); + } + match ours.read(&mut chunk) { + Ok(0) => break, + Ok(n) => received.extend_from_slice(&chunk[..n]), + Err(err) => panic!("{case}: read: {err}"), + } + } + assert!(child.wait().expect("wait").success(), "{case}: exit status"); + received +} + +#[test] +fn binary_chunks_and_encoded_strings_reach_the_fd_byte_for_byte() { + let dir = tempfile::tempdir().expect("create fixture directory"); + let binary = compile(dir.path()); + let mut failures: Vec = Vec::new(); + let mut report = |what: &str, problem: Option| { + if let Some(problem) = problem { + failures.push(format!("{what}: {problem}")); + } + }; + let unhex = |text: &str| -> Vec { + (0..text.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&text[i..i + 2], 16).expect("hex")) + .collect() + }; + + let (stdout, stderr) = run_case(&binary, "chunks"); + report( + "chunks stdout", + first_difference(&stdout, &unhex(CHUNKS_STDOUT)), + ); + report( + "chunks stderr", + first_difference(&stderr, &unhex(CHUNKS_STDERR)), + ); + + let (stdout, stderr) = run_case(&binary, "encodings"); + report( + "encodings stdout", + first_difference(&stdout, &unhex(ENCODINGS_STDOUT)), + ); + report( + "encodings stderr", + first_difference(&stderr, &unhex(ENCODINGS_STDERR)), + ); + + // Completion callbacks still fire, after the synchronous return values. + let (stdout, stderr) = run_case(&binary, "callbacks"); + report( + "callbacks stdout", + first_difference(&stdout, &unhex(CALLBACKS_STDOUT)), + ); + report( + "callbacks stderr", + first_difference(&stderr, b"returned true true true\ncb1\ncb2\ncb3\n"), + ); + + // `console.log` and `write` share fd 1 and must stay in program order. + let (stdout, _) = run_case(&binary, "interleave"); + report( + "interleave stdout", + first_difference(&stdout, &unhex(INTERLEAVE_STDOUT)), + ); + + // A view whose ArrayBuffer was transferred away: Node throws a TypeError + // and writes nothing for it. + let (stdout, _) = run_case(&binary, "detached"); + report( + "detached stdout", + first_difference(&stdout, b"TypeError,TypeError\n"), + ); + + let (stdout, _) = run_case(&binary, "framing"); + report( + "framing stdout", + first_difference(&stdout, &expected_framing()), + ); + let (stdout, _) = run_case(&binary, "large"); + report("large stdout", first_difference(&stdout, &expected_large())); + let (stdout, _) = run_case(&binary, "many"); + report("many stdout", first_difference(&stdout, &expected_many())); + + // One `write()` each of 16, 32 and 64 MiB. + let (stdout, _) = run_case(&binary, "huge"); + report("huge stdout", huge_mismatch(&stdout)); + + #[cfg(unix)] + { + let stdout = run_case_on_non_blocking_stdout(&binary, "large"); + report( + "large stdout, non-blocking fd", + first_difference(&stdout, &expected_large()), + ); + let stdout = run_case_on_non_blocking_stdout(&binary, "framing"); + report( + "framing stdout, non-blocking fd", + first_difference(&stdout, &expected_framing()), + ); + } + + assert!( + failures.is_empty(), + "stdout/stderr bytes differ from what Node writes (#10903):\n {}", + failures.join("\n ") + ); +} + +/// Undriven, the fixture is the parity sweep's text case: every chunk is +/// printable ASCII on the wire, so it must read exactly as it does under Node. +#[test] +fn undriven_fixture_prints_the_ascii_matrix() { + let dir = tempfile::tempdir().expect("create fixture directory"); + let binary = compile(dir.path()); + let output = Command::new(&binary) + .env_remove("PERRY_10903_CASE") + .stdin(Stdio::null()) + .output() + .expect("run undriven fixture"); + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "ok\nbuffer\nhi!\nu32\nwin\nin\ndv\nslice\nbound\nhex\nb64\nb64url\nlatin1\nign\nret\n\ +typeof: boolean\n" + ); + assert_eq!(String::from_utf8_lossy(&output.stderr), "err\ne2!\ne3\n"); +} diff --git a/crates/perry/tests/null_stub_is_an_object.rs b/crates/perry/tests/null_stub_is_an_object.rs new file mode 100644 index 0000000000..035af3e3b6 --- /dev/null +++ b/crates/perry/tests/null_stub_is_an_object.rs @@ -0,0 +1,90 @@ +//! #10917 / #10821 row 4 -- the unresolved-namespace stub is an ordinary +//! object. +//! +//! The stub was `NULL_OBJECT_BYTES`, a `.rodata` byte array laid out like an +//! `ObjectHeader` and handed to JS under `POINTER_TAG`, with no `GcHeader`. +//! `try_read_gc_header` read the 8 bytes the linker placed before it as the +//! header -- in the v0.5.1631 binary the tail of a string literal, `"nts]"`, +//! i.e. `obj_type == 110`, a kind that does not exist. So the stub, which is +//! meant to be an empty object, answered `JSON.stringify` with `""` and threw +//! on `String()`, and did so differently on a build whose literal layout +//! differed. +//! +//! The expected strings are what an empty object gives -- node's answer for +//! `JSON.stringify({})` and `String({})` -- because that is what the stub has +//! always claimed to be. + +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() +} + +/// `handle.constructor` on a common-registry handle falls through to the stub +/// (`ic_miss.rs` / `get_field_by_name*.rs`), which makes it reachable from an +/// ordinary program. Pre-fix: `json ""` and `String()` threw +/// `TypeError: Cannot convert object to primitive value`. +#[test] +fn the_unresolved_namespace_stub_answers_as_an_empty_object() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +import * as crypto from "node:crypto"; +const c: any = crypto.createHash("sha256").constructor; +console.log("typeof", typeof c); +console.log("json", JSON.stringify(c)); +console.log("nested", JSON.stringify({ a: c })); +console.log("keys", JSON.stringify(Object.keys(c))); +console.log("brand", Object.prototype.toString.call(c)); +console.log("string", String(c)); +console.log("stable", c === crypto.createHash("md5").constructor); +const m = new Map([[c, 1]]); +console.log("map", m.get(crypto.createHash("sha1").constructor)); +"#, + ); + assert_eq!( + stdout, + "typeof object\n\ + json {}\n\ + nested {\"a\":{}}\n\ + keys []\n\ + brand [object Object]\n\ + string [object Object]\n\ + stable true\n\ + map 1\n" + ); +} 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/docs/src/internals/explicit-memory.md b/docs/src/internals/explicit-memory.md index 62a748fc9c..bd056f023b 100644 --- a/docs/src/internals/explicit-memory.md +++ b/docs/src/internals/explicit-memory.md @@ -25,9 +25,16 @@ Perry gives detach real teeth: buffer bytes live inline in the GC heap, so the runtime hands the page-aligned interior of a detached payload back to the OS immediately (`madvise`). A large detached buffer stops costing RSS the moment you transfer it, even while the (now empty) ArrayBuffer object is still -reachable. `transferToFixedLength()` behaves identically (Perry has no -resizable ArrayBuffers), and `structuredClone(v, { transfer: [...] })` detaches -through the same path. +reachable. `transferToFixedLength()` detaches the same way, and +`structuredClone(v, { transfer: [...] })` goes through the same path. + +Resizable ArrayBuffers (`new ArrayBuffer(len, { maxByteLength })`) use the same +inline storage: the buffer reserves `maxByteLength` of address space once and +`resize()` only moves its length, so views never go stale and nothing is +copied. Pages are touched as the buffer grows, and a shrink of 64 KiB or more +returns the dropped pages to the OS — resident memory follows `byteLength`, not +`maxByteLength` and not the high-water mark. `transfer()` keeps a buffer +resizable; `transferToFixedLength()` does not. ## `perry/gc` — collection pacing diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 982dcbbcc6..599362dbc5 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -872,6 +872,13 @@ "verdict": "test_only", "why": "#[cfg(test)] diagnostic trace for the bound-method moving-GC regression: records the (before, after) addresses a test-forced minor produced so the test can assert the relocation happened. The addresses are compared as integers, never dereferenced, and the cell is dead in a shipped binary." }, + { + "file": "crates/perry-runtime/src/object/null_stub.rs", + "name": "NULL_STUB_SLOT", + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut -> object::null_stub::scan_null_stub_roots_mut", + "why": "#340/#341 row 4: the per-realm unresolved-namespace stub OBJECT. It used to be a `.data` static with no GcHeader; it is an ordinary GC_TYPE_OBJECT now, visited by `null_stub::scan_null_stub_roots_mut` from the registered `object::scan_object_cache_roots_mut`, which keeps it alive and rewrites the slot when it moves. Uncovered here only because that registered scanner is in another file." + }, { "file": "crates/perry-runtime/src/object/read_stub.rs", "name": "READ_STUB", @@ -1141,6 +1148,76 @@ "verdict": "not_a_gc_pointer", "why": "In-flight job counter for perry/thread." }, + { + "file": "crates/perry-runtime/src/tui/handle_object.rs", + "name": "STATE_PROTOTYPE_SLOT", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", @@ -3992,16 +4069,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" diff --git a/test-compat/test262/features-applicable.txt b/test-compat/test262/features-applicable.txt index ce98acbdb7..c58c81e7b6 100644 --- a/test-compat/test262/features-applicable.txt +++ b/test-compat/test262/features-applicable.txt @@ -93,6 +93,8 @@ eval # top-level-await — partial; semantics depend on loader # regexp-lookbehind — vestigial (no such test262 subdir); lookbehind cases run inline # regexp-v-flag — RegExp `v` flag not yet measured -# resizable-arraybuffer — not implemented +# resizable-arraybuffer — partial since #10873 (buffer + views + DataView; 156/408 of the +# tagged built-ins cases). Left off the list until the %TypedArray% +# method OOB semantics land and typed-array subclasses inherit statics. # Temporal — implemented, but scored via self-validate-features.txt # (the Node oracle lacks Temporal; see #4792) diff --git a/test-files/test_gap_10873_resizable_arraybuffer.ts b/test-files/test_gap_10873_resizable_arraybuffer.ts new file mode 100644 index 0000000000..35c3b9aaa7 --- /dev/null +++ b/test-files/test_gap_10873_resizable_arraybuffer.ts @@ -0,0 +1,204 @@ +// #10873: resizable ArrayBuffer (ES2024) — `new ArrayBuffer(len, { maxByteLength })`, +// `ArrayBuffer.prototype.resize`, `resizable` / `maxByteLength`, length-tracking +// and fixed-length views (typed arrays + DataView), `transfer` preserving +// resizability. Compared byte-for-byte against `node --experimental-strip-types`. + +function name(f: () => unknown): string { + try { + f(); + return "no throw"; + } catch (e) { + return (e as Error).name; + } +} + +// --- the issue's repro ------------------------------------------------------- +const b = new ArrayBuffer(0, { maxByteLength: 1024 }); +console.log(typeof (b as any).resize, b.byteLength, b.resizable, b.maxByteLength); +b.resize(16); +console.log(b.byteLength, b.resizable, b.maxByteLength); + +// --- a fixed-length buffer is unchanged -------------------------------------- +const fixed = new ArrayBuffer(8); +console.log(fixed.resizable, fixed.maxByteLength, fixed.byteLength); +console.log("resize fixed:", name(() => fixed.resize(4))); +const noMax = new ArrayBuffer(8, {} as any); +console.log(noMax.resizable, noMax.maxByteLength); +const undefMax = new ArrayBuffer(8, { maxByteLength: undefined }); +console.log(undefMax.resizable, undefMax.maxByteLength); +const nonObject = new ArrayBuffer(8, 5 as any); +console.log(nonObject.resizable, nonObject.maxByteLength); + +// --- constructor validation + evaluation order ------------------------------- +console.log("len > max:", name(() => new ArrayBuffer(10, { maxByteLength: 5 }))); +console.log("negative max:", name(() => new ArrayBuffer(0, { maxByteLength: -1 }))); +console.log("len == max:", new ArrayBuffer(5, { maxByteLength: 5 }).byteLength); +const order: string[] = []; +const ordered = new ArrayBuffer( + { + valueOf() { + order.push("length"); + return 2; + }, + } as any, + { + get maxByteLength() { + order.push("get max"); + return { + valueOf() { + order.push("max valueOf"); + return 8; + }, + } as any; + }, + }, +); +console.log(order.join(" > "), ordered.byteLength, ordered.maxByteLength); + +// --- resize: grow zero-fills, shrink truncates, regrow re-zeroes ------------- +const rab = new ArrayBuffer(4, { maxByteLength: 16 }); +const tracking = new Uint8Array(rab); +tracking.set([1, 2, 3, 4]); +console.log(tracking.length, Array.from(tracking).join(",")); +rab.resize(8); +console.log(rab.byteLength, tracking.length, Array.from(tracking).join(",")); +tracking[7] = 77; +rab.resize(2); +console.log(rab.byteLength, tracking.length, Array.from(tracking).join(",")); +rab.resize(8); +console.log(rab.byteLength, tracking.length, Array.from(tracking).join(",")); +console.log("resize > max:", name(() => rab.resize(17))); +console.log("resize < 0:", name(() => rab.resize(-1))); +console.log("resize to max:", (rab.resize(16), rab.byteLength), tracking.length); +console.log("resize returns:", rab.resize(8)); +rab.resize(8); +console.log("same-size resize:", rab.byteLength, tracking.length); + +// --- fixed-length views go out of bounds and come back ----------------------- +const rab2 = new ArrayBuffer(8, { maxByteLength: 16 }); +const whole = new Uint8Array(rab2); +for (let i = 0; i < 8; i++) whole[i] = i + 1; +const fixedView = new Uint8Array(rab2, 4, 4); // bytes 4..8 +const offsetTracking = new Uint8Array(rab2, 6); // bytes 6..end +console.log(fixedView.length, fixedView.byteOffset, fixedView.byteLength, Array.from(fixedView).join(",")); +console.log(offsetTracking.length, offsetTracking.byteOffset, Array.from(offsetTracking).join(",")); +rab2.resize(6); +console.log("shrunk 6:", fixedView.length, fixedView.byteOffset, fixedView.byteLength, fixedView[0]); +console.log("shrunk 6:", offsetTracking.length, offsetTracking.byteOffset); +rab2.resize(5); +console.log("shrunk 5:", offsetTracking.length, offsetTracking.byteOffset, offsetTracking[0]); +rab2.resize(12); +console.log("regrown:", fixedView.length, fixedView.byteOffset, Array.from(fixedView).join(",")); +console.log("regrown:", offsetTracking.length, offsetTracking.byteOffset, Array.from(offsetTracking).join(",")); +console.log("whole:", whole.length, Array.from(whole).join(",")); + +// --- multi-byte typed arrays ------------------------------------------------- +const rab3 = new ArrayBuffer(8, { maxByteLength: 64 }); +const i32 = new Int32Array(rab3); +const f64fixed = new Float64Array(rab3, 0, 1); +i32[0] = 0x01020304; +i32[1] = -1; +console.log(i32.length, i32.byteLength, f64fixed.length); +rab3.resize(18); // 4 whole int32 + 2 stray bytes: a tracking view floors +console.log(i32.length, i32.byteLength, i32[0], i32[1], i32[2], i32[3], i32[4]); +i32[3] = 123456; +rab3.resize(4); +console.log(i32.length, i32[0], i32[1], f64fixed.length, f64fixed.byteLength, f64fixed.byteOffset); +rab3.resize(16); +console.log(i32.length, i32[0], i32[1], i32[3], f64fixed.length); +const odd = new ArrayBuffer(6, { maxByteLength: 64 }); +console.log("tracking over 6 bytes:", new Int32Array(odd).length); +console.log("fixed buffer of 6 bytes:", name(() => new Int32Array(new ArrayBuffer(6)))); + +// --- subarray: no `end` on a tracking view keeps tracking -------------------- +const rab4 = new ArrayBuffer(8, { maxByteLength: 32 }); +const base = new Uint8Array(rab4); +const tailTracking = base.subarray(2); +const tailFixed = base.subarray(2, 6); +const i16 = new Int16Array(rab4); +const i16Tracking = i16.subarray(1); +const i16Fixed = i16.subarray(1, 3); +console.log(tailTracking.length, tailFixed.length, i16Tracking.length, i16Fixed.length); +rab4.resize(16); +console.log(tailTracking.length, tailFixed.length, i16Tracking.length, i16Fixed.length); +rab4.resize(4); +console.log(tailTracking.length, tailFixed.length, i16Tracking.length, i16Fixed.length); + +// --- DataView ---------------------------------------------------------------- +const rab5 = new ArrayBuffer(8, { maxByteLength: 16 }); +const dvTracking = new DataView(rab5); +const dvFixed = new DataView(rab5, 4, 4); +dvTracking.setUint32(4, 0xdeadbeef); +console.log(dvTracking.byteLength, dvFixed.byteLength, dvFixed.getUint32(0).toString(16)); +rab5.resize(16); +console.log(dvTracking.byteLength, dvFixed.byteLength, dvTracking.getUint8(15)); +rab5.resize(6); +console.log(dvTracking.byteLength, dvTracking.getUint8(5).toString(16)); +console.log("dv read past end:", name(() => dvTracking.getUint8(6))); +console.log("oob dv byteLength:", name(() => dvFixed.byteLength)); +console.log("oob dv get:", name(() => dvFixed.getUint8(0))); +console.log("oob dv set:", name(() => dvFixed.setUint8(0, 1))); +rab5.resize(8); +console.log("dv back:", dvFixed.byteLength, dvFixed.getUint32(0).toString(16)); + +// --- slice / transfer -------------------------------------------------------- +const rab6 = new ArrayBuffer(4, { maxByteLength: 8 }); +new Uint8Array(rab6).set([9, 8, 7, 6]); +const sliced = rab6.slice(1, 3); +console.log(sliced.resizable, sliced.byteLength, Array.from(new Uint8Array(sliced)).join(",")); +const moved = rab6.transfer(); +console.log(moved.resizable, moved.maxByteLength, moved.byteLength, rab6.detached, rab6.byteLength, rab6.maxByteLength); +moved.resize(8); +console.log(Array.from(new Uint8Array(moved)).join(",")); +console.log("transfer > max:", name(() => moved.transfer(9))); +const pinned = moved.transferToFixedLength(); +console.log(pinned.resizable, pinned.maxByteLength, pinned.byteLength, moved.detached); +console.log("resize detached:", name(() => moved.resize(1))); +console.log("resize pinned:", name(() => pinned.resize(1))); + +// --- reflection -------------------------------------------------------------- +console.log(typeof ArrayBuffer.prototype.resize, ArrayBuffer.prototype.resize.length, ArrayBuffer.prototype.resize.name); +console.log(typeof Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "resizable")?.get); +console.log(typeof Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "maxByteLength")?.get); +const viaProto = new ArrayBuffer(2, { maxByteLength: 4 }); +ArrayBuffer.prototype.resize.call(viaProto, 3); +console.log(viaProto.byteLength); +console.log("resize.call(non-buffer):", name(() => ArrayBuffer.prototype.resize.call({} as any, 1))); +console.log(typeof (new Uint8Array(2) as any).resize, typeof (new DataView(new ArrayBuffer(2)) as any).resize); +const dynamicCtor: any = [ArrayBuffer][0]; +const dyn = new dynamicCtor(1, { maxByteLength: 3 }); +console.log(dyn.resizable, dyn.maxByteLength, dyn.byteLength); + +// --- element reads in a loop that resizes underneath them -------------------- +const rab7 = new ArrayBuffer(8, { maxByteLength: 8 }); +const loopView = new Uint8Array(rab7); +for (let i = 0; i < 8; i++) loopView[i] = 10 + i; +let seen = ""; +for (let i = 0; i < loopView.length; i++) { + seen += loopView[i] + ";"; + if (i === 2) rab7.resize(5); +} +console.log(seen, loopView.length); +const f32 = new Float32Array(new ArrayBuffer(16, { maxByteLength: 16 })); +f32.fill(1.5); +let total = 0; +for (let i = 0; i < 4; i++) { + const v = f32[i]; + total += v === undefined ? 100 : v; + if (i === 1) (f32.buffer as ArrayBuffer).resize(8); +} +console.log(total, f32.length); + +// --- the Native Messaging host shape (guest271314/NativeMessagingHosts) ------- +const host = new ArrayBuffer(0, { maxByteLength: 1024 ** 2 * 64 }); +let sum = 0; +for (const size of [1024, 1024 * 1024, 3, 0, 5 * 1024 * 1024]) { + host.resize(size); + const view = new Uint8Array(host); + for (let i = 0; i < size; i += 4093) view[i] = i & 0xff; + for (let i = 0; i < size; i += 4093) sum += view[i]; + console.log(size, view.length, view[0], size > 0 ? view[size - 1] : -1); + host.resize(0); + console.log(view.length, host.byteLength); +} +console.log(sum); diff --git a/test-files/test_gap_10903_stdout_write_bytes.ts b/test-files/test_gap_10903_stdout_write_bytes.ts new file mode 100644 index 0000000000..b5b9b5cd42 --- /dev/null +++ b/test-files/test_gap_10903_stdout_write_bytes.ts @@ -0,0 +1,213 @@ +// #10903: `process.stdout.write(chunk[, encoding])` / `process.stderr.write(...)` +// started from the chunk's DISPLAY TEXT and ignored `encoding`: +// - a Buffer / Uint8Array was UTF-8 *decoded* and the text written, so every +// byte that is not valid UTF-8 reached the fd as EF BF BD; +// - any other TypedArray was written as its join(",") text, a DataView as +// "[object DataView]"; +// - write("6869", "hex") wrote four characters instead of two bytes; +// - on a non-blocking fd a large chunk was silently truncated at EAGAIN. +// Node writes a binary chunk byte for byte (exactly the view's window) and +// encodes a string chunk with `encoding`. +// +// Two modes: +// * undriven (the parity sweep, which compares TEXT): every chunk below is +// printable ASCII on the wire, so the wrong conversions still show up as +// wrong text ("26984,2593" instead of "hi!", "6865780a" instead of "hex"). +// * PERRY_10903_CASE=: raw bytes, compared fd by fd against what Node +// writes by `crates/perry/tests/issue_10903_stdout_write_bytes.rs`. +const which: string = process.env.PERRY_10903_CASE ?? ""; + +function pattern(length: number, mul: number): Uint8Array { + const bytes = new Uint8Array(length); + for (let i = 0; i < length; i++) bytes[i] = (i * mul + (i >>> 8)) & 0xff; + return bytes; +} + +// Native Messaging framing: 4-byte little-endian length, then the payload. +function sendFrame(payload: Uint8Array): void { + process.stdout.write(new Uint8Array(new Uint32Array([payload.length]).buffer)); + process.stdout.write(payload); +} + +function chunkKinds(): void { + const out = process.stdout; + // The bytes that found the bug: a frame length of 1,048,567. + process.stdout.write(new Uint8Array([0xf7, 0xff, 0x0f, 0x00, 0x0a])); + process.stdout.write(Buffer.from([0xf7, 0xff, 0x44, 0x0a])); + // Aliased and bound receivers reach the same method. + out.write(new Uint8Array([0xc8, 0x00, 0x80, 0x0a])); + const bound = process.stdout.write.bind(process.stdout); + bound(new Uint8Array([0xb0, 0xb1, 0x0a])); + // Views: only the window may be written. + const backing = new Uint8Array(16); + backing.set([0xfe, 0xfd, 0x80, 0x0a], 6); + out.write(backing.subarray(6, 10)); + out.write(new Uint8Array(backing.buffer, 7, 3)); + out.write(Buffer.from([0x01, 0x91, 0x92, 0x0a, 0x05]).subarray(1, 4)); + const wide = new ArrayBuffer(16); + new Uint8Array(wide).set([0, 0, 0xa1, 0xa2, 0xa3, 0x0a, 0, 0]); + out.write(new Uint16Array(wide, 2, 2) as any); + out.write(new DataView(wide, 3, 3) as any); + // Wider element types go out as their stored bytes, not as numbers. + out.write(new Uint16Array([0xfffe, 0x0a80]) as any); + out.write(new Uint32Array([0x0a80fffe]) as any); + out.write(new Int8Array([-1, -128, 10]) as any); + out.write(new Uint8ClampedArray([300, 0x99, 10]) as any); + out.write(new Float64Array([-2.5]) as any); + out.write(new Uint8Array([0x0a])); + out.write(new DataView(new Uint8Array([0x99, 0x98, 0x97, 0x0a]).buffer) as any); + // A zero-length chunk writes nothing. + out.write(new Uint8Array(0)); + out.write(Buffer.alloc(0)); + // Strings are unaffected. + out.write("héllo\n"); + process.stderr.write(Buffer.from([0xf1, 0xf2, 0x0a])); + const err = process.stderr; + err.write(new Uint8Array([0x80, 0x0a])); + err.write(new Uint16Array([0x0aff]) as any); +} + +function encodings(): void { + const out = process.stdout; + out.write("hé\n", "latin1"); + out.write("hé\n", "binary"); + out.write("hé\n", "ascii"); + out.write("hé\n", "utf8"); + out.write("hé\n", "UTF-8"); + out.write("f7ff0f000a", "hex"); + out.write("9/8PAAo=", "base64"); + out.write("9_8PAAo", "base64url"); + out.write("hé\n", "ucs2"); + out.write("hé\n", "utf16le"); + // An encoding names how to encode a STRING; a binary chunk ignores it. + out.write(Buffer.from([0xf7, 0x0a]), "hex"); + out.write(new Uint8Array([0xf8, 0x0a]), "latin1"); + process.stderr.write("ÿ\n", "latin1"); + process.stderr.write("800a", "hex"); +} + +function callbacks(): void { + const mark = (text: string) => () => process.stderr.write(text); + const a: boolean = process.stdout.write(new Uint8Array([0xe0, 0x0a]), mark("cb1\n")); + const b: boolean = process.stdout.write("e10a", "hex", mark("cb2\n")); + const c: boolean = process.stdout.write(Buffer.from([0xe2, 0x0a]), "utf8", mark("cb3\n")); + process.stderr.write(`returned ${a} ${b} ${c}\n`); +} + +function interleave(): void { + console.log("one"); + process.stdout.write(new Uint8Array([0x80, 0x0a])); + console.log("two"); + process.stdout.write("three"); + process.stdout.write(new Uint8Array([0x81, 0x0a])); + console.log("four"); + process.stdout.write("f50a", "hex"); + console.log("five"); +} + +function framing(): void { + sendFrame(pattern(200, 7)); // length C8 00 00 00 + sendFrame(pattern(1048567, 13)); // length F7 FF 0F 00 +} + +function large(): void { + process.stdout.write(pattern(5 * 1024 * 1024 + 3, 31)); + process.stdout.write(Buffer.from(pattern(3 * 1024 * 1024 + 1, 17))); + process.stdout.write("done\n"); +} + +function many(): void { + const out = process.stdout; + for (let i = 0; i < 20000; i++) { + out.write(new Uint8Array([i & 0xff, (i >>> 8) & 0xff, 0x80, 0x0a])); + if (i % 1000 === 0) console.log("k" + i); + } +} + +// One write() each of 16, 32 and 64 MiB: a chunk far larger than any pipe or +// socket buffer must still arrive whole and in order. +function huge(): void { + for (const mib of [16, 32, 64]) { + const bytes = new Uint8Array(mib * 1024 * 1024).fill(0x80 + mib); + bytes[0] = mib; + bytes[bytes.length - 1] = 0x0a; + process.stdout.write(bytes); + } +} + +// Node re-wraps a non-Buffer view over its ArrayBuffer, which throws once the +// buffer has been transferred away; nothing may be written for that chunk. +function detached(): void { + const names: string[] = []; + const ab = new ArrayBuffer(4); + const view = new Uint8Array(ab); + view.set([0xf1, 0xf2, 0xf3, 0x0a]); + const dataView = new DataView(ab, 1, 2); + (ab as any).transfer(); + for (const chunk of [view, dataView]) { + try { + process.stdout.write(chunk as any); + names.push("no throw"); + } catch (e: any) { + names.push(e.name); + } + } + process.stdout.write(names.join(",") + "\n"); +} + +function asciiParity(): void { + const out = process.stdout; + process.stdout.write(new Uint8Array([111, 107, 10])); // ok + process.stdout.write(Buffer.from("buffer\n")); + out.write(new Uint16Array([0x6968, 0x0a21]) as any); // hi! + out.write(new Uint32Array([0x0a323375]) as any); // u32 + const backing = new Uint8Array([120, 119, 105, 110, 10, 120]); + out.write(backing.subarray(1, 5)); // win + out.write(new Uint8Array(backing.buffer, 2, 3)); // in + out.write(new DataView(new Uint8Array([120, 100, 118, 10, 120]).buffer, 1, 3) as any); // dv + out.write(Buffer.from("xslice\ny").subarray(1, 7)); // slice + out.write(new Uint8Array(0)); + const bound = process.stdout.write.bind(process.stdout); + bound(new Uint8Array([98, 111, 117, 110, 100, 10])); // bound + out.write("6865780a", "hex"); // hex + out.write("YjY0Cg==", "base64"); // b64 + out.write("YjY0dXJsCg", "base64url"); // b64url + out.write("latin1\n", "latin1"); + out.write(new Uint8Array([105, 103, 110, 10]), "hex"); // ign: encoding ignored + console.log("typeof:", typeof out.write(new Uint8Array([114, 101, 116, 10]))); // ret + process.stderr.write(new Uint8Array([101, 114, 114, 10])); // err + process.stderr.write(new Uint16Array([0x3265, 0x0a21]) as any); // e2! + process.stderr.write("65330a", "hex"); // e3 +} + +switch (which) { + case "chunks": + chunkKinds(); + break; + case "encodings": + encodings(); + break; + case "callbacks": + callbacks(); + break; + case "interleave": + interleave(); + break; + case "framing": + framing(); + break; + case "large": + large(); + break; + case "many": + many(); + break; + case "huge": + huge(); + break; + case "detached": + detached(); + break; + default: + asciiParity(); +} diff --git a/test-files/test_issue_10895_stdin_pipe_stall.ts b/test-files/test_issue_10895_stdin_pipe_stall.ts new file mode 100644 index 0000000000..5e1196ef91 --- /dev/null +++ b/test-files/test_issue_10895_stdin_pipe_stall.ts @@ -0,0 +1,32 @@ +// #10895: `for await (const chunk of process.stdin)` on a pipe stalled forever +// part-way through the input. The async iterator pauses the source after every +// delivered chunk and resumes it on the next pull; `pause()` makes the fd-0 +// reader thread exit and `resume()` respawns it, and a resume that landed while +// the old reader was still on its way out lost the respawn for good. +// +// Driven by `crates/perry/tests/issue_10895_stdin_pipe_stall.rs`, which pipes +// several MiB in small writes. Undriven (the parity sweep) it must not touch +// stdin at all: the sweep inherits whatever stdin the caller has, and a fixture +// that waits for EOF on a terminal never finishes. +const driven = process.env.PERRY_10895_DRIVE === "1"; + +async function main(): Promise { + if (!driven) { + console.log("RESULT:idle"); + return; + } + let total = 0; + for await (const data of process.stdin) { + const chunk: Uint8Array = data; + total += chunk.length; + // Touch the payload so a chunk that arrives with the right length but the + // wrong bytes is caught too (every byte the driver writes is 1). + if (chunk[0] !== 1 || chunk[chunk.length - 1] !== 1) { + console.log("RESULT:corrupt@" + total); + return; + } + } + console.log("RESULT:" + total); +} + +main(); diff --git a/test-files/test_parity_region_guards.ts b/test-files/test_parity_region_guards.ts new file mode 100644 index 0000000000..b52b1c4db6 --- /dev/null +++ b/test-files/test_parity_region_guards.ts @@ -0,0 +1,130 @@ +// Differential fixture for step 4b (region-scoped guards). +// +// Every case here is a region a naive implementation WOULD admit and get +// wrong. An operator between two reads can be a call: `+` on unvouched +// operands has a cold arm that reaches ToPrimitive and therefore user +// valueOf/toString. JS fixes the order -- `O.a + O.b + O.c` is read a, read b, +// ADD, read c, add -- so a region that loads `c` by slot after that add ran, +// or hoists it above, can return a stale value. Nothing crashes; the guard +// held at entry; the answer is simply wrong. +// +// Expected values are node's. A region that hoists without verifying operand +// primitiveness first returns the number in the comment marked WRONG. + +// ---- A: mid-region transition via valueOf, delete ------------------------ +const A = { a: null, b: 1, c: 7 }; +A.a = { valueOf() { delete A.c; return 1; } }; +console.log("A delete:", A.a + A.b + A.c); // NaN (WRONG: 9) + +// ---- A2: mid-region transition via valueOf, plain overwrite -------------- +const A2 = { a: null, b: 1, c: 7 }; +A2.a = { valueOf() { A2.c = 100; return 1; } }; +console.log("A2 overwrite:", A2.a + A2.b + A2.c); // 102 (WRONG: 9) + +// ---- A3: the same through toString on the SECOND operand ----------------- +const A3 = { a: 1, b: null, c: 7 }; +A3.b = { toString() { delete A3.c; return "2"; } }; +console.log("A3 toString:", A3.a + A3.b + A3.c); // "12undefined" + +// ---- A4: relational operator, not arithmetic ----------------------------- +const A4 = { a: null, b: 5, c: 1 }; +A4.a = { valueOf() { delete A4.c; return 9; } }; +console.log("A4 rel:", (A4.a < A4.b) + ":" + A4.c); // "false:undefined" + +// ---- A5: a getter added mid-region -------------------------------------- +const A5 = { a: null, b: 1, c: 7 }; +A5.a = { valueOf() { Object.defineProperty(A5, "c", { get() { return 50; } }); return 1; } }; +console.log("A5 accessor:", A5.a + A5.b + A5.c); // 52 (WRONG: 9) + +// ---- B: a store inside the run, then a later bail ------------------------ +// If a region spans the store and a later operand fails its check, restarting +// the generic copy from the top increments `n` twice. +const B = { n: 10, m: 2, x: null }; +B.x = { valueOf() { return 3; } }; +let bt = B.n; +B.n = bt + 1; +const bu = B.m + B.x; +console.log("B once:", B.n, bu); // 11 5 (WRONG: 12 5) + +// ---- B2: the store targets a key the run also reads ---------------------- +const B2 = { n: 10, m: 2 }; +let b2t = B2.n; +B2.n = b2t + 1; +console.log("B2 same key:", B2.n + B2.m); // 13 (WRONG: 12) + +// ---- C: a Proxy receiver ------------------------------------------------- +const CT = { a: 1, b: 2, c: 3 }; +let ctrap = 0; +const C = new Proxy(CT, { get(t, k) { ctrap++; return Reflect.get(t, k); } }); +console.log("C proxy:", C.a + C.b + C.c, "traps:", ctrap); // 6 traps: 3 + +// ---- D: accessor on the prototype, read mid-run -------------------------- +const DP = {}; +Object.defineProperty(DP, "c", { get() { return 40; }, configurable: true }); +const D = Object.create(DP); +D.a = 1; D.b = 2; +console.log("D proto getter:", D.a + D.b + D.c); // 43 + +// ---- E: the honest control — all operands primitive ---------------------- +// This is the case a region MUST take. No operator can run user code, so +// hoisting the loads is unobservable and the answer is the same either way. +const E = { a: 1, b: 2, c: 3, d: 4 }; +let esum = 0; +for (let i = 0; i < 3; i++) esum += E.a + E.b + E.c + E.d; +console.log("E control:", esum); // 30 + +// ---- F: a late leaf that is a LOCAL, but one a closure assigns ------------ +// `(o.a + o.b) + z`: `z` is a local, yet `o.a`'s valueOf reaches it through +// `bump`. The fold may read a late leaf early only when no code a conversion +// runs can write it; this local fails that, so the chain must keep declining. +function fCaptured() { + const o = { a: null, b: 2 }; + let z = 1; + const bump = () => { z = 100; }; + o.a = { valueOf() { bump(); return 1; } }; + return o.a + o.b + z; +} +console.log("F captured let:", fCaptured()); // 103 (WRONG: 4) + +// ---- F2: a module-level binding, assigned by a plain function ----------- +// No closure captures `fz`: `fBump` writes the module global directly, so +// capture analysis says nothing about it. +let fz = 1; +function fBump() { fz = 100; } +const F2 = { a: null, b: 2 }; +F2.a = { valueOf() { fBump(); return 1; } }; +console.log("F2 module global:", F2.a + F2.b + fz); // 103 (WRONG: 4) + +// ---- F3: the leaves the fold MAY read early: const locals ---------------- +// Each read happened in its own statement, before the chain began, so the +// valueOf that assigns `o.c` cannot change `r2`. Node's answer is 6. +function fStmt(o) { + const r0 = o.a; const r1 = o.b; const r2 = o.c; + return r0 + r1 + r2; +} +const F3 = { a: null, b: 2, c: 3 }; +F3.a = { valueOf() { F3.c = 100; return 1; } }; +console.log("F3 const locals:", fStmt(F3)); // 6 + +// ---- G: the same chain through the declared-number entry ---------------- +// `number[]` types the elements, but nothing enforces an annotation, so this +// tree reaches the fold as statically numeric rather than as a dynamic tree. +// Same hazard: `a[0]`'s valueOf assigns `a[2]` before the spec reads it. +const G: number[] = [1, 2, 3]; +(G as any)[0] = { valueOf() { G[2] = 100; return 1; } }; +function gSum(a: number[]): number { return a[0] + a[1] + a[2]; } +console.log("G declared number[]:", gSum(G)); // 103 (WRONG: 6) + +// ---- H: the same hazard on a `this` receiver, declared number fields -- +// The commonest shape in class code, and the one the declared-number entry +// fuses: every field is annotated `number`, so the tree is statically +// numeric and nothing enforces that. Printed 10 before the entry was gated. +class HCls { + a: number = 1; b: number = 2; c: number = 3; e: number = 4; + sum(): number { return this.a + this.b + this.c + this.e; } +} +const H1 = new HCls(); +(H1 as any).a = { valueOf() { H1.c = 100; return 1; } }; +console.log("H this receiver:", H1.sum()); // 107 (WRONG: 10) + +console.log("done"); diff --git a/test-files/test_parity_shape_identity_order.ts b/test-files/test_parity_shape_identity_order.ts new file mode 100644 index 0000000000..7fa98a07ae --- /dev/null +++ b/test-files/test_parity_shape_identity_order.ts @@ -0,0 +1,143 @@ +// Stage 0 of #10868 step 2.5 (canonical shape identity). +// +// Key ORDER is observable in JS, so a canonicalisation that merged two layouts +// with the same key SET but different order — or a deleted-then-re-added key +// with a never-deleted one — is a silent wrong answer in every program, not a +// slow one. These rows pin the order across every way an object can reach a +// layout: born from a literal, grown by name, grown past its inline region +// into spill, tombstoned and re-added, and reached through defineProperty. +// +// Every row prints. Byte-identical to node is the contract. +// +// PROVEN ABLE TO FAIL — each view was reddened by a sabotage applied to the +// runtime and reverted, because a row that cannot redden is documentation. +// The four views reach the key list by THREE different paths, which is why +// one sabotage was not enough: +// +// view path sabotage that reddens it +// ---- ---- ------------------------ +// keys= js_object_keys (field_get_set/enumeration) reverse js_object_keys' result: 37 lines +// forin= same function as keys= (same sabotage; keys= and forin= move together) +// own= js_object_get_own_property_names reverse its result: own= only, 19 rows +// (object/descriptors.rs, via +// Reflect.ownKeys in proxy/reflect_misc.rs) +// json= object_keys_array read DIRECTLY by the reverse the keys array at publication +// json/stringify_* serialisers — it bypasses (object/mod.rs, before +// both enumeration functions above publish_object_shape_from): json= 9 rows, +// and keys/forin/own too +// +// The row that matters most is `ab` / `ba`: under the first sabotage both come +// back in the same order, i.e. the sabotage merges the two layouts this file +// exists to keep apart. Under the third, `hole` prints {"d":1,"b":3,"a":4} — +// keys reversed, values left in their slots — which is the silent-wrong-value +// failure a wrong canonicalisation would produce. + +function show(label, o) { + const keys = Object.keys(o); + const forin = []; + for (const k in o) forin.push(k); + const own = Reflect.ownKeys(o).map((k) => (typeof k === "symbol" ? String(k) : k)); + console.log(label + " keys=" + JSON.stringify(keys)); + console.log(label + " forin=" + JSON.stringify(forin)); + console.log(label + " own=" + JSON.stringify(own)); + console.log(label + " json=" + JSON.stringify(o)); +} + +// --- 1. born vs grown, same key set, same order ----------------------------- +const born3 = { a: 1, b: 2, c: 3 }; +const grown3 = {}; +grown3.a = 1; +grown3.b = 2; +grown3.c = 3; +show("born3", born3); +show("grown3", grown3); + +// --- 2. same SET, different ORDER. These must NOT be merged ----------------- +const ab = { a: 1, b: 2 }; +const ba = { b: 2, a: 1 }; +show("ab", ab); +show("ba", ba); + +// --- 3. tombstone: delete then re-add moves the key to the END -------------- +const tomb = { a: 1, b: 2, c: 3 }; +delete tomb.a; +tomb.a = 9; +show("tomb", tomb); +// ... and must not equal an object built directly in that order with the same +// values, as far as ORDER is concerned (they do match here, which is the +// point: the ORDER is what is observable, not the history). +const direct = { b: 2, c: 3, a: 9 }; +show("direct", direct); + +// --- 4. delete in the middle, no re-add ------------------------------------- +const hole = { a: 1, b: 2, c: 3, d: 4 }; +delete hole.b; +show("hole", hole); +const holeGrown = {}; +holeGrown.a = 1; +holeGrown.b = 2; +holeGrown.c = 3; +holeGrown.d = 4; +delete holeGrown.b; +show("holeGrown", holeGrown); + +// --- 5. past the inline region: born wide vs grown wide --------------------- +// INLINE_SLOT_FLOOR is 2, so a grown object spills from its third key while a +// literal is born with exactly its own count. Same layout, different physical +// placement — the pair L8.3.7 exists for. +const bornWide = { k0: 0, k1: 1, k2: 2, k3: 3, k4: 4, k5: 5, k6: 6, k7: 7 }; +const grownWide = {}; +for (let i = 0; i < 8; i++) grownWide["k" + i] = i; +show("bornWide", bornWide); +show("grownWide", grownWide); + +// --- 6. integer-like keys sort before string keys, ascending ---------------- +const mixed = {}; +mixed.z = 1; +mixed[2] = 2; +mixed.a = 3; +mixed[10] = 4; +mixed[1] = 5; +show("mixed", mixed); +const mixedBorn = { z: 1, 2: 2, a: 3, 10: 4, 1: 5 }; +show("mixedBorn", mixedBorn); + +// --- 7. defineProperty: enumerable false is in ownKeys, not in keys --------- +const dp = { a: 1 }; +Object.defineProperty(dp, "hidden", { value: 2, enumerable: false, configurable: true }); +dp.b = 3; +show("dp", dp); +const dpGrown = {}; +dpGrown.a = 1; +Object.defineProperty(dpGrown, "hidden", { value: 2, enumerable: false, configurable: true }); +dpGrown.b = 3; +show("dpGrown", dpGrown); + +// --- 8. accessor keeps its insertion position ------------------------------- +const acc = { a: 1 }; +Object.defineProperty(acc, "g", { get: function () { return 7; }, enumerable: true, configurable: true }); +acc.b = 3; +show("acc", acc); +console.log("acc.g=" + acc.g); + +// --- 9. symbols come after strings in ownKeys ------------------------------- +const sym = { a: 1 }; +const S = Symbol("s"); +sym[S] = 2; +sym.b = 3; +show("sym", sym); + +// --- 10. null-prototype objects ------------------------------------------- +const np = Object.create(null); +np.a = 1; +np.b = 2; +console.log("np keys=" + JSON.stringify(Object.keys(np))); +console.log("np own=" + JSON.stringify(Reflect.ownKeys(np))); + +// --- 11. two literal SITES with equal key lists ----------------------------- +// Different static keys arrays today; one layout under a content key. Order +// must be identical either way. +function siteA() { return { p: 1, q: 2 }; } +function siteB() { return { p: 3, q: 4 }; } +show("siteA", siteA()); +show("siteB", siteB());