Skip to content

regression: tracingChannel().traceCallback loses every events.push (test_gap_diagchannel_3082_3084_3085_3086, unnoticed since 2026-07-30) #7521

Closed

Description

@proggeramlug

Symptom

test-files/test_gap_diagchannel_3082_3084_3085_3086.ts is a non-snapshot gap
failure on main
— it is in neither test-parity/gap_snapshot.json nor
test-parity/known_failures.json, so nothing records it as accepted.

The #3086 block (tracingChannel(...).traceCallback) produces an empty
events array
:

$ node --experimental-strip-types test-files/test_gap_diagchannel_3082_3084_3085_3086.ts   # 23 lines, exit 0
$ perry test-files/test_gap_diagchannel_3082_3084_3085_3086.ts -o /tmp/dc && /tmp/dc       # exit 0, one blank line where 6 should be

Diff (node < vs perry >), lines 17-22:

< start:{"ctx":true}
< fn args:A,function,B,C
< asyncStart:"cb-value"
< callback:null:cb-value
< asyncEnd:"cb-value"
< end:{"ctx":true,"result":"cb-value"}
---
>

Everything before it matches byte-for-byte, including 3086 ret: target-ret —
so traceCallback did invoke target and did return its value. What is
lost is every events.push(...): the four subscriber handlers AND the
events.push("fn args:…") inside target itself. events.join("\n") then
prints one empty line.

That target ran but its own events.push did not land in the array the
console.log reads is the interesting part — it points at the array binding
captured by the traced function, not at the subscriber plumbing.

This is a regression, not a host quirk

I originally filed this (in #7518) as a suspected host-local flake because it
reproduced on clean main. That reasoning was wrong: reproducing on clean main
is evidence of an unnoticed regression, not of a host quirk. Measured on one
host with --profile perry-dev builds of
-p perry -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static,
own build at every point:

commit date verdict
17c0ff952 2026-07-30 PASS
c6ed8175d 2026-07-30 PASS
33acba4c1 (main) 2026-08-06 FAIL

So the break is somewhere in c6ed8175d..main. Bisecting that window is the
first step.

Why it went unnoticed

parity (which runs the gap suite) is gated to tag pushes only — see CLAUDE.md,
"Workflow Requirements" — so a gap regression lands on main with no red check
and the tag-time run is far from the commit that caused it. Same mechanism as
#7518.

Repro

perry test-files/test_gap_diagchannel_3082_3084_3085_3086.ts -o /tmp/dc && /tmp/dc
diff <(node --experimental-strip-types test-files/test_gap_diagchannel_3082_3084_3085_3086.ts) <(/tmp/dc)

Node 26.5.1 (the .node-version pin).

Acceptance

test_gap_diagchannel_3082_3084_3085_3086 byte-identical to node with exit 0,
plus the bisected commit.

Activity

  1. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    Confirmed still failing at v0.5.1308 on the pinned quiet mini (full 489-test gap run, node 26.5.1 oracle).

    Context from that run: it was one of only three failures outside gap_snapshot.json, and the other two are also tracked (#7548 proxy-mutator hang, #7522 zlib dead-strip). The run covers 43 PRs merged today — GC layout installation, write barriers, spread semantics, JSON materialization, constructor lowering — and introduced no new failures, so this remains exactly what it was: a regression from the c6ed8175d (#6853) window, unrelated to recent work.

    That window has now produced three confirmed regressions: #7518 (fixed in #7520), this one, and possibly #7548 (bisect in progress). Worth treating the window itself as suspect rather than each symptom separately — #6853 was "fix(globals): complete Node parity", and #7518's root cause was a one-line data-table addition whose damage landed three files away. A second look at what else that PR's table completion un-gated may be cheaper than three independent bisects.

  2. proggeramlug commented on Aug 7, 2026

    @proggeramlug
    ContributorAuthor

    Two data points that narrow this, both on current main (v0.5.1323, 2b54d58a0)

    1. It is not an auto-optimize artifact. My first repro linked the
    perry-auto-* archive, which matters because #7475 blames that same
    feature-stripped .a for two other kernel failures. Recompiled with
    PERRY_NO_AUTO_OPTIMIZE=1: identical failure, same six lines lost. So this
    is not in the #7475 family and the ordinary runtime is affected.

    17,22c17
    < start:{"ctx":true}
    < fn args:A,function,B,C
    < asyncStart:"cb-value"
    < callback:null:cb-value
    < asyncEnd:"cb-value"
    < end:{"ctx":true,"result":"cb-value"}
    ---
    >
    

    2. The closure-capture shape alone is NOT sufficient to reproduce. The
    issue reasons that "target ran but its own events.push did not land" points at
    the captured array binding. That is the right read of the symptom, but the
    binding shape on its own is fine. All three of these match node exactly:

    • bare block + function declaration capturing a block-scoped const array, called directly
    • the same, invoked through .apply(thisArg, args)
    • an arrow capturing the block-scoped const, stored in an object literal, called via the property

    So the fault needs traceCallback itself in the picture — the capture does not
    break just because the callee is reached indirectly or through a this-bound
    call. That argues against a general closure-capture regression in the
    c6ed8175d..main window and for something specific to how
    trace_callback_method_closure / diag_trace_callback re-enters the target,
    most likely the synthetic-arguments closure path that forwards the surrounding
    args (diagnostics.rs:1653, the position-honoring form added for #3086).

    That is worth trying before paying for the full bisect, since it is a
    one-file read; if it does not fall out, the bisect window is unchanged.

    Note both subscriber handlers (arrows) and target (function declaration)
    lose their pushes, so whatever goes wrong affects the array itself rather than
    one callee's capture.

  3. proggeramlug commented on Aug 7, 2026

    @proggeramlug
    ContributorAuthor

    Root cause found — it is not diagnostics_channel, and the window is #7105, not #6853

    PR #7580.

    traceCallback is a bystander. Following the "read diagnostics.rs first" hint
    produced nothing, but the reduction ladder collapsed fast: the very first probe
    showed the block failing with traceCallback removed entirely (just calling
    target directly), then with subscribe removed, then with the channel
    never created
    . The minimal repro has no traceCallback, no subscribers, no
    closures beyond one, and no array:

    import { tracingChannel } from "node:diagnostics_channel";   // ANY import will do
    {
      const events: string[] = [];
      function t1(a: any) { events.push("fn:" + a); }
      t1("A");
      console.log(JSON.stringify(events));   // node: ["fn:A"]   perry: []
    }

    Delete the import and it passes — which is exactly why the three probes on the
    issue were green. The import's only role is to make the file an ES module, hence
    strict, which selects the affected lowering. It is also not array-specific:
    objects, numbers and strings lose identically (const acc = {n:0} reads
    undefined inside t1, so acc.n++ throws).

    Where: crates/perry-codegen/src/stmt/mod.rs, emit_preallocate_boxes. It
    allocated a box — and, decisively, a ctx.locals slot — for every id in a
    Stmt::PreallocateBoxes, including ids already promoted to
    @perry_global_<mod>__<id>. Codegen has a rule for that collision, written out
    at every other read/write site (ctx.boxed_vars.contains(id) && !ctx.module_globals.contains_key(id)), and this was the one place that never
    checked. ctx.locals is consulted before ctx.module_globals on the
    Stmt::Let reuse path and the LocalGet/LocalSet store paths, so the
    declaration wrote into the box-pointer slot, the global was never stored, and
    the closure — which reads the global with zero captures — saw the undefined it
    was defined with. The IR says it plainly: @perry_global_m3_ts__1 is defined,
    registered as a GC root, read and written by the closure, and never stored by
    main.

    When: lower_strict_block_fn_decls was added by #7105 (76fa7b4c9,
    2026-08-01) — it made a bare block at the top level of a strict module emit
    PreallocateBoxes for its lexical bindings, the first time a promoted
    module-level id was ever handed to emit_preallocate_boxes. Both commits
    recorded here as PASS (17c0ff952, c6ed8175d, 2026-07-30) predate it, so the
    recorded evidence was consistent and no bisect was needed. The #6853 window is
    not involved — worth un-suspecting it for #7548 too.

    Scope is much larger than this test. Every ES module with a top-level { … }
    block containing a function declaration that reads a sibling let/const
    compiled to a silently-empty binding. Nothing threw, so it was invisible
    wherever no test printed the accumulated value.

    Regression coverage went in as an in-src unit module
    (crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs) rather than
    crates/*/tests/*.rs, so it runs in the per-PR cargo-test gate instead of only
    nightly — this gap test is in neither gap_snapshot.json nor
    known_failures.json and parity is tag-gated, which is the whole reason it sat
    since 2026-07-30.

  4. added 2 commits that reference this issue on Aug 7, 2026
    ed83e1a
    9688bbe
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions