Skip to content

flaky: capture-bearing class factory reports .name === undefined on some builds (build-nondeterministic) #6482

Closed

Description

@proggeramlug

Summary

#6470 (be2e23f86, "per-evaluation captures for class declarations in function bodies") regressed class .name for a captured-param class-declaration factory. A named class declared inside a factory function that captures an outer parameter now reports .name === undefined instead of its declared name.

Repro

function makeTagged(tag: string) {
  class Inner {
    readonly _tag = tag;          // captures the outer `tag` param
    who() { return "tagged:" + this._tag; }
  }
  return Inner;
}
const T = makeTagged("S");
console.log(JSON.stringify(T.name));  // node: "Inner"   perry(main): undefined
console.log(new T().who());           // node: "tagged:S"  perry: "tagged:S"  (construction is fine)

Construction, the captured field, and method dispatch all work — only .name is lost. Covered by the existing gap test test_gap_5952_mixin_factory_binding (case 7), which now fails on main.

Bisect

Mechanism

For a capturing class declaration with no static state, #6470 sets fresh_binding = true and binds the name to a ClassExprFresh per-evaluation object (lower_decl/body_stmt.rs). The fresh object gets the right class_id (so methods/construction work), but its class_id is never entered into the CLASS_NAMES registry: name registration (codegen/string_pool.rs → js_register_class_name) only fires for class_ids collected into named_classes, and this fresh-factory template ends up excluded (classified anon / name-mismatched). .name then resolves via class_name_for_id(class_id) → None → undefined. Named class expressions through the same ClassExprFresh machinery keep their name, so the gap is specific to the declaration route + the specialize_captured_class_factories interaction.

Impact

Blocks conformance-smoke (shard containing test_gap_5952) on every PR branched off current main — surfaced while merging #6476 (unrelated streams PR). Mixin/class-factory patterns that read .name (incl. effect-style tagged classes) are affected.

Activity

  1. proggeramlug commented on Jul 16, 2026

    @proggeramlug
    ContributorAuthor

    Correction — #6470 is NOT the culprit. I re-verified: the lowered HIR and the js_register_class_name codegen for test_gap_5952 are byte-identical at b4b47ba34 (#6459, PASSES) and current main (FAILS) — same Tagged = ClassRef("Inner"), same cid 11 → "Inner" registration. So this is a runtime regression, and #6470 (a HIR-only lower_decl/body_stmt.rs change that produces identical HIR here) cannot be it.

    The regression is a runtime change in the range (#6459 .. current main]. Ruled out by scope: #6464 (buffer_dispatch only), #6457 (intl/icu4x only), #6470 (HIR-only). Remaining suspects: #6474 (class_constructors.rs + codegen/method.rs, "default Error-init on every dynamic construct path") and #6466 (init-call topo ordering). Bisecting now.

    Symptom precisely: Tagged resolves to ClassRef("Inner") (class_id whose name IS registered as "Inner"), construction + methods work, but the runtime .name read returns undefined. So the break is in how .name resolves on a class-ref value / a captured-factory class at runtime.

  2. proggeramlug commented on Jul 16, 2026

    @proggeramlug
    ContributorAuthor

    Second correction — this is a build-variable flake, not a clean regression, and NOT #6474 either.

    After deeper testing the bisect does not hold up as a code regression:

    • test_gap_5952 (and a 2-line repro function f(t){ class Inner { x = t } return Inner } f("S").name) fails on current-main local builds (undefined vs node "Inner"), reproducibly within a given compiler binary (5× identical .o).
    • But it passed on the CI builds of the PRs merged earlier today (their conformance shards 2–8 were green, which includes this test's shard), and a no-capture variant (class Inner { x = 1 }, no factory param) always passes.
    • Different compiler builds of the same commit produced different results for the capturing-factory case.

    So the pass/fail tracks the compiler build, not the source commit — a build-order-nondeterministic latent bug in how a capture-bearing class factory (class declared in a function body that closes over a param) resolves its .name at runtime. #6470/#6474 are cleared.

    Root cause is in the runtime per-evaluation capture path (the class the factory returns resolves to a class_id whose name registration is order-dependent), NOT the HIR display-name recording — I tried recording class_display_names for function-body class declarations in lower_decl/body_stmt.rs and it did not fix it, confirming the break is downstream in the capture/cid resolution.

    Repro files + the (ineffective) HIR patch attached in the investigation. Needs someone with RegisterClassCaptures / per-eval-capture context. Marking as a flaky/nondeterministic gap failure.

  3. changed the title [-]regression: captured-param class-declaration factory reports .name === undefined (#6470)[/-] [+]flaky: capture-bearing class factory reports .name === undefined on some builds (build-nondeterministic)[/+] on Jul 16, 2026
  4. proggeramlug commented on Jul 18, 2026

    @proggeramlug
    ContributorAuthor

    Resolved by #6500 (08a6a4f3a, merged 2026-07-17) — no further code change needed.

    Root cause

    #6470 (be2e23f86) routes a capturing function-body class declaration through ClassExprFresh, so each factory evaluation returns a heap class object stamped with the template's class_id (construction/methods/_tag all dispatch through that id — which is why those worked). Reading .name on that heap object hit the OBJECT_TYPE_CLASS field path, which had no arm routing .name to the template's registry name — so it fell through to undefined. The registration itself (js_register_class_name(cid, "Inner", …)) was present and correct; the read side just never consulted it for this value shape.

    The "build-nondeterministic" appearance came from the object cache: a given compiler binary caches the .o (so repeated compiles = identical .o, "5× identical"), while a fresh binary re-runs codegen — but the deciding factor was always this missing read arm, not a genuine per-build coin-flip in registration.

    Fix (already on main)

    #6500 added a name arm next to the #4949 prototype arm in the OBJECT_TYPE_CLASS tail (crates/perry-runtime/src/object/field_get_set/class_object_props.rs), routing .name to class_name_for_id(class_id) (own static name still wins; a deleted key still reads undefined). It also fixed a companion boxed-capture read (shared_mutable_capture.rs now exempts ClassExprFresh.captured_args, matching RegisterClassCaptures).

    Verification on current main (9589f7153)

    • Exact repro from this issue → "Inner" (matches Node), and stable across 15 no-cache rebuilds (PERRY_NO_CACHE=1) — the flake does not reproduce.
    • test_gap_5952_mixin_factory_binding (case 7 is this repro) diffs byte-identical to node --experimental-strip-types, and is not listed in test-parity/known_failures.json.
    • Effect-style multi-evaluation variant (makeTagged("A") / makeTagged("B")) → each reports its own .name / ._tag correctly ("Inner" "Inner" / A B), stable across 6 no-cache rebuilds.

    Closing as fixed.

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