merge train 254: eight PRs, v0.5.1634 - #10930
Merged
Merged
Conversation
…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.
|
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 configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (77)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
#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.
This was referenced Sep 22, 2026
Closed
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
CONFLICTINGagainstmain, so they carry zero CI runs and cannot join a train until they are rebased; both are being rebased separately.Carried
process.stdinresume()racing the stopping fd-0 reader stranded piped stdin without a readerperry/tuihandles are ordinary objectsArrayBuffer:new ArrayBuffer(n, { maxByteLength }),resize, length-tracking views+tree must not read a leaf after a conversion that precedes itprocess.stdout/stderr.writeput the chunk's bytes on the fd — binary-safe, encoding-aware, EAGAIN-safePtr<Shape>receiver proofs it depends on#10924 is stacked on #10915; the train carries the stack, so both land.
Fixed in the train
tui/,object/null_stub.rs,native_class_ids.rsandhot_diag/receiver_repr.rsfailedcargo fmt --all -- --check, which is a requiredlintstep. Fixed in its own commit.changelog.d/fragment; feat(runtime): perry/tui handles are ordinary objects (#10821 row 3) #10915 and fix(runtime): the unresolved-namespace stub is an ordinary object, not a header-less static (#10821 row 4, fixes #10917) #10924 carried fragments that were not PR-keyed (honest-handle-tag-*.md). Written and renamed.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-targetsunder-D warnings,cargo auditwith the ignore list derived fromsecurity-audit.yml, the full 83-gaterun_lint_gates.sh, unit suites for the five touched crates, the integration suites derived from the diff,compiler_output_regressionfornative-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_mutandobject::null_stub::scan_null_stub_roots_mutare both reached fromobject::scan_object_cache_roots_mut, whichgc/mod.rsregisters withreg_scanner!. The chain thecovered_elsewhereverdicts 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_localsgainedshape_membersandshape_numeric_fields; four callers incollectors/ptr_shape_group_numeric_tests.rswere left at the old arity.cargo check --libcompiles nocfg(test)code, so that stays green —cargo check --workspace --all-targetsunder-D warnings(CI'swarningsgate) does not. Fixed with empty sets at each site, which is exactly what the function computed before the reorder.cargo auditwas 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, andsecurity-auditruns on every PR that touches a lockfile — which every train does, via the version bump.maincarries 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-daySOAK_DAYSwindow today.cargo update -p rustlslocks 0.23.45 with no cascade.Closes #10895
Closes #10873
Closes #10904
Closes #10903
Closes #10917
Closes #10791