diff --git a/dev/design/perl-ownership-rewrite.md b/dev/design/perl-ownership-rewrite.md
index 6759cfe64f..3a9c8efb19 100644
--- a/dev/design/perl-ownership-rewrite.md
+++ b/dev/design/perl-ownership-rewrite.md
@@ -293,8 +293,26 @@ and first path establish implementation cost.
target Perl loads the helper. These are pre-existing issues tracked in
[#1656](https://github.com/fglock/PerlOnJava/issues/1656) and
[#1434](https://github.com/fglock/PerlOnJava/issues/1434); the DBIx run's two
- assertion failures remain separately unclassified under
+ assertion failures are tracked separately under
[#1646](https://github.com/fglock/PerlOnJava/issues/1646).
+- [x] Classify DBIx::Class 0.082844 `t/storage/error.t` assertion 49 on the
+ current lifecycle candidate: system Perl passes, and the same failure
+ reproduces on the clean master baseline. A clean rebuild of the current
+ candidate still fails 48/49 assertions. The weak schema captured by DBI's
+ handler remains live after `$schema` is cleared; after a forced quiet GC,
+ owner tracing reports no active scalar owners or capture slots despite the
+ stale positive refcount. The exact stale-count/root cause remains open under
+ #1642; rerun after that lifecycle work is reconciled. This does not count as
+ DBIx acceptance.
+- [x] Rerun the three DBIx lifecycle files together on the clean candidate.
+ `t/storage/txn_scope_guard.t` now passes 18/18 and `t/52leaks.t` passes 11/11
+ (including their TODO skips); the prior clean-master run failed the scope
+ guard assertion, and the earlier leak outcomes varied. `t/storage/error.t`
+ remains at 48/49, with assertion 49 failing as it does on clean master while
+ passing on system Perl. A reduced scope-guard reproducer passes on system
+ Perl, JVM, interpreter, and clean master, so it is not standalone regression
+ evidence. Full DBIx acceptance remains open until #1642 is reconciled and
+ the complete suite is rerun.
### Next steps
diff --git a/docs/about/changelog.md b/docs/about/changelog.md
index 0404c5f142..0f7aa271f4 100644
--- a/docs/about/changelog.md
+++ b/docs/about/changelog.md
@@ -12,6 +12,10 @@ priorities and future plans.
- Snapshot mutable array entries while marking package roots and during reachability walks so concurrent thread teardown cannot fail with `ConcurrentModificationException`.
- Enforce restricted-hash key access and implement `Hash::Util` key, value, and hash locking (#1502, #1172).
+- Preserve captured referent ownership when foreach lexicals alias aggregate cells, fixing prompt cleanup of nested one-shot callback owners (#1646).
+- Match `B::REFCNT` to native B's treatment of its wrapper reference, preventing false resurrection from a schema's sole source registry owner (#1646).
+- Re-fire `DESTROY` after a resurrected object's final captured owner is released, even when a full-root snapshot still includes the earlier caller frame (#1646).
+- Recheck nested weak references after blessed container destruction returns, clearing DBIx::Class storage callbacks once their schema is gone (#1646).
- Report the caller's scalar context correctly when inspecting frames in overloaded exceptions (#1859).
- Avoid heap exhaustion when compiling very large generated subroutines, and preserve heredoc statement boundaries with `\&name` fallbacks (#1252).
- Cover nested require caller packages and imported `CLASS` constants, and match defer ordering and caller frames around loop `continue` blocks (#1601, #1405, #1383).
diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java
index 9de201e79b..781cd33f41 100644
--- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java
+++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java
@@ -145,7 +145,7 @@ private static void releaseConsumedTemp(RuntimeBase temp, RuntimeBase target) {
return;
}
- tempScalar.refCountOwned = false;
+ tempScalar.setRefCountOwned(false);
if (base.refCountTrace) {
base.traceRefCount(-1, "BytecodeInterpreter.releaseConsumedTemp");
base.releaseOwner(tempScalar, "releaseConsumedTemp");
diff --git a/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java b/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java
index 4df3e1b7af..3a2b83481f 100644
--- a/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java
+++ b/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java
@@ -1355,7 +1355,7 @@ private static void releaseHashSliceTemporaryOwner(RuntimeScalar value) {
base.releaseOwner(value, "HASH_SLICE_SET temporary owner transfer");
base.releaseActiveOwner(value);
base.refCount--;
- value.refCountOwned = false;
+ value.setRefCountOwned(false);
}
}
diff --git a/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java b/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java
index 6c9cf65989..6278889d08 100644
--- a/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java
+++ b/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java
@@ -224,7 +224,7 @@ private static RuntimeScalar bless(RuntimeScalar runtimeScalar, RuntimeScalar cl
// tracking wasn't active at assignment time). Count it as 1.
referent.refCount = 1;
referent.recordOwner(runtimeScalar, "rebless from untracked");
- runtimeScalar.refCountOwned = true;
+ runtimeScalar.setRefCountOwned(true);
} else {
// First bless: start at refCount=1 and add to MortalList.
// The mortal entry will decrement back to 0 at the next
@@ -257,7 +257,7 @@ private static RuntimeScalar bless(RuntimeScalar runtimeScalar, RuntimeScalar cl
referent.refCount = existingScalarOwner ? 2 : 1;
if (existingScalarOwner) {
referent.recordOwner(runtimeScalar, "first bless of existing scalar ref");
- runtimeScalar.refCountOwned = true;
+ runtimeScalar.setRefCountOwned(true);
}
referent.acquireTransientTraceOwner("bless mortal temporary",
"ReferenceOperators.first bless");
diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java b/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java
index c3a1d3afd4..1e88809fa8 100644
--- a/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java
+++ b/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java
@@ -12,7 +12,6 @@
* The Strict class provides functionalities similar to the Perl strict module.
*/
public class Internals extends PerlModuleBase {
-
/**
* Constructor initializes the module.
*/
@@ -543,24 +542,9 @@ public static RuntimeList svRefcount(RuntimeArray args, int ctx) {
// compensates for the ampersand-call argument alias below.
boolean compileHintsHash = base == GlobalVariable.getGlobalHash(
GlobalContext.encodeSpecialVar("H"));
+ boolean bObjectProbe = args.size() > 1 && args.get(1).getBoolean();
int extra = (base.localBindingExists && !compileHintsHash ? 1 : 0)
+ base.foreachAliasCount;
- if (rc == 2
- && args.size() > 1
- && args.get(1).getBoolean()
- && DestroyDispatch.isInsideDestroy()
- && !ReachabilityWalker.hasLiveStrongScalarReferentOtherThan(base, arg)) {
- // B::SV's private hash slot is one of the two selective
- // owners. Ordinarily it is the temporary owner discounted
- // below (a single live lexical therefore reports one). If
- // there is no independently live scalar pad, the other owner
- // is a real aggregate slot, as in DBIx::Class Schema's source
- // registry during DESTROY, and must remain visible to B. This
- // distinction is only meaningful while DESTROY is active:
- // outside it, an unregistered lexical can look identical and
- // must still report its single owner.
- extra++;
- }
// Legacy fudge: anonymous tracked container with no counted
// owners -- still report 1 to indicate "live SV". Used by
// Sub::Quote / Moo introspection paths that probe for liveness.
@@ -576,8 +560,8 @@ public static RuntimeList svRefcount(RuntimeArray args, int ctx) {
boolean fieldOwnedMethodResult = rc == 1
&& !base.localBindingExists
&& base.hashSlotOwnerCount > 0
- && !ReachabilityWalker.hasLiveStrongScalarReferentOtherThan(base, arg);
- boolean bObjectProbe = args.size() > 1 && args.get(1).getBoolean();
+ && (bObjectProbe
+ || !ReachabilityWalker.hasLiveStrongScalarReferentOtherThan(base, arg));
int adjust = compileHintsHash
? -1
: base.localBindingExists || fieldOwnedMethodResult ? 0 : -1;
@@ -589,7 +573,17 @@ public static RuntimeList svRefcount(RuntimeArray args, int ctx) {
if (bObjectProbe && base.localBindingExists && !compileHintsHash) {
adjust = -1;
}
- return new RuntimeScalar(rc + extra + adjust).getList();
+ boolean insideDestroy = DestroyDispatch.isInsideDestroy();
+ int staleHashOwners = 0;
+ if (rc > 1 && bObjectProbe && !base.localBindingExists && insideDestroy) {
+ int[] staleOwners = base.unreachableDirectHashSlotOwnerCounts();
+ // Once a counted owner has leaked, include every unreachable
+ // hash slot in the correction. Some slots retain the raw
+ // refcount after their wrapper has already cleared its flag.
+ if (staleOwners[0] > 0) staleHashOwners = staleOwners[1];
+ }
+ int reported = Math.max(0, rc + extra + adjust - staleHashOwners);
+ return new RuntimeScalar(reported).getList();
}
return new RuntimeScalar(1).getList();
}
@@ -613,12 +607,16 @@ public static RuntimeList jperlReferenceByAddress(RuntimeArray args, int ctx) {
*
{@code refCount} — raw {@link RuntimeBase#refCount}
* {@code localBindingExists} — true when a named-variable slot still holds the container
* {@code destroyFired} — true once DESTROY has run
+ * {@code needs_re_destroy} — true when a later release should re-fire DESTROY
+ * {@code currently_destroying} — true while the Perl DESTROY body is active
+ * {@code perl_lifecycle_state} — current explicit destruction lifecycle state
* {@code blessId} — bless class id (0 = unblessed)
* {@code class_name} — Perl class name (empty string if unblessed)
* {@code kind} — runtime type: SCALAR / ARRAY / HASH / CODE / GLOB / OTHER
* {@code has_weak_refs} — true if the weak-ref registry has entries pointing here
* {@code active_owner_count} — live scalar-store owners currently tracked for diagnostics
* {@code semantic_capture_owner_count} — distinct captured pad owners
+ * {@code hash_slot_owner_count} — direct strong owners in hash slots
*
*/
public static RuntimeList jperl_refstate(RuntimeArray args, int ctx) {
@@ -628,11 +626,15 @@ public static RuntimeList jperl_refstate(RuntimeArray args, int ctx) {
result.put("refCount", new RuntimeScalar(base.refCount));
result.put("localBindingExists", new RuntimeScalar(base.localBindingExists));
result.put("destroyFired", new RuntimeScalar(base.destroyFired));
+ result.put("needs_re_destroy", new RuntimeScalar(base.needsReDestroy));
+ result.put("currently_destroying", new RuntimeScalar(base.currentlyDestroying));
+ result.put("perl_lifecycle_state", new RuntimeScalar(base.perlLifecycleState().name()));
result.put("blessId", new RuntimeScalar(base.blessId));
String className = NameNormalizer.getBlessStr(base.blessId);
result.put("class_name", new RuntimeScalar(className == null ? "" : className));
result.put("active_owner_count", new RuntimeScalar(base.activeOwnerCount()));
result.put("semantic_capture_owner_count", new RuntimeScalar(base.semanticCaptureOwnerCount()));
+ result.put("hash_slot_owner_count", new RuntimeScalar(base.hashSlotOwnerCount));
String kind = "OTHER";
if (base instanceof RuntimeGlob) kind = "GLOB";
else if (base instanceof RuntimeHash) kind = "HASH";
diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Storable.java b/src/main/java/org/perlonjava/runtime/perlmodule/Storable.java
index c81c9751ef..455c8650b5 100644
--- a/src/main/java/org/perlonjava/runtime/perlmodule/Storable.java
+++ b/src/main/java/org/perlonjava/runtime/perlmodule/Storable.java
@@ -352,7 +352,7 @@ public static void releaseApplyArgs(RuntimeArray args) {
base.releaseOwner(elem, "Storable.releaseApplyArgs");
base.releaseActiveOwner(elem);
base.refCount--;
- elem.refCountOwned = false;
+ elem.setRefCountOwned(false);
}
}
args.elements.clear();
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/AutovivificationArray.java b/src/main/java/org/perlonjava/runtime/runtimetypes/AutovivificationArray.java
index dc8b750c16..dabd69c23c 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/AutovivificationArray.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/AutovivificationArray.java
@@ -47,7 +47,12 @@ public static void vivify(RuntimeArray array) {
RuntimeScalar referent = arrayProxy.scalarToAutovivify;
if (referent instanceof RuntimeBaseProxy proxy) {
- proxy.set(array.createReference());
+ // A proxy is an aggregate element slot, not the local pad
+ // binding for a named @array. Marking this array as
+ // localBindingExists would keep it alive after its hash/array
+ // slot is replaced, so captured callbacks in the old array
+ // would never be released.
+ proxy.set(array.createAnonymousReference());
} else {
referent.value = array;
referent.type = RuntimeScalarType.ARRAYREFERENCE;
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java b/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java
index 9b6049b2eb..5d2f75f74a 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java
@@ -528,7 +528,7 @@ private static void doCallDestroy(RuntimeBase referent, String className) {
}
base.releaseActiveOwner(elem);
base.refCount--;
- elem.refCountOwned = false;
+ elem.setRefCountOwned(false);
}
}
args.elements.clear();
@@ -635,6 +635,16 @@ private static void doCallDestroy(RuntimeBase referent, String className) {
RuntimeScalar.scopeExitCleanup(scalar);
MortalList.flush();
}
+ // A blessed container may hold weakly observed objects whose
+ // selective counts remain inflated after its fields are released.
+ // Reconsider those descendants after DESTROY has returned, when
+ // its temporary $self lexical is no longer a Perl root. DBIx::Class
+ // keeps a weak Storage reference in the DBI error callback and
+ // expects it to be cleared as soon as the owning Schema is gone.
+ if ((referent instanceof RuntimeHash || referent instanceof RuntimeArray)
+ && MortalList.requestWeakSweepsForDestroyedContainer(referent)) {
+ requestSweepAfterOuterDestroy();
+ }
} catch (Exception e) {
String msg = e.getMessage();
if (msg == null) msg = e.getClass().getName();
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalar.java
index 61b73e7919..05811f8c9d 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalar.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalar.java
@@ -227,7 +227,7 @@ public void dynamicRestoreState() {
&& localVar.value instanceof RuntimeBase base
&& base.refCount > 0) {
displacedBase = base;
- localVar.refCountOwned = false;
+ localVar.setRefCountOwned(false);
displacedBase.releaseActiveOwner(localVar);
}
if (localVar != null
@@ -281,7 +281,7 @@ public void dynamicRestoreState() {
localVar.value = saved.originalVariable.value;
localVar.blessId = saved.originalVariable.blessId;
}
- localVar.refCountOwned = false;
+ localVar.setRefCountOwned(false);
}
if (localVar != null) {
localVar.ownsScalarReferenceContents = false;
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java
index 3f496e2b6f..31772acee8 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java
@@ -540,7 +540,7 @@ public static void deferDecrementIfTracked(RuntimeScalar scalar) {
|| scalar.type == RuntimeScalarType.GLOB)
&& scalar.value instanceof RuntimeBase base) {
if (base.refCount > 0) {
- scalar.refCountOwned = false;
+ scalar.setRefCountOwned(false);
if (base.refCountTrace) {
base.traceRefCount(0, "MortalList.deferDecrementIfTracked (queued, scalar.refCountOwned->false)");
}
@@ -559,7 +559,7 @@ public static void deferDecrementIfTracked(RuntimeScalar scalar) {
// that stale ownership marker and ask the reachability walker to
// decide at the next safe (non-nested) boundary whether the weak
// observers should be cleared.
- scalar.refCountOwned = false;
+ scalar.setRefCountOwned(false);
if (base.refCountTrace) {
base.releaseOwner(scalar, "deferDecrementIfTracked zero-count owner");
}
@@ -893,7 +893,7 @@ public static void releaseTailCallCodeRef(RuntimeScalar codeRef) {
return;
}
- codeRef.refCountOwned = false;
+ codeRef.setRefCountOwned(false);
if (code.refCountTrace) {
code.traceRefCount(-1, "MortalList.releaseTailCallCodeRef");
code.releaseOwner(codeRef, "releaseTailCallCodeRef");
@@ -925,7 +925,7 @@ private static void releaseTailCallArgElement(RuntimeScalar scalar) {
return;
}
- scalar.refCountOwned = false;
+ scalar.setRefCountOwned(false);
if (base.refCountTrace) {
base.traceRefCount(-1, "MortalList.releaseTailCallArgElement");
base.releaseOwner(scalar, "releaseTailCallArgElement");
@@ -1009,7 +1009,7 @@ private static void deferDecrementRecursive(RuntimeScalar scalar) {
if (base.blessId != 0) {
boolean releasingLastOwner = false;
if (s.refCountOwned && base.refCount > 0) {
- s.refCountOwned = false;
+ s.setRefCountOwned(false);
if (base.refCountTrace) {
base.traceRefCount(0, "MortalList.deferDecrementRecursive (blessed, queued)");
base.releaseOwner(s, "deferDecrementRecursive blessed");
@@ -1057,7 +1057,7 @@ private static void deferDecrementRecursive(RuntimeScalar scalar) {
boolean hasCleanupTargets = hasDirectWeakElementRefs
|| containerMayContainCleanupTargets(base);
if (s.refCountOwned && base.refCount > 0) {
- s.refCountOwned = false;
+ s.setRefCountOwned(false);
if (base.refCountTrace) {
base.traceRefCount(0, "MortalList.deferDecrementRecursive (unblessed container, queued)");
}
@@ -1183,7 +1183,7 @@ public static void mortalizeForVoidDiscard(RuntimeList result) {
&& ReachabilityWalker.isScalarReachable(scalar);
if (scalar.refCountOwned && base.refCount > 0 && unblessedDiscardableContainer
&& !reachableCodeRef) {
- scalar.refCountOwned = false;
+ scalar.setRefCountOwned(false);
if (base.refCountTrace) {
base.traceRefCount(0, "MortalList.mortalizeForVoidDiscard (queued owned container discard)");
base.releaseOwner(scalar, "mortalizeForVoidDiscard container");
@@ -1519,13 +1519,32 @@ private static boolean isReachableCached(RuntimeBase base) {
static boolean isReachableFromExternalRootCached(RuntimeBase base) {
if (ReachabilityWalker.isReachableFromTemporaryRoots(base)) {
+ if (base != null && base.refCountTrace) {
+ System.err.println("[REFCOUNT-KEEP] base=" + System.identityHashCode(base)
+ + " reason=temporary-root");
+ }
return true;
}
- return isReachableFromPersistentRootCached(base);
+ boolean reachable = isReachableFromPersistentRootCached(base);
+ if (reachable && base != null && base.refCountTrace) {
+ System.err.println("[REFCOUNT-KEEP] base=" + System.identityHashCode(base)
+ + " reason=persistent-root");
+ }
+ return reachable;
}
static boolean isReachableFromPersistentRootCached(RuntimeBase base) {
LifecycleRuntimeState state = state();
+ // Check live lexicals before the package graph. Targeted weak sweeps
+ // often revisit objects that are still held by a current lexical; a
+ // positive lexical proof avoids walking the much larger, stable global
+ // graph for those candidates.
+ if (state.liveRootSnapshot == null) {
+ state.liveRootSnapshot = new ReachabilityWalker.LiveRootSnapshot();
+ }
+ if (state.liveRootSnapshot.isReachable(base)) {
+ return true;
+ }
if (state.externalRootSnapshot == null) {
state.externalRootSnapshot = new ReachabilityWalker.ExternalRootSnapshot();
}
@@ -1539,10 +1558,7 @@ static boolean isReachableFromPersistentRootCached(RuntimeBase base) {
// direct-lexical exclusion semantics.
return true;
}
- if (state.liveRootSnapshot == null) {
- state.liveRootSnapshot = new ReachabilityWalker.LiveRootSnapshot();
- }
- return state.liveRootSnapshot.isReachable(base);
+ return false;
}
static void invalidateExternalRootSnapshot() {
@@ -1562,7 +1578,12 @@ private static boolean isReachableFromFullRootSnapshot(RuntimeBase base) {
if (state.fullRootSnapshot == null) {
state.fullRootSnapshot = ReachabilityWalker.reachableFromRootsSnapshotWithStatus();
}
- return state.fullRootSnapshot.isReachable(base);
+ boolean reachable = state.fullRootSnapshot.isReachable(base);
+ if (reachable && base != null && base.refCountTrace) {
+ System.err.println("[REFCOUNT-KEEP] base=" + System.identityHashCode(base)
+ + " reason=full-root");
+ }
+ return reachable;
}
static boolean isUnblessedAggregateReachableDuringDrain(RuntimeBase base) {
@@ -1789,6 +1810,15 @@ private static void processDeferredBase(RuntimeBase base, boolean clearWeakRefsF
if (clearWeakRefsForLocalBinding) {
WeakRefRegistry.clearWeakRefsTo(base);
}
+ } else if (base.needsReDestroy && base.blessId != 0) {
+ // The object already ran DESTROY and was resurrected by a
+ // counted Perl owner. Reaching zero now releases that final
+ // owner, so honor Perl's re-fire before conservative weak-root
+ // recovery can restore the count from an in-flight caller
+ // snapshot left over from the first DESTROY.
+ base.refCount = Integer.MIN_VALUE;
+ DestroyDispatch.callDestroy(base);
+ return;
} else if (base instanceof RuntimeCode code
&& hasWeakRefs
&& (RuntimeCode.isActiveCode(code)
@@ -1899,6 +1929,13 @@ && canDeferCapturedTargetCheckToBoundarySweep(state())) {
// namespace. Preserve only objects proven reachable from a
// live Perl root; final owner removal still destroys them at
// a later boundary.
+ if (!isReachableFromPersistentRootCached(base)) {
+ // The full walker can see conservative in-flight roots
+ // that the persistent snapshot cannot classify. Recheck
+ // this target at the next statement boundary, once those
+ // transient owners unwind.
+ requestTargetedWeakSweep(base);
+ }
base.refCount = 1;
} else if (base.blessId != 0
&& hasWeakRefs
@@ -1948,6 +1985,10 @@ private static boolean blessedClassHasDestroy(RuntimeBase base) {
private static boolean restoreReachableOwnerCount(RuntimeBase base) {
int ownerCount = base.reachableOwnerCount();
if (ownerCount == 0) return false;
+ if (base.refCountTrace) {
+ System.err.println("[REFCOUNT-KEEP] base=" + System.identityHashCode(base)
+ + " reason=reachable-owner-count owners=" + ownerCount);
+ }
base.refCount = ownerCount;
return true;
}
@@ -2057,12 +2098,26 @@ private static void maybeAutoSweepAtStatementBoundary(
if (state.weakSweepSuppressionDepth > 0) return;
Set releasedTargets = Collections.emptySet();
ReachabilityWalker.ReleasedWeakSweepResult releasedSweepResult = null;
- if (!state.targetedWeakSweepReferents.isEmpty() && state.temporaryRoots.isEmpty()) {
+ if (!state.targetedWeakSweepReferents.isEmpty()) {
releasedTargets = Collections.newSetFromMap(new IdentityHashMap<>());
- releasedTargets.addAll(state.targetedWeakSweepReferents);
- state.targetedWeakSweepReferents.clear();
- releasedSweepResult =
- ReachabilityWalker.sweepReleasedWeakReferents(releasedTargets);
+ for (RuntimeBase referent : new ArrayList<>(state.targetedWeakSweepReferents)) {
+ // Temporary roots are transient: leave their candidates queued
+ // so the next boundary can retry after the expression unwinds.
+ if (ReachabilityWalker.isReachableFromTemporaryRoots(referent)) continue;
+ // Persistent roots will request another sweep when their owner
+ // exits. Drop the candidate now so every later statement does
+ // not repeatedly traverse the live/package root snapshots.
+ if (isReachableFromPersistentRootCached(referent)) {
+ state.targetedWeakSweepReferents.remove(referent);
+ continue;
+ }
+ releasedTargets.add(referent);
+ state.targetedWeakSweepReferents.remove(referent);
+ }
+ if (!releasedTargets.isEmpty()) {
+ releasedSweepResult =
+ ReachabilityWalker.sweepReleasedWeakReferents(releasedTargets);
+ }
}
if (topLevel) {
maybeAutoSweep(state, releasedTargets, releasedSweepResult);
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/MyVarCleanupStack.java b/src/main/java/org/perlonjava/runtime/runtimetypes/MyVarCleanupStack.java
index 807c62df40..2cdf185b95 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/MyVarCleanupStack.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/MyVarCleanupStack.java
@@ -362,7 +362,7 @@ public static void retainBlessedGlobAliases(RuntimeGlob glob, RuntimeBase refere
&& scalar.value == glob
&& !scalar.refCountOwned
&& seen.put(scalar, Boolean.TRUE) == null) {
- scalar.refCountOwned = true;
+ scalar.setRefCountOwned(true);
referent.recordOwner(scalar, "blessed glob lexical alias");
referent.recordActiveOwner(scalar);
referent.traceRefCount(+1, "MyVarCleanupStack.retainBlessedGlobAliases");
@@ -377,7 +377,7 @@ public static void retainBlessedGlobAliases(RuntimeGlob glob, RuntimeBase refere
&& scalar.value == glob
&& !scalar.refCountOwned
&& seen.put(scalar, Boolean.TRUE) == null) {
- scalar.refCountOwned = true;
+ scalar.setRefCountOwned(true);
referent.recordOwner(scalar, "blessed glob package alias");
referent.recordActiveOwner(scalar);
referent.traceRefCount(+1, "MyVarCleanupStack.retainBlessedGlobAliases");
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java b/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java
index d5153b130e..3addd1d150 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java
@@ -1987,6 +1987,18 @@ public static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnl
}
}
}
+ // Active Perl call arguments are owning roots until their frame
+ // returns. The full graph walk seeds these frames already; keep
+ // this target-specific query consistent so a hash passed through
+ // @_ is not mistaken for an abandoned owner.
+ for (RuntimeArray args : RuntimeCode.snapshotArgsStack()) {
+ if (!budget.visitEdge()) return true;
+ if (args == target) return true;
+ if (seen.add(args)) {
+ todo.addLast(args);
+ if (stats != null) stats.rootsSeeded++;
+ }
+ }
// Live regex lexicals own executable callback captures even when
// weak-ref tracking has not populated the optimized live-count
// snapshot. Seed only regex scalars from the actual cleanup stack
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArray.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArray.java
index 369ecc7732..5ffba17d7f 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArray.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArray.java
@@ -698,7 +698,7 @@ public static RuntimeScalar pop(RuntimeArray runtimeArray) {
&& (result.type & RuntimeScalarType.REFERENCE_BIT) != 0
&& result.value instanceof RuntimeBase base
&& base.refCount > 0) {
- result.refCountOwned = false;
+ result.setRefCountOwned(false);
runtimeArray.forgetOwnedAliasElement(result);
base.releaseActiveOwner(result);
MortalList.requestWeakSweepsForDestroyedContainer(base);
@@ -741,7 +741,7 @@ public static RuntimeScalar shift(RuntimeArray runtimeArray) {
&& (result.type & RuntimeScalarType.REFERENCE_BIT) != 0
&& result.value instanceof RuntimeBase base
&& base.refCount > 0) {
- result.refCountOwned = false;
+ result.setRefCountOwned(false);
runtimeArray.forgetOwnedAliasElement(result);
base.releaseActiveOwner(result);
MortalList.requestWeakSweepsForDestroyedContainer(base);
@@ -1757,7 +1757,7 @@ public RuntimeArray setFromListAliased(RuntimeList list) {
// overwrites will still work correctly because setLarge checks
// refCountOwned before decrementing.
for (RuntimeScalar elem : this.elements) {
- if (elem != null) elem.refCountOwned = false;
+ if (elem != null) elem.setRefCountOwned(false);
markPackageRootedValue(elem);
}
this.elementsOwned = false;
@@ -1788,7 +1788,7 @@ public RuntimeArray setFromReferenceList(RuntimeList list) {
RuntimeScalar referent = reference.refAliasScalarReference();
this.elements.add(referent);
markPackageRootedValue(referent);
- referent.refCountOwned = false;
+ referent.setRefCountOwned(false);
}
this.elementsOwned = false;
this.elementsAliased = true;
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java
index 553ac10c8b..aa6fe8a55a 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java
@@ -2,6 +2,9 @@
import java.util.Iterator;
import java.util.Objects;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.List;
import static org.perlonjava.runtime.runtimetypes.RuntimeScalarCache.scalarUndef;
@@ -198,6 +201,75 @@ boolean deferSharedDestroy() {
*/
public int hashSlotOwnerCount = 0;
+ private static final class DirectHashSlotOwner {
+ final WeakReference hash;
+ final WeakReference scalar;
+ boolean countedWhenAdded;
+
+ DirectHashSlotOwner(RuntimeHash hash, RuntimeScalar scalar) {
+ this.hash = new WeakReference<>(hash);
+ this.scalar = new WeakReference<>(scalar);
+ this.countedWhenAdded = scalar != null && scalar.refCountOwned;
+ }
+ }
+
+ private final List directHashSlotOwners = new ArrayList<>();
+
+ /** Track hash slots separately from their scalar wrappers, which may be replaced. */
+ public synchronized void updateDirectHashSlotOwner(
+ RuntimeHash owner, RuntimeScalar scalar, int delta) {
+ if (delta > 0) {
+ directHashSlotOwners.add(new DirectHashSlotOwner(owner, scalar));
+ return;
+ }
+ for (Iterator it = directHashSlotOwners.iterator(); it.hasNext();) {
+ DirectHashSlotOwner record = it.next();
+ RuntimeHash existing = record.hash.get();
+ RuntimeScalar existingScalar = record.scalar.get();
+ if (existing == null || existing == owner && existingScalar == scalar) {
+ it.remove();
+ if (existing == owner && existingScalar == scalar) return;
+ }
+ }
+ }
+
+ public synchronized void updateDirectHashSlotOwnerCounted(
+ RuntimeHash owner, RuntimeScalar scalar, boolean counted) {
+ for (DirectHashSlotOwner record : directHashSlotOwners) {
+ if (record.hash.get() == owner && record.scalar.get() == scalar) {
+ record.countedWhenAdded = counted;
+ }
+ }
+ }
+
+ /** Count direct hash slots whose owner hash is outside the Perl root graph. */
+ public synchronized int[] unreachableDirectHashSlotOwnerCounts() {
+ int countedUnreachable = 0;
+ int totalUnreachable = 0;
+ java.util.IdentityHashMap reachableCache =
+ new java.util.IdentityHashMap<>();
+ for (Iterator it = directHashSlotOwners.iterator(); it.hasNext();) {
+ DirectHashSlotOwner record = it.next();
+ RuntimeHash owner = record.hash.get();
+ RuntimeScalar scalar = record.scalar.get();
+ boolean counted = scalar == null ? record.countedWhenAdded : scalar.refCountOwned;
+ boolean unreachable;
+ if (owner == null) {
+ unreachable = true;
+ } else if (owner.perlLifecycleState() == PerlLifecycleState.DESTROYING) {
+ unreachable = false;
+ } else {
+ unreachable = reachableCache.computeIfAbsent(
+ owner, hash -> !ReachabilityWalker.isReachableFromRoots(hash));
+ }
+ if (unreachable) {
+ totalUnreachable++;
+ if (counted) countedUnreachable++;
+ }
+ }
+ return new int[]{countedUnreachable, totalUnreachable};
+ }
+
/**
* Marks a scalar/container whose contents are reachable from package
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java
index d7f02304e2..aba47d90a7 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java
@@ -596,7 +596,7 @@ private void restoreScalarReferenceOwnership(RuntimeScalar source, RuntimeScalar
referent.recordActiveOwner(target);
referent.refCount++;
referent.hadCountedReference = true;
- target.refCountOwned = true;
+ target.setRefCountOwned(true);
try (PerlRuntime.Binding ignored = targetRuntime.bind()) {
ScalarRefRegistry.registerRef(target);
}
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java
index d7ba5c74dd..eaca61812b 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java
@@ -214,7 +214,7 @@ public RuntimeScalar put(String key, RuntimeScalar value) {
}
owner.markPackageRootedValue(value);
RuntimeScalar result = super.put(key, value);
- updateReferentHashSlotOwnership(previous, value);
+ updateReferentHashSlotOwnership(owner, previous, value, true);
return result;
}
@@ -245,6 +245,9 @@ public void putAll(Map extends String, ? extends RuntimeScalar> m) {
}
owner.markPackageRootedValue(value);
}
+ for (Map.Entry extends String, ? extends RuntimeScalar> entry : m.entrySet()) {
+ updateReferentHashSlotOwnership(owner, super.get(entry.getKey()), entry.getValue(), false);
+ }
super.putAll(m);
}
@@ -252,6 +255,7 @@ public void putAll(Map extends String, ? extends RuntimeScalar> m) {
public RuntimeScalar remove(Object key) {
RuntimeScalar previous = super.remove(key);
if (previous != null) {
+ updateReferentHashSlotOwnership(owner, previous, null, false);
if (owner.isHintHash && !owner.suppressHintHashLifecycleCleanup) {
MortalList.deferDestroyForContainerClear(java.util.Collections.singletonList(previous));
}
@@ -263,6 +267,9 @@ public RuntimeScalar remove(Object key) {
@Override
public void clear() {
if (!isEmpty()) {
+ for (RuntimeScalar previous : values()) {
+ updateReferentHashSlotOwnership(owner, previous, null, false);
+ }
if (owner.isHintHash && !owner.suppressHintHashLifecycleCleanup) {
MortalList.deferDestroyForContainerClear(values());
}
@@ -707,16 +714,27 @@ private static RuntimeBase directReferent(RuntimeScalar scalar) {
}
private static void updateReferentHashSlotOwnership(
- RuntimeScalar oldValue, RuntimeScalar newValue) {
+ RuntimeHash owner, RuntimeScalar oldValue, RuntimeScalar newValue,
+ boolean updateLegacyCount) {
RuntimeBase oldReferent = directReferent(oldValue);
RuntimeBase newReferent = directReferent(newValue);
- if (oldReferent == newReferent) return;
- if (oldReferent != null && oldReferent.hashSlotOwnerCount > 0) {
- oldReferent.hashSlotOwnerCount--;
+ if (oldReferent == newReferent) {
+ if (oldReferent != null && oldValue != newValue) {
+ oldReferent.updateDirectHashSlotOwner(owner, oldValue, -1);
+ oldReferent.updateDirectHashSlotOwner(owner, newValue, 1);
+ }
+ return;
}
- if (newReferent != null) {
- newReferent.hashSlotOwnerCount++;
+ if (updateLegacyCount) {
+ if (oldReferent != null && oldReferent.hashSlotOwnerCount > 0) {
+ oldReferent.hashSlotOwnerCount--;
+ }
+ if (newReferent != null) {
+ newReferent.hashSlotOwnerCount++;
+ }
}
+ if (oldReferent != null) oldReferent.updateDirectHashSlotOwner(owner, oldValue, -1);
+ if (newReferent != null) newReferent.updateDirectHashSlotOwner(owner, newValue, 1);
}
/**
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeList.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeList.java
index a08f033257..fe9d18832b 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeList.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeList.java
@@ -792,7 +792,7 @@ public RuntimeArray setFromList(RuntimeList value) {
base.releaseOwner(rhsValue, "RuntimeList materialized-copy undo (undef-target)");
base.releaseActiveOwner(rhsValue);
base.refCount--;
- rhsValue.refCountOwned = false;
+ rhsValue.setRefCountOwned(false);
}
rhsIndex++;
}
@@ -816,7 +816,7 @@ public RuntimeArray setFromList(RuntimeList value) {
base.releaseOwner(assigned, "RuntimeList materialized-copy undo (scalar-target)");
base.releaseActiveOwner(assigned);
base.refCount--;
- assigned.refCountOwned = false;
+ assigned.setRefCountOwned(false);
}
} else if (elem instanceof RuntimeArray runtimeArray) {
List remaining = (rhsIndex < rhsSize)
@@ -842,7 +842,7 @@ public RuntimeArray setFromList(RuntimeList value) {
base.releaseOwner(r, "RuntimeList materialized-copy undo (array-target)");
base.releaseActiveOwner(r);
base.refCount--;
- r.refCountOwned = false;
+ r.setRefCountOwned(false);
}
}
} else {
@@ -877,7 +877,7 @@ public RuntimeArray setFromList(RuntimeList value) {
base.releaseOwner(r, "RuntimeList materialized-copy undo (hash-target)");
base.releaseActiveOwner(r);
base.refCount--;
- r.refCountOwned = false;
+ r.setRefCountOwned(false);
}
}
rhsIndex = rhsSize; // Consume the rest
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java
index 0ffffa56b9..7da83362cb 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java
@@ -659,6 +659,15 @@ void retainClosureCaptureReferentsForUnweaken() {
*/
public boolean refCountOwned;
+ /** Update ownership bookkeeping and any direct hash-slot witness for this cell. */
+ public void setRefCountOwned(boolean owned) {
+ this.refCountOwned = owned;
+ if (containerOwner instanceof RuntimeHash hashOwner
+ && value instanceof RuntimeBase referent) {
+ referent.updateDirectHashSlotOwnerCounted(hashOwner, this, owned);
+ }
+ }
+
/** A postfix ++/-- result transfers, rather than duplicates, its reference owner on assignment. */
private boolean postfixReferenceOwnershipTransfer;
@@ -2142,7 +2151,7 @@ public static void incrementRefCountForContainerStore(RuntimeScalar scalar) {
base.hadCountedReference = true;
base.recordOwner(scalar, "incrementRefCountForContainerStore");
base.recordActiveOwner(scalar);
- scalar.refCountOwned = true;
+ scalar.setRefCountOwned(true);
// Phase B1 (refcount_alignment_52leaks_plan.md): track the
// container element so ReachabilityWalker can see it via
// ScalarRefRegistry.
@@ -3098,7 +3107,13 @@ && hasLiveIo(assignedGlob)) {
}
// Update ownership: this scalar now owns a refCount iff we incremented.
- this.refCountOwned = newOwned;
+ this.setRefCountOwned(newOwned);
+ if (containerOwner instanceof RuntimeHash hashOwner) {
+ if (oldBase != null) oldBase.updateDirectHashSlotOwner(hashOwner, this, -1);
+ if (this.value instanceof RuntimeBase newBase) {
+ newBase.updateDirectHashSlotOwner(hashOwner, this, 1);
+ }
+ }
retainScalarReferenceContents(value);
// Flush deferred mortal decrements from the current function scope.
@@ -4958,7 +4973,7 @@ public RuntimeScalar undefine() {
boolean releasedCode = false;
releaseAllClosureCaptureReferents(code);
if (this.refCountOwned && code.refCount > 0) {
- this.refCountOwned = false;
+ this.setRefCountOwned(false);
code.releaseActiveOwner(this);
if (--code.refCount == 0) {
code.refCount = Integer.MIN_VALUE;
@@ -5030,7 +5045,7 @@ public RuntimeScalar undefine() {
oldBase.refCount = Integer.MIN_VALUE;
DestroyDispatch.callDestroy(oldBase);
} else if (this.refCountOwned && oldBase.refCount > 0) {
- this.refCountOwned = false;
+ this.setRefCountOwned(false);
oldBase.releaseActiveOwner(this);
if (--oldBase.refCount == 0) {
if (oldBase.localBindingExists) {
@@ -5960,9 +5975,9 @@ private RuntimeScalar postAutoIncrementLarge() {
// returned reference becomes the first durable owner. The
// assignment receiving this scalar will balance the temporary
// copy, so retain one cleanup token for that returned value.
- old.refCountOwned = true;
+ old.setRefCountOwned(true);
old.postfixReferenceOwnershipTransfer = true;
- this.refCountOwned = false;
+ this.setRefCountOwned(false);
// Check if object is eligible for overloading
int blessId = blessedId(this);
if (blessId != 0) {
@@ -6271,9 +6286,9 @@ private RuntimeScalar postAutoDecrementWithoutWatcherNotification() {
default -> { // All reference types
// See postAutoIncrementLarge(): the returned pre-mutation
// reference inherits this lvalue's ownership token.
- old.refCountOwned = true;
+ old.setRefCountOwned(true);
old.postfixReferenceOwnershipTransfer = true;
- this.refCountOwned = false;
+ this.setRefCountOwned(false);
// Check if object is eligible for overloading
int blessId = blessedId(this);
if (blessId != 0) {
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeStash.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeStash.java
index 0c26546dc2..554c36688d 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeStash.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeStash.java
@@ -264,7 +264,7 @@ private RuntimeScalar deleteGlob(String k) {
DestroyDispatch.callDestroy(base);
} else if (base.refCount > 0 && savedScalar.refCountOwned) {
// Tracked object: decrement refCount (the stash was holding a strong ref).
- savedScalar.refCountOwned = false;
+ savedScalar.setRefCountOwned(false);
base.releaseActiveOwner(savedScalar);
if (--base.refCount == 0) {
base.refCount = Integer.MIN_VALUE;
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java b/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java
index 5811970656..c6a2ce1dc9 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java
@@ -163,7 +163,7 @@ private static void weaken(
// get a double-decrement that causes premature destruction.
// Clear refCountOwned because weaken's DEC consumes the ownership —
// the weak scalar should not trigger another DEC on scope exit or overwrite.
- ref.refCountOwned = false;
+ ref.setRefCountOwned(false);
base.traceRefCount(-1, "WeakRefRegistry.weaken (decrement on weakening)");
base.releaseOwner(ref, "weaken");
base.releaseActiveOwner(ref);
@@ -236,7 +236,7 @@ && hasWeakRefsTo(base)
// is still alive in the stash. This breaks Sub::Quote/Sub::Defer
// (which use weaken() for back-references) and cascades to break
// Moo's accessor inlining (51 test failures). See §15.
- ref.refCountOwned = false;
+ ref.setRefCountOwned(false);
if (base.blessId == 0
&& (base instanceof RuntimeArray || base instanceof RuntimeHash)
&& ReachabilityWalker.isReachableFromRoots(base)) {
@@ -318,7 +318,7 @@ public static void unweaken(RuntimeScalar ref) {
if (base.refCount >= 0) {
base.traceRefCount(+1, "WeakRefRegistry.unweaken (restore strong count)");
base.refCount++; // restore strong count
- ref.refCountOwned = true; // restore ownership
+ ref.setRefCountOwned(true); // restore ownership
base.hadCountedReference = true;
base.recordOwner(ref, "WeakRefRegistry.unweaken");
base.recordActiveOwner(ref);
diff --git a/src/main/perl/lib/B.pm b/src/main/perl/lib/B.pm
index 879e87555f..6c43144746 100644
--- a/src/main/perl/lib/B.pm
+++ b/src/main/perl/lib/B.pm
@@ -64,30 +64,12 @@ package B::SV {
sub REFCNT {
# Return the selective refcount via Internals::SvREFCNT.
#
- # In PerlOnJava, B::SV stores the reference in $self->{ref} which is
- # a tracked (blessed) hash element. This inflates the referent's
- # selective refCount by +1 via setLargeRefCounted.
- #
- # In Perl 5, B::svref_2object() stores the SV pointer directly (a C
- # pointer), so it does NOT inflate the referent's refcnt. However,
- # Perl 5 has higher refcounts overall because ALL references count
- # (hash elements, stack temporaries, mortal slots). PerlOnJava's
- # selective refCount is lower because:
- # 1. Stack/JVM temporaries don't contribute
- # 2. Method call argument copies don't contribute
- #
- # The B::SV inflation (+1) roughly compensates for these deficits,
- # so we return the raw selective refCount WITHOUT subtracting 1.
- #
- # For Schema::DESTROY's `refcount($source) > 1` check:
- # - Source with 1 selective ref (e.g., source_registrations only):
- # B::SV inflation → 2, REFCNT = 2 → > 1 → rescue ✓
- # (In Perl 5 this source also shows > 1 because stack temps add refs)
- # - Source with 0 selective refs (untracked):
- # B::SV inflation → 1, REFCNT = 1 → no rescue ✓
- # B's C implementation inspects the referent without creating a Perl
- # owner. Ask the runtime to compensate for an independently live
- # lexical owner that selective refcounting may not have counted.
+ # PerlOnJava stores the referent in this B::SV object's `ref` field,
+ # while native B stores an SV pointer. The runtime's B-object probe
+ # compensates for that wrapper so its slot is not reported as an
+ # independent Perl owner. This matters in DESTROY methods such as
+ # DBIx::Class::Schema::DESTROY, where a source held only in the
+ # schema's registry must report one owner and must not be rescued.
Internals::SvREFCNT($_[0]->{ref}, 1);
}
diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchNestedWeakSweepTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchNestedWeakSweepTest.java
new file mode 100644
index 0000000000..de1bef40d9
--- /dev/null
+++ b/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchNestedWeakSweepTest.java
@@ -0,0 +1,59 @@
+package org.perlonjava.runtime.runtimetypes;
+
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.perlonjava.app.cli.CompilerOptions;
+import org.perlonjava.app.scriptengine.PerlLanguageProvider;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+@Tag("unit")
+class DestroyDispatchNestedWeakSweepTest {
+
+ @Test
+ void weakChildIsRecheckedAfterBlessedContainerDestroyReturns() throws Exception {
+ for (boolean interpreter : new boolean[]{false, true}) {
+ PerlRuntime runtime = new PerlRuntime();
+ try (PerlRuntime.Binding ignored = runtime.bind()) {
+ execute(runtime, interpreter, ""
+ + "use Scalar::Util qw(weaken); "
+ + "package Issue1646::Storage; sub DESTROY { } "
+ + "package Issue1646::Schema; sub DESTROY { } "
+ + "package main; "
+ + "our $schema = bless { storage => bless({}, 'Issue1646::Storage') }, "
+ + " 'Issue1646::Schema'; "
+ + "our $weak_storage = $schema->{storage}; weaken($weak_storage); "
+ + "our $handle = { handler => sub { "
+ + " $weak_storage ? 'DBI Exception' : 'unhandled by DBIC' "
+ + "} }; ");
+
+ RuntimeScalar schemaSlot = GlobalVariable.getGlobalVariable("main::schema");
+ RuntimeHash schema = (RuntimeHash) schemaSlot.value;
+ RuntimeHash storage = (RuntimeHash) schema.elements.get("storage").value;
+ // Model selectively counted owners that remain after a large
+ // blessed owner graph is destroyed. The reachability walk must
+ // still clear the weak callback once the schema has gone.
+ storage.refCount = 132;
+ schemaSlot.refCountOwned = false;
+ schemaSlot.value = null;
+ schemaSlot.type = RuntimeScalarType.UNDEF;
+ schema.refCount = 0;
+ DestroyDispatch.callDestroy(schema);
+
+ assertEquals("unhandled by DBIC", execute(runtime, interpreter,
+ "$handle->{handler}->()"));
+ } finally {
+ runtime.close();
+ }
+ }
+ }
+
+ private static String execute(
+ PerlRuntime runtime, boolean interpreter, String code) throws Exception {
+ CompilerOptions options = new CompilerOptions();
+ options.fileName = "";
+ options.useInterpreter = interpreter;
+ options.code = code;
+ return PerlLanguageProvider.executePerlCode(options, false).scalar().toString();
+ }
+}
diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListDeferredCaptureTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListDeferredCaptureTest.java
index 1302aa1b08..628f9f8eb9 100644
--- a/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListDeferredCaptureTest.java
+++ b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListDeferredCaptureTest.java
@@ -3,7 +3,10 @@
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
+import java.util.concurrent.atomic.AtomicInteger;
+
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("unit")
@@ -65,6 +68,95 @@ void unreachableBlessedCaptureWithWeakObserverReleasesAtItsScopeBoundary() {
}
}
+ @Test
+ void temporaryOnlyRootDefersDestroyUntilTheNextStatementBoundary() {
+ PerlRuntime runtime = new PerlRuntime();
+ try (PerlRuntime.Binding ignored = runtime.bind()) {
+ String className = "Issue1794::RequestTransaction";
+ AtomicInteger destroyCalls = new AtomicInteger();
+ RuntimeHash transaction = new RuntimeHash();
+ transaction.blessId = NameNormalizer.getBlessId(className);
+ transaction.refCount = 1;
+ installDestroy(className, (args, context) -> {
+ destroyCalls.incrementAndGet();
+ return new RuntimeScalar().getList();
+ });
+
+ RuntimeScalar weak = referenceTo(transaction);
+ WeakRefRegistry.weaken(weak);
+ assertFalse(MortalList.isReachableFromPersistentRootCached(transaction),
+ "the weak observer is not a persistent owner");
+ RuntimeHash unrelatedRoot = new RuntimeHash();
+ MortalList.pushTemporaryRoot(unrelatedRoot);
+ try {
+ MortalList.pushTemporaryRoot(transaction);
+ try {
+ MortalList.deferDecrement(transaction);
+ MortalList.flush();
+
+ assertEquals(0, destroyCalls.get(),
+ "an active temporary root delays destruction during nested cleanup");
+ assertEquals(1, transaction.refCount,
+ "the referent stays available until its temporary root is removed");
+ assertEquals(RuntimeScalarType.HASHREFERENCE, weak.type);
+ } finally {
+ MortalList.popTemporaryRoot(transaction);
+ }
+
+ MortalList.flushAboveMark();
+
+ assertEquals(1, destroyCalls.get(),
+ "the temporary-only owner is rechecked despite unrelated temporary roots");
+ assertEquals(RuntimeBase.PerlLifecycleState.DESTROYED,
+ transaction.perlLifecycleState());
+ assertEquals(RuntimeScalarType.UNDEF, weak.type,
+ "the weak observer clears when the request transaction is destroyed");
+ } finally {
+ MortalList.popTemporaryRoot(unrelatedRoot);
+ GlobalVariable.removeGlobalCodeRefForStashDelete(className + "::DESTROY");
+ }
+ }
+ }
+
+ @Test
+ void targetedWeakSweepPreservesReferentThatGainsALiveRootBeforeBoundary() {
+ PerlRuntime runtime = new PerlRuntime();
+ try (PerlRuntime.Binding ignored = runtime.bind()) {
+ RuntimeHash referent = new RuntimeHash();
+ referent.refCount = 1;
+ RuntimeScalar weak = referenceTo(referent);
+ WeakRefRegistry.weaken(weak);
+ RuntimeScalar lexicalOwner = referenceTo(referent);
+ RuntimeHash unrelatedTemporary = new RuntimeHash();
+
+ MyVarCleanupStack.register(lexicalOwner);
+ MyVarCleanupStack.snapshotStackToLiveCounts();
+ MortalList.invalidateAllRootSnapshots();
+ try {
+ MortalList.requestTargetedWeakSweep(referent);
+ MortalList.pushTemporaryRoot(unrelatedTemporary);
+ try {
+ MortalList.flushAboveMark();
+
+ assertTrue(MortalList.isReachableFromPersistentRootCached(referent),
+ "the lexical owner remains a current Perl root");
+ assertEquals(RuntimeScalarType.HASHREFERENCE, weak.type,
+ "an unrelated temporary root must not cause a live referent to be swept");
+ assertFalse(PerlRuntime.current().lifecycleState
+ .targetedWeakSweepReferents.contains(referent),
+ "a persistent root should not leave the candidate queued for every later boundary");
+ } finally {
+ MortalList.popTemporaryRoot(unrelatedTemporary);
+ }
+ MyVarCleanupStack.unregister(lexicalOwner);
+ MortalList.flushAboveMark();
+ } finally {
+ WeakRefRegistry.unweaken(weak);
+ MortalList.invalidateAllRootSnapshots();
+ }
+ }
+ }
+
private static RuntimeScalar referenceTo(RuntimeBase referent) {
RuntimeScalar scalar = new RuntimeScalar();
scalar.type = RuntimeScalarType.HASHREFERENCE;
@@ -73,7 +165,11 @@ private static RuntimeScalar referenceTo(RuntimeBase referent) {
}
private static void installDestroy(String className) {
- RuntimeCode code = new RuntimeCode((args, context) -> new RuntimeScalar().getList(), null);
+ installDestroy(className, (args, context) -> new RuntimeScalar().getList());
+ }
+
+ private static void installDestroy(String className, PerlSubroutine body) {
+ RuntimeCode code = new RuntimeCode(body, null);
int separator = className.lastIndexOf("::");
code.packageName = className.substring(0, separator);
code.subName = "DESTROY";
diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityQueryCostTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityQueryCostTest.java
index d24797810d..3604a2892a 100644
--- a/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityQueryCostTest.java
+++ b/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityQueryCostTest.java
@@ -128,6 +128,33 @@ void targetedWeakReleaseSkipsFullWalkerForSnapshotRootedTargets() {
}
}
+ @Test
+ void persistentRootQueryChecksLiveLexicalsBeforePackageGraph() {
+ PerlRuntime runtime = new PerlRuntime();
+ try (PerlRuntime.Binding ignored = runtime.bind()) {
+ RuntimeHash target = new RuntimeHash();
+ RuntimeScalar lexicalOwner = target.createReference();
+ MyVarCleanupStack.register(lexicalOwner);
+ MyVarCleanupStack.snapshotStackToLiveCounts();
+ MortalList.invalidateAllRootSnapshots();
+ try {
+ try (MortalList.ReachabilityQueryMeasurement measurement =
+ MortalList.measureReachabilityQueries()) {
+ assertTrue(MortalList.isReachableFromPersistentRootCached(target));
+
+ ReachabilityQueryStats stats = measurement.stats();
+ assertEquals(1, stats.liveRootSnapshotsBuilt,
+ "the live lexical graph is checked once");
+ assertEquals(0, stats.externalRootSnapshotsBuilt,
+ "a live lexical proof skips the package root graph");
+ }
+ } finally {
+ MyVarCleanupStack.unregister(lexicalOwner);
+ MortalList.invalidateAllRootSnapshots();
+ }
+ }
+ }
+
@Test
void targetedWeakReleaseDoesNotTreatTemporaryRootsAsPersistentOwners() {
PerlRuntime runtime = new PerlRuntime();
diff --git a/src/test/resources/unit/issue_1646_callback_branch_destroy.t b/src/test/resources/unit/issue_1646_callback_branch_destroy.t
new file mode 100644
index 0000000000..860e79aedd
--- /dev/null
+++ b/src/test/resources/unit/issue_1646_callback_branch_destroy.t
@@ -0,0 +1,23 @@
+use strict;
+use warnings;
+use lib 'src/test/resources/unit/lib';
+use Issue1646::PromiseProbe;
+use Test::More;
+
+my (@warnings, @errors);
+{
+ local $SIG{__WARN__} = sub { push @warnings, shift };
+
+ my $promise = Issue1646::PromiseProbe->new;
+ $promise->catch(sub { push @errors, @_ });
+ $promise->then(sub { });
+ $promise->reject('branches2');
+ $promise->wait;
+
+ is_deeply(\@errors, ['branches2'], 'handled branch receives the rejection');
+ like($warnings[0] // '', qr/Unhandled rejected promise: branches2/,
+ 'the unhandled branch warns when its callback chain is released');
+ is($warnings[1], undef, 'the branch warns only once');
+}
+
+done_testing;
diff --git a/src/test/resources/unit/issue_1646_foreach_alias_container_owner_destroy.t b/src/test/resources/unit/issue_1646_foreach_alias_container_owner_destroy.t
new file mode 100644
index 0000000000..11de0bc0a0
--- /dev/null
+++ b/src/test/resources/unit/issue_1646_foreach_alias_container_owner_destroy.t
@@ -0,0 +1,52 @@
+use strict;
+use warnings;
+use feature 'current_sub';
+use Scalar::Util qw(weaken);
+use Test::More;
+our $destroyed = 0;
+{
+ package Issue1646::Emitter;
+ sub emit {
+ my ($self, $name) = @_;
+ if (my $subscribers = $self->{events}{$name}) {
+ for my $cb (@$subscribers) { $self->$cb(@_) }
+ }
+ return $self;
+ }
+ sub once {
+ my ($self, $name, $cb) = @_;
+ Scalar::Util::weaken($self);
+ my $wrapper = sub { $self->unsubscribe($name => __SUB__); $cb->(@_) };
+ push @{$self->{events}{$name}}, $wrapper;
+ return $wrapper;
+ }
+ sub unsubscribe {
+ my ($self, $name, $cb) = @_;
+ $self->{events}{$name} = [grep { $_ ne $cb } @{$self->{events}{$name}}];
+ delete $self->{events}{$name} unless @{$self->{events}{$name}};
+ return $self;
+ }
+}
+{
+ package Issue1646::Owner;
+ our @ISA = ('Issue1646::Emitter');
+ sub DESTROY { $main::destroyed++ }
+ sub write_chunk {
+ my ($self, $chunk, $cb) = @_;
+ $self->once(drain => sub { shift; $self->$cb(@_) }) if $cb;
+ return $self;
+ }
+}
+my $owner = bless {}, 'Issue1646::Owner';
+my $weak = $owner;
+weaken($weak);
+$owner->write_chunk(P => sub {
+ my $self = shift;
+ $self->write_chunk(oll => sub { shift->write_chunk('') });
+ $self->emit('drain');
+});
+$owner->emit('drain');
+undef $owner;
+is($destroyed, 1, 'owner destruction follows nested one-shot emission');
+ok(!defined $weak, 'weak owner is cleared after nested emission');
+done_testing;
diff --git a/src/test/resources/unit/issue_1646_mojolicious_controller_lifetime.t b/src/test/resources/unit/issue_1646_mojolicious_controller_lifetime.t
new file mode 100644
index 0000000000..3cdd987662
--- /dev/null
+++ b/src/test/resources/unit/issue_1646_mojolicious_controller_lifetime.t
@@ -0,0 +1,40 @@
+use strict;
+use warnings;
+BEGIN { $ENV{MOJO_REACTOR} = 'Mojo::Reactor::Poll' }
+
+use Test::More;
+
+BEGIN {
+ plan skip_all => 'Mojolicious is not installed'
+ unless eval { require Mojolicious::Lite; require Test::Mojo; require Mojo::IOLoop; 1 };
+}
+
+use Mojolicious::Lite;
+use Test::Mojo;
+use Mojo::IOLoop;
+
+{
+ package Issue1646::Controller;
+ use Mojo::Base 'Mojolicious::Controller';
+
+ sub DESTROY { shift->stash->{destroyed} = 1 }
+}
+
+app->controller_class('Issue1646::Controller');
+get '/longpoll' => sub {
+ my $controller = shift;
+ $controller->on(finish => sub { shift->stash->{finished}++ });
+ $controller->write_chunk('P' => sub {
+ shift->write_chunk('oll!' => sub { shift->write_chunk('') });
+ });
+};
+
+my $test = Test::Mojo->new;
+my $stash;
+$test->app->plugins->once(before_dispatch => sub { $stash = shift->stash });
+$test->get_ok('/longpoll')->status_is(200)->content_is('Poll!');
+Mojo::IOLoop->one_tick until $stash->{finished};
+is($stash->{finished}, 1, 'finish event is emitted once');
+ok($stash->{destroyed}, 'controller is destroyed after the response completes');
+
+done_testing;
diff --git a/src/test/resources/unit/issue_1646_reentrant_callback_capture_destroy.t b/src/test/resources/unit/issue_1646_reentrant_callback_capture_destroy.t
new file mode 100644
index 0000000000..8efea9f69a
--- /dev/null
+++ b/src/test/resources/unit/issue_1646_reentrant_callback_capture_destroy.t
@@ -0,0 +1,42 @@
+use strict;
+use warnings;
+
+use Scalar::Util qw(weaken);
+use Test::More;
+
+BEGIN {
+ plan skip_all => 'Mojolicious is not installed'
+ unless eval { require Mojo::EventEmitter; 1 };
+}
+
+our $destroyed = 0;
+
+{
+ package Issue1646::ReentrantCallbackOwner;
+ our @ISA = ('Mojo::EventEmitter');
+
+ sub DESTROY { $main::destroyed++ }
+
+ sub write_chunk {
+ my ($self, $chunk, $callback) = @_;
+ $self->once(drain => sub { shift; $self->$callback(@_) }) if $callback;
+ return $self;
+ }
+}
+
+my $owner = bless {}, 'Issue1646::ReentrantCallbackOwner';
+my $weak_owner = $owner;
+weaken($weak_owner);
+$owner->write_chunk('P' => sub {
+ my $self = shift;
+ $self->write_chunk('oll!' => sub { shift->write_chunk('') });
+ $self->emit('drain');
+});
+
+ok(defined $weak_owner, 'registered callbacks retain their captured owner');
+$owner->emit('drain');
+undef $owner;
+is($destroyed, 1, 'reentrant one-shot callbacks release captures after emission');
+ok(!defined $weak_owner, 'captured owner weak reference clears after emission');
+
+done_testing;
diff --git a/src/test/resources/unit/lib/Issue1646/PromiseProbe.pm b/src/test/resources/unit/lib/Issue1646/PromiseProbe.pm
new file mode 100644
index 0000000000..3d92e299ff
--- /dev/null
+++ b/src/test/resources/unit/lib/Issue1646/PromiseProbe.pm
@@ -0,0 +1,93 @@
+package Issue1646::PromiseProbe;
+
+use strict;
+use warnings;
+use Scalar::Util qw(blessed);
+
+our @TICKS;
+our $LOOP_RUNNING = 0;
+our $LOOP_STOPPED = 0;
+
+sub new { bless { resolve => [], reject => [] }, shift }
+sub clone { ref($_[0])->new }
+sub catch { shift->then(undef, shift) }
+sub reject { shift->_settle('reject', @_) }
+sub resolve { shift->_settle('resolve', @_) }
+
+sub then {
+ my ($self, $resolve, $reject) = @_;
+ my $next = $self->clone;
+ $self->{handled} = 1;
+ push @{$self->{resolve}}, sub { _then_cb($next, $resolve, 'resolve', @_) };
+ push @{$self->{reject}}, sub { _then_cb($next, $reject, 'reject', @_) };
+ $self->_defer if $self->{results};
+ return $next;
+}
+
+sub wait {
+ my $self = shift;
+ return if $LOOP_RUNNING;
+ my $done;
+ $self->_finally(0, sub { $done++; $LOOP_STOPPED = 1 })->catch(sub { });
+ _start_loop() until $done;
+}
+
+sub _start_loop {
+ $LOOP_RUNNING = 1;
+ $LOOP_STOPPED = 0;
+ while (@TICKS && !$LOOP_STOPPED) {
+ my $tick = shift @TICKS;
+ $tick->();
+ undef $tick;
+ }
+ $LOOP_RUNNING = 0;
+}
+
+sub _finally {
+ my ($self, $handled, $finally) = @_;
+ my $next = $self->clone;
+ my $callback = sub {
+ my @results = @_;
+ $next->resolve($finally->())->then(sub { @results });
+ };
+ my $was_handled = $self->{handled};
+ $self->catch($callback);
+ my $returned = $self->then($callback);
+ delete $self->{handled} if !$was_handled && !$handled;
+ return $returned;
+}
+
+sub _defer {
+ my $self = shift;
+ return unless my $results = $self->{results};
+ my $callbacks = $self->{status} eq 'resolve' ? $self->{resolve} : $self->{reject};
+ @{$self}{qw(cycle resolve reject)} = (undef, [], []);
+ push @TICKS, sub { $_->(@$results) for @$callbacks };
+}
+
+sub _settle {
+ my ($self, $status, @results) = @_;
+ if (blessed($results[0]) && $results[0]->can('then') && $status eq 'resolve') {
+ $results[0]->then(sub { $self->resolve(@_) }, sub { $self->reject(@_) });
+ } elsif (!$self->{results}) {
+ @{$self}{qw(results status)} = (\@results, $status);
+ $self->_defer;
+ }
+ return $self;
+}
+
+sub _then_cb {
+ my ($next, $callback, $method, @results) = @_;
+ return $next->$method(@results) unless defined $callback;
+ my @values;
+ return $next->reject($@) unless eval { @values = $callback->(@results); 1 };
+ return $next->resolve(@values);
+}
+
+sub DESTROY {
+ my $self = shift;
+ return if $self->{handled} || ($self->{status} // '') ne 'reject' || !$self->{results};
+ warn "Unhandled rejected promise: @{$self->{results}}";
+}
+
+1;
diff --git a/src/test/resources/unit/mojolicious_promise_branch_destroy_regression.t b/src/test/resources/unit/mojolicious_promise_branch_destroy_regression.t
new file mode 100644
index 0000000000..1d5e788014
--- /dev/null
+++ b/src/test/resources/unit/mojolicious_promise_branch_destroy_regression.t
@@ -0,0 +1,38 @@
+use strict;
+use warnings;
+use Scalar::Util qw(weaken);
+use Test::More;
+
+plan skip_all => 'Mojolicious is not installed' unless eval { require Mojo::Promise; 1 };
+
+my (@warnings, @errors);
+{
+ local $SIG{__WARN__} = sub { push @warnings, shift };
+
+ Mojo::Promise->reject('one');
+ like($warnings[0] // '', qr/Unhandled rejected promise: one/,
+ 'a discarded rejected promise warns immediately');
+ is($warnings[1], undef, 'the discarded promise warns only once');
+
+ @warnings = ();
+ Mojo::Promise->reject('two')->then(sub { })->wait;
+ like($warnings[0] // '', qr/Unhandled rejected promise: two/,
+ 'a waited rejection warns when its child is released');
+ is($warnings[1], undef, 'the waited rejection warns only once');
+
+ @warnings = ();
+
+ my $promise = Mojo::Promise->new;
+ $promise->catch(sub { push @errors, @_ });
+ my $unhandled_branch = $promise->then(sub { });
+ weaken($unhandled_branch);
+ $promise->reject('branches2');
+ $promise->wait;
+ is_deeply(\@errors, ['branches2'], 'handled branch receives the rejection');
+ ok(!defined $unhandled_branch, 'unhandled child branch is released after wait');
+ like($warnings[0] // '', qr/Unhandled rejected promise: branches2/,
+ 'unhandled branch warns when its callback chain is released');
+ is($warnings[1], undef, 'unhandled rejection warns only once');
+}
+
+done_testing;
diff --git a/src/test/resources/unit/refcount/b_refcount_destroy_hash_field.t b/src/test/resources/unit/refcount/b_refcount_destroy_hash_field.t
new file mode 100644
index 0000000000..314050722d
--- /dev/null
+++ b/src/test/resources/unit/refcount/b_refcount_destroy_hash_field.t
@@ -0,0 +1,31 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+use Test::More;
+use B qw(svref_2object);
+
+{
+ package BRefcountDestroyHashField;
+
+ our $observed;
+
+ sub new {
+ my $source = bless {}, 'BRefcountDestroyHashField::Source';
+ return bless { sources => { Artist => $source } }, shift;
+ }
+
+ sub DESTROY {
+ my ($self) = @_;
+ $observed = B::svref_2object($self->{sources}{Artist})->REFCNT;
+ }
+}
+
+{
+ my $schema = BRefcountDestroyHashField->new;
+ undef $schema;
+}
+
+is($BRefcountDestroyHashField::observed, 1,
+ 'B reports one owner for a source held only by a DESTROYing hash field');
+
+done_testing;
diff --git a/src/test/resources/unit/refcount/b_refcount_ignores_dead_hash_owner.t b/src/test/resources/unit/refcount/b_refcount_ignores_dead_hash_owner.t
new file mode 100644
index 0000000000..bee03abdd0
--- /dev/null
+++ b/src/test/resources/unit/refcount/b_refcount_ignores_dead_hash_owner.t
@@ -0,0 +1,30 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+
+use B qw(svref_2object);
+use Test::More;
+
+our (%KEEP, $REFCOUNT);
+$KEEP{target} = bless {}, 'DeadHashOwnerTarget';
+
+sub add_temporary_hash_owner {
+ my %temporary;
+ $temporary{copy} = $KEEP{target};
+}
+
+add_temporary_hash_owner();
+
+{
+ package DeadHashOwnerProbe;
+ sub DESTROY {
+ $main::REFCOUNT = B::svref_2object($main::KEEP{target})->REFCNT;
+ }
+}
+
+my $probe = bless {}, 'DeadHashOwnerProbe';
+undef $probe;
+
+is($REFCOUNT, 1, 'B excludes a hash owner after its hash leaves scope');
+
+done_testing;
diff --git a/src/test/resources/unit/refcount/destroy_reref_after_captured_args.t b/src/test/resources/unit/refcount/destroy_reref_after_captured_args.t
new file mode 100644
index 0000000000..f0925356bd
--- /dev/null
+++ b/src/test/resources/unit/refcount/destroy_reref_after_captured_args.t
@@ -0,0 +1,82 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+use Test::More;
+use Carp qw(carp);
+use Scalar::Util qw(refaddr weaken);
+
+our @captured_args;
+my %destruction_registry;
+
+sub detected_reinvoked_destroy {
+ my ($object) = @_;
+ my $address = refaddr($object);
+ if (!defined $destruction_registry{$address}) {
+ $destruction_registry{$address} = $object;
+ weaken($destruction_registry{$address});
+ return 0;
+ }
+
+ carp('Preventing *MULTIPLE* DESTROY() invocations on CapturedTxnGuard');
+ return 1;
+}
+
+{
+ package CapturedTxnGuard::Storage;
+ sub new { bless { dbh => bless({}, 'CapturedTxnGuard::DBH') }, shift }
+ sub _verify_pid { 1 }
+ sub _seems_connected { 1 }
+ sub txn_rollback { 1 }
+}
+
+{
+ package CapturedTxnGuard;
+
+ sub new {
+ my ($class, $storage) = @_;
+ my $guard = { inactivated => 0, storage => $storage, dbh => $storage->{dbh} };
+ Scalar::Util::weaken($guard->{dbh});
+ return bless $guard, $class;
+ }
+
+ sub DESTROY {
+ return if &main::detected_reinvoked_destroy;
+
+ my $self = shift;
+ $self->{storage}->_verify_pid;
+ return unless $self->{dbh};
+
+ local $@;
+ Carp::carp('Guard went out of scope without explicit commit');
+ my $rollback_exception;
+ eval { $self->{storage}->_seems_connected && $self->{storage}->txn_rollback };
+ }
+}
+
+{
+ local $SIG{__WARN__} = sub {
+ package DB;
+ my $frame = 0;
+ while (my @caller = caller(++$frame)) {
+ push @main::captured_args, @DB::args;
+ }
+ };
+
+ my $storage = CapturedTxnGuard::Storage->new;
+ my $guard = CapturedTxnGuard->new($storage);
+ undef $guard;
+}
+
+ok(grep({ ref($_) eq 'CapturedTxnGuard' } @captured_args),
+ 'caller() captures the guard from its DESTROY warning');
+
+my @warnings;
+{
+ local $SIG{__WARN__} = sub { push @warnings, @_ };
+ @captured_args = ();
+}
+like(join('', @warnings),
+ qr/Preventing \*MULTIPLE\* DESTROY\(\) invocations on CapturedTxnGuard/,
+ 're-fire DESTROY when the captured guard reference is released');
+
+done_testing;