Skip to content

release: #10859 (worker-thread module graph + toolchain compile budget) as v0.5.1635 - #10971

Merged
proggeramlug merged 27 commits into
mainfrom
release-10859
Sep 22, 2026
Merged

proggeramlug merged 27 commits into
mainfrom
release-10859

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Lands #10859 on its own as v0.5.1635 — its head 0e27a6fbb1 plus a release commit (version bump, Cargo.lock, the CLAUDE.md version line). No other change.

It goes alone rather than in train 255 because it fixes the thing turning train 255's CI red: an ext-routed gap fixture pays an auto-optimize rebuild of runtime+stdlib inside the 300 s PERRY_COMPILE_TIMEOUT, and a train is the most runtime-touching tree there is. #10859 adds a second budget, PERRY_TOOLCHAIN_COMPILE_TIMEOUT (900 s), for test_routes_to_ext_wrapper / test_builds_wasm_host, and deliberately does not raise the 300 s default — #10757's own gap test depends on it to detect the HIR-lowering hang it was written for.

Why it is landable now

  • Maintainer decision on the PR (2026-09-22): the PASS1_MARKED hold is released. The re-pin is on the branch, carrying the written audit — both hunks are init-time in js_gc_init, unreachable from mark/sweep. gc_runtime_root_holders.py is rc=0 on this tree.
  • CI on 0e27a6fbb1 is green except the structural baseline: all six gap-suite shards, gap-suite-build, cargo-test, check, warnings, gc-stress and its matrix pass. lint fails on exactly one step, Public benchmark evidence freshness — the known-red public baseline — and nothing else.
  • Based on current main, 0 commits behind, merges cleanly. The release commit is mechanical; cargo fmt --check, the file-size cap, gc_runtime_root_holders and addr_class_inventory are all clean on the release tree, and Cargo.lock carries 62 × 0.5.1635, 0 × 0.5.1634.

It carries seven changelog fragments of its own (#10356, #10835, #10854, #10859, #10893 ×2, #10911).

Also known, and posted on the PR: main's poisonable guard-shape twin global says "same linkage", and after this lands that is false — a performance cliff on Worker programs, not a miscompile, and not to be fixed by making the guard thread-local.

Closes #10399

Ralph Kuepper and others added 27 commits September 22, 2026 04:56
A `worker_threads` worker never ran module init, so it aliased the
spawning thread's heap.

`entry.rs` emits the module-init once-guard as an ordinary process-wide
global:

    @__perry_init_done_<mod> = internal global i8 0

A worker reaching `<mod>__init` therefore finds the 1 the MAIN thread
stored, skips the body, and reads module-global slots that point into
the main thread's thread-local arena. `classify_heap_generation` returns
`Unknown` there, so the object reads back with no keys at all:
`Object.keys(obj)` is `[]`, `obj.a` is `undefined`, and nothing throws.

Node and bun evaluate the module graph once per worker. An 8-line
program shows the divergence directly — bun prints `MOD INIT ran` twice,
perry once.

A second shape of the same defect: `dyn_extern_i18n.rs` spawned the
worker on `<mod>__init_body`, the UNGUARDED body, deliberately bypassing
the once-guard so at least the worker's own entry would run. But the
guarded `__init` wrapper is what calls the dependency inits, so every
module the worker entry imports stayed uninitialized — a module
reachable only from the worker never ran on any thread and its bindings
stayed `undefined`.

Fix: when the program constructs a Worker, emit the module-init guard
and every global module init writes as thread-local, and spawn the
worker on the guarded wrapper so it initializes its dependency graph.

The two halves must travel together. A per-thread guard with
process-wide slots would be worse than the bug: a worker re-running init
would overwrite the main thread's bindings with pointers into the
worker's own arena.

Thread-local now, gated on `program_has_worker()`:
- `__perry_init_done_*` (entry.rs)
- module-global value slots and static class fields (module_globals_emit.rs)
- `perry_class_keys_*`, `perry_class_shape_id_*` and the `#8122` header
  image (codegen/mod.rs) — the same globals #10399 Paths 2 and 3 were
  patching up at runtime
- string-pool handle globals (string_pool.rs), populated by each
  module's init
- namespace object globals (artifacts.rs)

The flag is whole-program, computed by the driver before any module
codegen, and folded into the object-cache key: the thread-local form
changes the IR of every module, so an object cached from a worker-free
build must not be served to a build that has one.

A program with no Worker keeps the process-wide globals and pays no TLS
cost, so the single-threaded path is unchanged.

Claude-Session: https://claude.ai/code/session_017utYgB6CH497RthE5Y3fTe
#10399)

`external_decl_for_global` strips the linkage keyword and then matches
`global `/`constant ` to rebuild a declaration. LLVM puts the TLS
specifier between the two, so `internal thread_local global i8 0` fell
through every arm and returned `None` — and `split_units` turns that
into `panic!("cannot form external declaration for generated global")`.

A declaration that merely dropped the specifier would be worse than the
panic: `@g = external global i8` and `@g = external thread_local global
i8` are different symbols to LLVM.

Adds `split_thread_local` and carries the specifier into the emitted
declaration. `promote_global_for_units` and `make_unique_owner_global`
already preserved it (they keep the post-linkage text intact); the new
tests pin all three.

Claude-Session: https://claude.ai/code/session_017utYgB6CH497RthE5Y3fTe
…rom it (#10399)

Three prettier plugins stopped compiling with:

    ld: perry_class_keys_flow_mjs____AnonShape_<hash>:
        TLS definition in perry_cgu_0_2.o section .tbss
        mismatches non-TLS reference in perry_cgu_0_7.o

`split_units` builds `decl_by_name` from `self.declarations` and then, per
its own comment, means to "replace any entry that is also defined locally
with a declaration synthesized from that definition". Only functions got
that treatment. A GLOBAL this module defines can also sit in
`self.declarations` as an `external` line — import metadata declares a
class-keys / ShapeId / module-value slot before the defining pass runs —
and the stale entry then wins in every unit that does not define it.

That was harmless while every global was non-TLS. Once the definition is
`thread_local` the two disagree, and the TLS specifier is part of the
symbol's identity, so `ld -r` refuses the unit.

Synthesizes global declarations from their definitions, as the function
arm already does. Also routes the cross-MODULE declarations of
module-state globals (imported object producers, `#8772` ShapeId slots,
imported static class fields, namespace objects) through a new
`add_external_module_state_global`, so the final link agrees too.

Claude-Session: https://claude.ai/code/session_017utYgB6CH497RthE5Y3fTe
…ead_local (#10399)

freeze_unit pushes the COMPLETE external declaration table into every
codegen unit, and its dedup set is built by parsing `declare`/`define`
lines — so it covers functions only and a global declaration is never
deduped against the unit that defines it.

A declaration that omits `thread_local` for a global the module defines
thread-local therefore lands in the defining unit and every other one,
and `ld -r` rejects the object:

    ld: perry_class_keys_flow_mjs____AnonShape_<hash>:
        TLS definition in unit 2 section .tbss
        mismatches non-TLS reference in unit 7

Rewrites the table to agree with the definitions before it is handed to
the units. The earlier decl_by_name fix covered the TEXT split_units
path; this is the native in-process LLVM path, which is what real
modules actually take.

Claude-Session: https://claude.ai/code/session_017utYgB6CH497RthE5Y3fTe
…#10399)

The real cause of the three prettier plugins failing to link. Dumped with
PERRY_SAVE_LL, unit 7 of flow.mjs contains:

    @perry_literal_flow_mjs__init_body_14781_shapes = constant
      [1 x { i32, i32, ptr, ptr, ptr, i32, ptr, i32 }]
      [{ ... ptr @perry_class_keys_flow_mjs____AnonShape_<hash>,
              ptr @perry_class_shape_id_flow_mjs____AnonShape_<hash>, ... }]

a link-time `constant` holding the ADDRESS of the per-class keys and
ShapeId globals. With a Worker in the program those globals are
thread-local, and the address of a thread-local is not a link-time
constant, so `ld -r` rejects the object:

    ld: perry_class_keys_...: TLS definition in unit2.o section .tbss
        mismatches non-TLS reference in unit7.o

Every declaration was already correct (`external thread_local global`);
the table was the non-TLS reference. Worker-bearing programs now fall
back to ordinary literal evaluation. Programs with no Worker keep the
fast path untouched.

Claude-Session: https://claude.ai/code/session_017utYgB6CH497RthE5Y3fTe
The split_units dump never fired: real modules take the native
in-process LLVM path, and PERRY_SAVE_LL already dumps those units.
It also did not compile (LlFunction has no render()), which silently
kept a stale perry binary in place across several verification runs.

Claude-Session: https://claude.ai/code/session_017utYgB6CH497RthE5Y3fTe
…`declarations` (#10399)

`add_external_global` pushes its line into `self.globals`, where the
split-unit path gives every symbol one owning unit and hands the rest an
`external` declaration. `add_external_module_state_global` pushed the
thread-local form into `self.declarations` instead — a different
collection, outside that bookkeeping — and `freeze_unit` copies the whole
declaration table into EVERY unit, on top of whatever the globals path
already emitted:

    error: redefinition of global
      '@perry_class_shape_id_..._ri'
    @perry_class_shape_id_..._ri = external thread_local global i32

79 such errors across 26 modules, every one a symbol kind this helper
touches: perry_class_shape_id_* (31), perry_global_* (12),
perry_static_* (12), __perry_ns_* (5).

Same collection as before, just with the TLS keyword, so all existing
owner/dedup logic applies unchanged.

Claude-Session: https://claude.ai/code/session_017utYgB6CH497RthE5Y3fTe
…ic TLS (#10399)

glibc carves a thread's static TLS block out of the same mapping as its
stack. Once module state is per-thread, OpenCode's binary carries 5.79 MB
of PT_TLS (up from 263 KB), so against tokio's 2 MB default the blocking
threads had almost no usable stack left and SIGSEGV'd deep inside
reqwest's connector on first use:

    Thread 2 "tokio-rt-worker" received signal SIGSEGV
    #0 reqwest::connect::ConnectorService::call
    #8 perry_ext_fetch::do_fetch
    #9 perry_ffi::async_runtime::spawn_blocking_with_reactor::invoke

The main thread, whose TLS is allocated separately, was unaffected —
which is why only commands that touch the network died while --version
and --help passed.

Proven by A/B on the built binary: `opencode models` dumps core at the
default stack and prints the model list under RUST_MIN_STACK=16MB. The
full CLI ladder goes 4/10 -> 8/10, which matches the pre-change binary
measured with the same ladder, so this is not a regression.

Reserves 32 MB for the blocking pool and for worker_threads workers,
overridable with PERRY_THREAD_STACK_SIZE. A stack is reserved address
space committed lazily, so the reservation costs no RSS.

Claude-Session: https://claude.ai/code/session_017utYgB6CH497RthE5Y3fTe
…rapper (#10399)

There are two places that hand a thread entry to
js_worker_threads_worker_new: dyn_extern_i18n.rs for a single resolved
path, and worker_new.rs for the multi-path specifier form. Only the
first was switched to the guarded `<target>__init`; OpenCode's TUI takes
the second, so its worker still entered the bare `__init_body` and
initialized none of its imports.

Traced with gdb on the built binary: `heap_ts__init` fires on thread 1
only, `tui_worker_ts__init` never fires at all, and thread 2 throws from
`heap_ts.start` called out of `tui_worker_ts.init_body`.

The symptom is worth recording because it is not obvious: heap.ts's
string-pool handles are module state, so on a thread that never ran its
init they are empty, and `Flag.OPENCODE_AUTO_HEAP_SNAPSHOT` became a
property read whose NAME was the empty string —

    TypeError: Cannot read properties of undefined (reading '')

Claude-Session: https://claude.ai/code/session_017utYgB6CH497RthE5Y3fTe
…10399)

The TUI segfaulted 2.0 s in, deterministically (6/6 runs). The core dump
names it exactly: si_addr equal to rsp, faulting on instruction +27 of
`ensure_stdin_reader`'s closure — a guard-page hit on the thread's very
first frame, i.e. the thread was created with no usable stack.

glibc carves a thread's static TLS block out of the same mapping as its
stack, and per-thread module state makes that block large: OpenCode's
binary carries 5.79 MB of PT_TLS against 263 KB before. Sizing the tokio
blocking pool and the worker_threads workers (earlier commit) missed
every other thread the runtime starts — the stdin reader, the signal
wake thread, the event pump, and so on.

`std::thread` reads RUST_MIN_STACK once and caches it, and every
`std::thread::spawn` honors it, so setting a 32 MB floor in `js_gc_init`
— documented as the first runtime call of every `main`, before any
thread exists — covers all of them without touching each spawn site. An
explicit RUST_MIN_STACK from the environment still wins.

A recursive `quicksort::<usize>` in another thread's backtrace looked
like the culprit and was not; the faulting-address check settled it.

Claude-Session: https://claude.ai/code/session_017utYgB6CH497RthE5Y3fTe
The stack-floor helper was inserted between the attribute and its
function, so #[no_mangle] bound to the private helper and js_gc_init got
a mangled symbol — 'undefined reference to js_gc_init' at link.

Claude-Session: https://claude.ai/code/session_017utYgB6CH497RthE5Y3fTe
)

`perry-ext-fetch` handed JS bare `f64` registry ids and registered neither
handle-dispatch extension, while `perry-ext-net`, `-ws`, `-http` and
`-mysql2` all register. Both this crate and `perry-stdlib` `#[no_mangle]`
the `js_headers_*` family, and the sets differ — stdlib exports 16, this
crate 12, and the four it does not export include
`js_headers_method_value`, which is what a dynamic call routes through.
So one Headers value lived in this crate's registry while dynamic
dispatch read stdlib's.

A bare id is a JS *number*, so the access never reached the handle tower
at all:

    tui bootstrap failed { error: '(number).delete is not a function' }

which is where OpenCode's TUI stops once #10399 is fixed.

Two halves, useful only together:
- NaN-box (`POINTER_TAG`) every handle this crate hands out —
  `js_headers_new`, `js_request_new`, `js_request_get_headers`,
  `js_response_get_headers`, `js_response_clone`. `handle_id` already
  decodes both the boxed and legacy bare form, so existing entry points
  keep working on either.
- Register method and property dispatch extensions so the tower answers
  from THIS crate's registries.

Boxing alone is worse than the bug: the throw disappears and every
dynamic Headers op silently misreads stdlib's registry, which is header
loss in an HTTP client instead of a visible error.

Layers 3-4 of #10310 are left as annotated groundwork: they need a
runtime hook that does not exist yet. `js_register_handle_prototype_dispatch`
and `..._own_property_names_dispatch` have no `_extension` variant, so an
ext-owned Headers still reports kind 0 from stdlib's
`js_fetch_handle_kind` and `Object.entries(handle)` is empty.

Claude-Session: https://claude.ai/code/session_017utYgB6CH497RthE5Y3fTe
… too

#10356, second registration path. The implicit import-walk loop was not the
only way an un-named class gets registered under its bare name: the transitive
class closure pulls in whatever an imported class's FIELD and RETURN types
mention.

OpenCode's `OpencodeClient` carries `private _request?: Request` and
`get request(): Request` (gen/sdk.gen.ts:6396-6398), so `import { OpencodeClient }`
registered `Request` through this path and `new Request(url, init)` in the
importer still built the SDK's `class Request extends HeyApiClient`. That is why
the first fix passed every synthetic probe and still left the OpenCode TUI dead
on `next.headers.delete(...)`.

Parent refs stay exempt: `class Sub extends Request` genuinely needs its
parent's layout registered (#485 — too few inline slots otherwise), and parent
refs already resolve path-aware in the child's own module (#26/#321).
A `static get`/`static set` on a class that closes over its factory's
arguments read the capture from the class's DECLARATION-site slot rather than
the receiver it was invoked on. Those slots are keyed by class name, so the
last evaluation of the declaration wins and every earlier class the same
factory produced answered with the last one's captured values.

Static methods already resolved per-receiver; only accessors took the
decl-site path. Route them to the same `ClassCaptureValue` strategy: filter
the static accessors out of the instance-rewrite loops and emit a per-capture
prologue for them, mirroring the static-method block.

This is Effect's `Context.Service` shape, which is why OpenCode's services
collapsed onto one tag: `Auth.layer` answered {"for":"tag-Flags"} while
`Auth.key` -- a plain static field, a different path -- stayed correct. That
asymmetry is the tell, so the test pins both. Verified both ways: the test
passes with the fix and fails without it, reporting exactly that diff.
)

After a worker's module body ran, the thread parked in a blocking receive and
called the JS handler straight from there, then parked again. Nothing drained
the microtask queue, so an `async onmessage` handler never got past its first
`await` and the reply was never posted -- silently: no rejection, no
exception, no exit. Later messages did not drain the pending continuations
either.

That is the ordinary shape for a request/response worker protocol, and it is
what left the OpenCode TUI painting nothing: `Rpc.listen` awaits
`rpc[method](input)` before `postMessage`, so every request was received and
none answered, its `Sync` provider never left "loading", no provider below it
mounted, and no frame was drawn -- while `render()` still resolved.

Drain microtasks/nextTicks after each delivered message and before parking.

Deliberately NOT the `AllowTimers` pump: `timer.rs` keeps the timer queues in
global mutexes rather than thread-locals, so that drain runs the MAIN thread's
timer callbacks on the worker thread against the worker's globals, and a later
main-thread timer then dies with "value is not a function" nondeterministically.
The microtask/nextTick queues are `perry_thread_local!`, so draining those is
confined to the worker.

The receive stays blocking when nothing is pending, so an idle worker costs
what it did before. When something is pending the wait is bounded and floored
at 5ms, because the global timer queues mean a TUI's own 60fps render timers
would otherwise wake every worker ~1000x/s for the whole process lifetime.

Known remaining gap on the issue: `await` of a timer inside a worker handler
still does not resume -- the timer is run by whichever thread owns the loop,
resolving a promise owned by the worker's thread-local queue.
A `worker_threads` Worker gets its own arena and GC but never claimed an
agent, so `current_agent()` fell back to `PRIMARY_AGENT` -- and `agent.rs`
defines a thread with no agent of its own as a pump acting for the primary
heap. The owner tag on TIMER_QUEUE/CALLBACK_TIMERS/INTERVAL_TIMERS therefore
could not tell a worker's timers from the main thread's, in either direction:
the main thread fired timer closures living in the worker's arena, and an
owner-filtered tick on the worker fired the main thread's. That is what made
an AllowTimers drain here corrupt the main thread nondeterministically.

The `perry/thread` workers in thread.rs have always claimed an agent; the Web
Worker path was simply missing it. Claim it before anything can allocate or
enqueue, and retire it at exit so entries naming this arena are purged.

With the worker distinguishable, its pump runs its OWN timers through the
owner-filtered tick, which closes the remaining gap: `await` of a timer inside
a worker handler now resumes (13ms, was never), alongside the microtask and
async-call cases the drain already covered.

Measured end to end on OpenCode v1.18.30: its worker RPC now answers, the
server boots and serves requests, and the TUI reaches bootstrap instead of
hanging forever on six unanswered SDK calls.
`c.g(1)` where `g` is an instance getter returning a function threw
"g is not a function", while `const f = c.g; f(1)` returned that same
function. The dispatch tower in `js_native_call_method` probes vtable
methods, own fields and the prototype chain for a callable VALUE but never
RUNS an accessor, so an accessor-exposed callable fell through every arm --
the runtime's own diagnostic said so: "call-method (no method/field/proto
match)".

Add an accessor arm at the END of the tower: read the property through the
ordinary by-name get, which runs the getter, and call the result with the
receiver bound as `this`. Last position keeps a real method of the same name
winning, and a getter yielding a non-callable still throws as before.

Found while bringing up OpenCode (#10107): Effect's schema classes reach
their constructor through accessors of this shape.
Rebasing #10399 onto main pushed two files past `scripts/check_file_size.sh`:
`codegen/entry.rs` 1997 -> 2008 (main grew it to 1997 independently) and
`module.rs` 1843 -> 2004.

Pure file moves, no behaviour change:

- `module.rs`'s inline `#[cfg(test)] mod tests` block -> `module/tests.rs`
  (`super` still resolves to `module`, so the test bodies are unchanged).
- `entry.rs`'s two self-contained helpers, `emit_plugin_abi_shim` and
  `collect_entry_env_literals`, -> `codegen/entry/shims.rs`, re-imported by
  `entry.rs`. They are `pub(super)` there and the one relative path inside
  (`super::entry_outline::logical_entry_stmts`) is now absolute.

`compile_module_entry` itself is untouched: it is a single ~1690-line
function and decomposing it is separate surgery.
…rom (#10911)

`Sub.accessor` ran with `this` === the DECLARING class. The static-side walk
in `js_object_get_field_by_name` re-enters ITSELF with the parent class object
as the receiver -- written when effect's `ast` was a static DATA field, where
the object does not matter. effect now makes `ast` a static GETTER, and spec
OrdinaryGet threads the original Receiver through unchanged.

Use the device the runtime already had: `accessor_receiver_override`, which
`resolve_proto_chain_field_inner` uses so an inherited INSTANCE getter binds
the original instance. The static walk now stashes the class the read started
from and the class-body accessor path takes it.

`this` and the capture/private OWNER are kept separate. `this` is where the
read started; the owner is the evaluation the getter was FOUND on, whose
`__perry_ctor_caps` hold its captures. `js_class_capture_value_for_receiver`
prefers the owner, so binding it to the subclass loses every capture -- a
first cut did exactly that. The test pins both halves.

Downstream this is #10891: effect's
`static get ast() { return getClassSchema(this).ast }` memoised against the
base class, so `Schema.decodeUnknownSync` built decoded values from the base
and they were not `instanceof` their own class.

Also narrows the #10893 accessor arm, which was mine and too broad: it did an
ordinary by-name read and called whatever came back, resurrecting members the
tower had refused (`delete C.prototype.m; obj.m()` stopped throwing). It now
requires a declared accessor on the receiver's class chain and skips deleted
keys. Verified by A/B against the commit before #10893, which is how the
regression was attributed rather than guessed.
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.
`cargo fmt --all -- --check` is red on this branch and has been since the
#10911 commit: three hunks in `get_field_by_name.rs` and
`native_call_method.rs` that rustfmt rewraps. The branch is otherwise
formatted, so this is a pure reformat with no behaviour change.

This is also the reason to look past the `lint` job being "systemically red":
the public-baseline step cannot pass on any PR (Cargo.toml is in
`public_baseline.SOURCE_PATHS` and every merge train bumps the workspace
version), but two of lint's three failing steps on this branch were real and
ours.
… gc/mod.rs

`scripts/gc_runtime_root_holders.py --self-test` is red on this branch:

    crates/perry-runtime/src/gc/census.rs:PASS1_MARKED:
      non_moving_snapshot source changed: crates/perry-runtime/src/gc/mod.rs;
      re-audit the window before updating its pin

The `non_moving_snapshot` verdict pins a SHA-256 of every file that could move
the mark-complete -> sweep-entry window in which `PASS1_MARKED` holds untraced
GC header addresses. #10399's thread-stack-floor fix edits one of those files,
so the pin went stale by design: the gate is asking for an audit, not for a
new hash.

The audit. `gc/mod.rs` gains two hunks, both init-time:

  * a new free function `raise_default_thread_stack_floor()`, which reads
    RUST_MIN_STACK and, only when it is unset, sets it to 32 MiB; and
  * one call to it near the top of `js_gc_init`.

It touches no heap object, allocates no GC object, relocates nothing and runs
no JS callback. `js_gc_init` is the first runtime call of a compiled `main`,
so it runs once before any cycle exists, and it is not reachable from
`step_mark_propagation` or `step_sweep`. This is the same shape as the
2026-09-11 startup-memory-profile re-audit already recorded in the entry,
which cleared the pre-main allocator-policy constructor in this same function.
Neither census boundary moved and the window is unchanged.

The verdict is written into the entry's `why` — the gate's contract is that a
pin is only ever bumped alongside the reasoning that justifies it — and the
`gc/mod.rs` digest is updated. Nothing else in the inventory changes:

    gc_runtime_root_holders self-test: OK (90 planted declarations classified,
      423 inventory entries checked)
    gc_runtime_root_holders: OK — 1485 holder declarations scanned, 646
      reached by a registered scanner, 423 classified in the inventory
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
proggeramlug merged commit c1569e2 into main Sep 22, 2026
22 of 23 checks passed
@proggeramlug
proggeramlug deleted the release-10859 branch September 22, 2026 12:12
@coderabbitai

coderabbitai Bot commented Sep 22, 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: b0edc717-e0b7-4d49-be2a-4b75cdd895d5

📥 Commits

Reviewing files that changed from the base of the PR and between a022cf2 and 441212d.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (45)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10356-closure-walk-global-names.md
  • changelog.d/10835-static-accessor-captures.md
  • changelog.d/10854-worker-async-onmessage.md
  • changelog.d/10859-gap-suite-toolchain-rebuild-timeout.md
  • changelog.d/10893-accessor-arm-narrowed.md
  • changelog.d/10893-instance-getter-call.md
  • changelog.d/10911-static-getter-receiver.md
  • crates/perry-codegen/src/codegen/artifacts.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/entry/shims.rs
  • crates/perry-codegen/src/codegen/helpers.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/codegen/module_globals_emit.rs
  • crates/perry-codegen/src/codegen/string_pool.rs
  • crates/perry-codegen/src/expr/dyn_extern_i18n.rs
  • crates/perry-codegen/src/expr/literal_descriptor.rs
  • crates/perry-codegen/src/expr/worker_new.rs
  • crates/perry-codegen/src/lib.rs
  • crates/perry-codegen/src/module.rs
  • crates/perry-codegen/src/module/linkage.rs
  • crates/perry-codegen/src/module/tests.rs
  • crates/perry-codegen/src/native_emit.rs
  • crates/perry-ext-fetch/src/dispatch.rs
  • crates/perry-ext-fetch/src/lib.rs
  • crates/perry-ext-fetch/src/request_fields.rs
  • crates/perry-hir/src/lower_decl/class_captures.rs
  • crates/perry-hir/src/lower_decl/class_decl.rs
  • crates/perry-hir/src/lower_decl/class_decl/from_ast.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/object/class_registry/parent_static.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs
  • crates/perry-runtime/src/object/native_call_method.rs
  • crates/perry-stdlib/src/common/async_bridge.rs
  • crates/perry-stdlib/src/worker_threads.rs
  • crates/perry/src/commands/compile/object_cache.rs
  • crates/perry/src/commands/compile/run_pipeline.rs
  • crates/perry/tests/issue_10356_closure_walk_shadows_global.rs
  • crates/perry/tests/issue_10835_static_accessor_captures.rs
  • crates/perry/tests/issue_10854_worker_async_onmessage.rs
  • crates/perry/tests/issue_10893_instance_getter_call.rs
  • crates/perry/tests/issue_10911_static_getter_receiver.rs
  • run_parity_tests.sh
  • scripts/gc_runtime_root_holders.json
 __________________________________________
< Duck and cover! I'm reviewing your code. >
 ------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant