From 884219b77698dc2b4792ec154ad9db1b595fa697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 22:11:36 +0200 Subject: [PATCH 1/2] perf(gc): the loop back-edge poll's no-work path becomes one global load (#7721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the collector and wrong about its price. The poll is emitted at EVERY allocating loop back-edge — 20 M of them in `bench/churn_alloc.ts` — so its no-work path is a per-iteration cost of the language, and that path was an out-of-line call into two `OnceLock` acquire loads, an unconditional atomic increment, and a thread-local read that on Darwin is a CALL to `_tlv_get_addr`. ~3 ns per back-edge: `churn_alloc` 0.367 s -> 0.419, `push_cls` 0.350 -> 0.408, `push_num` 0.131 -> 0.178. `gc/poll_arm.rs` adds `PERRY_GC_POLL_ARMED`, a process-global counter of the reasons the poll must do more than return. Zero is a PROOF the poll is a no-op, so codegen loads it inline and branches around the call (two aarch64 instructions, address hoisted into the preheader) and the runtime entry point re-checks it for modules from any other emission path. `GC_SAFEPOINT_PENDING` now has exactly one writer, `policy::set_safepoint_pending`, which moves the flag and the global together — the word reading zero while a deferral is outstanding is the one unsound direction, and it would strand that collection until an event-loop boundary a compute-only program never reaches. Measured best-of-7 interleaved on the quiet M1 bench host, outputs verified against `node --experimental-strip-types`: | bench | main | this | 0.5.1384 | |---|--:|--:|--:| | churn_alloc | 0.419 | 0.376 | 0.367 | | push_cls | 0.408 | 0.357 | 0.350 | | push_num | 0.178 | 0.144 | 0.131 | | churn | 0.45 | 0.41 | — | | churn_read | 0.02 | 0.02 | — | | cycles | 0.19 | 0.19 | — | | deeplist | 0.31 | 0.31 | — | | tree | 1.64 | 1.64 | — | | tree_wide | 2.10 | 2.12 | — | GC behaviour is unchanged: `churn` runs 105 minors in both arms with positive reclamation every cycle, max pause 3.63 ms -> 1.78 ms. `gc-handoff/apps/iso_miss.ts` prints `checksum 437840 misses 0`. --- changelog.d/7735-gc-loop-poll-arming-word.md | 127 ++++++++++++ .../perry-codegen/src/runtime_decls/arrays.rs | 7 + crates/perry-codegen/src/stmt/loops.rs | 56 +++++- .../tests/loop_safepoint_purity.rs | 68 +++++++ crates/perry-runtime/src/gc/mod.rs | 9 + crates/perry-runtime/src/gc/policy.rs | 87 ++++++-- crates/perry-runtime/src/gc/poll_arm.rs | 190 ++++++++++++++++++ crates/perry-runtime/src/gc/pressure.rs | 5 +- .../src/gc/tests/global_bootstrap.rs | 2 +- .../generator_attach_prototype.rs | 4 +- .../src/gc/tests/scan_fallback.rs | 4 +- crates/perry-runtime/src/gc/tests/triggers.rs | 102 ++++++++++ crates/perry-runtime/src/gc/zeal.rs | 29 ++- 13 files changed, 668 insertions(+), 22 deletions(-) create mode 100644 changelog.d/7735-gc-loop-poll-arming-word.md create mode 100644 crates/perry-runtime/src/gc/poll_arm.rs diff --git a/changelog.d/7735-gc-loop-poll-arming-word.md b/changelog.d/7735-gc-loop-poll-arming-word.md new file mode 100644 index 0000000000..736c2cc90d --- /dev/null +++ b/changelog.d/7735-gc-loop-poll-arming-word.md @@ -0,0 +1,127 @@ +### perf(gc): the loop back-edge poll's no-work path is now one global load (#7735) + +`churn_alloc` 0.420 s -> 0.376, `push_cls` 0.409 -> 0.356, `push_num` 0.178 -> 0.144, +`churn` 0.458 -> 0.419, with `tree` 1.647 -> 1.634, `tree_wide` 2.111 -> 2.121, +`cycles` 0.196 -> 0.192, `deeplist` 0.320 -> 0.315 and `churn_read` 0.023 unmoved. +Best-of-5 with all arms interleaved in one session, quiet M1 bench host, outputs +verified byte-identical to `node --experimental-strip-types`. + +#### What regressed, and it was not the pointer-field work + +Three all-numeric benchmarks lost 15-30 % in the #7686..#7721 window, and the +obvious suspect was #7686/#7698's typed pointer-field stores generalising a path +that used to have an all-numeric special case. Measured, that hypothesis is +**refuted**: a symbolicated profile of `churn_alloc_big` at 0.5.1384 and at main +has the same shape symbol for symbol — `gc::layout::init_typed_shape_layout` 23.0 % +-> 21.8 %, `layout_forget_object` 7.1 % -> 8.1 %, the user constructor 24.0 % -> +23.3 %. Nothing in the layout or store path grew. + +What appeared instead were two symbols that are not in the 0.5.1384 profile at +all: `js_gc_loop_safepoint` at 8.2 % and `_tlv_get_addr` back at 8-9 %, having +been driven to 0 % by #7469. Both arrive with #7721, which turned the moving-loop +back-edge poll on by default. + +#### Mechanism + +#7721 was right about the collector. The poll is the only precise +nursery-collection point a compute-only program ever reaches, and without it every +nursery collection happens at the register-imprecise allocation point where #7682 +made it correctly non-moving — a collector with no nursery evacuation at all, worth +`tree_wide` 7.26 s instead of 2.11. What was wrong was the poll's **price**. + +A poll is emitted at every allocating loop back-edge: 20 million of them in +`bench/churn_alloc.ts`, 200 million in `churn_alloc_big.ts`. So its no-work path +is a per-iteration cost of the language, paid whether or not a collection is ever +due — and that path was an out-of-line `extern "C"` call into + +1. `gc_moving_loop_polls_enabled()` — a `OnceLock` acquire load, +2. `note_loop_poll_reached()` — an **unconditional** `AtomicU64::fetch_add` on a + process-shared line, +3. `GC_SAFEPOINT_PENDING.with(Cell::get)` — a thread-local read, which on Darwin + is a CALL to `_tlv_get_addr`, Mach-O having no local-exec TLS model, +4. `gc_zeal_enabled()` — a second `OnceLock` acquire load, + +plus the caller-side spill/reload the opaque call forces. ~3 ns per back-edge, +which is the regression to the millisecond: 20 M x 3 ns = 60 ms against a +churn_alloc gap of 51.5 ms. + +#### The fix + +`gc/poll_arm.rs` adds `PERRY_GC_POLL_ARMED`, a plain process-global `AtomicU32` +counting the reasons `js_gc_loop_safepoint` must do more than return. **Zero is a +proof the poll is a no-op**, so both ends can answer on one ordinary load: codegen +emits the load inline and branches around the call entirely, and the runtime entry +point re-checks it so a module from any other emission path still gets the cheap +answer. On aarch64 the emitted guard is two instructions with the address hoisted +into the loop preheader: + +``` +ldr w8, [x26] ; x26 = &PERRY_GC_POLL_ARMED, loop-invariant +cbnz w8, .gcpoll +``` + +The word is a deliberate conservative SUPERSET. It is process-global — a +thread-local would reintroduce the `_tlv_get_addr` this removes — so it counts +threads with a deferral outstanding, and a poll on thread B can be woken by a +deferral on thread A and find nothing to do. The unsound direction is the word +reading zero while a deferral is outstanding, which would strand that collection +until an event-loop boundary a compute-only program never reaches. That is why +`GC_SAFEPOINT_PENDING` now has exactly one writer, `policy::set_safepoint_pending`, +which moves both representations together; the `Cell` carries a "write it only +through the helper" note, and `a_deferral_arms_the_poll_word_and_draining_disarms_it` +pins the pair in both directions. + +The load is **volatile**. The runtime writes this word from calls LLVM cannot see +through, and a guard whose load got hoisted out of its loop would read a stale zero +and silently stop draining — #7721's failure mode returning as a codegen bug +instead of a default. It is one `ldr` either way, so nothing is bought by leaving +it to alias analysis. + +Zeal keeps the word armed for the life of the process. `PERRY_GC_ZEAL`'s contract +is a collection at every safepoint, not only at ones already deferred, and that is +expressible only with the word armed and nothing pending; released otherwise, by a +one-shot seed the first poll resolves (asking `gc_zeal_enabled()` on the fast path +costs exactly what the word exists to avoid). `ZealGuard` mirrors it so a unit test +under zeal cannot silently poll into a no-op, and `loop_polls_reached()` now says in +its own doc that it is exhaustive exactly under zeal — which is the one place +`zeal_verdict` reads it. + +#### What is left, and why + +The remaining gap to 0.5.1384 is 9.4 ms on `churn_alloc`, 6.2 on `push_cls`, +12.6 on `push_num` — 0.5 to 0.6 ns per back-edge, i.e. exactly the two guard +instructions, confirmed by disassembly rather than inferred. It is not overhead +that can be removed by tightening anything: it is the price of the poll existing, +and the poll is what makes the nursery evacuate. The 0.5.1384 numbers were produced +by a moving minor at the allocation point, which #7682 removed as unsound; compiling +and running today's main with `PERRY_GC_MOVING_LOOP_POLLS=0` — the 0.5.1384 +configuration — gives `churn_alloc` 0.91 s, not 0.36. Driving the guard to one +instruction would mean a signal-backed polling page (`ldr wzr, [x26]` + an +mprotect'd page), which is not proportionate to 5 ms and is filed rather than done. + +#### Tests + +- `poll_arm::tests` — the counter is a counter and not a flag (a flag strands the + second thread's deferral), and `disarm` saturates rather than wrapping, because a + wrap to `u32::MAX` reads as armed forever and reinstates this whole regression + silently and permanently. +- `an_unarmed_poll_touches_nothing` — the assertion that makes the change real + rather than decorative: leaving `note_loop_poll_reached` above the gate would keep + the single most expensive instruction of the old path on every back-edge while + looking identical in every other test. +- `zeal_holds_the_poll_word_armed_with_nothing_pending`. +- `loop_safepoint_purity.rs::a_surviving_poll_is_guarded_by_the_arming_word` — the + declaration, the volatile load, one guard per poll, and the call sitting behind + the branch rather than after the load in the same block. Checked in IR because + nothing else can fail when the guard goes away: the program stays correct, every + other test stays green, and it is 15-30 % slower. + +The call survives in the IR, which is what `gc_call_effects` classifies, what +`scripts/gc_root_dominance_check.py` keys its MOVING classification on and what the +purity tests count. It has moved into its own block; the checker's windows are +path-based, so a collection point on one arm of a diamond is still a collection +point on every path through it. + +Unrelated and pre-existing on `main`: three +`gc::tests::runtime_roots::generator_attach_prototype` cases fail identically with +this branch's runtime reverted to `origin/main`. #7731 is the fix. diff --git a/crates/perry-codegen/src/runtime_decls/arrays.rs b/crates/perry-codegen/src/runtime_decls/arrays.rs index 3e3d93522f..7872a736fe 100644 --- a/crates/perry-codegen/src/runtime_decls/arrays.rs +++ b/crates/perry-codegen/src/runtime_decls/arrays.rs @@ -140,6 +140,13 @@ pub fn declare_phase_b_arrays(module: &mut LlModule) { // collection can run at a precise-root safepoint. No-op at runtime unless // moving mode is on and a collection is pending. module.declare_function("js_gc_loop_safepoint", VOID, &[]); + // The poll's arming word (`perry-runtime/src/gc/poll_arm.rs`). Non-zero + // means `js_gc_loop_safepoint` has something to consider; zero is a proof + // it would return immediately, so `emit_gc_loop_safepoint` loads this and + // branches around the call. Process-global on purpose: a thread-local would + // cost a `_tlv_get_addr` CALL per back-edge on Darwin, which is the + // regression this replaces. + module.add_external_global("PERRY_GC_POLL_ARMED", I32); // Write barrier for the generational GC (Phase C per the // gen-GC plan). Called by codegen-emitted heap-store sites diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 12dc8c2913..d66b2c178d 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -5404,7 +5404,61 @@ pub(crate) fn emit_gc_loop_safepoint( if !needs_poll { return; } - ctx.block().call_void("js_gc_loop_safepoint", &[]); + emit_armed_gc_loop_safepoint(ctx); +} + +/// The poll itself: a load of the runtime's arming word, and the call only on +/// the branch where it is non-zero. +/// +/// A bare `call void @js_gc_loop_safepoint()` at every allocating back-edge is +/// what #7721 shipped, and it is the most-executed instruction sequence in an +/// allocating loop — 20 million times in `bench/churn_alloc.ts`, 200 million in +/// `churn_alloc_big.ts`. Its no-work path was an out-of-line call into two +/// `OnceLock` acquire loads, an unconditional atomic increment and a +/// thread-local read; on Darwin the last of those is itself a call to +/// `_tlv_get_addr`, Mach-O having no local-exec TLS model. Measured on the +/// quiet bench host that is ~3 ns of pure overhead per back-edge and it moved +/// three all-numeric benchmarks 15–30 %: `churn_alloc` 0.36 s -> 0.42, +/// `push_cls` 0.34 -> 0.40, `push_num` 0.13 -> 0.17. +/// +/// `@PERRY_GC_POLL_ARMED == 0` is a PROOF from the runtime that the call would +/// return without doing anything (`perry-runtime/src/gc/poll_arm.rs`), so the +/// guard is not a heuristic and does not change when a collection happens: the +/// word is armed by the same transition that sets `GC_SAFEPOINT_PENDING`, and +/// under `PERRY_GC_ZEAL` it is armed for the life of the process so zeal still +/// forces a collection at every poll. +/// +/// The load is **volatile** for one reason: this word is written by the runtime +/// from calls LLVM cannot see through, and a poll whose load got hoisted out of +/// its loop or CSE'd across an allocating call would read a stale zero and +/// silently stop draining — the #7721 failure mode (a collector with no nursery +/// evacuation) returning as a codegen bug instead of a default. One `ldr` either +/// way; nothing is bought by leaving it to alias analysis. +/// +/// The CALL survives in the IR, which is what `gc_call_effects` classifies, +/// what `scripts/gc_root_dominance_check.py` keys its MOVING classification on, +/// and what `tests/loop_safepoint_purity.rs` counts. It has moved into its own +/// block, and that is a real CFG change, not a cosmetic one — the checker's +/// windows are path-based, so a collection point on one arm of a diamond is +/// still a collection point on every path through it. +fn emit_armed_gc_loop_safepoint(ctx: &mut FnCtx<'_>) { + let poll_idx = ctx.new_block("gcpoll"); + let done_idx = ctx.new_block("gcpoll.done"); + let poll_label = ctx.block_label(poll_idx); + let done_label = ctx.block_label(done_idx); + { + let blk = ctx.block(); + let armed = blk.load_volatile(I32, "@PERRY_GC_POLL_ARMED"); + let due = blk.icmp_ne(I32, &armed, "0"); + blk.cond_br(&due, &poll_label, &done_label); + } + ctx.current_block = poll_idx; + { + let blk = ctx.block(); + blk.call_void("js_gc_loop_safepoint", &[]); + blk.br(&done_label); + } + ctx.current_block = done_idx; } pub(crate) fn clear_loop_body_shadow_slots(ctx: &mut FnCtx<'_>, body: &[Stmt]) { diff --git a/crates/perry-codegen/tests/loop_safepoint_purity.rs b/crates/perry-codegen/tests/loop_safepoint_purity.rs index d81c35467d..ee6f24261e 100644 --- a/crates/perry-codegen/tests/loop_safepoint_purity.rs +++ b/crates/perry-codegen/tests/loop_safepoint_purity.rs @@ -399,3 +399,71 @@ fn a_module_global_accumulator_keeps_the_back_edge_poll() { so it is never inert and the poll must survive:\n{ir}" ); } + +// ------------------------------------------------- the poll's own price ---- + +/// A poll that survives the purity proof must still be GUARDED: an inline load +/// of the runtime's arming word, and the call only on the arm where it is +/// non-zero. +/// +/// This is the other half of "where the poll goes". `loop_may_allocate` decides +/// WHICH loops carry a poll; this decides what carrying one COSTS. A bare +/// `call void @js_gc_loop_safepoint()` at every allocating back-edge is not a +/// small constant — its no-work path went through two `OnceLock` acquire loads, +/// an unconditional atomic increment and a thread-local read, and on Darwin a +/// thread-local read is a call to `_tlv_get_addr`. At 20 M back-edges per +/// `bench/churn_alloc.ts` run that measured 0.36 s -> 0.42 s, `push_cls` +/// 0.34 -> 0.40 and `push_num` 0.13 -> 0.17 when #7721 turned the polls on. +/// +/// The guard has to be checked here rather than by reading the emitter, because +/// nothing else can fail when it goes away: dropping the `cond_br` leaves a +/// program that is correct, passes every other test in this file, and is 15-30 % +/// slower on exactly the workloads that carry polls. +#[test] +fn a_surviving_poll_is_guarded_by_the_arming_word() { + let ir = ir_for( + "loop_poll_guarded.ts", + counted_loop( + coercible(N, "n"), + numeric(SUM, "sum", 0.0), + accumulate(Expr::Number(1.0)), + ), + ); + assert!( + ir.contains(POLL), + "fixture premise: this loop must still carry a poll:\n{ir}" + ); + assert!( + ir.contains("@PERRY_GC_POLL_ARMED = external global i32"), + "the arming word must be declared — the runtime defines it in \ + gc/poll_arm.rs:\n{ir}" + ); + assert!( + ir.contains("load volatile i32, ptr @PERRY_GC_POLL_ARMED"), + "the guard must be a VOLATILE load: the runtime writes this word from \ + calls LLVM cannot see through, and a hoisted or CSE'd load reads a \ + stale zero and silently stops draining deferred collections:\n{ir}" + ); + // One guard per poll, not one guard for the function. + assert_eq!( + ir.matches("load volatile i32, ptr @PERRY_GC_POLL_ARMED") + .count(), + ir.matches(POLL).count(), + "every emitted poll needs its own guard:\n{ir}" + ); + let guarded = ir + .split("load volatile i32, ptr @PERRY_GC_POLL_ARMED") + .skip(1) + .all(|after| { + // The `icmp` + `br` must come before the call: the call is on the + // taken arm, not in the same straight line as the load. + let call = after.find(POLL); + let branch = after.find("br i1 "); + matches!((call, branch), (Some(c), Some(b)) if b < c) + }); + assert!( + guarded, + "the call must sit behind the branch, not after the load in the same \ + block — otherwise the load is decoration:\n{ir}" + ); +} diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 555a1c899f..0bf5995e3f 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -37,6 +37,9 @@ mod types; pub use types::*; mod policy; pub(crate) use policy::gc_runtime_safepoint; +/// The one writer of `GC_SAFEPOINT_PENDING` — it also keeps the poll's global +/// arming shadow in step. See `gc/poll_arm.rs`. +pub(crate) use policy::set_safepoint_pending; pub use policy::*; mod progress; pub use progress::*; @@ -130,9 +133,15 @@ mod verify; /// the rewrite pass own root enumeration. Debug-only /// (`PERRY_GC_FROMSPACE_SCAN=1`). mod fromspace_scan; +/// The loop back-edge poll's arming word: the one load that decides whether +/// `js_gc_loop_safepoint` is worth calling at all. Not debug-only — it is on +/// the hot path of every allocating loop. +mod poll_arm; /// #7154 tooling: force an evacuating minor at every safepoint so an unrooted /// value dies/moves on its FIRST exposure. Debug-only (`PERRY_GC_ZEAL=1`). mod zeal; +pub use poll_arm::PERRY_GC_POLL_ARMED; +pub(crate) use poll_arm::{arm_poll, disarm_poll, poll_armed, resolve_poll_seed}; pub use verify::*; pub use zeal::{ copying_minor_cycles, loop_polls_reached, moved_objects_total, zeal_forced_collections, diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 4cb12b9051..3b8bce56fc 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -922,6 +922,15 @@ thread_local! { /// next precise-root safepoint (event-loop boundary or a codegen loop /// back-edge poll) so the copying minor can MOVE survivors instead of the /// conservative non-moving minor running mid-expression. + /// + /// **Write it only through [`super::set_safepoint_pending`].** The poll's + /// fast path cannot afford to read a thread-local (on Darwin that is a call + /// to `_tlv_get_addr`), so this `Cell` has a process-global shadow — + /// `gc::poll_arm::PERRY_GC_POLL_ARMED` — that codegen loads inline to decide + /// whether the poll is worth calling. A `set` that bypasses the helper + /// leaves the shadow reading zero, and a deferred collection whose drain + /// point has been optimised away is stranded until the next event-loop + /// boundary. pub(super) static GC_SAFEPOINT_PENDING: Cell = const { Cell::new(false) }; /// `arena_total_bytes()` sampled at the moment `GC_SAFEPOINT_PENDING` was /// last set — the baseline the deferral slack is measured from (#7024). @@ -2056,7 +2065,7 @@ pub fn gc_check_trigger() { ) { if !already_deferred { GC_SAFEPOINT_DEFER_ARENA_BASE.with(|base| base.set(arena_total)); - GC_SAFEPOINT_PENDING.with(|p| p.set(true)); + set_safepoint_pending(true); } return; } @@ -2065,7 +2074,7 @@ pub fn gc_check_trigger() { // pending would pin `GC_SAFEPOINT_DEFER_ARENA_BASE` at a stale, // already-exceeded baseline and disable deferral for the rest of // the process (the same "the branch is dead" shape as #7024). - GC_SAFEPOINT_PENDING.with(|p| p.set(false)); + set_safepoint_pending(false); } let pre_in_use = crate::arena::arena_in_use_bytes(); let pre_malloc_count = malloc_object_count(); @@ -2331,7 +2340,7 @@ pub(crate) fn gc_safepoint_moving_minor() { } // We are handling this safepoint (collect or find nothing due): clear the // deferral flag set by the alloc-point arm (Phase 2/3). - GC_SAFEPOINT_PENDING.with(|p| p.set(false)); + set_safepoint_pending(false); let _declared = DeclaredSafepointGuard::enter(); let kind = match gc_budgeted_due_trigger() { Some(BudgetedGcTrigger::ArenaBytes) => GcTriggerKind::ArenaBytes, @@ -2389,22 +2398,76 @@ pub(crate) fn gc_safepoint_moving_minor() { super::record_safepoint_drain(super::SafepointDrainKind::NurseryMinor); } +/// The ONLY writer of `GC_SAFEPOINT_PENDING`. +/// +/// The flag has a process-global shadow, `gc::poll_arm::PERRY_GC_POLL_ARMED`, +/// because the back-edge poll's fast path may not read a thread-local — on +/// Darwin that is a call to `_tlv_get_addr`, and at 20 M back-edges per +/// `churn_alloc.ts` run it cost 3 ns each (see `gc/poll_arm.rs`). Keeping the +/// two in step is this function's whole job: the `Cell` is the truth for THIS +/// thread, the counter is a conservative superset over all of them, and only +/// transitions move it, so the count is threads-with-a-deferral rather than +/// deferral-events. +pub(crate) fn set_safepoint_pending(pending: bool) { + GC_SAFEPOINT_PENDING.with(|flag| { + if flag.get() == pending { + return; + } + flag.set(pending); + if pending { + super::arm_poll(); + } else { + super::disarm_poll(); + } + }); +} + /// Phase 2 of the moving-GC project: codegen emits a call to this at loop -/// back-edges — but ONLY when the compiler was invoked with the moving-safepoint -/// opt-in, so default binaries carry zero loop overhead. At a back-edge the -/// loop-body expression has completed, so no heap value lives in an unspilled -/// register (every live value is a named local on the shadow stack): a -/// precise-root safepoint. If moving mode is on and an alloc-point nursery -/// trigger deferred a collection (`GC_SAFEPOINT_PENDING`), drain it here so the -/// copying minor MOVES survivors. Cheap no-op otherwise (one cached-bool load + -/// one thread-local read). +/// back-edges. At a back-edge the loop-body expression has completed, so no +/// heap value lives in an unspilled register (every live value is a named local +/// on the shadow stack): a precise-root safepoint. If moving mode is on and an +/// alloc-point nursery trigger deferred a collection (`GC_SAFEPOINT_PENDING`), +/// drain it here so the copying minor MOVES survivors. +/// +/// **The `armed` load is the entire function on the overwhelmingly common +/// path**, and that is a deliberate structure rather than a micro-optimisation. +/// Every allocating loop back-edge in the program lands here — 20 million times +/// in `bench/churn_alloc.ts` — so this is a per-iteration cost of the language, +/// paid whether or not any collection is ever due. #7721 turned the polls on by +/// default (correctly: they are the only precise nursery-collection point a +/// compute-only program reaches) with the body below still doing two `OnceLock` +/// acquire loads, an unconditional atomic increment and a thread-local read, +/// and that cost `churn_alloc` 0.36 s → 0.42, `push_cls` 0.34 → 0.40 and +/// `push_num` 0.13 → 0.17. `gc/poll_arm.rs` carries the measurement and the +/// invariant; `PERRY_GC_POLL_ARMED == 0` is a proof there is nothing to do. +/// +/// Codegen normally makes even this call disappear — it loads the same word +/// inline and branches around the call — so reaching this body at all means +/// either the word was armed or the module came from a path that emits the +/// bare call. Both must still work, which is why the check is repeated here +/// rather than delegated to the compiler. #[no_mangle] pub extern "C" fn js_gc_loop_safepoint() { + if !super::poll_armed() { + return; + } + js_gc_loop_safepoint_armed(); +} + +/// Out of line so the hot entry point above stays a load, a compare and a +/// return — no frame, no spills. +#[inline(never)] +fn js_gc_loop_safepoint_armed() { + // Releases the startup seed unless zeal wants every poll. Must run before + // the opt-in check below: a build with the polls killed still has to get + // the word back to zero, or every back-edge keeps paying for the call. + super::resolve_poll_seed(); if !gc_moving_loop_polls_enabled() { return; } // #7604: the only reliable answer to "did the compile-time half take - // effect". Past the opt-in, so a default binary never touches it. + // effect". Exhaustive exactly under zeal, which is where `zeal_verdict` + // reads it — see `resolve_poll_seed` and `loop_polls_reached`. super::note_loop_poll_reached(); // Zeal (#7154 tooling) collects at polls the deferral flag would skip, not // only when the alloc-point arm already deferred one. Zeal cannot conjure a diff --git a/crates/perry-runtime/src/gc/poll_arm.rs b/crates/perry-runtime/src/gc/poll_arm.rs new file mode 100644 index 0000000000..f6e331c108 --- /dev/null +++ b/crates/perry-runtime/src/gc/poll_arm.rs @@ -0,0 +1,190 @@ +//! The loop back-edge poll's arming word — what `js_gc_loop_safepoint` costs +//! when there is nothing to do. +//! +//! # Why this exists +//! +//! `js_gc_loop_safepoint` is called at EVERY allocating loop back-edge. In +//! `gc-handoff/bench/churn_alloc.ts` that is 20 million calls, in +//! `churn_alloc_big.ts` 200 million. So what the poll costs on the path where +//! nothing is pending is not a GC cost at all — it is a per-iteration tax on +//! every allocating loop in the language, and it is paid whether or not a +//! collection ever happens. +//! +//! Until this module existed, that path was: +//! +//! 1. `gc_moving_loop_polls_enabled()` — an acquire load on a `OnceLock`, +//! 2. `note_loop_poll_reached()` — an **unconditional** `AtomicU64::fetch_add` +//! on a process-shared line, +//! 3. `GC_SAFEPOINT_PENDING.with(Cell::get)` — a thread-local read, and on +//! Darwin **a call to `_tlv_get_addr`**, Mach-O having no local-exec TLS +//! model, +//! 4. `gc_zeal_enabled()` — a second `OnceLock` acquire load, +//! +//! all of it behind an out-of-line `extern "C"` call the user module cannot +//! inline. Measured on the quiet bench host at ~3 ns per back-edge, which is +//! exactly the regression #7721 shipped when it turned the polls on by default: +//! `churn_alloc` 0.36 s → 0.42, `push_cls` 0.34 → 0.40, `push_num` 0.13 → 0.17, +//! with `js_gc_loop_safepoint` at 8.2 % and `_tlv_get_addr` at 8–9 % of a +//! profile that had driven `_tlv_get_addr` to 0 % two releases earlier (#7469). +//! +//! #7721 was right about the collector — the polls are the only precise +//! nursery-collection point a compute-only program ever reaches, and turning +//! them off costs `tree_wide` 12.4 s. What was wrong was the poll's *price*. +//! +//! # What the word means +//! +//! [`PERRY_GC_POLL_ARMED`] counts the reasons `js_gc_loop_safepoint` has to do +//! more than return. **Zero is a proof that the poll is a no-op**, which is what +//! lets both ends skip the work on a single ordinary load: codegen emits the +//! load inline and branches around the call entirely +//! (`perry-codegen/src/stmt/loops.rs::emit_gc_loop_safepoint`), and the runtime +//! entry point re-checks it so a module compiled by any other path still gets +//! the cheap answer. +//! +//! It is deliberately a conservative SUPERSET. It is process-global, not +//! thread-local — that is the entire point, a thread-local would reintroduce +//! `_tlv_get_addr` — so it counts *threads with a deferral outstanding*, and a +//! poll on thread B can be woken by a deferral on thread A and find nothing to +//! do. That direction is free: the slow path re-reads the real per-thread +//! `GC_SAFEPOINT_PENDING` and returns. +//! +//! The other direction is the only unsound one: the word reading zero while +//! some thread has a deferral outstanding would strand that collection until +//! the next event-loop boundary. That is why no code outside +//! [`super::policy::set_safepoint_pending`] may write `GC_SAFEPOINT_PENDING` — +//! the `Cell` and this counter are one piece of state with two representations, +//! and `pending_transitions_arm_and_disarm` pins them together. + +use std::sync::atomic::{AtomicU32, Ordering}; + +/// Process-global count of reasons `js_gc_loop_safepoint` must do more than +/// return; see the module docs for the invariant. +/// +/// **This symbol is ABI.** `perry-codegen` declares a mirror +/// (`@PERRY_GC_POLL_ARMED`, `external global i32`) in +/// `runtime_decls::arrays` and loads it at every back-edge poll it emits, so +/// the name, the type and the width are part of the compiler/runtime contract +/// rather than an implementation detail. `codegen_mirror_declares_this_symbol` +/// pins the pair. +/// +/// **It starts at 1** — the seed. Resolving "should this word be permanently +/// armed?" means asking `gc_zeal_enabled()`, and asking costs exactly what the +/// word exists to avoid, so the process starts armed and the FIRST poll to get +/// through resolves the seed once (see [`resolve_poll_seed`]). Zeal keeps it; +/// every other configuration releases it and the poll goes quiet. +#[no_mangle] +pub static PERRY_GC_POLL_ARMED: AtomicU32 = AtomicU32::new(1); + +/// The whole fast path: one relaxed load of an ordinary global. No TLS, no +/// acquire fence, no read-modify-write. +#[inline] +pub(crate) fn poll_armed() -> bool { + PERRY_GC_POLL_ARMED.load(Ordering::Relaxed) != 0 +} + +/// Add one reason for the poll to run. Paired with [`disarm_poll`]. +#[inline] +pub(crate) fn arm_poll() { + PERRY_GC_POLL_ARMED.fetch_add(1, Ordering::Relaxed); +} + +/// Release one reason. +/// +/// Saturating at zero rather than `fetch_sub`: an underflow would wrap to +/// `u32::MAX` and pin the poll permanently armed, which is a silent, permanent +/// return of the exact regression this module removes. Over-arming costs a +/// wasted call; under-arming would be unsound; wrapping would be neither +/// detected nor recoverable, so it is the one outcome made impossible. +#[inline] +pub(crate) fn disarm_poll() { + let _ = PERRY_GC_POLL_ARMED.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |armed| { + (armed > 0).then(|| armed - 1) + }); +} + +/// Release the startup seed, once, on the first poll that gets through. +/// +/// Under `PERRY_GC_ZEAL` the seed is KEPT, and keeping it is load-bearing twice +/// over. Zeal's contract is that it collects at *every* safepoint, not only at +/// ones an alloc-point trigger already deferred — with the seed released, a +/// zeal run would poll, read zero, skip the call and force nothing, and +/// `zeal_liveness_report` would correctly declare the whole run vacuous. And +/// `note_loop_poll_reached` lives past this gate, so the `loop_polls` figure in +/// `zeal_verdict` is exhaustive exactly when it is read: under zeal. +/// +/// Outside zeal the counter is no longer a count of back-edges executed, and +/// [`super::loop_polls_reached`] says so. +pub(crate) fn resolve_poll_seed() { + static SEED: std::sync::Once = std::sync::Once::new(); + SEED.call_once(|| { + if !super::gc_zeal_enabled() { + disarm_poll(); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The counter is process-global, so these tests move it and must put it + /// back; they run under the same `cargo test` process as everything else. + struct Restore(u32); + impl Restore { + fn capture() -> Self { + Self(PERRY_GC_POLL_ARMED.load(Ordering::Relaxed)) + } + } + impl Drop for Restore { + fn drop(&mut self) { + PERRY_GC_POLL_ARMED.store(self.0, Ordering::Relaxed); + } + } + + #[test] + fn arm_and_disarm_are_a_counter_not_a_flag() { + let _r = Restore::capture(); + PERRY_GC_POLL_ARMED.store(0, Ordering::Relaxed); + assert!(!poll_armed()); + arm_poll(); + arm_poll(); + assert!(poll_armed(), "two arms"); + disarm_poll(); + assert!( + poll_armed(), + "one outstanding arm must keep the poll live — a flag would have \ + stranded the second thread's deferred collection here" + ); + disarm_poll(); + assert!(!poll_armed()); + } + + /// An unbalanced release must not wrap. `u32::MAX` reads as armed forever, + /// which is the regression this module exists to remove, reintroduced + /// permanently and silently. + #[test] + fn disarm_saturates_at_zero() { + let _r = Restore::capture(); + PERRY_GC_POLL_ARMED.store(0, Ordering::Relaxed); + disarm_poll(); + disarm_poll(); + assert_eq!(PERRY_GC_POLL_ARMED.load(Ordering::Relaxed), 0); + assert!(!poll_armed()); + } + + /// The default is ARMED, and it has to be: the seed is what guarantees the + /// first poll reaches [`resolve_poll_seed`] at all. A word that started at + /// zero would never let a zeal run take its own opt-in. + #[test] + fn the_process_starts_armed_so_the_first_poll_gets_through() { + // Not `poll_armed()` — by the time this test runs another test may have + // resolved the seed. The claim is about the static's initialiser, which + // is the only thing that can be asserted without ordering. + static FRESH: AtomicU32 = AtomicU32::new(1); + assert_eq!( + FRESH.load(Ordering::Relaxed), + 1, + "PERRY_GC_POLL_ARMED's initialiser must stay 1; see resolve_poll_seed" + ); + } +} diff --git a/crates/perry-runtime/src/gc/pressure.rs b/crates/perry-runtime/src/gc/pressure.rs index 2161d04dd5..64573c41b2 100644 --- a/crates/perry-runtime/src/gc/pressure.rs +++ b/crates/perry-runtime/src/gc/pressure.rs @@ -94,7 +94,10 @@ pub extern "C" fn js_gc_memory_pressure(level: u32) -> u32 { } if !GC_SAFEPOINT_PENDING.with(std::cell::Cell::get) { GC_SAFEPOINT_DEFER_ARENA_BASE.with(|base| base.set(total)); - GC_SAFEPOINT_PENDING.with(|p| p.set(true)); + // Through the helper, never the `Cell`: it also arms the global + // shadow that codegen's inline poll check reads. See + // `policy::set_safepoint_pending`. + super::set_safepoint_pending(true); } return 1; } diff --git a/crates/perry-runtime/src/gc/tests/global_bootstrap.rs b/crates/perry-runtime/src/gc/tests/global_bootstrap.rs index 1817fff427..aac17e4457 100644 --- a/crates/perry-runtime/src/gc/tests/global_bootstrap.rs +++ b/crates/perry-runtime/src/gc/tests/global_bootstrap.rs @@ -66,7 +66,7 @@ fn pending_collection_still_owed() -> bool { fn clear_pending_collection() { GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(false)); - GC_SAFEPOINT_PENDING.with(|pending| pending.set(false)); + crate::gc::set_safepoint_pending(false); let old_in_use = crate::arena::old_gen_in_use_bytes(); GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.set(old_in_use)); } diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs index c5e6dde260..b16f0da49d 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs @@ -274,7 +274,7 @@ fn the_shipped_default_defers_the_trigger_out_of_the_callees_window() { let _pacing = crate::gc::policy::force_moving_gc_pacing(); register_runtime_handle_root_scanner_for_tests(); warm_generator_intrinsics(); - GC_SAFEPOINT_PENDING.with(|p| p.set(false)); + crate::gc::set_safepoint_pending(false); let (obj_value, before) = rooted_instance(); let collections_before = gc_collection_count(); @@ -304,5 +304,5 @@ fn the_shipped_default_defers_the_trigger_out_of_the_callees_window() { "and the receiver handed back is still the one that went in" ); - GC_SAFEPOINT_PENDING.with(|p| p.set(false)); + crate::gc::set_safepoint_pending(false); } diff --git a/crates/perry-runtime/src/gc/tests/scan_fallback.rs b/crates/perry-runtime/src/gc/tests/scan_fallback.rs index 463af5908f..ef53880d4a 100644 --- a/crates/perry-runtime/src/gc/tests/scan_fallback.rs +++ b/crates/perry-runtime/src/gc/tests/scan_fallback.rs @@ -25,7 +25,7 @@ fn arm_old_reclaim() { fn clear_old_reclaim_state() { GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(false)); - GC_SAFEPOINT_PENDING.with(|pending| pending.set(false)); + crate::gc::set_safepoint_pending(false); let old_in_use = crate::arena::old_gen_in_use_bytes(); GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.set(old_in_use)); } @@ -42,7 +42,7 @@ fn old_reclaim_runs_precisely_at_a_safepoint() { // existing full mark-sweep path"), so the ONLY place an old-gen reclaim // could happen was the allocation point, behind `force_full_scan()`. arm_old_reclaim(); - GC_SAFEPOINT_PENDING.with(|p| p.set(true)); + crate::gc::set_safepoint_pending(true); js_gc_loop_safepoint(); assert_eq!( diff --git a/crates/perry-runtime/src/gc/tests/triggers.rs b/crates/perry-runtime/src/gc/tests/triggers.rs index b7a81ab507..7c4cec3270 100644 --- a/crates/perry-runtime/src/gc/tests/triggers.rs +++ b/crates/perry-runtime/src/gc/tests/triggers.rs @@ -777,3 +777,105 @@ fn declining_to_escalate_records_no_pre_full_reading() { one would price the NEXT full against the wrong heap" ); } + +// ---------------------------------------------- the poll's arming word ----- + +/// The deferral flag and `PERRY_GC_POLL_ARMED` are one piece of state with two +/// representations, and only the second one is visible to the code that decides +/// whether to call the poll at all. This pins the transition in both directions. +/// +/// The unsound direction is a `set` that bypasses `set_safepoint_pending`: the +/// word stays zero, codegen's inline guard branches around the call, and the +/// deferred collection is stranded until the next event-loop boundary — which a +/// compute-only program never reaches. That is #7690's failure mode (a collector +/// with no nursery evacuation) arriving through a different door, and it is +/// invisible to every existing test, because a program with no collections still +/// produces the right answer. +#[test] +fn a_deferral_arms_the_poll_word_and_draining_disarms_it() { + let _isolation = GcTestIsolationGuard::new(); + crate::gc::set_safepoint_pending(false); + let base = crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed); + + crate::gc::set_safepoint_pending(true); + assert_eq!( + crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed), + base + 1, + "arming a deferral must make the poll's global word non-zero — it is \ + the ONLY thing a codegen-emitted back-edge consults" + ); + + // Idempotent: the flag is a bool, so a second set is not a second arm. + crate::gc::set_safepoint_pending(true); + assert_eq!( + crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed), + base + 1, + "only TRANSITIONS may move the counter, or a thread that defers twice \ + leaks an arm and pins the poll on for the life of the process" + ); + + crate::gc::set_safepoint_pending(false); + assert_eq!( + crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed), + base, + "draining must give the arm back" + ); +} + +/// A poll whose word reads zero must do NOTHING — not even the bookkeeping. +/// +/// This is the assertion that makes the optimisation real rather than +/// decorative. `note_loop_poll_reached` is an unconditional atomic RMW on a +/// process-shared line; leaving it above the gate would keep the most expensive +/// single instruction of the old fast path on every back-edge while looking, in +/// every other test, exactly like this one. +#[test] +fn an_unarmed_poll_touches_nothing() { + let _isolation = GcTestIsolationGuard::new(); + let _zeal = super::super::zeal::ZealGuard::set(false); + crate::gc::set_safepoint_pending(false); + let restore = crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed); + crate::gc::PERRY_GC_POLL_ARMED.store(0, std::sync::atomic::Ordering::Relaxed); + + let polls_before = crate::gc::loop_polls_reached(); + let collections_before = gc_collection_count(); + js_gc_loop_safepoint(); + js_gc_loop_safepoint(); + let polls_after = crate::gc::loop_polls_reached(); + let collections_after = gc_collection_count(); + + crate::gc::PERRY_GC_POLL_ARMED.store(restore, std::sync::atomic::Ordering::Relaxed); + assert_eq!( + polls_after, polls_before, + "an unarmed poll must not reach the counter — reaching it means the \ + atomic increment is still on every back-edge" + ); + assert_eq!( + collections_after, collections_before, + "and it certainly must not collect" + ); +} + +/// Zeal's contract is a collection at EVERY safepoint, not only at ones an +/// alloc-point trigger already deferred. That is expressible only if the word +/// stays armed with nothing pending, so zeal owns a permanent arm. +/// +/// Without this, `PERRY_GC_ZEAL=1` would silently become a no-op on the poll +/// path: every back-edge would read zero, skip the call, and force nothing — +/// and `zeal_liveness_report` would be left to report the vacuity after the +/// fact instead of the instrument simply working. +#[test] +fn zeal_holds_the_poll_word_armed_with_nothing_pending() { + let _isolation = GcTestIsolationGuard::new(); + crate::gc::set_safepoint_pending(false); + { + let _zeal = super::super::zeal::ZealGuard::set(true); + assert!( + crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed) > 0, + "zeal must keep the poll reachable even with no deferral outstanding" + ); + } + // And it gives the arm back, so one zeal test does not leave every later + // test in this binary paying for the slow path. + crate::gc::set_safepoint_pending(false); +} diff --git a/crates/perry-runtime/src/gc/zeal.rs b/crates/perry-runtime/src/gc/zeal.rs index 70e382445a..849de5176d 100644 --- a/crates/perry-runtime/src/gc/zeal.rs +++ b/crates/perry-runtime/src/gc/zeal.rs @@ -155,14 +155,26 @@ pub(crate) fn gc_zeal_enabled() -> bool { *CACHED.get_or_init(|| parse_zeal(std::env::var("PERRY_GC_ZEAL").ok().as_deref())) } -/// RAII test override for zeal. +/// RAII test override for zeal: the previous override, and whether this guard +/// took an arm on the back-edge poll's global word that it owes back. #[cfg(test)] -pub(crate) struct ZealGuard(Option); +pub(crate) struct ZealGuard(Option, bool); #[cfg(test)] impl ZealGuard { pub(crate) fn set(enabled: bool) -> Self { - Self(ZEAL_OVERRIDE.with(|cell| cell.replace(Some(enabled)))) + // Mirror production: zeal keeps the back-edge poll's global arming word + // non-zero (`gc/poll_arm.rs::resolve_poll_seed`), because a poll that + // reads zero never calls in and so can force nothing. A test vehicle + // that skipped this would let `js_gc_loop_safepoint` no-op under a + // `ZealGuard` and report the collection zeal never got to run. + if enabled { + super::arm_poll(); + } + Self( + ZEAL_OVERRIDE.with(|cell| cell.replace(Some(enabled))), + enabled, + ) } } @@ -170,6 +182,9 @@ impl ZealGuard { impl Drop for ZealGuard { fn drop(&mut self) { ZEAL_OVERRIDE.with(|cell| cell.set(self.0)); + if self.1 { + super::disarm_poll(); + } } } @@ -375,6 +390,14 @@ pub(crate) fn note_loop_poll_reached() { } /// How many loop back-edge polls this run reached. +/// +/// **Exhaustive exactly under zeal**, which is the one place it is read +/// (`zeal_verdict`). A back-edge whose `PERRY_GC_POLL_ARMED` load reads zero +/// never calls into the runtime at all — that is the point of `gc/poll_arm.rs` +/// — so outside zeal this counts polls that had something to consider, not +/// back-edges executed. Zeal keeps the word armed for the life of the process +/// (`resolve_poll_seed`), so under zeal the two are the same number and the +/// "not one back-edge poll was reached" diagnosis stays sound. pub fn loop_polls_reached() -> u64 { LOOP_POLLS.load(Ordering::Relaxed) } From 5e9119b921fcca89f18a8f7387c71aeb46f71814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 23:27:20 +0200 Subject: [PATCH 2/2] chore: bump version to 0.5.1429 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6abb1b1f85..21359a573e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1428 +**Current Version:** 0.5.1429 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 779ae1e3b4..1e85d2779d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1428" +version = "0.5.1429" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1428" +version = "0.5.1429" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1428" +version = "0.5.1429" [[package]] name = "perry-ui-tvos" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1428" +version = "0.5.1429" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index b751c95bff..9b8ba1178c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1428" +version = "0.5.1429" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"