Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions dev/modules/moose_support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions src/test/resources/unit/refcount/drift/cmop_add_attr_loop.t
Original file line number Diff line number Diff line change
@@ -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;
142 changes: 142 additions & 0 deletions src/test/resources/unit/refcount/drift/try_tiny_weak.t
Original file line number Diff line number Diff line change
@@ -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;
Loading