Skip to content

fix(gc)+perf(closure): root the uint8 Buffer callback dispatcher (#8179) and hoist per-element closure dispatch (#8180) - #8188

Merged
proggeramlug merged 2 commits into
mainfrom
fix/8179-8180-array-callback
Aug 16, 2026
Merged

fix(gc)+perf(closure): root the uint8 Buffer callback dispatcher (#8179) and hoist per-element closure dispatch (#8180)#8188
proggeramlug merged 2 commits into
mainfrom
fix/8179-8180-array-callback

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Two changes that had to land together: #8179 adds rooting traffic to exactly
the loops #8180 makes cheaper, so measuring them apart would misattribute
both. #8179 is first in the branch history, #8180 second, and the three-arm
measurement below is taken at those two commits.

Closes #8179. Closes #8180.


#8179 — unrooted values held across js_closure_call*

dispatch_uint8_buffer_method is the shared uint8 %TypedArray%.prototype
dispatcher that every Buffer-backed Uint8Array callback method funnels
through, from three entries. It kept the callback closure, the receiver, map's
freshly allocated result buffer, sort/toSorted's permuted output and
reduce/reduceRight's accumulator in bare Rust locals across
js_closure_call{2,3,4}.

The closure is the live half. It is an ordinary nursery allocation
(GC_TYPE_CLOSURE, GcAllocationPolicy::ArenaOrMalloc, with a
GcMoveHookKind::ClosureDynamicProps move hook — it both moves and dies), and a
callback handed in by a frameless caller is reachable only through that raw
parameter plus the native stack, which an evacuating minor does not scan.
array::buffer_receiver_dispatch rooted it at the boundary; the
%TypedArray%.prototype thunk and dispatch_buffer_method's catch-all did not.

The fixture fails before and passes after

test-files/test_gap_gc_uint8_buffer_callback_rooting.ts, registered in
test-parity/gc_repsel_corpus.txt. Buffer-backed Uint8Array, string
accumulators, allocating callbacks — and the callbacks allocate inside a
loop
, because a back-edge poll is the only safepoint reachable from user JS
and an allocation-free callback would make the file pass vacuously.

It reproduces on the shipped default, no instrument required:

arm plain run SCHEDULE_RATE=1 SCHEDULE_SEED={1,7,42} PROTECT_FROMSPACE=1 VERIFY_EVACUATION=1
main TypeError: value is not a function, exit 1 exit 138 (SIGBUS), 1 FAULT, all three seeds
main+#8179 0, exit 0 exit 0, 0 FAULTs, all three seeds
main+#8179+#8180 0, exit 0 exit 0, 0 FAULTs, all three seeds

The pre-fix fault names the object:

[gc-fromspace-protect] mode=ProtectPages retired_set=#0 blocks=1 sets_held=1/4 bytes_protected=1048576
[gc-fromspace-protect] FAULT: signal 10 at 0x2454161058c
  This address is RETIRED FROM-SPACE. ...
  last-known object: user_ptr=0x24541610580 obj_type=4 size=24
[gc-schedule] FAILURE (signal 10) under seed=7
[gc-schedule]   safepoints=1 scheduled_collections=1

obj_type=4 is GC_TYPE_CLOSURE, and the faulting address is user_ptr + 12
CLOSURE_TYPE_TAG_OFFSET, i.e. get_valid_func_ptr's CLOSURE_MAGIC probe
reading a retired from-space closure header. It dies on the first scheduled
collection.

The instrument is armed, and the green run is not vacuous. Post-fix, the
same seeds print the schedule instrument's own exit verdict:

[gc-fromspace-protect] ... retired_set=#N ...        (5306 such lines)
[gc-schedule] done: seed=7 safepoints=5306 scheduled_collections=5306
              copying_minors=5306 moved_objects=25760 loop_polls=294600

5306 copying minors and 25,760 objects actually moved, so "no fault" is a
verdict rather than "nothing ran".

Note on knobs: my brief specified PERRY_GC_ZEAL / PERRY_GC_ZEAL_ALLOC_KB.
Those have no parser anywhere in the tree — only doc-comment mentions — so
everything above uses the live replacement, PERRY_GC_SCHEDULE_RATE /
PERRY_GC_SCHEDULE_SEED (which implies forced evacuation). The
shipped-default reproduction does not depend on any knob at all.

The receiver is the latent half, and is deliberately not re-read per element

A Buffer is arena_alloc_gc_old + GC_FLAG_TENURED (buffer/header.rs) — the
same old-arena space typed_array_alloc documents as "non-movable space: raw
data pointers are handed out"
— and every %TypedArray% sibling in
typedarray/iterate.rs / typedarray/transform.rs already holds its receiver in
a plain local across callbacks on that invariant.

So the receiver is rooted for liveness (the raw parameter is otherwise its
only reference on two of the three entries) and its address is read from the
root once per arm — after the callback validation, before the loop — rather
than carried in from the parameter. The only collector arm that relocates an
old-arena page is old-page defrag, which is opt-in and default-off
(PERRY_GC_OLD_DEFRAG=1, and gc/oldgen_defrag.rs says so explicitly). Making
that arm safe is a tree-wide property of every holder of an old-arena raw
address, not something one dispatcher can establish — and I measured re-reading
per element at +28 % on the Uint8Array benchmark, for a knob that is off.
That trade is written into the type's doc comment so the next reader does not
have to re-derive it.

Adjacent things fixed rather than filed

  • js_typed_array_reduce / js_typed_array_reduce_right now root their
    accumulator, as their plain-array sibling js_array_reduce has since the
    2026-07-02 audit. It is a nursery object whenever the seed or a callback
    result is a string/object/array.
  • The two non-BigInt arms of js_typed_array_sort_with_comparator /
    js_typed_array_to_sorted_with_comparator now root the comparator closure.
    sorted_bigint_lanes, directly above them, has done so since it was written
    ("the comparator closure itself is re-derived from a rooted handle per call") —
    the two arms beside it were missed.

Raw-handle conversion form used at each site

The ratchet is unchanged at 990 (scripts/raw_handle_debt.py, plus
--self-test). No site in this PR is a bare get_raw_*_ptr.

site form why
RootedCallback{2,3,4}::call (uint8 dispatcher) with_const_ptr the pointer is an argument to a C-ABI call — a position across_* cannot express, since it hands the address back only after the call. The handle is re-read on every iteration immediately before the pointer is consumed, and the RawTagged slot is one scan_runtime_handle_roots_mut both marks and rewrites, so a collection during call i is reflected in the address call i+1 uses.
js_typed_array_sort_with_comparator / ..._to_sorted_... comparator with_const_ptr same shape: argument position inside sort_by.
uint8 reduce accumulator root_nanbox_f64 + get/set_nanbox_f64 a value, not a pointer — no raw-handle form applies.
js_typed_array_reduce{,_right} accumulator root_nanbox_f64 + get/set_nanbox_f64 same.
uint8 receiver root_nanbox_f64 + one live() per arm see above; the address is derived from a nanbox read, not a raw-pointer handle.

scripts/gc_root_dominance_check.py is structurally blind here — these are Rust
locals, not emitted IR — so the runtime instruments above are the only detector,
which is why the witness carries its verdict lines.


#8180 — ~368 instructions per array-callback invocation

js_closure_callN re-derived, on every element, three answers that cannot
change while one closure is being called: get_valid_func_ptr (two address-band
checks, a volatile CLOSURE_MAGIC probe through *(closure + 12), a volatile
func_ptr load), resolve_strategy (a perry_thread_local! single-slot cache —
on Darwin a tlv_get_addr call plus a load and a compare even on a hit), and
the DispatchStrategy match before the indirect jump.

New closure/dispatch/direct.rs generalises array/sort.rs's ComparatorCall
trick — introduced to "skip ~50M HashMap lookups over a 1.25M-element sort",
and until now its only consumer — into DirectCall{1,2,3,4}. Resolve once, call
directly, fall back to js_closure_callN for a bound method/function, a rest
parameter, a declared arity above the call arity, or an invalid closure pointer,
so the proxy-callee/throw path, the rest bundling and the undefined-padding stay
in exactly one place. resolve_call2_direct is deleted rather than left
standing beside its generalisation; ComparatorCall now holds a DirectCall2.

Hoisted at 31 call sites: array/iter_methods.rs (14),
array/reduce_right.rs (1), typedarray/iterate.rs (9),
typedarray/transform.rs (5 — including the BigInt lane comparator, which
resolved once per comparison) and the uint8 dispatcher's
RootedCallback{2,3,4}.

Why hoisting is sound

Each input is invariant for a fixed closure:

  • closure->func_ptr is written once by js_closure_alloc and never mutated,
    so get_valid_func_ptr answers the same code address every time. A moving
    collection cannot change it either — that is the same argument
    ComparatorCall::compare_at already documents, and it is why callers that
    root their callback pass the current header address to call while the
    resolved target stays cached.
  • lookup_closure_rest / lookup_closure_arity are keyed by that func_ptr and
    are insert-only per key; registration happens at closure creation, before the
    closure can be passed anywhere.
  • BOUND_METHOD_FUNC_PTR / BOUND_FUNCTION_FUNC_PTR are process constants.

So the only way a loop could observe a different dispatch strategy mid-iteration
is by calling a different closure, and an array method calls one. A site that
can retarget its callee per element must not use these types, and the module doc
says so.

The unit tests in direct.rs assert the fast path is live (is_direct()) —
not merely that nothing threw — and assert each decline: declared arity above the
call arity, a rest parameter, an invalid closure pointer.

Not converted, on purpose

array/generic.rs's js_arraylike_* engine. Its per-element cost is dominated
by generic array-like property access (al_has + al_get, full
prototype-chain lookups) rather than by dispatch, so the win would be small; and
the spec order it implements reads LengthOfArrayLike before IsCallable,
so a hoisted resolve would have to be sequenced after the existing callable()
call rather than inserted at the top of the function. That is not the same
mechanical edit and it risks moving an observable throw.

#8103 is a different defect and is not touched here.


Measurement

Quiet M1 mini (load ~1.5). Instructions retired is primary — load-independent.
Best of 5, arms interleaved so host drift cannot land on one arm. Per-arm
PERRY_RUNTIME_DIR and PERRY_CACHE_DIR, PERRY_NO_AUTO_OPTIMIZE=1, built
with an identical -p perry -p perry-runtime-static -p perry-stdlib-static set
each hop, and all three arms' perry, libperry_runtime.a, libperry_stdlib.a
and both benchmark binaries cmp-verified pairwise different. Every run's
exit code checked; every arm's stdout matches node's.

benchmark arm instructions retired Δ vs main max RSS (B) peak footprint (B)
arr — 21M plain-Array callback invocations main 5,027,207,909 46,628,864 42,452,096
+#8179 5,027,015,176 −0.004 % 46,514,176 42,452,096
+#8179+#8180 3,970,592,563 −21.0 % 46,628,864 42,435,648
u8 — 7.9M Buffer-Uint8Array callback invocations main 1,979,043,224 14,794,752 10,126,400
+#8179 2,535,814,615 +28.1 % 14,761,984 10,126,400
+#8179+#8180 1,978,697,176 −0.02 % 14,876,672 10,142,848

Reading it:

The final commits differ from the exact sources measured only in doc comments
(the PERRY_GC_ZEALPERRY_GC_SCHEDULE_* correction); verified by diffing the
measured blobs against the committed ones with doc lines filtered out — no
non-doc line differs.


Local validation

CI is deliberately not consulted (owner instruction); this is the gate.

All green: cargo fmt --all -- --check, scripts/check_file_size.sh,
scripts/raw_handle_debt.py (990, unchanged) and its --self-test,
scripts/gc_runtime_root_holders.py, scripts/shape_descriptor_census.py,
scripts/addr_class_inventory.py, scripts/check_gc_env_knobs.py,
scripts/gc_store_site_inventory.py, scripts/gc_pin_sites.py,
scripts/gc_gate_wiring_check.py, scripts/check_test_registration.py,
scripts/gc_root_dominance_check.py --audit-poll-reach,
scripts/workspace_architecture.py --check.

  • cargo test -p perry-runtime --lib2481 passed / 0 failed / 4 ignored.
    main's reference is 2477 / 0 / 4; the delta is exactly the 4 new direct.rs
    tests.
  • cargo test -p perry-codegen --no-fail-fast1480 passed / 9 failed,
    identical to the recorded baseline. The 9 are the pre-existing
    native_proof_buffer_views (6), loop_safepoint_purity (1),
    shadow_slot_hygiene (1) and typed_feedback (1) failures. Zero new, and
    by construction: this PR touches no file in perry-codegen — the whole diff is
    confined to crates/perry-runtime/, test-files/, test-parity/ and
    changelog.d/.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability of Uint8Array and typed-array callbacks during garbage collection.
    • Prevented callback receivers, accumulators, and comparators from becoming invalid during allocating operations.
    • Preserved correct behavior for mapping, filtering, reduction, searching, sorting, and iteration methods.
  • Performance

    • Improved repeated callback execution by reusing optimized dispatch paths where supported, with safe fallback behavior.
  • Tests

    • Added garbage-collection stress coverage for Uint8Array callback operations.

@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1d2c3ed6-8ec0-408f-a711-96e652dbfd05

📥 Commits

Reviewing files that changed from the base of the PR and between 1f381f2 and 7c6212c.

📒 Files selected for processing (14)
  • changelog.d/8188-uint8-callback-rooting-and-hoisted-dispatch.md
  • crates/perry-runtime/src/array/iter_methods.rs
  • crates/perry-runtime/src/array/reduce_right.rs
  • crates/perry-runtime/src/array/sort.rs
  • crates/perry-runtime/src/array/typed_array_receiver_tests.rs
  • crates/perry-runtime/src/closure/dispatch.rs
  • crates/perry-runtime/src/closure/dispatch/calln.rs
  • crates/perry-runtime/src/closure/dispatch/direct.rs
  • crates/perry-runtime/src/closure/mod.rs
  • crates/perry-runtime/src/object/typed_array_proto_thunks.rs
  • crates/perry-runtime/src/typedarray/iterate.rs
  • crates/perry-runtime/src/typedarray/transform.rs
  • test-files/test_gap_gc_uint8_buffer_callback_rooting.ts
  • test-parity/gc_repsel_corpus.txt

📝 Walkthrough

Walkthrough

The PR adds hoisted direct callback dispatch for arities one through four. It roots Uint8Array receivers, callbacks, comparators, and reduction accumulators across allocating calls. It adds GC stress coverage for Buffer-backed typed-array callbacks.

Changes

Callback dispatch and GC rooting

Layer / File(s) Summary
Direct dispatch primitives
crates/perry-runtime/src/closure/...
Adds DirectCall1DirectCall4, validates closure eligibility, and falls back to existing dispatchers.
Array callback adoption
crates/perry-runtime/src/array/..., crates/perry-runtime/src/array/typed_array_receiver_tests.rs
Replaces repeated callback resolution in array iteration, reduction, and sorting paths with resolved dispatch sites.
Typed-array callback dispatch
crates/perry-runtime/src/typedarray/...
Reuses direct dispatch for iteration, reduction, reverse search, and comparator operations. Roots typed-array reduction accumulators and comparator closures where needed.
Uint8 buffer rooting
crates/perry-runtime/src/object/typed_array_proto_thunks.rs
Roots Buffer receivers and callbacks, refreshes relocated pointers, and preserves reduction accumulators across callback calls.
GC regression coverage
test-files/test_gap_gc_uint8_buffer_callback_rooting.ts, test-parity/gc_repsel_corpus.txt, changelog.d/...
Adds allocating callback stress tests for Uint8Array operations and registers the test in the GC corpus. Documents the rooting and dispatch changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Uint8ArrayMethod
  participant RuntimeHandleScope
  participant DirectCall3
  participant Callback
  Uint8ArrayMethod->>RuntimeHandleScope: root receiver and callback
  Uint8ArrayMethod->>DirectCall3: resolve callback once
  loop Each element
    Uint8ArrayMethod->>RuntimeHandleScope: read current rooted pointers
    Uint8ArrayMethod->>DirectCall3: call callback
    DirectCall3->>Callback: invoke direct target or fallback
  end
Loading

Possibly related PRs

Suggested labels: bug

Suggested reviewers: thehypnoo

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/8179-8180-array-callback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Ralph Küpper added 2 commits August 16, 2026 10:12
…ross user callbacks (#8179)

`dispatch_uint8_buffer_method` — the shared uint8 `%TypedArray%.prototype`
dispatcher every Buffer-backed `Uint8Array` callback method funnels through, on
all three of its entries — kept the callback closure, the receiver, `map`'s
freshly allocated result buffer, `sort`/`toSorted`'s permuted output and
`reduce`/`reduceRight`'s accumulator in bare Rust locals across
`js_closure_call{2,3,4}`.

THE CLOSURE IS THE LIVE HALF. It is an ordinary nursery allocation
(`GC_TYPE_CLOSURE`, with a `GcMoveHookKind::ClosureDynamicProps` move hook — it
both moves and dies), and a callback handed in by a frameless caller is
reachable only through that raw parameter plus the native stack, which an
evacuating minor does not scan. `array::buffer_receiver_dispatch` rooted it at
the boundary; the `%TypedArray%.prototype` thunk and `dispatch_buffer_method`'s
catch-all did not. It is now rooted here, where all three entries get it, and
RE-READ from the root before every call.

`test-files/test_gap_gc_uint8_buffer_callback_rooting.ts` (registered in
`test-parity/gc_repsel_corpus.txt`) fails on the SHIPPED DEFAULT before this
change — `TypeError: value is not a function`, exit 1 — and under
`PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_SEED=<n> PERRY_GC_PROTECT_FROMSPACE=1
PERRY_GC_VERIFY_EVACUATION=1` dies on the FIRST scheduled collection, for every
seed tried (1, 7, 42):

    [gc-fromspace-protect] FAULT: signal 10 at 0x2454161058c
      last-known object: user_ptr=0x24541610580 obj_type=4 size=24
    [gc-schedule] FAILURE (signal 10) under seed=7
    [gc-schedule]   safepoints=1 scheduled_collections=1

`obj_type=4` is `GC_TYPE_CLOSURE`; the faulting address is `user_ptr + 12` —
`CLOSURE_TYPE_TAG_OFFSET`, i.e. `get_valid_func_ptr`'s `CLOSURE_MAGIC` probe
reading a retired from-space closure header. After the fix the same seeds run to
completion with the instrument's own liveness verdict proving the subject ran:
`safepoints=5306 scheduled_collections=5306 copying_minors=5306
moved_objects=25760`, 0 faults, exit 0.

THE RECEIVER IS THE OTHER HALF, and is treated differently on purpose. A Buffer
is `arena_alloc_gc_old` + `GC_FLAG_TENURED` (`buffer/header.rs`) — the same
old-arena space `typed_array_alloc` calls "non-movable space: raw data pointers
are handed out" — and every `%TypedArray%` sibling in `typedarray/iterate.rs` /
`typedarray/transform.rs` already holds its receiver in a plain local across
callbacks on that invariant. The receiver is therefore rooted for LIVENESS (the
raw parameter is otherwise its only reference on two of the three entries) and
its address is read from the root ONCE per arm — after the callback validation,
before the loop — instead of being carried in from the parameter. The one arm
that relocates an old-arena page is old-page defrag, which is opt-in and
default-off (`PERRY_GC_OLD_DEFRAG=1`); making that safe is a tree-wide property
of every holder of an old-arena raw address, not something one dispatcher can
establish, and re-reading per element measured +28 % on a `Uint8Array`
forEach/map/reduce benchmark for a knob that is off.

Two sibling families in the same shape get the same treatment:

  * `js_typed_array_reduce` / `js_typed_array_reduce_right` now root their
    accumulator, as their plain-array sibling `js_array_reduce` has since the
    2026-07-02 audit. It is a nursery object whenever the seed or a callback
    result is a string/object/array.
  * the two non-BigInt arms of `js_typed_array_sort_with_comparator` /
    `js_typed_array_to_sorted_with_comparator` now root the comparator closure.
    `sorted_bigint_lanes`, directly above them, has done so since it was
    written — "the comparator closure itself is re-derived from a rooted handle
    per call (a comparator-triggered GC can relocate its own closure header)".
…llback loops (#8180)

`js_closure_callN` re-derived, on EVERY element of every fused array-callback
loop, three answers that cannot change while one closure is being called:

  1. `get_valid_func_ptr` — two address-band checks, a volatile `CLOSURE_MAGIC`
     probe through `*(closure + 12)` and a volatile `func_ptr` load;
  2. `resolve_strategy` — a `perry_thread_local!` single-slot cache, which on
     Darwin is a `tlv_get_addr` CALL plus a load and a compare even on a hit;
  3. the `DispatchStrategy` match before the indirect jump.

The tree already contained the answer, applied to exactly one call site:
`array/sort.rs`'s `ComparatorCall`, introduced to "skip ~50M HashMap lookups
over a 1.25M-element sort". New `closure/dispatch/direct.rs` generalises it to
arities 1–4 as `DirectCall{1,2,3,4}` — resolve once, call directly, fall back to
`js_closure_callN` for a bound method/function, a rest parameter, a declared
arity above the call arity, or an invalid closure pointer, so the
proxy-callee/throw path, the rest bundling and the undefined-padding stay in one
place. `resolve_call2_direct` is DELETED rather than left standing beside it;
`ComparatorCall` now holds a `DirectCall2`.

Hoisted at 31 call sites: `array/iter_methods.rs` (14), `array/reduce_right.rs`
(1), `typedarray/iterate.rs` (9), `typedarray/transform.rs` (5 — including the
BigInt lane comparator, which resolved once per COMPARISON) and the uint8
`%TypedArray%.prototype` dispatcher's `RootedCallback{2,3,4}`.

Measured on a quiet M1 mini, instructions retired, best of 5, arms interleaved,
per-arm `PERRY_RUNTIME_DIR` + `PERRY_CACHE_DIR`, `PERRY_NO_AUTO_OPTIMIZE=1`:

    bench   main(A)         +8179(B)        +8179+8180(C)   C vs A
    arr     5,027,207,909   5,027,015,176   3,970,592,563   -21.0 %
    u8      1,979,043,224   2,535,814,615   1,978,697,176    -0.02 %

`arr` is 21M plain-`Array` callback invocations (forEach/map/filter/reduce/
findIndex/some/every); `u8` is 7.9M Buffer-backed `Uint8Array` ones. Peak RSS is
flat: `arr` 46,628,864 B on both A and C, `u8` 14,794,752 -> 14,876,672 B
(+0.55 %, 20 pages, the handle stack and the resolved sites). #8179's rooting
costs +28 % on the `u8` path on its own; this change pays all of it back and the
plain-`Array` path is 21 % cheaper than main.

SOUNDNESS. `closure->func_ptr` is written once at `js_closure_alloc` and never
mutated; `lookup_closure_rest` / `lookup_closure_arity` are keyed by it and are
insert-only per key, registered at closure creation; the two sentinels are
process constants. The only way to observe a different strategy mid-loop is to
call a DIFFERENT closure, and an array method calls one. Callers that root their
callback pass the CURRENT address to `call`; the resolved target is a static
CODE address, which relocation does not change — the argument
`ComparatorCall::compare_at` already documents. The unit tests in `direct.rs`
assert the fast path is LIVE (`is_direct()`), not merely that nothing threw, and
assert each decline (higher declared arity, rest parameter, invalid pointer).

`array/generic.rs`'s `js_arraylike_*` engine is deliberately NOT converted. Its
per-element cost is dominated by generic array-like property access (`al_has` +
`al_get`, full prototype-chain lookups) rather than by dispatch, and the spec
order it implements reads `LengthOfArrayLike` BEFORE `IsCallable` — so a hoisted
resolve would have to be sequenced after the existing `callable()` call rather
than inserted at the top of the function, which is not the same mechanical edit
and risks moving an observable throw.
@proggeramlug
proggeramlug force-pushed the fix/8179-8180-array-callback branch from dce5052 to 7c6212c Compare August 16, 2026 08:19
@proggeramlug
proggeramlug marked this pull request as ready for review August 16, 2026 08:21
@proggeramlug
proggeramlug merged commit 5db30fa into main Aug 16, 2026
2 of 18 checks passed
@proggeramlug
proggeramlug deleted the fix/8179-8180-array-callback branch August 16, 2026 08:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant