Merge train 214: poll stride (removes the probe hack), dynamic-call ceilings, generator yields, timer ref state, BigInt bitwise (v0.5.1592) - #10559
Merged
Conversation
…10166) The poll before each search costs 502 of the 4,792 instructions a hoisted `.test()` call takes, and measurement says it buys very little. It cannot cancel. `host::poll` returns `Ok(())` unconditionally, and `EngineError::Cancelled` has no producer anywhere in perry-runtime outside tests, where a test supplies its own cancelling closure to prove the engine's paths clean up. It performs no cycle stepping in practice either. With the poll removed entirely, `cycle_starts`, `completions` and `steps` were IDENTICAL across 48,000,000 allocation-free `.test()` calls interleaved with allocation churn — 5,286 steps on both arms. Every step comes from an allocation-site assist; the What it does retain is the one thing no witness could rule out: the option of servicing a due collection from a loop that allocates nothing, which is how a non-allocating mutator participates in an incremental cycle. Three witness designs failed to construct a program where that mattered, but "could not construct" is not "cannot happen". So the poll is strided rather than removed, keeping a participation point every 64 searches. The stride is a fixed constant, not an environment knob, so it does not owe the GC knob policy an OFF-state CI arm. Measured on perrymaster, both arms from one commit: hoisted .test() 4,792 -> 4,3xx instructions per call regex-replace-callback unchanged at n=700,000, all four GC counters within n=700,000 noise, checksum 203210458 on both arms
…alls
Calling a function VALUE with more than 16 arguments was a hard codegen
error (`closure call with 18 args (max 16)`) at three sites, and dynamic
calls silently dropped arguments past several fixed widths:
- `__perry_wrap_*` function-value wrappers took at most 16 params, so
apply/call/spread/object-literal-method calls of an 18-param function
passed 0 for params 17 and 18 (class-method wrappers stopped at 32);
- `dispatch_with_arity` (> 32 declared params) and `dispatch_rest_bundled`
(>= 16 fixed params, including `arguments` users) returned `undefined`
without calling the body;
- `Reflect.apply` dispatched every list of 4+ arguments through
`js_closure_call4`;
- setTimeout/setInterval/process.nextTick clamped trailing args to 9.
Closure-value call sites now share `emit_closure_handle_call`: up to 16
args keep `js_closure_call{N}`, wider calls marshal a stack buffer into
`js_closure_call_array`. Wrappers take every declared param. Bodies wider
than the exact runtime arms are called through a padded ladder of widths
(64/256/1024 slots, undefined-filled) in `closure::wide_call`, and the
wide `js_closure_call_array` arm resolves its route with one memoized
strategy probe. qs 6.15.3 compiles from source.
A `yield` in a while/do-while condition or a for condition/update was only split into resume states when the loop was a direct statement of the generator body. `body_contains_yield` looked at loop bodies but never loop headers, so an enclosing `if`/`try`/`switch`/label/loop whose only yield sat in a nested loop header was emitted inline and the residual yield never suspended: `[...g()]` was empty and sent-value loops never terminated (lru-cache 11.5.2's minified `*#A`/`*#z` iterators). - body_contains_yield also checks loop conditions and for updates. - A for-init's own top-level yield (`for (let t = yield x; ...)`, `for (yield x; ...)`, and an async function's `for (let x = await p; ...)` after the await-to-yield rewrite) is hoisted ahead of the loop. - A yielding for update left in place by the header arm (a `continue` inside try/finally) is linearized in the loop's update state. - In an async generator, a header yield's operand is awaited like every other yield operand.
The bounded id->ref-state registry (#6084) evicted the oldest ids by insertion order whether or not they were still scheduled, so 65,536 later timers undid a live timer's unref() (the missing id read as ref'd and kept the process alive until the timer fired) and dropped the id from is_known_timer_id (.hasRef()/.ref()/.unref()/.constructor stopped dispatching). The kind table had the same cap with an O(n) min() scan per insert once full. Merge both tables into one registry keyed by id. A scheduled id is pinned by a ScheduledTimerId token stored in its queue entry; dropping the entry on any path (fire, clear, agent purge, mock reset) retires the id, and only retired ids are eviction candidates, so the map stays bounded by live timers + 65,536.
`&` `|` `^` `<<` `>>` compute a BigInt from two BigInt operands, but the compiler assumed every bitwise result is an int32 Number: - HIR typed `a & b` over unknown operands `Number` (lower_types.rs, value_types.rs), so `stable_local_type_proof` vouched for it. - The integer-local proofs (integer_locals.rs judge, int_valued_ta_locals.rs) admitted every bitwise write, so `const x = a & b` took an int32 slot and `ToInt32`'d the BigInt result to `0`; a `bigint`-typed binding reached a call as `fptosi` of its box. - `Number(a & b)` elided `js_number_coerce` for any bitwise operand (bigint_set.rs), returning the BigInt unchanged. Each site now requires an operand that provably is not a BigInt. The not-BigInt fixpoint is exposed as `NotBigIntFacts` and computed ahead of the integer-local proofs. With the int32 slot no longer covering unproven operands, those operators take the existing guarded numeric diamond (tag test, inline `ToInt32 <op> ToInt32`, BigInt-aware helper on the cold arm).
This was referenced Sep 17, 2026
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.
This train lands #10494, #10532, #10537, #10538 and #10540 as v0.5.1592, on
193889dd93. Ten source commits, each verified to preserve its patch-id and authorship.#10166) — the pre-search safepoint poll runs on one search in 64 instead of every one: −438 instructions per call, about −9.1% on a hoisted.test(). The poll cannot cancel (nothing in production constructsEngineError::Cancelled) and does no stepping in practice — identicalcycle_starts,completionsandstepsacross 48M allocation-free calls — but it is strided rather than removed, because it keeps the one thing measurement could not rule out: the option of servicing a due collection from a loop that allocates nothing.#10420) — removes the argument-count ceilings on dynamic calls; more than 8 trailing args used to clamp to 9.#10447) — never evicts a scheduled timer's ref state.#10494 also removes a debug hack from
mainmainwas carrying this atperex_runtime.rs:339, in place of thepoll()?call:It reached
maininside an unrelated guard commit — an uncommitted probe edit thatgit checkout -Bcarried onto a branch andgit add -Aswept in. Nothing caught it: the removal is functionally invisible (the poll cannot cancel and does no stepping, so every test and gate stays green), and the hunk is commented, which makes it read as deliberate to a reviewer. A comment saying NEVER MERGE is not a gate — and a commented hack survives review better than an uncommented one.This PR replaces that hunk with
poll_on_stride(poll)?, so the hack is removed and the poll restored in the same change, at the stride that was actually reviewed. Verified directly: noPROBE ONLYorNEVER MERGEremains anywhere undercrates/.Worth recording about #10494's own test, because it is a good pattern: the expected poll count is a literal, not
SEARCHES / PRE_SEARCH_POLL_STRIDE. The derived form was self-consistent at every stride including 1, so it passed unchanged with the feature disabled — it asserted the arithmetic, not the behaviour. Both sabotage directions are run rather than reasoned: forcing stride 1 fails the pinned stride, and removing the call fails the count.Validation
Validated head
16f4bf4417. Five-package release build pinned and hash-verified, and re-verified after the gap run.TIMER_HANDLE_KINDSentry while perf(regex): run the pre-search safepoint poll on one search in 64 (#10166) #10494 adds two, so the meaningful check is that the gate passes on the merged tree — 407 classified — not on either branch alone.timer,regex,generator,yield,bigint,bitwise,dynamic. Five of seven clean.Reds attributed
Two fixtures A/B'd against
main's own artifact set, both identical on both arms (node=1, main=0, train=0) with distinct build stamps per arm:test_dynamic_import_data_10104andtest_jwt_sign_dynamic_alg. That mattered here rather than being a formality — #10532 changes dynamic-call dispatch, so a verdict measured on binaries predating it would not transfer.test_timeris listed verbatim inrun_parity_tests.sh'sSKIP_TESTSonmain(crypto.randomUUID()differs).Two runtime failures, both release-profile artifacts rather than regressions.
heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_buildsismain's long-known one, andcopy_slot_decode::sabotaged_remembering_arm_is_refused_by_the_coverage_cross_checkcannot pass under--releaseby construction: its observable is therestore_surviving_dirty_coveragecross-check, gated behind#[cfg(debug_assertions)]atcopying.rs:1033. Confirmed under[profile.gcaudit](release codegen withdebug-assertions = true) on the previous train: 4 passed, 0 failed.Held out of this train
#10530 —
test_gap_cron_cronjobgoespass -> parity_fail, and it is not ingap_snapshot.json, so it is expected to pass. That is atest_gap_*fixture inside CI's actual gate scope, so it needs an explanation before it can ride.#10539 — its
cargo-testfailure iscommands::run::entry::tests::local_runtime_discovery_uses_install_layouts, in a file the PR does not touch (it is confined tofs/*andutil_syserr.rs).perrybin tests are known to mutate globalPATH, so this looks like interference rather than the PR's own defect — but that wants confirming, not assuming.Before merging, the pushed head and unchanged main are checked again. After merging, the rewritten commits are checked for preserved authorship and the main tree must match the validated train exactly.