Skip to content

Merge train 214: poll stride (removes the probe hack), dynamic-call ceilings, generator yields, timer ref state, BigInt bitwise (v0.5.1592) - #10559

Merged
proggeramlug merged 11 commits into
mainfrom
train214r
Sep 17, 2026
Merged

proggeramlug merged 11 commits into
mainfrom
train214r

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

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.

#10494 also removes a debug hack from main

main was carrying this at perex_runtime.rs:339, in place of the poll()? call:

// PROBE ONLY (#10166 poll experiment) — NEVER MERGE. Prices the
// pre-search safepoint poll by removing it. Unsafe by construction: ...

It reached main inside an unrelated guard commit — an uncommitted probe edit that git checkout -B carried onto a branch and git add -A swept 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: no PROBE ONLY or NEVER MERGE remains anywhere under crates/.

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.

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_10104 and test_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_timer is listed verbatim in run_parity_tests.sh's SKIP_TESTS on main (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_builds is main's long-known one, and copy_slot_decode::sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check cannot pass under --release by construction: its observable is the restore_surviving_dirty_coverage cross-check, gated behind #[cfg(debug_assertions)] at copying.rs:1033. Confirmed under [profile.gcaudit] (release codegen with debug-assertions = true) on the previous train: 4 passed, 0 failed.

Held out of this train

#10530test_gap_cron_cronjob goes pass -> parity_fail, and it is not in gap_snapshot.json, so it is expected to pass. That is a test_gap_* fixture inside CI's actual gate scope, so it needs an explanation before it can ride.

#10539 — its cargo-test failure is commands::run::entry::tests::local_runtime_discovery_uses_install_layouts, in a file the PR does not touch (it is confined to fs/* and util_syserr.rs). perry bin tests are known to mutate global PATH, 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.

Ralph Küpper and others added 11 commits September 17, 2026 15:33
…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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant