Skip to content
Merged
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
19 changes: 14 additions & 5 deletions dev/architecture/weaken-destroy.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ The system is designed around three principles:
reference-counting burden. Weak references are registered externally and
cleared as a side-effect of DESTROY.

3. **Perl-semantics first.** When selective refcount drifts from Perl's
accurate refcount (due to JVM temporaries, call-stack lexicals the walker
can't see, etc.), the reachability walker (`ReachabilityWalker` + opt-in
`Internals::jperl_gc()`) fills the gap, matching what Perl's refcount
would have concluded.
3. **Perl-semantics first.** Ownership that affects deterministic destruction
is recorded at the Perl scalar boundary. The reachability walker
(`ReachabilityWalker` + opt-in `Internals::jperl_gc()`) remains a
conservative cleanup aid for weak references, not the source of closure
lifetime semantics.

---

Expand Down Expand Up @@ -176,6 +176,15 @@ on a captured variable. This tells `releaseCaptures()` that the variable's scope
has already exited, so it should call `deferDecrementIfTracked()` on that
variable to trigger destruction.

When a captured non-CODE scalar is a borrowed copy without its own
`refCountOwned` token, `retainClosureCapture()` gives the referent one
capture-owned token per closure. `releaseClosureCapture()` releases those
tokens when the closure is discarded; reassignment and `weaken()` release them
from the old referent as well. Weak slots, destroyed or `WEAKLY_TRACKED`
referents, and untracked values receive no token. Thus a live closure retains
the object reachable through its lexical pad, while generated capture metadata
and conservative JVM object graphs remain opaque to the lifetime accounting.

---

## System Components
Expand Down
503 changes: 503 additions & 0 deletions dev/design/refcount-owner-ledger.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions docs/about/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Release history of PerlOnJava. See [Roadmap](roadmap.md) for future plans.

## Work in progress

- Release captured closure owners when their callback is discarded, preventing
stale refcounts after a temporary global owner (such as an IO::Async loop
notifier) is removed, and retire unreachable eval-capture ownership before
global destruction.
- Release interpreter hash-slice RHS staging owners after their durable hash
slots are created, restoring Net::Async::HTTP connection refcounts.
- Add Mojolicious 9.49 support through `jcpan`; 109 files and 4,194 tests pass
in 955 seconds with only upstream developer/optional-feature skips.
- Make Catalyst::Runtime pass 199 supported files and 3,774 assertions in
Expand Down Expand Up @@ -83,6 +89,8 @@ Release history of PerlOnJava. See [Roadmap](roadmap.md) for future plans.
used by `Perl6::Slurp`.
- Duplicate the underlying handle when `open` is given a dup mode for a tied
filehandle, as `CPAN.pm` does with a tied `STDOUT`.
- Preserve objects captured by live closures across weak-reference sweeps and
release them when the final closure is discarded.

## v5.44.1: Regex, Threads, Async/Await, and CPAN Compatibility

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -508,22 +508,33 @@ private void emitScopeCleanup(int scopeIdx, boolean flush) {
emit(Opcodes.MORTAL_PUSH_MARK);
}

// The implicit final expression of a subroutine is returned after its
// outer scope exits. Preserve anonymous IO aliases carried by that
// result just as explicit `return` does; otherwise IO::Socket's
// implicit `($left, $right)` return closes both socketpair ends before
// the caller receives them.
boolean preserveImplicitReturn = scopeIndices.isEmpty() && lastResultReg >= 0;

// Emit SCOPE_EXIT_CLEANUP for each my-scalar register in the exiting scope.
// This calls RuntimeScalar.scopeExitCleanup() which handles:
// 1. IO fd recycling for anonymous filehandle globs
// 2. refCount decrement for blessed references with DESTROY
for (int reg : scalarIndices) {
emit(Opcodes.SCOPE_EXIT_CLEANUP);
emit(preserveImplicitReturn
? Opcodes.RETURN_SCOPE_CLEANUP : Opcodes.SCOPE_EXIT_CLEANUP);
emitReg(reg);
if (preserveImplicitReturn) emitReg(lastResultReg);
}

// Walk hash/array variables for nested blessed references.
for (int reg : hashIndices) {
emit(Opcodes.SCOPE_EXIT_CLEANUP_HASH);
emit(preserveImplicitReturn
? Opcodes.RETURN_SCOPE_CLEANUP_HASH : Opcodes.SCOPE_EXIT_CLEANUP_HASH);
emitReg(reg);
}
for (int reg : arrayIndices) {
emit(Opcodes.SCOPE_EXIT_CLEANUP_ARRAY);
emit(preserveImplicitReturn
? Opcodes.RETURN_SCOPE_CLEANUP_ARRAY : Opcodes.SCOPE_EXIT_CLEANUP_ARRAY);
emitReg(reg);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1012,11 +1012,15 @@ public static int executeCreateClosure(int[] bytecode, int pc, RuntimeBase[] reg
// via this closure, and may prematurely clear weak references to its value.
java.util.List<RuntimeScalar> capturedScalars = new java.util.ArrayList<>();
java.util.List<RuntimeBase> capturedAggregates = new java.util.ArrayList<>();
java.util.Set<RuntimeScalar> seenScalars = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
java.util.Set<RuntimeBase> seenAggregates = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
for (RuntimeBase captured : capturedVars) {
if (captured instanceof RuntimeScalar s) {
if (!seenScalars.add(s)) continue;
capturedScalars.add(s);
s.retainClosureCapture();
} else if (captured instanceof RuntimeArray || captured instanceof RuntimeHash) {
if (!seenAggregates.add(captured)) continue;
capturedAggregates.add(captured);
captured.retainClosureCapture();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1242,9 +1242,35 @@ public static int executeHashSliceSet(
// Set all key-value pairs
hash.setSlice(keysList, valuesList);

// HASH_SLICE_SET receives the compiler-created RHS array. Its elements
// and the valuesArray copies both own temporary container stores before
// RuntimeHash.setSlice() creates the durable hash slots. Transfer both
// temporary owners immediately; a deferred release is too late here,
// because a top-level HASH_SLICE_SET need not have a later MORTAL_FLUSH.
if (valuesBase instanceof RuntimeArray sourceArray) {
for (RuntimeScalar value : sourceArray.elements) {
releaseHashSliceTemporaryOwner(value);
}
}
for (RuntimeScalar value : valuesArray.elements) {
releaseHashSliceTemporaryOwner(value);
}

return pc;
}

/** Release a compiler-only RHS slice scalar after its durable hash copy exists. */
private static void releaseHashSliceTemporaryOwner(RuntimeScalar value) {
if (value != null && value.refCountOwned
&& RuntimeScalarType.isReference(value)
&& value.value instanceof RuntimeBase base && base.refCount > 0) {
base.releaseOwner(value, "HASH_SLICE_SET temporary owner transfer");
base.releaseActiveOwner(value);
base.refCount--;
value.refCountOwned = false;
}
}

/**
* SLOWOP_LIST_SLICE_FROM: rd = list[start..]
* Extract a slice from a list starting at given index to the end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,11 @@ static void handleReturnOperator(EmitterVisitor emitterVisitor, OperatorNode nod
"(Lorg/perlonjava/runtime/runtimetypes/RuntimeBase;)V",
false);
}
for (int idx : allScalarIndices) {
// Captured scalar slots belong to the enclosing closure frame.
// They can receive an anonymous socket while this callback runs,
// but returning from the callback must not release that enclosing
// pad's IO owner. Only locals declared by this frame end here.
for (int idx : scalarIndices) {
ctx.mv.visitVarInsn(Opcodes.ALOAD, idx);
ctx.javaClassInfo.loadSpillRef(ctx.mv, spillRef);
ctx.mv.visitMethodInsn(Opcodes.INVOKESTATIC,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,11 @@ public static RuntimeScalar bless(RuntimeScalar runtimeScalar, RuntimeScalar cla
// increments refCount first, so the mortal flush leaves it at the
// correct count.
referent.setBlessId(newBlessId);
referent.traceRefCount(+1, "ReferenceOperators.bless tracked referent");
referent.refCount++; // 0 → 1 (or N → N+1 for edge cases)
MortalList.deferDecrement(referent);
referent.acquireTransientTraceOwner("bless mortal temporary",
"ReferenceOperators.bless tracked referent");
MortalList.deferDecrement(referent, "bless mortal temporary");
} else {
// Re-bless: update class, keep refCount.
referent.setBlessId(newBlessId);
Expand Down Expand Up @@ -175,7 +178,9 @@ public static RuntimeScalar bless(RuntimeScalar runtimeScalar, RuntimeScalar cla
referent.recordOwner(runtimeScalar, "first bless of existing scalar ref");
runtimeScalar.refCountOwned = true;
}
MortalList.deferDecrement(referent);
referent.acquireTransientTraceOwner("bless mortal temporary",
"ReferenceOperators.first bless");
MortalList.deferDecrement(referent, "bless mortal temporary");
}
// Activate the mortal mechanism
MortalList.setActive(true);
Expand Down
25 changes: 25 additions & 0 deletions src/main/java/org/perlonjava/runtime/perlmodule/IOHandle.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public static void initialize() {
// (Perl 5's IO.xs subs have no prototypes; adding prototypes would force
// scalar context on array args like @_, breaking callers)
ioHandle.registerMethod("ungetc", null);
ioHandle.registerMethod("fileno", null);
ioHandle.registerMethod("_error", null);
ioHandle.registerMethod("_clearerr", null);
ioHandle.registerMethod("_sync", null);
Expand Down Expand Up @@ -50,6 +51,30 @@ public static RuntimeList ungetc(RuntimeArray args, int ctx) {
return arg1.getList();
}

/**
* Return the descriptor for an IO::Handle object.
*
* <p>Perl implements this as an XS method inherited by {@code IO::Socket}
* and other handle classes. Route it through {@link RuntimeIO#fileno()} so
* virtual descriptors remain stable and usable by select()-based event
* loops.</p>
*/
public static RuntimeList fileno(RuntimeArray args, int ctx) {
if (args.size() != 1) {
throw new IllegalArgumentException("fileno requires one argument");
}

RuntimeIO fh = RuntimeIO.getRuntimeIO(args.get(0));
if (fh instanceof TieHandle tieHandle) {
return TieHandle.tiedFileno(tieHandle).getList();
}
if (fh == null || fh.ioHandle == null
|| fh.ioHandle instanceof org.perlonjava.runtime.io.ClosedIOHandle) {
return RuntimeScalarCache.scalarUndef.getList();
}
return fh.fileno().getList();
}

/**
* Check if handle has experienced errors
*/
Expand Down
26 changes: 25 additions & 1 deletion src/main/java/org/perlonjava/runtime/perlmodule/Internals.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public static void initialize() {
// against native Perl. See dev/design/refcount_alignment_plan.md.
internals.registerMethod("jperl_refstate", "jperl_refstate", "$");
internals.registerMethod("jperl_refstate_str", "jperl_refstate_str", "$");
internals.registerMethod("jperl_owner_trace", "jperlOwnerTrace", "$");
internals.registerMethod("jperl_reference_by_address", "jperlReferenceByAddress", "$");
// Phase 4 (refcount_alignment_plan.md): On-demand reachability
// sweep. Walks Perl-visible roots (globals, stashes, rescued
Expand Down Expand Up @@ -444,13 +445,17 @@ public static RuntimeList svRefcount(RuntimeArray args, int ctx) {
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.
// 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
Expand Down Expand Up @@ -491,6 +496,8 @@ public static RuntimeList jperlReferenceByAddress(RuntimeArray args, int ctx) {
* <li>{@code class_name} — Perl class name (empty string if unblessed)</li>
* <li>{@code kind} — runtime type: SCALAR / ARRAY / HASH / CODE / GLOB / OTHER</li>
* <li>{@code has_weak_refs} — true if the weak-ref registry has entries pointing here</li>
* <li>{@code active_owner_count} — live scalar-store owners currently tracked for diagnostics</li>
* <li>{@code semantic_capture_owner_count} — distinct captured pad owners</li>
* </ul>
*/
public static RuntimeList jperl_refstate(RuntimeArray args, int ctx) {
Expand All @@ -503,6 +510,8 @@ public static RuntimeList jperl_refstate(RuntimeArray args, int ctx) {
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()));
String kind = "OTHER";
if (base instanceof RuntimeGlob) kind = "GLOB";
else if (base instanceof RuntimeHash) kind = "HASH";
Expand Down Expand Up @@ -550,6 +559,21 @@ public static RuntimeList jperl_refstate_str(RuntimeArray args, int ctx) {
return new RuntimeScalar("NOT_REF").getList();
}

/**
* Return a target-filtered owner-ledger snapshot for a referent at the
* current Perl assertion boundary. The snapshot includes both active
* scalar-store tokens and deferred-release provenance, without retaining
* any runtime object for diagnostic purposes. Detailed acquisition and
* queue sites are populated when {@code PJ_REFCOUNT_TRACE} is enabled.
*/
public static RuntimeList jperlOwnerTrace(RuntimeArray args, int ctx) {
RuntimeScalar arg = args.get(0);
if (arg.value instanceof RuntimeBase base) {
return new RuntimeScalar(base.ownerTraceSnapshot()).getList();
}
return new RuntimeScalar("NOT_REF").getList();
}

/**
* Phase 4 (refcount_alignment_plan.md): Run a reachability sweep from
* Perl roots (globals, rescued objects) and clear weak refs for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ final class LifecycleRuntimeState {
final AtomicBoolean boundaryWorkRegistered = new AtomicBoolean();
boolean mortalActive = true;
final ArrayList<RuntimeBase> pending = new ArrayList<>();
// Parallel to pending. A non-null entry retains trace-only provenance for
// an owner token whose scalar was cleared when its decrement was queued.
final ArrayList<RuntimeBase.PendingOwnerRelease> pendingOwnerReleases = new ArrayList<>();
// Parallel to pending. A non-scalar transient owner kind is recorded only
// for trace attribution; it never changes runtime reachability.
final ArrayList<String> pendingTransientOwnerKinds = new ArrayList<>();
final ArrayList<TiedVariableBase> pendingTiedReleases = new ArrayList<>();
final ArrayList<RuntimeScalar> pendingIoReleases = new ArrayList<>();
final ArrayList<RuntimeScalar> deferredCaptures = new ArrayList<>();
Expand Down Expand Up @@ -65,7 +71,18 @@ final class LifecycleRuntimeState {

void clear() {
mortalActive = true;
for (int i = 0; i < pending.size(); i++) {
RuntimeBase.PendingOwnerRelease release = pendingOwnerReleases.get(i);
if (release != null) {
pending.get(i).cancelQueuedOwnerRelease(release,
"LifecycleRuntimeState.clear");
}
pending.get(i).releaseTransientTraceOwner(pendingTransientOwnerKinds.get(i),
"LifecycleRuntimeState.clear");
}
pending.clear();
pendingOwnerReleases.clear();
pendingTransientOwnerKinds.clear();
pendingTiedReleases.clear();
pendingIoReleases.clear();
deferredCaptures.clear();
Expand Down
Loading
Loading