diff --git a/dev/modules/moose_support.md b/dev/modules/moose_support.md index d2409d792..05445e0f1 100644 --- a/dev/modules/moose_support.md +++ b/dev/modules/moose_support.md @@ -1991,6 +1991,103 @@ Tests fixed: - A handful of cmop/method introspection edge cases (constants, forward declarations, eval-defined subs). +## Phase D-W6.6: precise failure-stack diagnosis (2026-04-29) + +Installing a `$SIG{__DIE__}` probe before `use Class::MOP` (with the +gate disabled) finally pinpoints WHERE the failure originates: + +``` +FAILURE at: + main::__ANON__ at .../Try/Tiny.pm:139 + (eval) at .../Try/Tiny.pm:140 + Try::Tiny::try at .../Class/MOP/Class.pm:897 + Class::MOP::Class::_post_add_attribute + at .../Class/MOP/Mixin/HasAttributes.pm:41 + Class::MOP::Mixin::HasAttributes::add_attribute + at .../Class/MOP.pm:188 +``` + +The chain inside the bootstrap: + +```perl +# CMOP.pm:188 — bootstrap +Class::MOP::Mixin::HasMethods->meta->add_attribute( + Class::MOP::Attribute->new('wrapped_method_metaclass' => ( + reader => { 'wrapped_method_metaclass' => \&...wrapped_method_metaclass }, + ... + )) +); + +# HasAttributes.pm:41 — add_attribute body +$self->_attach_attribute($attribute); # weaken back-ref +$self->{_attribute_map}{...} = $attr; +$self->_post_add_attribute($attribute); + +# Class.pm:897 — _post_add_attribute body +try { + local $SIG{__DIE__}; + $attribute->install_accessors; # ← dies inside here somewhere +} catch { + $self->remove_attribute($attribute->name); # ← then THIS dies because + die $_; # $attr->associated_class +}; # reads as undef +``` + +So `install_accessors` dies first — when `$self->associated_class` +reads as undef inside `_process_accessors`. Then the catch block +calls `remove_attribute` which calls `remove_accessors` which calls +`_remove_accessor($accessor, $self->associated_class())` — and the +weak ref is undef, so `$class->get_method($accessor)` dies. + +The visible "Can't call method 'get_method' on an undefined value at +Attribute.pm line 475" is the SECOND failure; the first is hidden by +Try::Tiny's `local $SIG{__DIE__}` and only manifests via the catch. + +### Reproducer + +`src/test/resources/unit/refcount/drift/cmop_add_attr_loop.t` — 11 +iterations of the exact `_attach_attribute` → +`_attribute_map` insert → `_post_add_attribute` → `try { +install_accessors } catch { remove_attribute }` shape, with +`reader => { name => CV }` HASH-form readers (which is what triggers +`install_accessors` to actually do `$self->associated_class->name`). + +**Both Perl 5 and PerlOnJava (gate disabled) pass this reproducer.** +So the simple shape isn't enough to surface the drift. + +### What's still missing from the reproducer + +The real `Class::MOP` bootstrap differs from the reproducer in that +`Class::MOP::Mixin::HasMethods->meta` returns a metaclass that is: + +- An instance of `Class::MOP::Class` (not bare-Perl Meta). +- That metaclass itself has a multi-level `@ISA` chain (Module, + HasAttributes, HasMethods, HasOverloads). +- Has been built up by *itself* — `meta` initialises a metaclass for + the package using the same `_construct_class_instance` → + `add_attribute` chain. +- Has Class::MOP's own `add_method`, `attribute_metaclass`, + `method_metaclass`, etc. — each a CMOP-machine method. + +The bug is somewhere in **the recursive bootstrap**, where the +metaclass's own add_attribute uses methods that themselves traverse +the metaclass-method-map (which is being built simultaneously). The +specific transient drift happens when `install_accessors` calls a +method that walks `$class->linearized_isa` or `$class->_method_map` +during the partial-build state. + +### Suggested next probe + +- Run with `PJ_DESTROY_TRACE=1 PJ_WEAKCLEAR_TRACE=1` and add a + `$SIG{__DIE__}` Perl-side probe that prints + `$self->associated_class // 'UNDEF'` immediately before each `die`. + The first die where `associated_class` is `'UNDEF'` reveals the + exact LAST PRIOR refCount-decrement step that took the metaclass + to 0. +- That decrement's stack will identify the specific `linearized_isa` + / `_method_map` traversal inside `install_accessors` that loses + the metaclass strong hold. + ## Related Documents - [xs_fallback.md](xs_fallback.md) — XS fallback mechanism diff --git a/src/test/resources/unit/refcount/drift/cmop_add_attr_loop.t b/src/test/resources/unit/refcount/drift/cmop_add_attr_loop.t new file mode 100644 index 000000000..6e823c802 --- /dev/null +++ b/src/test/resources/unit/refcount/drift/cmop_add_attr_loop.t @@ -0,0 +1,91 @@ +# D-W6.6 — Class::MOP add_attribute loop drift reproducer. +# +# Recreates the exact shape of the Class::MOP bootstrap that fails +# during `use Class::MOP` with the gate disabled. The bootstrap calls +# `Class::MOP::Mixin::HasMethods->meta->add_attribute(...)` repeatedly +# for each MOP attribute. Each call: +# +# 1. _attach_attribute — sets `weaken($attr->{associated_class} = $self)`. +# 2. Stores attribute in `$self->{_attribute_map}{...}` (strong). +# 3. _post_add_attribute — `try { install_accessors } catch { ... }`. +# +# Both Perl 5 and PerlOnJava handle this correctly. The bare-Perl +# shape passes. The actual failure during real Class::MOP load must +# involve something more specific (multi-level @ISA, role +# composition, or a specific code path inside install_accessors). +use strict; +use warnings; +use Test::More; +use Try::Tiny; + +package Meta; +use Try::Tiny; +use Scalar::Util qw(weaken); + +sub new { bless { name => $_[1], _attrs => {} }, $_[0] } +sub name { $_[0]->{name} } +sub _attach_attribute { + my ($self, $attr) = @_; + $attr->{associated_class} = $self; + weaken($attr->{associated_class}); +} +sub _post_add_attribute { + my ($self, $attr) = @_; + try { + local $SIG{__DIE__}; + $attr->install_accessors; + } catch { + $self->remove_attribute($attr->{name}); + }; +} +sub add_attribute { + my ($self, $attribute) = @_; + $self->_attach_attribute($attribute); + $self->{_attrs}{$attribute->{name}} = $attribute; + $self->_post_add_attribute($attribute); +} +sub remove_attribute { + my ($self, $name) = @_; + my $attr = delete $self->{_attrs}{$name} or return; + $attr->remove_accessors; +} + +package Attr; +sub new { bless { %{$_[1]} }, $_[0] } +sub install_accessors { + my $self = shift; + my $reader = $self->{reader} or return; + if (ref $reader eq 'HASH') { + my $cls = $self->{associated_class}; + return if defined $cls; + die "install: associated_class UNDEF for $self->{name}!"; + } +} +sub remove_accessors { + my $self = shift; + return if defined $self->{associated_class}; + die "*** remove: UNDEF associated_class for $self->{name}"; +} + +package main; +our %METAS; + +my $cv = sub { 'reader' }; +my $meta = Meta->new('TestPkg'); +$METAS{TestPkg} = $meta; + +my $err = 0; +for my $n (qw(_method_map method_metaclass wrapped_method_metaclass + attribute_metaclass attribute_map list_methods + foo bar baz quux corge)) { + eval { + $meta->add_attribute(Attr->new({ + name => $n, + reader => { "${n}_reader" => $cv }, + })); + }; + if ($@) { $err++; diag("attr $n: $@") } +} +is $err, 0, '11 add_attribute iterations all succeed'; +is scalar(keys %{$meta->{_attrs}}), 11, '11 attributes registered'; +done_testing; diff --git a/src/test/resources/unit/refcount/drift/try_tiny_weak.t b/src/test/resources/unit/refcount/drift/try_tiny_weak.t new file mode 100644 index 000000000..e2e7f5d3c --- /dev/null +++ b/src/test/resources/unit/refcount/drift/try_tiny_weak.t @@ -0,0 +1,142 @@ +# D-W6.6 — Try::Tiny + weak back-ref drift reproducer. +# +# The actual failure during `use Class::MOP` (with the gate disabled) +# happens at Class/MOP/Class.pm:897 inside `_post_add_attribute`: +# +# sub _post_add_attribute { +# my ($self, $attribute) = @_; +# $self->invalidate_meta_instances; +# try { +# local $SIG{__DIE__}; +# $attribute->install_accessors; +# } catch { +# $self->remove_attribute($attribute->name); +# die $_; +# }; +# } +# +# In the catch path, `$attribute->associated_class()` (a WEAK ref to +# `$self`) reads as undef — even though `$self` is alive on the stack. +# +# Hypothesis: Try::Tiny's `try { ... } catch { ... }` builds a closure +# that captures `$self` and `$attr`. The captures drop refCount +# transiently. The captured weak-ref-target's refCount hits 0 and +# `clearWeakRefsTo` fires, wiping the back-ref before the catch +# reads it. +use strict; +use warnings; +use Test::More; +use Try::Tiny; +use Scalar::Util qw(weaken); + +# Bare-Perl recreation of Class::MOP's _post_add_attribute pattern. + +package Meta; +sub new { + my ($class, $name) = @_; + bless { name => $name }, $class; +} +sub name { $_[0]->{name} } +sub attach_attr { + my ($self, $attr) = @_; + $attr->{associated_class} = $self; + Scalar::Util::weaken($attr->{associated_class}); +} +sub remove_attr { + my ($self, $attr) = @_; + my $cls = $attr->associated_class; + die "associated_class is undef!" unless defined $cls; + return $cls->name; +} + +package Attr; +sub new { bless { name => $_[1] }, $_[0] } +sub name { $_[0]->{name} } +sub associated_class { $_[0]->{associated_class} } +sub install_accessors_with_die { + my $self = shift; + die "install_accessors deliberately dying\n"; +} + +package main; + +# ---- Pattern A: bare Try::Tiny + weak back-ref --------------------------- +{ + my $meta = Meta->new('FooA'); + my $attr = Attr->new('attr1'); + $meta->attach_attr($attr); + + my $caught_class; + try { + local $SIG{__DIE__}; + $attr->install_accessors_with_die; + } catch { + # In Class::MOP this is `$self->remove_attribute($attr->name)` + # which dispatches into _remove_accessor that calls + # `$attr->associated_class()`. The weak ref must still resolve. + $caught_class = $meta->remove_attr($attr); + }; + + is $caught_class, 'FooA', + 'A: weak ref to $meta survived Try::Tiny try/catch'; +} + +# ---- Pattern B: $self captured by try-block, no outer my-var ------------- +# Mimics _post_add_attribute where $self is a parameter, not a global. +sub do_post_add_attribute { + my ($self, $attr) = @_; + my $caught_class; + try { + local $SIG{__DIE__}; + $attr->install_accessors_with_die; + } catch { + $caught_class = $self->remove_attr($attr); + }; + return $caught_class; +} + +{ + my $meta = Meta->new('FooB'); + my $attr = Attr->new('attr1'); + $meta->attach_attr($attr); + my $r = do_post_add_attribute($meta, $attr); + is $r, 'FooB', + 'B: weak ref survives Try::Tiny inside a sub'; +} + +# ---- Pattern C: 20-iteration loop ---------------------------------------- +# Mimics Class::MOP bootstrap building many attributes. +{ + my @failures; + for my $i (1 .. 20) { + my $meta = Meta->new("PkgC$i"); + my $attr = Attr->new("attr$i"); + $meta->attach_attr($attr); + my $r = do_post_add_attribute($meta, $attr); + push @failures, $i unless defined $r && $r eq "PkgC$i"; + } + is scalar(@failures), 0, + 'C: 20 iterations of try/catch + weak ref all succeed'; +} + +# ---- Pattern D: meta in a global hash -------------------------------------- +# Mimics Class::MOP's %METAS pattern. +{ + package Reg; + our %METAS; + package main; + %Reg::METAS = (); + my @failures; + for my $i (1 .. 5) { + my $meta = Meta->new("PkgD$i"); + $Reg::METAS{"PkgD$i"} = $meta; + my $attr = Attr->new("attr$i"); + $meta->attach_attr($attr); + my $r = do_post_add_attribute($meta, $attr); + push @failures, $i unless defined $r && $r eq "PkgD$i"; + } + is scalar(@failures), 0, + 'D: 5 metaclasses in %METAS + try/catch + weak ref all succeed'; +} + +done_testing;