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
2,723 changes: 894 additions & 1,829 deletions dev/modules/moose_support.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/main/java/org/perlonjava/core/Configuration.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public final class Configuration {
* Automatically populated by Gradle/Maven during build.
* DO NOT EDIT MANUALLY - this value is replaced at build time.
*/
public static final String gitCommitId = "d7eacf972";
public static final String gitCommitId = "4d19735d1";

/**
* Git commit date of the build (ISO format: YYYY-MM-DD).
Expand All @@ -48,7 +48,7 @@ public final class Configuration {
* Parsed by App::perlbrew and other tools via: perl -V | grep "Compiled at"
* DO NOT EDIT MANUALLY - this value is replaced at build time.
*/
public static final String buildTimestamp = "Apr 28 2026 21:49:56";
public static final String buildTimestamp = "Apr 28 2026 23:34:37";

// Prevent instantiation
private Configuration() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
*/
public class DestroyDispatch {

/** Phase D-W6 debug: enable destroy tracing via -Dperlonjava.destroyTrace=1
* or env PJ_DESTROY_TRACE=1. */
private static final boolean DESTROY_TRACE =
"1".equals(System.getProperty("perlonjava.destroyTrace"))
|| "1".equals(System.getenv("PJ_DESTROY_TRACE"));

// BitSet indexed by |blessId| — set if the class defines DESTROY (or AUTOLOAD)
private static final BitSet destroyClasses = new BitSet();

Expand Down Expand Up @@ -75,44 +81,6 @@ public static java.util.List<RuntimeBase> snapshotRescuedForWalk() {
* @param className the Perl class name
* @return true if DESTROY (or AUTOLOAD) is defined in the class hierarchy
*/
/**
* Phase D-W2c: walker-gated destroy is restricted to known-needed
* class hierarchies (Class::MOP and Moose / Moo). The gate is
* essential for those modules' bootstrap (their metaclasses and
* %METAS rely on transient refCount drift being absorbed by the
* walker), but it actively breaks DBIC's lazy-cache pattern and
* other CDBI / DBIx::Class flows where rows are MEANT to be
* destroyed at refCount=0 even when stack-local my-vars
* transiently reference them.
*
* The gate applies if and only if the class is in the
* Class::MOP / Moose family. The check is fast: a per-blessId
* BitSet lookup after the first miss-and-resolve.
*
* Patterns outside this family (e.g. user weak-ref cycles
* documented in dev/sandbox/walker_gate_dbic_minimal.t) do NOT
* get the gate; they were already broken on master and need a
* separate fix path.
*/
private static final java.util.BitSet walkerGateClasses = new java.util.BitSet();
private static final java.util.BitSet walkerGateChecked = new java.util.BitSet();

public static boolean classNeedsWalkerGate(int blessId) {
int idx = Math.abs(blessId);
if (walkerGateChecked.get(idx)) return walkerGateClasses.get(idx);
String cn = NameNormalizer.getBlessStr(blessId);
boolean needs = cn != null && (
cn.startsWith("Class::MOP")
|| cn.startsWith("Moose::")
|| cn.equals("Moose")
|| cn.startsWith("Moo::")
|| cn.equals("Moo")
);
walkerGateChecked.set(idx);
if (needs) walkerGateClasses.set(idx);
return needs;
}

public static boolean classHasDestroy(int blessId, String className) {
int idx = Math.abs(blessId);
if (destroyClasses.get(idx)) return true;
Expand Down Expand Up @@ -146,6 +114,24 @@ public static void invalidateCache() {
public static void callDestroy(RuntimeBase referent) {
// refCount is already MIN_VALUE (set by caller)

// Phase D-W6 debug: optional trace of every destroy call.
// Enable with -Dperlonjava.destroyTrace=1 (or env PJ_DESTROY_TRACE=1)
// to find refCount-drift sources.
if (DESTROY_TRACE) {
String klass = referent.blessId != 0
? NameNormalizer.getBlessStr(referent.blessId)
: referent.getClass().getSimpleName();
String extra = "";
if (referent instanceof RuntimeCode rc) {
extra = " name=" + (rc.packageName != null ? rc.packageName : "?")
+ "::" + (rc.subName != null ? rc.subName : "(anon)");
}
System.err.println("[DESTROY] " + klass + "@"
+ System.identityHashCode(referent)
+ " refCount=" + referent.refCount + extra);
new RuntimeException("destroy trace").printStackTrace(System.err);
}

// Phase 3 (refcount_alignment_plan.md): Re-entry guard.
// If this object is already inside its own DESTROY body, a transient
// decrement-to-0 (local temp release, deferred MortalList flush,
Expand Down
41 changes: 22 additions & 19 deletions src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java
Original file line number Diff line number Diff line change
Expand Up @@ -557,28 +557,31 @@ public static void flush() {
// so the clear is no longer needed and broke #76716.
} else if (base.blessId != 0
&& WeakRefRegistry.hasWeakRefsTo(base)
&& DestroyDispatch.classNeedsWalkerGate(base.blessId)
&& ReachabilityWalker.isReachableFromRoots(base)) {
// Phase D / Step W3-Path 2: blessed object with
// outstanding weak refs whose cooperative refCount
// dipped to 0 under deferred-decrement flush, BUT
// the walker can still reach it from package globals
// or hash/array element seeds. Treat as transient
// refCount drift — leave at 0; the next assignment
// that writes a tracked ref will bump it back up.
// Phase D-W6 (deferred): walker gate retained as
// a temporary safeguard until cooperative
// refCount accuracy is audited and fixed at the
// sources documented in
// dev/modules/moose_support.md (Phase D-W6).
//
// Don't fire DESTROY, don't clear weak refs.
// Empirically: removing the gate makes cycles
// leak correctly (52leaks.t passes), DESTROY
// fires twice on the second-DESTROY pattern
// (txn_scope_guard.t passes), and DBIC row
// construction stops losing column data
// (cdbi/04-lazy.t passes). But it also breaks
// Moose bootstrap because anonymous CVs are
// briefly the only ref to themselves before
// being installed in package stashes; the
// cooperative refCount transient-drops to 0,
// DESTROY fires on a CV that should still be
// live, and Class::MOP::Class loses its
// `initialize` method.
//
// The walker correctly distinguishes this case from
// the cycle-break-via-weaken case: an isolated
// cycle has no path to roots, so isReachableFromRoots
// returns false and the cycle is properly destroyed.
//
// The hasWeakRefsTo gate keeps this safeguard cheap
// for the overwhelmingly common case of objects
// without weak refs (no walker call needed).
//
// See dev/modules/moose_support.md (Phase D / Step W).
// The walker check absorbs this drift safely.
// It is no longer class-name-based (PR #599)
// and uses MyVarCleanupStack-seeded reachability
// so live `my` variables pin their referents.
} else {
base.refCount = Integer.MIN_VALUE;
DestroyDispatch.callDestroy(base);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1197,18 +1197,9 @@ private RuntimeScalar setLargeRefCounted(RuntimeScalar value) {
// Cleanup will happen at scope exit (scopeExitCleanupHash/Array).
} else if (oldBase.blessId != 0
&& WeakRefRegistry.hasWeakRefsTo(oldBase)
&& DestroyDispatch.classNeedsWalkerGate(oldBase.blessId)
&& ReachabilityWalker.isReachableFromRoots(oldBase)) {
// Phase D / Step W3-Path 2: mirror of the gate in
// MortalList.flush(). Blessed object with outstanding
// weak refs whose cooperative refCount dipped to 0
// under an overwrite, but the walker says it's still
// reachable from roots (e.g. held by `our %METAS`).
// Treat as transient refCount drift; don't fire
// DESTROY; don't clear weak refs.
//
// See MortalList.flush() for full rationale and
// dev/modules/moose_support.md (Phase D / Step W).
// Phase D-W6 (deferred): see MortalList.flush() for
// rationale — gate retained until refCount audit.
} else {
oldBase.refCount = Integer.MIN_VALUE;
DestroyDispatch.callDestroy(oldBase);
Expand Down
104 changes: 104 additions & 0 deletions src/test/resources/unit/refcount/drift/sub_install.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# D-W6.1 — Sub-installation drift reproducer.
#
# Tracing `PJ_DESTROY_TRACE=1 ./jperl -e 'use Class::MOP::Class'` revealed
# two specific patterns where anonymous CVs are getting refCount=0
# transiently with the walker gate disabled:
#
# 1. `Sub::Install`'s anon CVs during `install_sub({ code => $cv, ... })`.
# 2. `Module::Implementation`'s `try { ... } catch { ... }` block CVs.
#
# Both patterns share a shape: an anonymous CV is created, passed through
# `@_` to a subroutine, the subroutine stores or invokes it, and the
# original CV's container scope completes — and at that point the CV's
# cooperative refCount drops to zero even though the receiver's structure
# (a closure-captured array, a hash slot, a glob slot) still holds it.
#
# This file recreates each pattern in bare Perl.
use strict;
use warnings;
use Test::More;

# ---- Pattern A: install_sub-shaped pass-through --------------------------
# Mimics Sub::Install's `install_sub({ code => sub { ... }, ... })`.
# A hashref containing the anonymous CV is built, passed to a function,
# the function stores the CV in a package stash, the hashref scope ends.
sub install_via_args {
my $args = shift;
no strict 'refs';
*{ $args->{into} . '::' . $args->{as} } = $args->{code};
}

install_via_args({
code => sub { 'A-result' },
into => 'D_W6_1_A',
as => 'method',
});

ok exists &D_W6_1_A::method, 'A: install_sub-shaped CV present in stash';
is D_W6_1_A->method, 'A-result',
'A: install_sub-shaped CV callable after caller scope ends';

# ---- Pattern B: try/catch-shaped block invocation ------------------------
# Mimics `Try::Tiny`'s `try { ... } catch { ... }`. Two CVs are passed by
# argument; the receiver eval-runs the first, optionally calls the second.
sub mini_try {
my ($try_cv, $catch_cv) = @_;
my $r = eval { $try_cv->() };
if (!defined $r && $catch_cv) {
$r = $catch_cv->($@);
}
return $r;
}

is mini_try(sub { 'no-error' }), 'no-error',
'B: try-shaped success path returns CV result';
is mini_try(sub { die "boom\n" }, sub { my $e = shift; "caught: $e" }),
"caught: boom\n",
'B: try-shaped error path runs catch CV';

# Loop variant — Module::Implementation does this in a list of candidates.
my @candidates = map {
my $i = $_;
sub { "try-$i" };
} (1 .. 10);
my $hit = 0;
for my $cv (@candidates) {
$hit++ if mini_try($cv) =~ /^try-/;
}
is $hit, 10, 'B: 10 try-shaped CVs all callable through pass-through';

# ---- Pattern C: temp lexical drop, then call through stash ---------------
# This is the precise shape of Sub::Install's failure: the original lexical
# holding the CV is dropped after install_sub returns, leaving the stash
# slot as the only strong holder.
{
no strict 'refs';
my $temp_cv = sub { 'C-from-temp' };
install_via_args({
code => $temp_cv,
into => 'D_W6_1_C',
as => 'method',
});
$temp_cv = undef; # explicit drop
}
ok exists &D_W6_1_C::method, 'C: stash holds CV after temp dropped';
is D_W6_1_C->method, 'C-from-temp', 'C: stash CV still callable';

# ---- Pattern D: pass CV through @_ then return it ------------------------
# `Sub::Install` and many other frameworks pass a CV through one or more
# layers of indirection before installing it. Each layer's `shift`/`return`
# must preserve the refCount.
sub return_arg { return $_[0] }
sub indirect_return { return return_arg(shift) }
sub deep_return { return indirect_return(shift) }

{
no strict 'refs';
my $cv = sub { 'D-deep' };
*{"D_W6_1_D::method"} = deep_return($cv);
$cv = undef;
}
ok exists &D_W6_1_D::method, 'D: deeply-passed CV present in stash';
is D_W6_1_D->method, 'D-deep', 'D: deeply-passed CV callable';

done_testing;
Loading