Skip to content

merge train 254: eight PRs, v0.5.1634 - #10930

Merged
proggeramlug merged 26 commits into
mainfrom
train254r
Sep 22, 2026
Merged

proggeramlug merged 26 commits into
mainfrom
train254r

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Merge train 254 — every open, undrafted PR that rebases cleanly onto main, validated as one tree and landed together. Releases v0.5.1634.

The two remaining open PRs (#10859, #10403) are CONFLICTING against main, so they carry zero CI runs and cannot join a train until they are rebased; both are being rebased separately.

Carried

PR Closes What
#10913 #10895 process.stdin resume() racing the stopping fd-0 reader stranded piped stdin without a reader
#10915 — (#10821 row 3) perry/tui handles are ordinary objects
#10916 #10873 resizable ArrayBuffer: new ArrayBuffer(n, { maxByteLength }), resize, length-tracking views
#10919 — (#10868 stage 0) parity: pin key order across every way an object reaches a layout
#10921 #10904 a + tree must not read a leaf after a conversion that precedes it
#10923 #10903 process.stdout/stderr.write put the chunk's bytes on the fd — binary-safe, encoding-aware, EAGAIN-safe
#10924 #10917 the unresolved-namespace stub is an ordinary object, not a header-less static (#10821 row 4)
#10929 — (refs #10777) compute numeric provenance after the Ptr<Shape> receiver proofs it depends on

#10924 is stacked on #10915; the train carries the stack, so both land.

Fixed in the train

Verification

Full gate set on the assembled tree, not on the PRs individually: cargo fmt, the file-size cap, raw_handle_debt (self-test + bare + vs-main), gc_runtime_root_holders, check_test_registration, addr_class_inventory, the non-workspace-feature check, cargo check --workspace --all-targets under -D warnings, cargo audit with the ignore list derived from security-audit.yml, the full 83-gate run_lint_gates.sh, unit suites for the five touched crates, the integration suites derived from the diff, compiler_output_regression for native-region-proof / native-abi-proof, the repsel census, and the gap suite across seven areas. Artifacts are pinned by sha256 before the test phase and re-verified after it.

The rooting claims in #10915/#10924 were checked by hand rather than taken from the verdict file: tui::handle_object::scan_tui_handle_roots_mut and object::null_stub::scan_null_stub_roots_mut are both reached from object::scan_object_cache_roots_mut, which gc/mod.rs registers with reg_scanner!. The chain the covered_elsewhere verdicts name is real.

  • fix(codegen): compute numeric provenance after the Ptr<Shape> receiver proofs it depends on #10929 broke the crate as a test target. collect_numeric_by_construction_locals gained shape_members and shape_numeric_fields; four callers in collectors/ptr_shape_group_numeric_tests.rs were left at the old arity. cargo check --lib compiles no cfg(test) code, so that stays green — cargo check --workspace --all-targets under -D warnings (CI's warnings gate) does not. Fixed with empty sets at each site, which is exactly what the function computed before the reorder.

  • cargo audit was red on a fixable advisory. RUSTSEC-2026-0285 (rustls 0.23.44 — TLS 1.3 handshake messages accepted across encryption-level boundaries, medium/5.3) is the one unignored finding in the workspace, and security-audit runs on every PR that touches a lockfile — which every train does, via the version bump. main carries the identical rustls, so this is not the train's regression, but security: cargo audit fails on RUSTSEC-2026-0285 (rustls 0.23.44) — the fix is unblocked by the soak window on 2026-09-21 #10791 records that the fix cleared the 7-day SOAK_DAYS window today. cargo update -p rustls locks 0.23.45 with no cascade.

Closes #10895
Closes #10873
Closes #10904
Closes #10903
Closes #10917
Closes #10791

Ralph Küpper and others added 23 commits September 21, 2026 23:16
…eader no longer strands stdin without a reader (#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 it 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.

Make the reader's check-and-clear and the restart CAS atomic with respect to
each other under one lifecycle lock (never held across read()). The detach
exit now releases the reader slot itself and disarms the drop guard, which
otherwise could clobber the flag of a reader respawned in between.

Introduced by bb57392 (2026-09-04, unified fd-0 reader): before it, a piped
stdin was read by readline's own reader, which never consulted the latch, so
v0.5.1520 does not reproduce.

Fixes #10895
`new ArrayBuffer(len, { maxByteLength })` silently returned a fixed-length
buffer: the codegen arm in lower_call/builtin.rs lowered only args[0] and
never even evaluated the options bag, `ArrayBuffer.prototype.resize` did not
exist, and the `resizable` / `maxByteLength` getters were hard-coded to
`false` / `byteLength` (get_field_by_name_tail.rs carried the comment "Perry
has no resizable ArrayBuffers"). A program using one died on its first
`.resize()` with `TypeError: (Buffer).resize is not a function`.

Storage model (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 therefore reserves
`maxByteLength` once (its `capacity`) and `resize()` only rewrites `length`.
A grow clears only what it exposes and only what may be dirty — each buffer
carries a `dirty_end` boundary past which bytes are known zero — so a
`new ArrayBuffer(0, { maxByteLength: 64 MiB })` reserves address space, not
resident memory, and a 64 MiB regrow into released pages costs nothing on
Linux. A shrink of >= 64 KiB hands the dropped pages back to the OS (the
madvise detach uses), so RSS follows `byteLength`.

Views: `resize` eagerly recomputes the header length of every registered view
over the buffer (buffer-shaped Uint8Array/Buffer/DataView via view.rs, typed
arrays via typedarray_view.rs) — the way detach zeroes them — so every fast
tier that reads a view's length keeps working unchanged. Length-tracking views
(constructed without an explicit length; `subarray()` without `end` of one)
follow byteLength; fixed-length views read as length 0 / byteOffset 0 while
they do not fit and come back when the buffer regrows; an out-of-bounds
DataView throws TypeError from its accessors and `byteLength`. `transfer()`
preserves resizability, `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.

Cost when unused: every new probe on a shared path is gated on one
RegistryLatch load (`any_resizable_buffer`); the per-access ViewInfo/ViewMeta
copies stay two words (the bookkeeping lives in separate records). Measured
instructions:u on typed-array / view / DataView / ArrayBuffer micro-rows are
within +-0.3% of unpatched main.

Verified: test-files/test_gap_10873_resizable_arraybuffer.ts byte-identical
against node 26.5.1 (fails on unpatched main at line 1), also under
PERRY_GC_SCHEDULE_SEED=1 RATE=1 ALLOC_KB=0 PROTECT_FROMSPACE=1
VERIFY_EVACUATION=1 (1572 forced copying minors); 11 new unit tests in
buffer/resizable_tests.rs; perry-runtime --lib 4207 passed; test262
resizable-arraybuffer built-ins subset 33 -> 156 of 408 (the rest are
%TypedArray% method mid-iteration semantics and a typed-array-subclass
static-inheritance gap the harness itself trips on).
…ut (#10868 stage 0)

Step 2.5 of #10868 makes shape identity content-canonical, which changes
identity for every object in every program. 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 everywhere, not a slow one. These tests land BEFORE any identity
change so that every later slice is checked against them rather than written
alongside them.

Eleven scenarios, 75 output rows, each printed through Object.keys, for-in,
Reflect.ownKeys and JSON.stringify: born vs grown; same set in two orders;
tombstone then re-add; mid-list delete; born-wide vs grown-into-spill (the pair
the design's birth-sizing precondition exists for); integer-like keys before
string keys; defineProperty with enumerable:false; accessor position; symbols
after strings; a null-prototype object; and two literal sites with equal key
lists.

Proven able to fail. The four views reach the key list by three different
paths, so one sabotage was not enough; each was applied to the runtime and
reverted, and the file's header records which proves which:

  keys= / forin=  reverse js_object_keys' result          37 lines red
  own=            reverse js_object_get_own_property_names 19 rows red, own= only
  json=           reverse the keys array at publication    9 json= rows red
                  (json/stringify_* read object_keys_array
                   directly and bypass both functions above)

The row that matters most is ab/ba: under the first sabotage both come back in
the same order, i.e. it merges the two layouts this file exists to keep apart.
…plementation

Ten differential cases against node. Four of them (A, A2, A3, A5) FAIL on
pristine v0.5.1618 today: a left-associative + chain of three or more operands
reads every operand before the adds, so a mutating valueOf or toString sees a
stale later operand. Filed as #10904. That is their proof they can fail.

The other six are what a region implementation must not break, and each has a
sabotage that must turn it red: B/B2 by admitting a store inside a region, C/D
by skipping the entry guard, E by declining everything.

The .js twin is gitignored in test-files; it is byte-identical and regenerated
by copying the .ts, which carries no TypeScript syntax.
…ecedes it

lower_guarded_numeric_add fuses a whole + tree into ONE shared guard, which
means evaluating 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 ToPrimitives
both. So if L is itself an Add, L's own conversions run BEFORE R is evaluated,
and a user valueOf or toString in L can change what a leaf in R reads:

  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

By induction that gives an exact rule rather than a leaf-count approximation:
Add(L, R) is faithful iff L is not an Add and R is faithful -- no Add node may
have an Add as its LEFT child. Only that condition is added, as an early return
in dynamic_add_tree_benefits_shared_guard; the rest of the predicate, its
PERRY_DYNAMIC_ADD_PAIR_GUARD semantics and its call site are unchanged.

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. What loses it is a
chain with an inner left-leaning +, such as h += a + b + c.

The cold arm does not rescue the unfaithful case: rebuild_add_tree(fast = false)
rebuilds over the already-lowered leaf values, so even the spec-+ path adds the
stale leaf. That is why the symptom is a wrong number and not a crash.

The cheaper-looking fix is not available. Making the cold arm correct would
mean discarding the pre-read values and re-evaluating later leaves in order, but
a leaf can be a call or a read that reaches a getter, so redoing it can run an
effect twice. That is only legal once something has proven the leaves
effect-free, which is what a region guard establishes (#10884).
The first commit declined every left-leaning + chain. That is sound but
broader than the bug. #10904 needs a leaf the specification evaluates AFTER an
earlier conversion AND whose value or evaluation that conversion can affect. A
property read is one (valueOf can assign O.c); a local nothing else can write
is not, and neither is a literal. As written, `x + y + z` over plain locals,
one of the commonest expressions in JavaScript, got slower for no correctness
gain: lane 13's read4_stmt column (four const locals summed) was +91..+99.

Rule: Add(L, R) is faithful iff L and R are, and, when L is an Add, every leaf
of R is evaluation-invariant. Invariant leaves are literals and LocalGets 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 (any function can assign one without capturing it), not a POD
record. No new analysis: boxed_vars already is "some other code can write it".

Soundness: 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.

Fixture: F (captured let a closure assigns, the must-fail control for the
exemption), F2 (module global a plain function assigns), F3 (const locals, the
shape that folds again). Unit tests pin each exemption and each refusal.
lower_guarded_numeric_add is reached from two places, and the first commit
gated only one. The declared-number entry (both operands "numeric" only
because an annotation says so) fused the whole tree unconditionally, so the
same stale read survived there: 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 this branch and on main.

The faithfulness check now lives at the top of the fold, where every entry
passes, and a declined tree lowers node by node through the spec helper.
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 dynamic entry's code is unchanged in effect: its declined trees took the
same lower_rooted_dynamic_binary call before, from the call site.

Fixture G and a unit test pin the declared-number entry.
…e fd (#10903)

`process.stdout.write(chunk[, encoding])` / `process.stderr.write(...)` started
from `js_jsvalue_to_string(chunk)` — the chunk's display text — and ignored
`encoding`. So:

* a Buffer / Uint8Array was UTF-8 *decoded* and the text written: 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;
* any other TypedArray was written as its join(",") text
  (`new Uint16Array([0x6968, 0x0a21])` printed `26984,2593`), a DataView as
  `[object DataView]`;
* `write("6865780a", "hex")` wrote eight characters instead of four bytes;
* the write went through `Stdout::write_all`, which gives up 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.

Node writes a binary chunk byte for byte (exactly the view's window) and
encodes a string chunk with `encoding`.

New module `os_process_stream_write.rs`:

* `with_write_bytes` borrows the bytes to write: the string payload as is
  (utf8, the default — no allocation and no copy; the old conversion
  `to_vec()`'d every chunk), the view's window for Buffer / TypedArray /
  DataView via the shared native-span accessor, and a transcoded buffer only
  for a non-utf8 string encoding (latin1/binary/ascii/hex/base64/base64url/
  ucs2/utf16le, through `Buffer.from`'s own encoder).
* a view whose ArrayBuffer was transferred away throws a TypeError, as Node
  does when it re-wraps the view; a detached Buffer is written as it is, empty.
* `write_all_fd` owns the partial-write loop: EINTR retries, EAGAIN waits for
  POLLOUT, requests are capped 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.

Non-chunk values (number, null, ArrayBuffer, …) and unknown encoding names keep
perry's existing leniency; Node throws there. Deliberately not changed here.

instructions:u per call, 100k calls to /dev/null: string write -300 (-8.3%),
49-byte Uint8Array write -23,961 (-86.7%).
…e-chunk classifier

The address-classification audit (handle-floor rule) rejects a bare
`addr < 0x1000`. It was redundant here: every probe that follows is an
address-keyed registry lookup, and nothing dereferences `addr` unless a
registry has vouched for it.
Every value `perry/tui` handed TypeScript was a small registry integer wearing
`POINTER_TAG` -- a number pretending to be a pointer. Three registries minted
those ids and three more kinds were plain constants, so SIX id spaces shared
one encoding and they collided:

    useApp()          -> 1     Text("hi")   -> 1     useRef(x), first  -> 1
    useStdout()       -> 2     Box()        -> 2     useRef(y), second -> 2
    useFocusManager() -> 3     Spacer()     -> 3
    state(0), first   -> 0     <- POINTER_TAG | 0, a null pointer wearing
                                  the pointer tag

`useApp() === Text("hi")` was `true`. A `Map` or `Set` keyed on two different
handles kept one entry. A `WeakMap` entry stored under a widget was readable
through the App handle. And the first `state(0)` of a program was a tagged
null -- the exact shape the honest-tag invariant exists to forbid. None of it
was reachable through a type error, because the encoding carries no provenance:
at run time the values are indistinguishable.

Each kind is now an ORDINARY object: `GC_TYPE_OBJECT` with a real ShapeId, a
class id from the web-builtin block (`0xFFFF_240B..0x2410`) and ZERO own keys.
`typeof` is `"object"`, `Object.keys` is `[]`, `JSON.stringify` is `{}` where
it was `null`, and two handles are two values.

* The registry ids are UNCHANGED and stay the module's internal currency. The
  widget tree, the Taffy layout pass, the paint pass and the hook slots all
  still speak ids; only the value crossing the `#[no_mangle]` boundary changed,
  through two helpers (`widget_object` out, `widget_id` in). A raw argument is
  not a GC root, so every consumer resolves at entry, before anything that can
  allocate and move it.
* `useApp()` / `useStdout()` / `useFocusManager()` answer the SAME object on
  every call -- ink's do, and perry's did too while they were constants -- so
  they are per-realm singletons in rooted slots rather than re-minted per call.
  That is the resource->object mapping at singleton scale.
* `useRef` is likewise stable across renders, so the HOOK SLOT owns its handle
  object. That makes it a GC pointer in a side table with two scanners over it;
  both now go through one `visit_hook_slot_roots` whose `match` destructures
  every field, so a forgotten edge is a compile error rather than a scavenge
  crash.
* `state.get()/.set()`, `ref.get()/.set()`, `app.exit()/.waitUntilExit()`,
  `stdout.write()/.columns()/.rows()` and `focusManager.focusNext()/
  .focusPrevious()/.focus(id)` are real methods on a per-kind prototype as well
  as the statically lowered `class_filter` rows they already were. Before this
  they existed ONLY as static lowerings, so a handle reached through an untyped
  value answered `undefined` for every one of them. The prototypes are lazy
  per-realm singletons built in the runtime (the timer family's shape), not
  `populate_builtin_prototype_methods` entries, so unlike #10831 there is no
  `global-*` feature to make load-bearing -- `perry/tui` has none.
* A foreign receiver is answered leniently with `undefined`, not thrown at.
  `perry/tui` is not WebIDL and has no node equivalent to copy a brand policy
  from, and this is the choice that cannot turn a working program into a
  throwing one. It is never READ as an id of the wrong kind: that is what the
  class-id brand prevents, and with six overlapping id spaces it had to.

Deleted by the representation: `tui::is_known_handle` and the three
`contains_handle` probes it unioned. They answered "is this integer one of our
registries' ids?" by taking three mutexes, and could not answer correctly
because the spaces overlap. A class-id load answers it with one load and no
ambiguity.

Also here, because the third family is where it stopped being avoidable: the
class ids move into `perry-runtime/src/native_class_ids.rs`. The
worker-transfer guard had grown into a range spelled
`TEXT_ENCODER_CLASS_ID..=IMMEDIATE_CLASS_ID` INSIDE `text.rs` -- a check about
every family living in one family's file, widened by hand per landing. The new
module owns the whole `0xFFFF_24xx` block, each family aliases its own id from
it, and a `const fn` assertion (not a `debug_assert`, which is free in release)
fails the BUILD if two families ever claim one id or if an id wanders toward
the `ShapeId` window -- #10824 was a shipped aliasing bug.

Gate A: the `tui` arm of
`receiver_repr_family_fixtures_move_constructed_and_observed_old` is inverted
from "observed_old moves" to "stays 0", and its `observe_pointer` arm is
deleted. Note what the pre-migration fixture had to do: retry when the
constructed handle came back 0, because the first `state(0)` was a tagged null.
Gate B: a producer-side assertion that each kind's constructor result is not in
the handle band, carries zero own keys and resolves back to its id -- which is
what covers the `class_filter`-lowered reads gate A cannot see.

Three tests named the old representation and are re-baselined, all stated in
the PR: `alloc_returns_sequential_handles` and
`state_slots_survive_a_foreign_clear` asserted the handle WAS the slot index
(`h0 == 0`, `h_next == h + 1`) and now assert that of the slot the handle
carries, and `out_of_range_handle_returns_undefined` is renamed for what it now
proves -- an arbitrary integer can no longer address a live slot at all.

The emitted small-handle guards and every `addr_class` band predicate stay
exactly as they are. They come out only after the last family has moved.
…ly; root-holder verdicts

The three tests compile and run real TS programs, which is the only way to
reach the statically lowered class_filter rows and the emitted IC-miss edge --
every by-name unit test in #10831 passed while the compiled program answered
undefined, and this family lowers even more of its surface statically.

The eight new GC root-holder entries take the RESEARCHED-verdict form
(covered_elsewhere + the scanner chain) rather than the frontier ratchet, and
the two Timeout/Immediate prototype slots move with them. The frontier means
ENUMERATED BUT SCANNED BY NOTHING; these are scanned by
object::scan_object_cache_roots_mut, so recording them as debt understated the
gate own coverage by ten holders.
…t a header-less static (#10821)

`js_unresolved_namespace_stub()` and ten dispatch catch-alls handed JS the
address of `NULL_OBJECT_BYTES` under `POINTER_TAG` -- a `.rodata` byte array
laid out like an `ObjectHeader`. 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 the stub
read whatever the linker placed before the static. In the v0.5.1631 binary
those eight bytes are `6e 74 73 5d 00 00 00 00` -- the tail of a string
literal, "nts]" -- so the stub reported `obj_type == 110`, a kind that does
not exist. That is observable from a compiled program today, through a value
any common-registry handle hands out:

    const c: any = crypto.createHash("sha256").constructor;  // the stub
    JSON.stringify(c)   // ""   -- an empty object answers "{}"
    String(c)           // TypeError: Cannot convert object to primitive value
                        //          -- an empty object answers "[object Object]"

and the answer is BUILD-dependent: a different literal before the static is a
different fake kind. This is the hazard the honest-tag invariant (a
`POINTER_TAG` value is always a dereferenceable GC cell) exists to forbid, and
`native_call_method.rs` already documents the same shape for a `Box`-allocated
`SymbolHeader`.

The stub is now an ordinary `GC_TYPE_OBJECT` with class id 0 and zero own keys
-- exactly what `{}` allocates -- so the header at `addr - 8` is real and the
object answers as the empty object it always claimed to be.

* ONE object per realm, as before: every stub was the same address, so every
  stub was `===` every other, and that is kept. Per realm rather than per
  process because a GC object belongs to the thread whose arena allocated it;
  the static was shared across threads, which a heap object must not be.
* Lazy, so a program that never reaches it pays nothing. All eleven sites
  return the stub immediately with no raw receiver live across the call, which
  is what makes allocating from inside the property-read funnels safe here.
  They now go through one funnel, `object::null_stub_value()`.
* Rooted from `object::scan_object_cache_roots_mut`, with a researched
  `covered_elsewhere` verdict in the root-holder manifest (the gate reddens
  with the entry removed).
* Class id 0, deliberately not a family id: the stub carries no native state,
  so it stays an ordinary object a worker can deep-copy like any `{}`.

Collapsed on the way: an `is_valid_obj_ptr(obj)` branch in
`js_native_call_method` whose two arms both returned the stub -- a test that
could not change the answer. Its premise was the static's address lying
outside the macOS heap window; a re-entrant `stub.raw().all(...)` now takes the
ordinary-object path, finds a zero-key shape and reaches the same catch-all.

Deleted: `NullObjectBytes`, `NULL_OBJECT_BYTES`, and `is_null_stub_address`,
whose only production caller was the receiver-repr ledger arm gate A removes.

Gate A: the `null_stub` arm of the receiver-repr fixture is inverted to
`assert_fixture_migrated`, and the rendered sink line now witnesses
`null_stub=0` in the `observed_old` section (it read 1). Gate B:
`the_stub_is_an_ordinary_object_with_a_real_header` -- outside the handle band,
a real `GcHeader` with `GC_TYPE_OBJECT`, class id 0, zero own keys.

This retires no probe from the receiver-kind cascade: nothing asked about the
stub by name. It removes one of the six header-less addresses that keep
`try_read_gc_header`'s caller-side screens (`is_plausible_heap_addr`,
`try_read_tracked_gc_header`) load-bearing.
…l band ids (#10821)

assert_fixture_migrated checked one thing about a migrated producer value: not
in the small-handle band. That is one of the TWO dishonest classes (plan 1.1).
The other is a pointer-tagged address with NO GcHeader -- the null stub, a
Box-allocated SymbolHeader, a SAB or external buffer backing -- and it is not in
the band, so for those families gate A could not fail. Measured: with the stub
sabotaged back to a header-less block, gate A stayed green while gate B went
red.

The gate now also requires try_read_tracked_gc_header(value).is_some(), which
proves allocator ownership instead of trusting addr - 8. Under the same
sabotage it reports: NullStub producer returned 0x39ead543390, which is not an
allocator-owned GC cell. Clean, it stays green for every migrated family (text,
timer, tui, null_stub: 99 passed).
…r proofs it depends on

`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. A probe on the `+` routing decision (expr/binary.rs
`both_numeric`) reports, on a fixture whose opt report says
`Ptr<Shape> 1 selected / 1 CONSUMED`:

    left=LocalGet(num=false canon=false)  right=PropertyGet(num=true canon=true)
    both_numeric=false => GUARDED

The slot is proven; the local is not. Writing the same read as `o.a * 1` makes
the accumulator's own fixpoint close (its write becomes a Binary), which is why
that spelling reaches INLINE_FADD and costs half — not because the multiply
normalises anything.

This moves the computation after the receiver proofs and threads them in. Two
parameters that were hardcoded empty 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.

Refs #10777
…issed

collect_numeric_by_construction_locals gained shape_members and
shape_numeric_fields (#10777). Four callers in
ptr_shape_group_numeric_tests.rs were not updated, so the crate failed to
compile as a test target while `cargo check --lib` stayed green -- the hole
CI's `warnings` gate (--all-targets) exists to close.

Empty sets at every updated site: that is exactly what the function computed
before the reorder, so the tests keep asserting what they asserted.
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: e9b3f19c-2268-4de1-ad37-44d620b98e89

📥 Commits

Reviewing files that changed from the base of the PR and between 0fa3915 and 11197ed.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (77)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10913-stdin-reader-restart-race.md
  • changelog.d/10915-honest-handle-tag-tui.md
  • changelog.d/10916-resizable-arraybuffer.md
  • changelog.d/10919-shape-identity-key-order.md
  • changelog.d/10921-add-tree-leaf-after-conversion.md
  • changelog.d/10923-stdout-write-bytes.md
  • changelog.d/10924-honest-handle-tag-null-stub.md
  • changelog.d/10929-nbc-after-ptr-shape-proofs.md
  • changelog.d/10930-l14-nbc-order-build-cache.md
  • changelog.d/10930-rustls-advisory.md
  • crates/perry-codegen/src/collectors/hir_facts.rs
  • crates/perry-codegen/src/collectors/number_by_construction.rs
  • crates/perry-codegen/src/collectors/ptr_shape.rs
  • crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs
  • crates/perry-codegen/src/collectors/ptr_shape_numeric.rs
  • crates/perry-codegen/src/expr/binary.rs
  • crates/perry-codegen/src/expr/dynamic_add_tree_tests.rs
  • crates/perry-codegen/src/lower_call/builtin.rs
  • crates/perry-codegen/src/runtime_decls/strings_part2.rs
  • crates/perry-runtime/src/buffer/dataview.rs
  • crates/perry-runtime/src/buffer/detach.rs
  • crates/perry-runtime/src/buffer/from.rs
  • crates/perry-runtime/src/buffer/header.rs
  • crates/perry-runtime/src/buffer/mod.rs
  • crates/perry-runtime/src/buffer/resizable.rs
  • crates/perry-runtime/src/buffer/resizable_tests.rs
  • crates/perry-runtime/src/buffer/view.rs
  • crates/perry-runtime/src/event_target.rs
  • crates/perry-runtime/src/hot_diag/receiver_repr.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/native_class_ids.rs
  • crates/perry-runtime/src/object/buffer_dispatch.rs
  • crates/perry-runtime/src/object/class_registry/construct.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs
  • crates/perry-runtime/src/object/field_get_set/ic_miss.rs
  • crates/perry-runtime/src/object/global_this.rs
  • crates/perry-runtime/src/object/global_this/proto_methods.rs
  • crates/perry-runtime/src/object/global_this/typed_array.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/native_call_method.rs
  • crates/perry-runtime/src/object/null_stub.rs
  • crates/perry-runtime/src/os.rs
  • crates/perry-runtime/src/os_process_stream_write.rs
  • crates/perry-runtime/src/os_process_stream_write_tests.rs
  • crates/perry-runtime/src/os_process_streams.rs
  • crates/perry-runtime/src/text.rs
  • crates/perry-runtime/src/thread.rs
  • crates/perry-runtime/src/timer.rs
  • crates/perry-runtime/src/timer/handle_object.rs
  • crates/perry-runtime/src/timer/tests_inline.rs
  • crates/perry-runtime/src/tui/ffi.rs
  • crates/perry-runtime/src/tui/handle_object.rs
  • crates/perry-runtime/src/tui/hooks.rs
  • crates/perry-runtime/src/tui/mod.rs
  • crates/perry-runtime/src/tui/run.rs
  • crates/perry-runtime/src/tui/state.rs
  • crates/perry-runtime/src/tui/tree.rs
  • crates/perry-runtime/src/typedarray/slice_ops.rs
  • crates/perry-runtime/src/typedarray_view.rs
  • crates/perry-runtime/src/url/abort.rs
  • crates/perry/src/commands/compile/build_cache.rs
  • crates/perry/src/commands/compile/object_cache.rs
  • crates/perry/tests/issue_10895_stdin_pipe_stall.rs
  • crates/perry/tests/issue_10903_stdout_write_bytes.rs
  • crates/perry/tests/null_stub_is_an_object.rs
  • crates/perry/tests/tui_handle_identity.rs
  • docs/src/internals/explicit-memory.md
  • scripts/gc_runtime_root_holders.json
  • test-compat/test262/features-applicable.txt
  • test-files/test_gap_10873_resizable_arraybuffer.ts
  • test-files/test_gap_10903_stdout_write_bytes.ts
  • test-files/test_issue_10895_stdin_pipe_stall.ts
  • test-files/test_parity_region_guards.ts
  • test-files/test_parity_shape_identity_order.ts
 _______________________________________________________________________________________________________
< Make it easy to reuse. If it's easy to reuse, people will. Create an environment that supports reuse. >
 -------------------------------------------------------------------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Ralph Küpper and others added 3 commits September 21, 2026 23:27
The one unignored cargo-audit finding in the workspace: TLS 1.3 handshake
messages accepted across encryption level boundaries (medium, 5.3). A
single-crate lock bump, no cascade. 0.23.45 cleared the 7-day SOAK_DAYS window
today, which is what #10791 was waiting for.

Closes #10791
#10929 keyed the knob into the OBJECT cache but not the BUILD cache, so
codegen_env_vars_are_build_cache_inputs failed (#6394's rule). The two settings
emit different code -- on, 'h = h + o.a' is admitted and the '+' routes to
INLINE_FADD; off, the shape inputs are empty and it stays guarded -- so it is a
cache input, not an exclusion.
Lane 13's matrix flagged `read4__this__cctor/cfield` moving +29.2% on the
amended head against +12.7% on the first one. That extra cost is the second
entry being gated, not something the `this` receiver lost: a class method's
`this.a + this.b + this.c + this.e` over `number`-annotated fields is
statically numeric, so it reaches the fold through the declared-number entry,
which the first head did not check.

Measured on the same counterexample, both arms built in place at their own
commit: `d1f83034f` prints 10, the amended head prints 107, node prints 107.
Those cells were fast because they were wrong.

`this` is the commonest receiver in class code, so it gets its own fixture
case rather than riding on G's element reads.
@proggeramlug
proggeramlug marked this pull request as ready for review September 22, 2026 04:47
@proggeramlug
proggeramlug merged commit a022cf2 into main Sep 22, 2026
54 of 61 checks passed
@proggeramlug
proggeramlug deleted the train254r branch September 22, 2026 04:47
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
The three gap-suite reds on this PR — `test_gap_http2_settings`,
`test_gap_3527_http_ctor_prototype`, `test_gap_gc_net_once_flags_rekey` — are
not compile failures. They are `PERRY_COMPILE_TIMEOUT` expiring. In the CI log
each one is exactly 300.1 s wide:

    02:37:46.96 -> 02:42:47.08   test_gap_3527_http_ctor_prototype   300.12 s
    02:45:24.62 -> 02:50:24.77   test_gap_gc_net_once_flags_rekey    300.15 s
    02:47:57    (shard 2)        test_gap_http2_settings             300.1  s

All three compile and pass by hand on this branch (perrymaster, cold
`target/perry-auto-*`): 3m43s, and byte-parity against the node 26.5.1 oracle.

WHY THE BUDGET IS TOO SMALL

#10757 sized one 300 s budget for every fixture, on the stated belief that
"the fast-mode/PERRY_SKIP_BUILD tiers don't pay [an auto-optimize rebuild] per
test". They do, and the reason is four hundred lines further down the same
file: the #7629 block unsets PERRY_NO_AUTO_OPTIMIZE for every fixture that
routes a module to a `perry-ext-*` wrapper, because no single prebuilt stdlib
can serve the mixed corpus. perry then runs `cargo build` for a
feature-stripped runtime + stdlib + wrapper INSIDE the per-test compile
budget, once per distinct feature set, into a fresh `target/perry-auto-<hash>`.
The gap-suite workflow comment already records that cost as ~200 s per
distinct feature set; with runner variance it measures 270-300 s. So the
rebuild sits ON the line:

    #10930  test_gap_gc_net_once_flags_rekey   287.4 s   PASS  (12.6 s margin)
    #10930  test_gap_http2_settings            300.13 s  COMPILE_FAIL
    #10930  test_gap_3527_http_ctor_prototype  300.13 s  COMPILE_FAIL

That is merge train 254 — v0.5.1634, now on main. The same red, on a rotating
cast, is in every recent run: 11 fixtures on #10918, 5 on #10892, 4 on #10930.
All three merged. A gate whose red is overridden by hand every time is not a
gate, and this one cannot even tell a killed compile from a rejected one.

THE FIX

Split the budget by the property that predicts the cost — this compile may
rebuild the toolchain — and not by test name. `PERRY_EXT_COMPILE_TIMEOUT`
(default 900 s, 3x the observed cost) applies when auto-optimize is on for
that compile AND the fixture routes to an ext wrapper, reusing the existing
`test_routes_to_ext_wrapper` predicate. The ordinary 300 s budget is
untouched, so a genuine hang in a plain compile is still bounded at 300 s, and
the shard's 110-minute cap has room (shards run 17-46 min).

A killed compile now says so — `compile TIMEOUT after <N>s — killed, not
rejected` — in the console line and at the head of the persisted
`*.compile_error.log`. The old `(compile error)` with no message is what made
this take a night to find: the fixture compiled fine by hand, and the cause
was only visible by subtracting two timestamps out of a CI log.

WITNESS

Four arms on perrymaster, `test_gap_http2_settings`, PERRY_SKIP_BUILD=1 (the
CI path), `target/perry-auto-*` and the perry object cache wiped before each
cold arm:

  A  unpatched, cold, PERRY_COMPILE_TIMEOUT=120
     -> FAIL (compile error)                  killed at 2:03
  B  patched,   cold, PERRY_COMPILE_TIMEOUT=120
     -> PASS                                  4:02
  C  patched,   cold, PERRY_COMPILE_TIMEOUT=900 PERRY_EXT_COMPILE_TIMEOUT=60
     -> FAIL (compile TIMEOUT after 60s — killed, not rejected)
  D  patched,   test_gap_symbols (not ext-routed), PERRY_COMPILE_TIMEOUT=1
     -> FAIL (compile TIMEOUT after 1s — killed, not rejected)

A vs B is the same fixture, same cold state, same ordinary budget: the patch
is the only difference. C shows the ext budget is the one governing an
ext-routed fixture. D shows the ordinary budget still bites, so this is not a
blanket raise.

The three fixtures then pass through the harness on this branch:
test_gap_http2_settings, test_gap_3527_http_ctor_prototype and
test_gap_gc_net_once_flags_rekey — 1/1 parity pass each, 0 compile fail.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
The previous commit gave a compile that may rebuild the toolchain its own
budget, scoped to fixtures that route a module to a `perry-ext-*` wrapper.
CI then showed there is a second way in — and showed it in one line, which is
the whole point of the label that commit added:

    FAIL  test_gap_6558_webassembly_graceful_fail
          (compile TIMEOUT after 300s — killed, not rejected)

No ext-routed import, auto-optimize off, so it kept the ordinary 300 s budget.
It still builds native artifacts inside the compile:

      wasm-host: building perry-wasm-host from workspace source
      wasm-host (no-auto): rebuilding runtime with wasm-host feature

Cold, that compile measures 395 s here — over the line by a third, and under
the old `(compile error)` wording it would have read as a broken fixture.
It is in #10918's list of the same red for the same reason.

So the predicate gets the second property, symmetric with the first and still
a property of the test rather than its name. Across the four runs sampled
(#10859, #10918, #10892, #10930) the two together cover the entire observed
population: 13 of 14 distinct fixtures route to an ext wrapper, and the 14th
is this one. `PERRY_EXT_COMPILE_TIMEOUT` is renamed
`PERRY_TOOLCHAIN_COMPILE_TIMEOUT` — it was never only about ext wrappers, and
it has not shipped under the old name.

WITNESS — three more arms, same harness, on `test_gap_6558_webassembly_graceful_fail`
unless stated:

  E  PERRY_COMPILE_TIMEOUT=1  PERRY_TOOLCHAIN_COMPILE_TIMEOUT=900
     -> PASS                         the ordinary budget is not in force
  F  PERRY_COMPILE_TIMEOUT=900 PERRY_TOOLCHAIN_COMPILE_TIMEOUT=1
     -> FAIL (compile TIMEOUT after 1s — killed, not rejected)
                                     the toolchain budget is
  G  test_gap_symbols (neither ext-routed nor wasm), PERRY_COMPILE_TIMEOUT=1
     -> FAIL (compile TIMEOUT after 1s)
                                     the ordinary budget still bites

E and F are the same fixture and the same harness with the two budgets
swapped, so they isolate which one governs; G is the control that this is not
a blanket raise.

Run 35692315543 on the previous commit: gap shards 1, 2, 3, 4 and 5 green —
including 2 and 3, which carried all three of this PR's original reds — and 6
red on this one fixture alone.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
The three gap-suite reds on this PR — `test_gap_http2_settings`,
`test_gap_3527_http_ctor_prototype`, `test_gap_gc_net_once_flags_rekey` — are
not compile failures. They are `PERRY_COMPILE_TIMEOUT` expiring. In the CI log
each one is exactly 300.1 s wide:

    02:37:46.96 -> 02:42:47.08   test_gap_3527_http_ctor_prototype   300.12 s
    02:45:24.62 -> 02:50:24.77   test_gap_gc_net_once_flags_rekey    300.15 s
    02:47:57    (shard 2)        test_gap_http2_settings             300.1  s

All three compile and pass by hand on this branch (perrymaster, cold
`target/perry-auto-*`): 3m43s, and byte-parity against the node 26.5.1 oracle.

WHY THE BUDGET IS TOO SMALL

#10757 sized one 300 s budget for every fixture, on the stated belief that
"the fast-mode/PERRY_SKIP_BUILD tiers don't pay [an auto-optimize rebuild] per
test". They do, and the reason is four hundred lines further down the same
file: the #7629 block unsets PERRY_NO_AUTO_OPTIMIZE for every fixture that
routes a module to a `perry-ext-*` wrapper, because no single prebuilt stdlib
can serve the mixed corpus. perry then runs `cargo build` for a
feature-stripped runtime + stdlib + wrapper INSIDE the per-test compile
budget, once per distinct feature set, into a fresh `target/perry-auto-<hash>`.
The gap-suite workflow comment already records that cost as ~200 s per
distinct feature set; with runner variance it measures 270-300 s. So the
rebuild sits ON the line:

    #10930  test_gap_gc_net_once_flags_rekey   287.4 s   PASS  (12.6 s margin)
    #10930  test_gap_http2_settings            300.13 s  COMPILE_FAIL
    #10930  test_gap_3527_http_ctor_prototype  300.13 s  COMPILE_FAIL

That is merge train 254 — v0.5.1634, now on main. The same red, on a rotating
cast, is in every recent run: 11 fixtures on #10918, 5 on #10892, 4 on #10930.
All three merged. A gate whose red is overridden by hand every time is not a
gate, and this one cannot even tell a killed compile from a rejected one.

THE FIX

Split the budget by the property that predicts the cost — this compile may
rebuild the toolchain — and not by test name. `PERRY_EXT_COMPILE_TIMEOUT`
(default 900 s, 3x the observed cost) applies when auto-optimize is on for
that compile AND the fixture routes to an ext wrapper, reusing the existing
`test_routes_to_ext_wrapper` predicate. The ordinary 300 s budget is
untouched, so a genuine hang in a plain compile is still bounded at 300 s, and
the shard's 110-minute cap has room (shards run 17-46 min).

A killed compile now says so — `compile TIMEOUT after <N>s — killed, not
rejected` — in the console line and at the head of the persisted
`*.compile_error.log`. The old `(compile error)` with no message is what made
this take a night to find: the fixture compiled fine by hand, and the cause
was only visible by subtracting two timestamps out of a CI log.

WITNESS

Four arms on perrymaster, `test_gap_http2_settings`, PERRY_SKIP_BUILD=1 (the
CI path), `target/perry-auto-*` and the perry object cache wiped before each
cold arm:

  A  unpatched, cold, PERRY_COMPILE_TIMEOUT=120
     -> FAIL (compile error)                  killed at 2:03
  B  patched,   cold, PERRY_COMPILE_TIMEOUT=120
     -> PASS                                  4:02
  C  patched,   cold, PERRY_COMPILE_TIMEOUT=900 PERRY_EXT_COMPILE_TIMEOUT=60
     -> FAIL (compile TIMEOUT after 60s — killed, not rejected)
  D  patched,   test_gap_symbols (not ext-routed), PERRY_COMPILE_TIMEOUT=1
     -> FAIL (compile TIMEOUT after 1s — killed, not rejected)

A vs B is the same fixture, same cold state, same ordinary budget: the patch
is the only difference. C shows the ext budget is the one governing an
ext-routed fixture. D shows the ordinary budget still bites, so this is not a
blanket raise.

The three fixtures then pass through the harness on this branch:
test_gap_http2_settings, test_gap_3527_http_ctor_prototype and
test_gap_gc_net_once_flags_rekey — 1/1 parity pass each, 0 compile fail.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
The previous commit gave a compile that may rebuild the toolchain its own
budget, scoped to fixtures that route a module to a `perry-ext-*` wrapper.
CI then showed there is a second way in — and showed it in one line, which is
the whole point of the label that commit added:

    FAIL  test_gap_6558_webassembly_graceful_fail
          (compile TIMEOUT after 300s — killed, not rejected)

No ext-routed import, auto-optimize off, so it kept the ordinary 300 s budget.
It still builds native artifacts inside the compile:

      wasm-host: building perry-wasm-host from workspace source
      wasm-host (no-auto): rebuilding runtime with wasm-host feature

Cold, that compile measures 395 s here — over the line by a third, and under
the old `(compile error)` wording it would have read as a broken fixture.
It is in #10918's list of the same red for the same reason.

So the predicate gets the second property, symmetric with the first and still
a property of the test rather than its name. Across the four runs sampled
(#10859, #10918, #10892, #10930) the two together cover the entire observed
population: 13 of 14 distinct fixtures route to an ext wrapper, and the 14th
is this one. `PERRY_EXT_COMPILE_TIMEOUT` is renamed
`PERRY_TOOLCHAIN_COMPILE_TIMEOUT` — it was never only about ext wrappers, and
it has not shipped under the old name.

WITNESS — three more arms, same harness, on `test_gap_6558_webassembly_graceful_fail`
unless stated:

  E  PERRY_COMPILE_TIMEOUT=1  PERRY_TOOLCHAIN_COMPILE_TIMEOUT=900
     -> PASS                         the ordinary budget is not in force
  F  PERRY_COMPILE_TIMEOUT=900 PERRY_TOOLCHAIN_COMPILE_TIMEOUT=1
     -> FAIL (compile TIMEOUT after 1s — killed, not rejected)
                                     the toolchain budget is
  G  test_gap_symbols (neither ext-routed nor wasm), PERRY_COMPILE_TIMEOUT=1
     -> FAIL (compile TIMEOUT after 1s)
                                     the ordinary budget still bites

E and F are the same fixture and the same harness with the two budgets
swapped, so they isolate which one governs; G is the control that this is not
a blanket raise.

Run 35692315543 on the previous commit: gap shards 1, 2, 3, 4 and 5 green —
including 2 and 3, which carried all three of this PR's original reds — and 6
red on this one fixture alone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment