Skip to content

docs(D-W6.2): closure-capture + hash-slot reproducers, identify real drift#605

Open
fglock wants to merge 1 commit intomasterfrom
fix/d-w6-2-closure-capture-drift
Open

docs(D-W6.2): closure-capture + hash-slot reproducers, identify real drift#605
fglock wants to merge 1 commit intomasterfrom
fix/d-w6-2-closure-capture-drift

Conversation

@fglock
Copy link
Copy Markdown
Owner

@fglock fglock commented Apr 29, 2026

Summary

D-W6.2 investigation outcome. The simple closure-capture and
hash-slot patterns all work correctly without the walker gate

they are not the drift source. The actual refCount drift is in the
metaclass-instance lifecycle during Class::MOP load.

What landed

src/test/resources/unit/refcount/drift/:

  • sub_install.t (12 tests) — five sub-install shapes.
  • closure_capture.t (8 tests) — single through five-layer wrap, plus
    a 20-closure chain.
  • hash_slot.t (14 tests) — direct slot, package global, 50-entry
    registry, slot overwrite.

All pass on master AND with the walker gate disabled.

DestroyDispatch.callDestroy gets a PJ_DESTROY_TRACE=1 env-flag
that prints every destroy with Pkg::subname for RuntimeCode and
class name for blessed objects. Zero cost when off.

What we actually found

PJ_DESTROY_TRACE=1 ./jperl -e 'use Class::MOP' (gate disabled)
fails with Can't call method "get_method" on an undefined value
in Class::MOP::Attribute::_remove_accessor. The $class is undef
because $self->associated_class() (a weakened ref) reads as
undef — the metaclass it pointed to was destroyed.

The trace shows the same Class::MOP::Class@1424108509 instance
destroyed twice (same identity hash):

[DESTROY] Class::MOP::Class@1424108509 ...
  at MortalList.flush(line 585)
  at anon1205.apply(.../Class/MOP/Class.pm:260)

[DESTROY] Class::MOP::Class@1424108509 ...        ← second destroy!
  at MortalList.drainPendingSince(line 659)
  at DestroyDispatch.doCallDestroy(line 373)
  at DestroyDispatch.callDestroy(line 266)
  at MortalList.flush(line 585)
  at anon1205.apply(.../Class/MOP/Class.pm:260)

So the actual drift is either the metaclass being added to the
MortalList.pending queue twice, or the same entry being processed
once via flush and once via the cascading drainPendingSince
inside another destroy.

Next steps (D-W6.4 — a new sub-phase)

Three concrete leads, all documented in
dev/modules/moose_support.md:

  1. Audit MortalList.deferDecrementIfTracked for double-add. A
    single RuntimeBase should never appear twice in pending.
  2. Audit MortalList.drainPendingSince for entries already at
    refCount=MIN_VALUE (already destroyed) — should skip them.
  3. Trace which scope-exit on Class/MOP/Class.pm:260 puts the
    metaclass on the deferred queue. That line is likely the last
    statement of _construct_class_instance.

D-W6.1 (sub-install) and D-W6.2 (closure-capture) are now closed:
the simple patterns demonstrably work; the destroys we observed in
use Class::MOP are symptoms of the metaclass-lifecycle bug, not
direct manifestations of those code paths.

Test plan

  • make (build + unit tests) green.
  • All three drift reproducers pass on master (gate active).
  • All three pass with the gate disabled in a probe build.
  • No production behaviour change (PJ_DESTROY_TRACE is off by
    default; reproducers are new files).

Generated with Devin

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

…drift

D-W6.2 investigation outcome:

The simple closure-capture and hash-slot patterns all work correctly
without the walker gate. Reproducers landing in
src/test/resources/unit/refcount/drift/:

- closure_capture.t (8 tests) — single, two-, three-, five-layer
  wrap, plus a 20-closure chain.
- hash_slot.t (14 tests) — direct slot, package global, 50-entry
  registry, slot overwrite.
- sub_install.t (12 tests, copied from earlier branch) — five
  sub-install patterns.

All pass on master AND with the walker gate disabled. Therefore
the simple shapes of these three code paths have correct cooperative
refCount semantics; they are NOT the source of the drift.

PJ_DESTROY_TRACE=1 instrumentation added to DestroyDispatch.callDestroy
(zero-cost when off; prints Pkg::subname for RuntimeCode and the
class name for blessed objects).

The actual drift, surfaced by `PJ_DESTROY_TRACE=1 ./jperl -e 'use
Class::MOP'` (gate disabled), is in the metaclass-instance lifecycle:
the same Class::MOP::Class instance is destroyed TWICE (same
identity hash) — once via MortalList.flush, once via
MortalList.drainPendingSince in a cascading flush. Investigation
notes in dev/modules/moose_support.md (Phase D-W6.2) describe three
concrete next leads:

1. Audit MortalList.deferDecrementIfTracked for double-add.
2. Audit MortalList.drainPendingSince for entries that have already
   been zeroed.
3. Trace which scope-exit on Class/MOP/Class.pm:260 puts the
   metaclass on the deferred queue.

D-W6.4 (a new sub-phase) is added to track this work; D-W6.1 and
D-W6.2 are closed as "the simple patterns work, the actual drift is
elsewhere".

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant