release: #10859 (worker-thread module graph + toolchain compile budget) as v0.5.1635 - #10971
Merged
Merged
Conversation
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.
|
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 (45)
✨ 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 |
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.
Lands #10859 on its own as v0.5.1635 — its head
0e27a6fbb1plus a release commit (version bump,Cargo.lock, theCLAUDE.mdversion 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), fortest_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
PASS1_MARKEDhold is released. The re-pin is on the branch, carrying the written audit — both hunks are init-time injs_gc_init, unreachable from mark/sweep.gc_runtime_root_holders.pyis rc=0 on this tree.0e27a6fbb1is green except the structural baseline: all sixgap-suiteshards,gap-suite-build,cargo-test,check,warnings,gc-stressand its matrix pass.lintfails on exactly one step,Public benchmark evidence freshness— the known-red public baseline — and nothing else.main, 0 commits behind, merges cleanly. The release commit is mechanical;cargo fmt --check, the file-size cap,gc_runtime_root_holdersandaddr_class_inventoryare all clean on the release tree, andCargo.lockcarries 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