From 0451d6ec7ddc394d050f6dcc3fb0cf85531f2379 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 10:53:55 +0200 Subject: [PATCH 01/18] wip: investigate closure capture ownership Add the issue #1132 regression, document the failed per-capture accounting approach, and record the owner-ledger design for a Perl-exact implementation. Known limitation: the current partial RuntimeScalar change does not yet pass the focused regression or the full make gate; follow-up work starts from the owner-ledger design. Generated with Codex (https://openai.com/codex) Co-Authored-By: Codex <2237389410+openai-codex[bot]@users.noreply.github.com> --- dev/architecture/weaken-destroy.md | 19 +- dev/design/refcount-owner-ledger.md | 349 ++++++++++++++++++ docs/about/changelog.md | 2 + .../runtime/runtimetypes/RuntimeScalar.java | 7 + .../refcount/closure_capture_weak_owner.t | 76 ++++ 5 files changed, 448 insertions(+), 5 deletions(-) create mode 100644 dev/design/refcount-owner-ledger.md create mode 100644 src/test/resources/unit/refcount/closure_capture_weak_owner.t diff --git a/dev/architecture/weaken-destroy.md b/dev/architecture/weaken-destroy.md index 4864e4d5e..06d1e5bc4 100644 --- a/dev/architecture/weaken-destroy.md +++ b/dev/architecture/weaken-destroy.md @@ -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. --- @@ -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 diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md new file mode 100644 index 000000000..54c1dd559 --- /dev/null +++ b/dev/design/refcount-owner-ledger.md @@ -0,0 +1,349 @@ +# Perl-Exact Reference Ownership and Closure-Pad Lifetime + +**Status:** Design in progress +**Issue:** [#1132](https://github.com/fglock/PerlOnJava/issues/1132) +**Related architecture:** +[Weaken & DESTROY](../architecture/weaken-destroy.md), +[Refcount alignment plan](refcount_alignment_plan.md), and +[Refcount alignment progress](refcount_alignment_progress.md) + +## Objective + +Align PerlOnJava's observable reference counts, weak-reference lifetime, closure +capture lifetime, cycles, and deterministic `DESTROY` behavior with system Perl. +The implementation must distinguish real Perl ownership from JVM reachability +and compiler-generated capture metadata. + +Issue #1132 is the first delivery milestone. The complete design also replaces +remaining selective-refcount heuristics with explicit, auditable owner tokens. + +## New Findings + +The original issue #1132 plan proposed adding one referent token for each +closure capture when the captured scalar did not already have a +`refCountOwned` token. Verification exposed two problems with that model: + +1. The focused issue reproducer captures a pad scalar that already has + `refCountOwned = true`. A borrowed-only token does not protect its referent + from a weak-reference sweep on either backend. +2. Adding one referent token for every closure makes the issue reproducer live, + but overcounts real Perl references. It regresses existing captured-scalar, + weak-callback, tail-call, and exact-refcount tests. + +A system Perl oracle confirms that a lexical's referent count remains one when +one or two closures share that lexical: + +```text +lexical=1 +one_closure=1 +two_closures=1 +``` + +The closure retains the lexical pad scalar. The pad scalar, in turn, owns one +strong edge to its referent. Multiple closures sharing the pad do not each own +the referent independently. + +The current reachability walker deliberately excludes captured and +scope-exited scalars to avoid treating conservative captures as Perl roots. +Consequently, a weak sweep can destroy an issue #1132 referent even while the +captured pad retains a positive selective count. The fix therefore requires an +authoritative ownership distinction, not broader walking or unconditional +capture increments. + +## Semantic Ownership Model + +```text +RuntimeCode --semantic capture--> RuntimeScalar pad cell --strong slot--> referent +``` + +These edges have different meanings: + +- The declaring scope and semantic closure captures own the pad cell. +- A strong pad cell owns exactly one referent token. +- A weak pad cell owns no referent token. +- Multiple closures can share one pad cell without changing the referent count. +- Conservative compiler metadata and generated JVM object fields are not Perl + roots and own neither the pad nor its referent. + +### Required invariants + +1. A live pad cell owns at most one token for its current referent. +2. A strong, live pad cell owns exactly one token when its referent is tracked. +3. `semanticCaptureCount` affects pad lifetime, not referent cardinality. +4. Metadata captures never affect Perl lifetime. +5. Reassignment releases the old pad token before acquiring the new one. +6. `weaken()` releases the pad token and captures do not recreate it. +7. The final pad owner releases the token exactly once. +8. Weak sweeping cannot destroy a referent with authoritative strong owners. +9. Java reachability alone cannot create a Perl owner. +10. Strong Perl cycles remain alive until a strong edge is explicitly broken. + +## Runtime State + +The scalar and closure state should make ownership explicit: + +- `slotOwnsReferent`: the scalar slot owns one strong referent token. The + existing `refCountOwned` field can initially implement this state, but its + meaning must be narrowed and documented. +- `scopeOwnerAlive`: the declaring lexical scope still owns the pad cell. +- `semanticCaptureCount`: number of live Perl closures sharing the pad cell. +- `metadataCaptureCount`: conservative or diagnostic captures that do not + affect Perl ownership. +- `CaptureBinding`: a `RuntimeCode` record pairing a pad cell with capture + provenance. Each semantic binding is released exactly once. + +`scopeExited` may remain during migration, but it must become derived lifecycle +state rather than an ownership heuristic. + +## State Transitions + +| Event | Pad ownership | Referent ownership | +|---|---|---| +| Strong lexical assignment | Scope owns pad | Pad owns one token | +| First semantic closure capture | Add pad owner | Unchanged | +| Additional closure capture | Add pad owner | Unchanged | +| Metadata capture | Unchanged | Unchanged | +| Declaring scope exits | Remove scope owner | Retain token while semantic captures remain | +| One of several closures is released | Remove one pad owner | Unchanged | +| Final closure is released after scope exit | Pad becomes dead | Release one token | +| Captured scalar is reassigned | Pad remains live | Release old token; acquire one new token | +| Captured scalar is weakened | Pad remains live but weak | Release token | +| Captured scalar is unweakened | Pad remains live and strong | Acquire one token | + +A borrowed scalar promoted into a semantic captured pad acquires one pad-slot +token. Further closures sharing that pad do not acquire additional tokens. + +## Exact Capture Provenance + +Both execution backends must produce equivalent capture descriptors. + +### JVM backend + +- Attach semantic `CaptureBinding` records during compilation. +- Do not infer Perl ownership solely from reflected generated fields. +- Keep generated closure objects and compiler metadata opaque to ownership. + +### Interpreter backend + +- Distinguish lexicals referenced by the closure from conservative snapshots of + all visible registers. +- Preserve `eval STRING` access to lexicals without making unrelated register + metadata permanent owners. +- Release semantic bindings through the same `RuntimeCode.releaseCaptures()` + path as the JVM backend. + +### Threads + +- Reconstruct slot ownership and semantic captures in an independent ithread + snapshot from capture descriptors. +- Do not infer cloned ownership from the source scalar's transient + `refCountOwned` value. + +## Authoritative Owner Ledger + +Tracked referents need an authoritative count separate from conservative JVM +reachability and historical selective-count drift. Every owner-producing path +must identify an owner kind: + +- Scalar or pad slot +- Aggregate element +- Package global +- Glob or stash slot +- CODE storage +- Tie wrapper +- Pad constant +- Temporary call hold +- Explicit destruction rescue +- Other runtime-specific strong Perl edges + +Debug validation should assert: + +```text +refCount == sum(authoritative owner tokens) +``` + +Temporary aliases, generated fields, and conservative captures must not produce +authoritative tokens. During migration, legacy or uncertain holds must be +recorded separately so they cannot prevent weak cleanup indefinitely. + +## Weak Sweeping and Reachability + +Weak-reference cleanup must follow these rules: + +1. Never clear or destroy a referent with authoritative strong owners. +2. Do not traverse arbitrary closure fields or conservative captures as Perl + roots. +3. Use explicit semantic pad ownership to protect captured referents. +4. Preserve strong cycles because their real strong edges keep authoritative + counts positive, matching Perl reference counting. +5. Use `ReachabilityWalker` only for still-unmodelled roots, diagnostics, and + migration checks—not as the source of closure semantics. +6. Gradually eliminate `WEAKLY_TRACKED` heuristics by reconstructing real + scalar, aggregate, and package owners when an untracked referent first + requires weak-reference accounting. + +## Implementation Phases + +### Phase 0: Preserve and specify + +- Keep the pre-flight patches and WIP snapshot of the failed walker experiment. +- Replace the current partial capture-token implementation before runtime work + proceeds. +- Retain the new issue regression test, but align its assertions with shared-pad + ownership rather than per-closure tokens. +- Document invariants and owner transitions in this design. + +### Phase 1: Perl oracle and owner instrumentation + +- Add system-Perl-validated tests for stable `B::REFCNT` observations. +- Add owner-ledger tracing and balance assertions without changing behavior. +- Inventory every direct refcount increment, decrement, promotion, and + `Integer.MIN_VALUE` transition. +- Record which existing counts are authoritative, conservative, or temporary. + +### Phase 2: Captured-pad lifecycle + +- Implement semantic versus metadata capture counts. +- Preserve the pad's single referent token after declaring-scope exit. +- Release the token when both scope ownership and semantic captures reach zero. +- Promote borrowed semantic captures to one pad-slot owner. +- Make reassignment, weakening, unweakening, and closure release generation-safe + so deferred decrements cannot target a newly assigned referent. +- Update CODE recursion and ithread clone paths. + +### Phase 3: Weak-sweep authority + +- Make authoritative owner tokens protect weak referents. +- Remove captured-pad dependence on broad capture walking. +- Keep generated and conservative captures opaque. +- Assert that the walker cannot destroy a referent with authoritative owners. +- Preserve DBIx::Class, Moo, Sub::Quote, and Sub::Defer cleanup by ensuring + metadata captures never enter the owner ledger. + +Issue #1132 is deliverable after Phases 0 through 3 pass their acceptance gates. + +### Phase 4: Complete owner-source migration + +Audit and convert all ownership paths: + +- Scalar assignments, aliases, references, localization, and returns +- Hash and array stores, removals, and clears +- Globals, stashes, globs, and CODE slots +- Arguments, temporaries, tail calls, and lvalue returns +- Tied variables and handlers +- Pad constants and installed subroutines +- Resurrection and repeated `DESTROY` +- Threads and runtime graph cloning + +Direct refcount mutation outside the owner API should become forbidden except +inside the destruction state machine. + +### Phase 5: Remove superseded heuristics + +- Remove `WEAKLY_TRACKED` premature-clearing heuristics where owner coverage is + complete. +- Retire class-specific and capture-specific walker exceptions superseded by + authoritative ownership. +- Keep the walker for diagnostics and explicit graph queries. +- Add property tests for balanced owners, cycles, weak edges, and deterministic + destruction. + +## Regression Matrix + +Every new or changed Perl test must pass system Perl before PerlOnJava is used +as an implementation oracle. + +### Pad and closure ownership + +- Zero, one, and two closures sharing one lexical retain one referent owner. +- Independent lexical copies produce independent referent owners. +- Scope exit preserves a referent until the final closure is released. +- Releasing one of two closures does not release the shared pad token. +- The final release clears weak observers and invokes `DESTROY` exactly once. +- Nested closures and returned closures share the correct pad cell. +- Argument aliases and borrowed captures are promoted exactly once. +- Self-capturing CODE and strong closure cycles match Perl behavior. + +### Mutation + +- Weakening a captured slot makes it non-owning. +- Unweakening a live captured slot restores one owner. +- Reassignment releases the old referent and retains the new referent. +- Repeated reassignment cannot release a stale referent through deferred work. +- Ordinary weak references clear when no strong owner exists. + +### Capture provenance + +- JVM and interpreter closures report equivalent semantic captures. +- Conservative interpreter register snapshots do not retain unrelated objects. +- `eval STRING` retains only the Perl-visible lexical environment for the + correct code-object lifetime. +- Generated JVM objects and reflected fields do not become Perl roots. +- ithread clones reconstruct independent ownership. + +### Existing compatibility guards + +Preserve and run: + +- `unit/weak_localized_cache_lifetime.t` +- Closure-cycle, captured-scalar, weak-callback, Sub::Quote, and Sub::Defer tests + under `unit/refcount/` +- Focused Moo and Sub::Quote tests +- DBIx::Class leak-tracer and weak-registry tests +- Exact-refcount tests using `B::REFCNT` or `Test2::Tools::Refcount` + +## Issue #1132 and Ecosystem Acceptance + +Run on JVM and interpreter backends with `timeout` and complete output logs: + +1. The issue #1132 Future reproducer prints `completed=1` without a lost-sequence + warning. +2. `Future::Utils::repeat` prints `ready=1 result=final`. +3. `Net::Async::HTTP` `t/05redir.t` completes without warning or timeout. +4. Full Future and Net::Async::HTTP distributions complete. +5. Cookie and Bzip2 failures remain separately classified. +6. Focused Moo, Sub::Quote, and DBIx::Class leak tests remain green. + +## Final Gates + +- Run every new or changed Perl test with system Perl first. +- Retain evidence that the focused issue test fails on the unfixed parent. +- Run focused tests on both PerlOnJava backends. +- Run `make` with complete captured output and zero failures. +- Run `make check-links` and offline `lychee` where required. +- Verify no unexpected high-CPU PerlOnJava JVM remains. +- Rebase onto current `master`, rerun focused and full gates on the exact commit, + and only then mark the issue-fix PR ready. + +## Progress Tracking + +### Current Status: Phase 0 in progress + +### Completed Work + +- [x] Preserved the prior walker experiment and dirty-tree state on a WIP branch + (2026-09-01). +- [x] Confirmed the borrowed-only capture token does not fix the focused test on + either backend (2026-09-01). +- [x] Confirmed unconditional per-closure tokens regress existing exact-lifetime + tests (2026-09-01). +- [x] Confirmed with system Perl that multiple closures sharing one lexical do + not add referent owners (2026-09-01). + +### Next Steps + +1. Replace the partial capture-token implementation with owner-ledger + instrumentation. +2. Add the stable system-Perl owner-cardinality oracle tests. +3. Inventory and classify every direct refcount mutation. +4. Implement explicit semantic capture descriptors for both backends. +5. Implement the captured-pad lifecycle before changing weak sweeping. + +### Open Questions + +- Which existing conservative interpreter captures are required solely for + `eval STRING`, and which can be removed entirely? +- Should authoritative owner tokens be production objects, compact per-kind + counters with debug provenance, or a debug ledger over production counters? +- Which remaining `WEAKLY_TRACKED` paths cannot yet reconstruct their real Perl + owners at first weakening? diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 5935eb64c..876e5e028 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -83,6 +83,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 diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index c142261ac..d672d99fb 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -311,6 +311,13 @@ public void retainClosureCapture() { captureCount++; if (type == RuntimeScalarType.CODE) { retainClosureCaptureReferent(); + } else if (!refCountOwned) { + // A captured lexical is a Perl owner even when this scalar is a + // borrowed copy of the pad slot and therefore has no ordinary + // refCountOwned token of its own. Keep the referent alive for the + // lifetime of this closure capture without making weak slots, + // untracked values, or conservative capture metadata strong. + retainClosureCaptureReferent(); } } diff --git a/src/test/resources/unit/refcount/closure_capture_weak_owner.t b/src/test/resources/unit/refcount/closure_capture_weak_owner.t new file mode 100644 index 000000000..8c63e959b --- /dev/null +++ b/src/test/resources/unit/refcount/closure_capture_weak_owner.t @@ -0,0 +1,76 @@ +use strict; +use warnings; + +use Scalar::Util qw(weaken); +use Test::More; + +our @destroyed; + +{ + package ClosureCaptureWeakOwner; + sub DESTROY { push @main::destroyed, $_[0]{id} } +} + +sub object { bless { id => $_[0] }, 'ClosureCaptureWeakOwner' } + +# A callback stored in a live object keeps its captured lexical alive after the +# lexical's declaring block exits, as in Future::PP callback records. +my $holder = bless {}, 'ClosureCaptureWeakOwner'; +my $weak_sequence; +{ + my $sequence = object('sequence'); + $weak_sequence = $sequence; + weaken($weak_sequence); + $holder->{on_cancel} = sub { $sequence }; +} + +ok(defined($weak_sequence), + 'reachable callback keeps its captured referent alive'); +is($holder->{on_cancel}->(){id}, 'sequence', + 'retained callback still observes the captured object'); + +$holder = undef; +ok(!defined($weak_sequence), + 'releasing the callback clears the weak reference'); +is(scalar(grep { defined($_) && $_ eq 'sequence' } @destroyed), 1, + 'captured object is destroyed exactly once'); + +# A weak capture must not turn into a strong capture when the lexical is +# weakened after the closure is created. +@destroyed = (); +my ($weak_capture, $weak_callback); +{ + my $value = object('weakened'); + $weak_capture = $value; + weaken($weak_capture); + my $callback = sub { $value }; + weaken($value); + $weak_callback = $callback; +} +ok(!defined($weak_capture), 'weakening a captured scalar releases ownership'); +undef $weak_callback; +is(scalar(grep { $_ eq 'weakened' } @destroyed), 1, + 'weakened capture does not retain the referent'); + +# Reassignment of a captured scalar drops the old referent and keeps the new +# one until the closure goes away. +@destroyed = (); +my ($old_weak, $new_weak, $reassign); +{ + my $value = object('old'); + $old_weak = $value; + weaken($old_weak); + $reassign = sub { $value }; + $value = object('new'); + $new_weak = $value; + weaken($new_weak); +} +ok(!defined($old_weak), 'reassigning a capture releases the old referent'); +ok(defined($new_weak), 'reassigned capture retains the new referent'); +is($reassign->(){id}, 'new', 'reassigned callback observes the new referent'); +undef $reassign; +ok(!defined($new_weak), 'releasing reassigned callback clears the new weak ref'); +is_deeply([sort @destroyed], [qw(new old)], + 'reassignment destroys both referents exactly once'); + +done_testing; From 99636a4104d50b2ef4cea8f0cb2bf5093acfaf69 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 12:02:15 +0200 Subject: [PATCH 02/18] fix: retain tracked referents through captured pads Model a captured pad as one semantic owner shared by all closures, and consult that ownership during weak-reference cleanup. Transfer the owner on reassignment and release it on weaken, unweaken, and final capture release. Generated with Codex (https://openai.com/codex) Co-Authored-By: Codex <2237389410+openai-codex[bot]@users.noreply.github.com> --- dev/design/refcount-owner-ledger.md | 24 +++++++++ .../bytecode/OpcodeHandlerExtended.java | 4 ++ .../runtime/runtimetypes/MortalList.java | 7 ++- .../runtimetypes/ReachabilityWalker.java | 9 ++++ .../runtime/runtimetypes/RuntimeBase.java | 24 +++++++++ .../runtime/runtimetypes/RuntimeCode.java | 4 ++ .../runtime/runtimetypes/RuntimeScalar.java | 54 +++++++++++++++++-- .../runtime/runtimetypes/WeakRefRegistry.java | 12 ++++- 8 files changed, 131 insertions(+), 7 deletions(-) diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index 54c1dd559..a9379c868 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -235,6 +235,30 @@ Audit and convert all ownership paths: - Resurrection and repeated `DESTROY` - Threads and runtime graph cloning +## Progress Tracking + +### Current Status: Issue #1132 milestone implemented for tracked captured referents (2026-09-01) + +### Completed Work + +- [x] Capture-pad ownership + - Added one semantic owner per captured pad cell, shared by all closures. + - Reassignment, `weaken()`, `unweaken()`, and final closure release transfer + or release that owner without affecting a newly assigned referent. +- [x] Weak-sweep authority + - Both deferred cleanup and `ReachabilityWalker` preserve a tracked referent + while a semantic pad owner is live. +- [x] Backend parity + - JVM reflective closure discovery and interpreter closure creation dedupe + repeated pad cells before retaining their capture binding. + +### Next Steps + +1. Extend the owner ledger from tracked captured referents to aggregate, global, + glob, CODE, tie, temporary, and untracked owner sources (Phase 4). +2. Replace the remaining `WEAKLY_TRACKED` migration heuristics with reconstructed + owner tokens. + Direct refcount mutation outside the owner API should become forbidden except inside the destruction state machine. diff --git a/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java b/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java index 0baa51103..aba00474e 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java +++ b/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java @@ -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 capturedScalars = new java.util.ArrayList<>(); java.util.List capturedAggregates = new java.util.ArrayList<>(); + java.util.Set seenScalars = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + java.util.Set 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(); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java index 6434b4963..43c931d44 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java @@ -1232,7 +1232,12 @@ private static void processDeferredBase(RuntimeBase base, boolean clearWeakRefsF base.traceRefCount(-1, "MortalList.flush (deferred decrement)"); } if (base.refCount > 0 && --base.refCount == 0) { - if (base.localBindingExists) { + if (base.hasSemanticCaptureOwner()) { + // The shared captured pad cell is an authoritative strong + // owner. It is intentionally independent of the transient + // selective count being drained here. + base.refCount = 1; + } else if (base.localBindingExists) { if (base instanceof RuntimeScalar scalar && scalar.referencedByScalarReference && scalar.captureCount == 0 diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java b/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java index 5dbbb8f2a..42642f68f 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java @@ -1799,6 +1799,12 @@ public static int sweepWeakRefs(boolean quiet, boolean forceJvmGc) { : Collections.emptySet(); for (RuntimeBase referent : WeakRefRegistry.snapshotWeakRefReferents()) { boolean liveReferent = live.contains(referent); + // Semantic closure ownership is an explicit Perl edge. It is not + // necessarily visible to this conservative graph walk because + // generated closure fields and metadata are intentionally opaque. + if (!liveReferent && referent.hasSemanticCaptureOwner()) { + continue; + } boolean localBinding = (referent instanceof RuntimeHash || referent instanceof RuntimeArray) && referent.localBindingExists; boolean cycleProtected = quiet && strongCycleProtected.contains(referent); @@ -1871,6 +1877,9 @@ && isCapturedByWeakBackrefCode(referent)) { if (live.contains(referent)) { continue; } + if (referent.hasSemanticCaptureOwner()) { + continue; + } if ((referent instanceof RuntimeHash || referent instanceof RuntimeArray) && referent.localBindingExists) { continue; diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java index 519781000..a03ac5fda 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java @@ -219,6 +219,30 @@ public void releaseClosureCapture() { // ───────────────────────────────────────────────────────────────────── public java.util.Set activeOwners = null; + /** + * Semantic pad owners. Unlike {@link #activeOwners}, this set models the + * one strong edge from a captured pad cell to its current referent. It is + * deliberately keyed by the pad cell, not by the number of closures which + * share that cell: two closures must not create two Perl references. + */ + private java.util.Set semanticCaptureOwners = null; + + public void acquireSemanticCaptureOwner(RuntimeScalar pad) { + if (semanticCaptureOwners == null) { + semanticCaptureOwners = java.util.Collections.newSetFromMap( + new java.util.IdentityHashMap<>()); + } + semanticCaptureOwners.add(pad); + } + + public void releaseSemanticCaptureOwner(RuntimeScalar pad) { + if (semanticCaptureOwners != null) semanticCaptureOwners.remove(pad); + } + + public boolean hasSemanticCaptureOwner() { + return semanticCaptureOwners != null && !semanticCaptureOwners.isEmpty(); + } + // Conservative gate for the tied-handler reachability fallback. Once a // base has appeared in a tie handler's strong object graph it remains // marked; a stale true only permits the exact walker to run, while false diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index 7a7357630..445bd3335 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -3634,10 +3634,13 @@ public static RuntimeScalar makeCodeObject( Field[] allFields = clazz.getDeclaredFields(); List captured = new ArrayList<>(); List capturedAggregates = new ArrayList<>(); + Set seenScalars = Collections.newSetFromMap(new IdentityHashMap<>()); + Set seenAggregates = Collections.newSetFromMap(new IdentityHashMap<>()); for (Field f : allFields) { if (f.getType() == RuntimeScalar.class && !"__SUB__".equals(f.getName())) { RuntimeScalar capturedVar = (RuntimeScalar) f.get(codeObject); if (capturedVar != null) { + if (!seenScalars.add(capturedVar)) continue; if (code.closedOverVariables == null) { code.closedOverVariables = new LinkedHashMap<>(); } @@ -3648,6 +3651,7 @@ public static RuntimeScalar makeCodeObject( } else if (f.getType() == RuntimeArray.class || f.getType() == RuntimeHash.class) { RuntimeBase capturedAggregate = (RuntimeBase) f.get(codeObject); if (capturedAggregate != null) { + if (!seenAggregates.add(capturedAggregate)) continue; if (code.closedOverVariables == null) { code.closedOverVariables = new LinkedHashMap<>(); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index d672d99fb..b05c8b87a 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -308,16 +308,24 @@ private boolean isDetachedFromContainerOwner() { } public void retainClosureCapture() { - captureCount++; - if (type == RuntimeScalarType.CODE) { + boolean firstCapture = captureCount++ == 0; + if (firstCapture && type == RuntimeScalarType.CODE) { retainClosureCaptureReferent(); - } else if (!refCountOwned) { + } else if (firstCapture && !refCountOwned) { // A captured lexical is a Perl owner even when this scalar is a // borrowed copy of the pad slot and therefore has no ordinary // refCountOwned token of its own. Keep the referent alive for the // lifetime of this closure capture without making weak slots, // untracked values, or conservative capture metadata strong. + RuntimeBase base = strongCaptureReferent(); + if (ledgerEligible(base)) base.acquireSemanticCaptureOwner(this); retainClosureCaptureReferent(); + } else if (firstCapture) { + // The ordinary pad-slot increment already exists in refCount. + // Record the semantic edge separately so weak sweeping cannot + // mistake a captured pad for an unowned JVM temporary. + RuntimeBase base = closureCaptureReferent(); + if (ledgerEligible(base)) base.acquireSemanticCaptureOwner(this); } } @@ -331,10 +339,14 @@ void retainThreadCloneClosureCapture() { } public void releaseClosureCapture() { - releaseOneClosureCaptureReferent(); if (captureCount > 0) { captureCount--; } + if (captureCount == 0) { + RuntimeBase base = strongCaptureReferent(); + if (base != null) base.releaseSemanticCaptureOwner(this); + releaseOneClosureCaptureReferent(); + } } private void retainClosureCaptureReferent() { @@ -344,6 +356,7 @@ private void retainClosureCaptureReferent() { base.refCount++; base.hadCountedReference = true; captureRefCountOwned++; + base.acquireSemanticCaptureOwner(this); } private void retainMissingClosureCaptureReferents() { @@ -369,6 +382,22 @@ private RuntimeBase closureCaptureReferent() { return base; } + /** The referent edge is semantic even when selective refcounting is not. */ + private RuntimeBase strongCaptureReferent() { + if (WeakRefRegistry.isweak(this) + || (type & RuntimeScalarType.REFERENCE_BIT) == 0 + || !(value instanceof RuntimeBase base) + || base.refCount == WeakRefRegistry.WEAKLY_TRACKED + || base.refCount == Integer.MIN_VALUE) { + return null; + } + return base; + } + + private static boolean ledgerEligible(RuntimeBase base) { + return base != null && base.blessId != 0; + } + private void releaseOneClosureCaptureReferent() { if (captureRefCountOwned <= 0) return; if ((type & RuntimeScalarType.REFERENCE_BIT) != 0 @@ -389,10 +418,17 @@ private void releaseAllClosureCaptureReferents(RuntimeBase oldBase) { } void releaseClosureCaptureReferentsForWeaken(RuntimeBase oldBase) { + if (captureCount > 0 && oldBase != null) { + oldBase.releaseSemanticCaptureOwner(this); + } releaseAllClosureCaptureReferents(oldBase); } void retainClosureCaptureReferentsForUnweaken() { + if (captureCount > 0) { + RuntimeBase base = strongCaptureReferent(); + if (ledgerEligible(base)) base.acquireSemanticCaptureOwner(this); + } retainMissingClosureCaptureReferents(); } @@ -1959,6 +1995,9 @@ && isSocketIOHandle(oldIo.ioHandle)) { if (oldBase != null && this.captureRefCountOwned > 0) { releaseAllClosureCaptureReferents(oldBase); } + if (oldBase != null && this.captureCount > 0) { + oldBase.releaseSemanticCaptureOwner(this); + } // Increment new value's refCount for tracked stores. RuntimeHash/RuntimeArray // at refCount==-1 are promoted to 0 here (aligned with @@ -1989,6 +2028,13 @@ && isSocketIOHandle(oldIo.ioHandle)) { // Do the assignment this.type = value.type; this.value = value.value; + if (this.captureCount > 0 && (this.type & RuntimeScalarType.REFERENCE_BIT) != 0 + && this.value instanceof RuntimeBase capturedBase + && !WeakRefRegistry.isweak(this)) { + if (ledgerEligible(capturedBase)) { + capturedBase.acquireSemanticCaptureOwner(this); + } + } this.utf8UncheckedOctets = value.utf8UncheckedOctets; this.tainted = value.tainted; this.numericLiteralText = value.numericLiteralText; diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java b/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java index 86d1bd540..e007d3874 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java @@ -160,7 +160,14 @@ private static void weaken( base.releaseOwner(ref, "weaken"); base.releaseActiveOwner(ref); if (--base.refCount == 0) { - if (base.localBindingExists) { + if (base.hasSemanticCaptureOwner()) { + // A captured pad is an authoritative Perl owner even when + // the weak probe being weakened held the last ordinary + // counted slot. Keep the referent alive until the shared + // pad cell is released; multiple closures share this one + // owner and must not inflate refCount. + base.refCount = 1; + } else if (base.localBindingExists) { // Named container (my %hash / my @array): the local variable // slot holds a strong reference not counted in refCount. // Don't call callDestroy — the container is still alive. @@ -265,7 +272,8 @@ && codeRefHasCountedOwners(base) } private static boolean codeRefHasCountedOwners(RuntimeBase base) { - return base.refCount > 0 || base.activeOwnerCount() > 0; + return base.refCount > 0 || base.activeOwnerCount() > 0 + || base.hasSemanticCaptureOwner(); } /** From 473fdaeeb50847e1b9529a3bc87b70ca94153a91 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 14:28:25 +0200 Subject: [PATCH 03/18] fix: preserve weak callback targets held by closure captures Honor semantic capture owners at the weak-reference clearing boundary, and allow their lifecycle lookup through WEAKLY_TRACKED states. Add a focused, system-Perl-validated weak callback-slot regression and record the Future and Net::Async::HTTP acceptance evidence in the owner-ledger design. Generated with Codex (OpenAI) Co-Authored-By: Codex --- dev/design/refcount-owner-ledger.md | 75 +++++++++++++------ .../runtime/runtimetypes/RuntimeScalar.java | 35 ++++++--- .../runtime/runtimetypes/WeakRefRegistry.java | 5 ++ .../closure_capture_weak_callback_slot.t | 32 ++++++++ 4 files changed, 113 insertions(+), 34 deletions(-) create mode 100644 src/test/resources/unit/refcount/closure_capture_weak_callback_slot.t diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index a9379c868..61c7a17d8 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -237,27 +237,35 @@ Audit and convert all ownership paths: ## Progress Tracking -### Current Status: Issue #1132 milestone implemented for tracked captured referents (2026-09-01) +### Current Status: Phase 1 inventory and Phase 2 capture-binding implementation in progress (2026-09-01) ### Completed Work -- [x] Capture-pad ownership - - Added one semantic owner per captured pad cell, shared by all closures. - - Reassignment, `weaken()`, `unweaken()`, and final closure release transfer - or release that owner without affecting a newly assigned referent. -- [x] Weak-sweep authority - - Both deferred cleanup and `ReachabilityWalker` preserve a tracked referent - while a semantic pad owner is live. -- [x] Backend parity - - JVM reflective closure discovery and interpreter closure creation dedupe - repeated pad cells before retaining their capture binding. +- [x] Initial tracked-pad owner experiment + - Added a shared pad owner for tracked referents and protected it during weak + sweeping. + - The generic closure regression passes, but this is not sufficient evidence + for the Issue #1132 milestone. +- [x] Failure characterization + - The direct `Future` reproducer still loses its sequence Future on both + backends: its weak callback slot targets a scalar wrapper around the + captured Future rather than the Future hash directly. + - A first binding-liveness experiment regressed + `unit/weak_localized_cache_lifetime.t`; do not use `captureCount`, Java + reachability, or CODE selective counts as binding authority. ### Next Steps -1. Extend the owner ledger from tracked captured referents to aggregate, global, - glob, CODE, tie, temporary, and untracked owner sources (Phase 4). -2. Replace the remaining `WEAKLY_TRACKED` migration heuristics with reconstructed - owner tokens. +1. Add explicit `CaptureBinding` records with semantic versus metadata + provenance for both backends, and protect both a captured pad cell and its + current referent without broad capture walking. +2. Add system-Perl-validated Future and Future::Utils regressions, then require + the direct Future reproducer on both backends before marking Phase 2 done. +3. Migrate owner sources in incremental Phase 4 commits: scalar/reference and + temporary paths; aggregate paths; globals/globs/CODE/pad constants; then + tie, rescue, and thread-clone paths. +4. Replace `WEAKLY_TRACKED` only once each corresponding owner source has + authoritative tokens and its regression matrix is green. Direct refcount mutation outside the owner API should become forbidden except inside the destruction state machine. @@ -341,7 +349,7 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: ## Progress Tracking -### Current Status: Phase 0 in progress +### Current Status: Phase 2 capture-binding model in progress ### Completed Work @@ -353,15 +361,38 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: tests (2026-09-01). - [x] Confirmed with system Perl that multiple closures sharing one lexical do not add referent owners (2026-09-01). +- [x] Traced the Issue #1132 Future failure to an exact captured + `HASHREFERENCE` scalar whose referent is also the weak callback target + (2026-09-01). +- [x] Rejected broad unblessed-capture ownership (2026-09-01). + - It keeps the Future target alive, but leaks ordinary captured array and + scalar-reference targets after callback release. + - `unit/weak_localized_cache_lifetime.t` fails its release assertions, so + referent blessing, `captureCount`, and generic captured-field discovery are + not authoritative binding provenance. +- [x] Closed the semantic-owner weak-clearing bypass for tracked captured pads + (2026-09-01). + - `WeakRefRegistry.clearWeakRefsTo()` now preserves a referent with an + existing semantic capture owner. + - The Issue #1132 Future reproducer prints `completed=1` on JVM and + interpreter without the lost-sequence warning. + - Added `unit/refcount/closure_capture_weak_callback_slot.t`; it passes + system Perl, JVM, and interpreter. +- [x] Ran Net::Async::HTTP 0.50 acceptance (2026-09-01). + - `t/05redir.t` is blocked before redirect execution by IO::Async's required + `fileno` capability; the distribution fails 24/41 programs for the same + handle/connection limitation. This is separate from closure ownership. ### Next Steps -1. Replace the partial capture-token implementation with owner-ledger - instrumentation. -2. Add the stable system-Perl owner-cardinality oracle tests. -3. Inventory and classify every direct refcount mutation. -4. Implement explicit semantic capture descriptors for both backends. -5. Implement the captured-pad lifecycle before changing weak sweeping. +1. Introduce a `CaptureBinding` descriptor at closure creation, with an exact + source pad cell, a semantic/metadata kind, and one shared owner token. +2. Populate it from named lexical captures in the JVM emitter and the + interpreter; keep register/reflection discovery as metadata only. +3. Route reassignment, weaken/unweaken, closure destruction, and ithread clone + through that descriptor before retiring further weak-sweeping heuristics. +4. Resume the Phase 4 owner-source inventory incrementally after the focused + module matrix is green. ### Open Questions diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index b05c8b87a..013023ea1 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -317,15 +317,15 @@ public void retainClosureCapture() { // refCountOwned token of its own. Keep the referent alive for the // lifetime of this closure capture without making weak slots, // untracked values, or conservative capture metadata strong. - RuntimeBase base = strongCaptureReferent(); - if (ledgerEligible(base)) base.acquireSemanticCaptureOwner(this); + RuntimeBase base = semanticCaptureReferent(); + if (base != null && base.blessId != 0) base.acquireSemanticCaptureOwner(this); retainClosureCaptureReferent(); } else if (firstCapture) { // The ordinary pad-slot increment already exists in refCount. // Record the semantic edge separately so weak sweeping cannot // mistake a captured pad for an unowned JVM temporary. - RuntimeBase base = closureCaptureReferent(); - if (ledgerEligible(base)) base.acquireSemanticCaptureOwner(this); + RuntimeBase base = semanticCaptureReferent(); + if (base != null && base.blessId != 0) base.acquireSemanticCaptureOwner(this); } } @@ -343,7 +343,7 @@ public void releaseClosureCapture() { captureCount--; } if (captureCount == 0) { - RuntimeBase base = strongCaptureReferent(); + RuntimeBase base = semanticCaptureReferent(); if (base != null) base.releaseSemanticCaptureOwner(this); releaseOneClosureCaptureReferent(); } @@ -394,10 +394,23 @@ private RuntimeBase strongCaptureReferent() { return base; } - private static boolean ledgerEligible(RuntimeBase base) { - return base != null && base.blessId != 0; + /** + * The owner ledger tracks Perl's strong capture edge separately from the + * selective refcount. A live referent can be WEAKLY_TRACKED when a weak + * probe was installed before the closure was constructed; that sentinel + * must not prevent the later strong capture from becoming an owner. + */ + private RuntimeBase semanticCaptureReferent() { + if (WeakRefRegistry.isweak(this) + || (type & RuntimeScalarType.REFERENCE_BIT) == 0 + || !(value instanceof RuntimeBase base) + || base.refCount == Integer.MIN_VALUE) { + return null; + } + return base; } + private void releaseOneClosureCaptureReferent() { if (captureRefCountOwned <= 0) return; if ((type & RuntimeScalarType.REFERENCE_BIT) != 0 @@ -426,8 +439,8 @@ void releaseClosureCaptureReferentsForWeaken(RuntimeBase oldBase) { void retainClosureCaptureReferentsForUnweaken() { if (captureCount > 0) { - RuntimeBase base = strongCaptureReferent(); - if (ledgerEligible(base)) base.acquireSemanticCaptureOwner(this); + RuntimeBase base = semanticCaptureReferent(); + if (base != null && base.blessId != 0) base.acquireSemanticCaptureOwner(this); } retainMissingClosureCaptureReferents(); } @@ -2031,9 +2044,7 @@ && isSocketIOHandle(oldIo.ioHandle)) { if (this.captureCount > 0 && (this.type & RuntimeScalarType.REFERENCE_BIT) != 0 && this.value instanceof RuntimeBase capturedBase && !WeakRefRegistry.isweak(this)) { - if (ledgerEligible(capturedBase)) { - capturedBase.acquireSemanticCaptureOwner(this); - } + if (capturedBase.blessId != 0) capturedBase.acquireSemanticCaptureOwner(this); } this.utf8UncheckedOctets = value.utf8UncheckedOctets; this.tainted = value.tainted; diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java b/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java index e007d3874..22419e76e 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java @@ -338,6 +338,11 @@ public static boolean hasWeakRefsTo(RuntimeBase referent) { * before DESTROY. Sets all weak scalars pointing to this referent to undef. */ public static void clearWeakRefsTo(RuntimeBase referent) { + // A captured pad is a semantic strong owner even when the selective + // refcount has reached a weak-tracking sentinel. All destruction and + // sweeping paths funnel through this method, so preserve that edge at + // the weak-clearing boundary as well as in refcount transitions. + if (referent.hasSemanticCaptureOwner()) return; // CODE refs can live in both lexicals and the symbol table. Do not // clear weak CODE refs while a stash slot still owns the sub, but do // clear anonymous CODE refs when their selective refcount reaches zero. diff --git a/src/test/resources/unit/refcount/closure_capture_weak_callback_slot.t b/src/test/resources/unit/refcount/closure_capture_weak_callback_slot.t new file mode 100644 index 000000000..f65e5ef60 --- /dev/null +++ b/src/test/resources/unit/refcount/closure_capture_weak_callback_slot.t @@ -0,0 +1,32 @@ +use strict; +use warnings; + +use Scalar::Util qw(weaken); +use Test::More; + +my $source = bless { callbacks => [] }, 'WeakCallbackSource'; +my $holder = bless {}, 'WeakCallbackHolder'; +my $completed = 0; + +{ + my $sequence = bless {}, 'WeakCallbackSequence'; + push @{$source->{callbacks}}, [sub { $completed = 1 }, $sequence]; + weaken($source->{callbacks}[-1][1]); + + # This is the same ownership shape as Future's cancellation callback: a + # live callback captures the sequence while the source stores only a weak + # callback slot for it. + $holder->{on_cancel} = sub { $sequence }; +} + +my $callback = $source->{callbacks}[0][0]; +ok(defined($source->{callbacks}[0][1]), + 'captured callback keeps its weak callback-slot target alive'); +$callback->(); +is($completed, 1, 'source dispatch observes the retained sequence callback'); + +undef $holder; +ok(!defined($source->{callbacks}[0][1]), + 'weak callback slot clears after its final capture owner is released'); + +done_testing; From 6456871f518030f633fb91b89a59ed2991d2e7ae Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 15:51:01 +0200 Subject: [PATCH 04/18] wip: checkpoint IO owner-transfer handoff Document the remaining JVM closure-captured socket return boundary and retain the focused IO::Socket fileno regression while that case is completed. Generated with Codex (https://openai.com/codex) Co-Authored-By: Codex --- dev/design/refcount-owner-ledger.md | 75 ++++++++----------- .../backend/bytecode/BytecodeCompiler.java | 17 ++++- .../runtime/perlmodule/IOHandle.java | 25 +++++++ .../runtime/runtimetypes/RuntimeScalar.java | 12 +++ .../resources/unit/io_socket_method_fileno.t | 46 ++++++++++++ 5 files changed, 128 insertions(+), 47 deletions(-) create mode 100644 src/test/resources/unit/io_socket_method_fileno.t diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index 61c7a17d8..5f902fff5 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -349,56 +349,43 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: ## Progress Tracking -### Current Status: Phase 2 capture-binding model in progress +### Current Status: Phase 2 capture ownership landed; JVM closure-captured IO return ownership in progress ### Completed Work -- [x] Preserved the prior walker experiment and dirty-tree state on a WIP branch - (2026-09-01). -- [x] Confirmed the borrowed-only capture token does not fix the focused test on - either backend (2026-09-01). -- [x] Confirmed unconditional per-closure tokens regress existing exact-lifetime - tests (2026-09-01). -- [x] Confirmed with system Perl that multiple closures sharing one lexical do - not add referent owners (2026-09-01). -- [x] Traced the Issue #1132 Future failure to an exact captured - `HASHREFERENCE` scalar whose referent is also the weak callback target - (2026-09-01). -- [x] Rejected broad unblessed-capture ownership (2026-09-01). - - It keeps the Future target alive, but leaks ordinary captured array and - scalar-reference targets after callback release. - - `unit/weak_localized_cache_lifetime.t` fails its release assertions, so - referent blessing, `captureCount`, and generic captured-field discovery are - not authoritative binding provenance. -- [x] Closed the semantic-owner weak-clearing bypass for tracked captured pads - (2026-09-01). - - `WeakRefRegistry.clearWeakRefsTo()` now preserves a referent with an - existing semantic capture owner. - - The Issue #1132 Future reproducer prints `completed=1` on JVM and - interpreter without the lost-sequence warning. - - Added `unit/refcount/closure_capture_weak_callback_slot.t`; it passes - system Perl, JVM, and interpreter. -- [x] Ran Net::Async::HTTP 0.50 acceptance (2026-09-01). - - `t/05redir.t` is blocked before redirect execution by IO::Async's required - `fileno` capability; the distribution fails 24/41 programs for the same - handle/connection limitation. This is separate from closure ownership. +- [x] Closure captures now retain tracked referents through captured pads, and + weak callback targets with semantic capture owners are not cleared early. + The focused Future regression is covered by + `unit/refcount/closure_capture_weak_callback_slot.t`. +- [x] `IO::Handle` supplies `fileno` for normal and tied handles. Direct + `IO::Socket->socketpair` and `IO::Async::OS->pipepair` fileno probes work on + both backends. +- [x] Return-scope cleanup preserves IO ownership for materialized list + returns, including the interpreter path used by `IO::Async::OS->socketpair`. +- [x] Added `unit/io_socket_method_fileno.t`. System Perl passes its 12 + assertions, including the callback-captured socket-pair shape used by + IO::Async. ### Next Steps -1. Introduce a `CaptureBinding` descriptor at closure creation, with an exact - source pad cell, a semantic/metadata kind, and one shared owner token. -2. Populate it from named lexical captures in the JVM emitter and the - interpreter; keep register/reflection discovery as metadata only. -3. Route reassignment, weaken/unweaken, closure destruction, and ithread clone - through that descriptor before retiring further weak-sweeping heuristics. -4. Resume the Phase 4 owner-source inventory incrementally after the focused - module matrix is green. +1. Rebuild, then run `unit/io_socket_method_fileno.t` on both backends and + `unit/socket_scope_exit_eof.t` before further changes. +2. Trace JVM lowering of assignment from a returned socket list into a + closure-captured pad. Add a narrow owner transfer for that durable captured + destination; it must not generalize to every GLOB assignment or capture. +3. Run the focused tests, full `make`, and `jcpan -t Net::Async::HTTP`. + `t/05redir.t` is complete only when the callback-captured peer still has a + defined `fileno` after `IO::Async::OS->socketpair` returns. +4. Resume the remaining owner-source inventory once the module acceptance gate + is green. ### Open Questions -- Which existing conservative interpreter captures are required solely for - `eval STRING`, and which can be removed entirely? -- Should authoritative owner tokens be production objects, compact per-kind - counters with debug provenance, or a debug ledger over production counters? -- Which remaining `WEAKLY_TRACKED` paths cannot yet reconstruct their real Perl - owners at first weakening? +- How can the JVM emitter identify a capture-field assignment as the exact + durable pad destination without relying on broad runtime heuristics? +- The owner transfer must preserve `socket_scope_exit_eof.t`: treating every + capture or GLOB assignment as durable retains a socket past its lexical + lifetime. +- Net::Async::HTTP acceptance remains incomplete: JVM loses the callback's + captured socket peer after `IO::Async::OS->socketpair` returns, while the + interpreter retains it. diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java index f6786bdef..8a739bacb 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java @@ -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); } diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/IOHandle.java b/src/main/java/org/perlonjava/runtime/perlmodule/IOHandle.java index a969b651d..9dd58b33b 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/IOHandle.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/IOHandle.java @@ -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); @@ -50,6 +51,30 @@ public static RuntimeList ungetc(RuntimeArray args, int ctx) { return arg1.getList(); } + /** + * Return the descriptor for an IO::Handle object. + * + *

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.

+ */ + 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 */ diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index 013023ea1..8149cd0ee 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -4727,6 +4727,18 @@ public static void scopeExitCleanupPreservingReturnedLvalue(RuntimeScalar scalar MortalList.deferTiedObjectRelease(tiedVariable); return; } + + // EmitBlock materializes anonymous-IO aliases only for list-like + // implicit returns, so the returned list contains independent scalar + // containers before this lexical owner is cleaned up. Hand the owner + // token to that container first; ordinary scopeExitCleanup then sees + // that the lexical no longer owns the socket and cannot close it. + // A scalar return deliberately stays on the historical path: it is + // not materialized here and its final ownership transfer happens in + // RuntimeCode's normal return coercion. + if (returnedLvalue instanceof RuntimeList || returnedLvalue instanceof RuntimeArray) { + releaseIoOwnerPreservingReturned(scalar, returnedLvalue); + } scopeExitCleanup(scalar); } diff --git a/src/test/resources/unit/io_socket_method_fileno.t b/src/test/resources/unit/io_socket_method_fileno.t new file mode 100644 index 000000000..1be088463 --- /dev/null +++ b/src/test/resources/unit/io_socket_method_fileno.t @@ -0,0 +1,46 @@ +use v5.14; +use strict; +use warnings; +use Test::More; +use IO::Socket; +use Socket qw(AF_UNIX SOCK_STREAM PF_UNSPEC); + +my ($left, $right) = IO::Socket->new->socketpair( + AF_UNIX, SOCK_STREAM, PF_UNSPEC, +) or die "IO::Socket socketpair failed: $!"; + +my $left_method_fd = $left->fileno; +my $right_method_fd = $right->fileno; + +ok defined $left_method_fd, 'IO::Socket left handle has a method fileno'; +ok defined $right_method_fd, 'IO::Socket right handle has a method fileno'; +is $left_method_fd, fileno($left), 'method fileno matches core fileno on left'; +is $right_method_fd, fileno($right), 'method fileno matches core fileno on right'; +isnt $left_method_fd, $right_method_fd, 'socketpair ends have distinct descriptors'; + +is syswrite($right, 'ready'), 5, 'write through IO::Socket handle'; +my $buffer = ''; +is sysread($left, $buffer, 5), 5, 'read through IO::Socket handle'; +is $buffer, 'ready', 'socketpair transport remains usable after fileno'; + +my $captured_peer; +my $connect = sub { + my ($self_socket, $peer_socket) = IO::Socket->new->socketpair( + AF_UNIX, SOCK_STREAM, PF_UNSPEC, + ) or die "captured socketpair failed: $!"; + $captured_peer = $peer_socket; + return $self_socket; +}; + +my $self_socket = $connect->(); +ok defined $captured_peer->fileno, + 'socket peer assigned to a captured lexical retains method fileno after callback return'; +is syswrite($self_socket, 'captured'), 8, + 'write through callback-returned socket handle'; +$buffer = ''; +is sysread($captured_peer, $buffer, 8), 8, + 'read through closure-captured socket peer'; +is $buffer, 'captured', + 'closure-captured socket peer remains usable after callback return'; + +done_testing; From c68515e62763a48d4d1f9a661c7fac57723685b9 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 16:05:33 +0200 Subject: [PATCH 05/18] fix: retain IO owners in captured closure pads Explicit JVM returns now clean only frame-local scalar slots, leaving an enclosing closure capture responsible for an anonymous socket assigned during the callback. Refs: dev/design/refcount-owner-ledger.md Generated with Codex (https://openai.com/codex) Co-Authored-By: Codex --- dev/design/refcount-owner-ledger.md | 34 ++++++++----------- .../backend/jvm/EmitControlFlow.java | 6 +++- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index 5f902fff5..fae9abc74 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -349,7 +349,7 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: ## Progress Tracking -### Current Status: Phase 2 capture ownership landed; JVM closure-captured IO return ownership in progress +### Current Status: Phase 2 capture and anonymous-IO ownership complete; remaining module compatibility work is separate ### Completed Work @@ -365,27 +365,23 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: - [x] Added `unit/io_socket_method_fileno.t`. System Perl passes its 12 assertions, including the callback-captured socket-pair shape used by IO::Async. +- [x] JVM explicit-return cleanup now leaves constructor-captured scalar pads + to their enclosing frame. A callback can retain one end of + `IO::Async::OS->socketpair` while returning the other. +- [x] The focused regression passes on JVM and interpreter; `make` passes. + Net::Async::HTTP `t/05redir.t` and its socket/stream follow-on tests pass. ### Next Steps -1. Rebuild, then run `unit/io_socket_method_fileno.t` on both backends and - `unit/socket_scope_exit_eof.t` before further changes. -2. Trace JVM lowering of assignment from a returned socket list into a - closure-captured pad. Add a narrow owner transfer for that durable captured - destination; it must not generalize to every GLOB assignment or capture. -3. Run the focused tests, full `make`, and `jcpan -t Net::Async::HTTP`. - `t/05redir.t` is complete only when the callback-captured peer still has a - defined `fileno` after `IO::Async::OS->socketpair` returns. -4. Resume the remaining owner-source inventory once the module acceptance gate - is green. +1. Classify the remaining Net::Async::HTTP failures separately: Cookie2 value + formatting (`t/09cookies.t`), content-coding exception handling + (`t/18content-coding.t`), and refcount expectations (`t/30timeout.t`, + `t/32remove.t`). They are not anonymous-IO lifetime failures. +2. Resume the remaining owner-source inventory. ### Open Questions -- How can the JVM emitter identify a capture-field assignment as the exact - durable pad destination without relying on broad runtime heuristics? -- The owner transfer must preserve `socket_scope_exit_eof.t`: treating every - capture or GLOB assignment as durable retains a socket past its lexical - lifetime. -- Net::Async::HTTP acceptance remains incomplete: JVM loses the callback's - captured socket peer after `IO::Async::OS->socketpair` returns, while the - interpreter retains it. +- Does the content-coding exception trace share a root cause with the remaining + Future compatibility work, or should it be tracked independently? +- Are the Net::Async::HTTP refcount expectations supported by the project's + current selective reference-count model? diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitControlFlow.java b/src/main/java/org/perlonjava/backend/jvm/EmitControlFlow.java index f2c965a20..ddb9d6ea1 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitControlFlow.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitControlFlow.java @@ -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, From 3c3ee39d20bf60fdbb5528f9f8408a141c359736 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 16:45:29 +0200 Subject: [PATCH 06/18] fix: scope B refcount compensation to destruction Keep the DBIx aggregate compatibility adjustment inside DESTROY so ordinary fresh lexical B probes report their single owner. Expose diagnostic owner counts for the remaining IO::Async scalar-store investigation and update the Issue #1132 handoff. Generated with [Codex](https://openai.com/codex) Co-Authored-By: Codex --- dev/design/refcount-owner-ledger.md | 64 +++++++------------ .../runtime/perlmodule/Internals.java | 10 ++- .../runtime/runtimetypes/RuntimeBase.java | 5 ++ .../unit/refcount/b_refcount_fresh_lexical.t | 23 +++++++ 4 files changed, 59 insertions(+), 43 deletions(-) create mode 100644 src/test/resources/unit/refcount/b_refcount_fresh_lexical.t diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index fae9abc74..4d23aceb9 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -235,38 +235,6 @@ Audit and convert all ownership paths: - Resurrection and repeated `DESTROY` - Threads and runtime graph cloning -## Progress Tracking - -### Current Status: Phase 1 inventory and Phase 2 capture-binding implementation in progress (2026-09-01) - -### Completed Work - -- [x] Initial tracked-pad owner experiment - - Added a shared pad owner for tracked referents and protected it during weak - sweeping. - - The generic closure regression passes, but this is not sufficient evidence - for the Issue #1132 milestone. -- [x] Failure characterization - - The direct `Future` reproducer still loses its sequence Future on both - backends: its weak callback slot targets a scalar wrapper around the - captured Future rather than the Future hash directly. - - A first binding-liveness experiment regressed - `unit/weak_localized_cache_lifetime.t`; do not use `captureCount`, Java - reachability, or CODE selective counts as binding authority. - -### Next Steps - -1. Add explicit `CaptureBinding` records with semantic versus metadata - provenance for both backends, and protect both a captured pad cell and its - current referent without broad capture walking. -2. Add system-Perl-validated Future and Future::Utils regressions, then require - the direct Future reproducer on both backends before marking Phase 2 done. -3. Migrate owner sources in incremental Phase 4 commits: scalar/reference and - temporary paths; aggregate paths; globals/globs/CODE/pad constants; then - tie, rescue, and thread-clone paths. -4. Replace `WEAKLY_TRACKED` only once each corresponding owner source has - authoritative tokens and its regression matrix is green. - Direct refcount mutation outside the owner API should become forbidden except inside the destruction state machine. @@ -349,7 +317,7 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: ## Progress Tracking -### Current Status: Phase 2 capture and anonymous-IO ownership complete; remaining module compatibility work is separate +### Current Status: Issue #1132 lifetime paths complete; exact owner migration remains in progress ### Completed Work @@ -370,18 +338,30 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: `IO::Async::OS->socketpair` while returning the other. - [x] The focused regression passes on JVM and interpreter; `make` passes. Net::Async::HTTP `t/05redir.t` and its socket/stream follow-on tests pass. +- [x] `B::SV::REFCNT` no longer applies its DBIx destruction-only aggregate + adjustment to ordinary lexical probes. The new fresh-runtime regression + passes on system Perl and both PerlOnJava backends. +- [x] Future's exact-count programs `10wait_all`, `11wait_any`, + `12needs_all`, `13needs_any`, and `25retain` now pass on the JVM. +- [x] `Internals::jperl_refstate` reports active scalar-store and semantic + captured-pad owner counts for focused ownership diagnosis. ### Next Steps -1. Classify the remaining Net::Async::HTTP failures separately: Cookie2 value - formatting (`t/09cookies.t`), content-coding exception handling - (`t/18content-coding.t`), and refcount expectations (`t/30timeout.t`, - `t/32remove.t`). They are not anonymous-IO lifetime failures. -2. Resume the remaining owner-source inventory. +1. Continue the exact-owner migration for Net::Async::HTTP. `t/30timeout.t` + still has two surplus raw `$http` owners after removal (B reports 3 instead + of 1); the diagnostic shows one active scalar-store owner and zero semantic + capture owners. `t/32remove.t` still reports a connection at 7/4 instead of + 4/1. +2. Use the targeted `PJ_REFCOUNT_TRACE=1 PJ_REFCOUNT_TRACE_CLASS=Net::Async::HTTP` + trace together with `jperl_refstate` to identify the remaining ordinary + scalar-store owners. Do not alter capture accounting to compensate for them. +3. Re-run the Future exact-count programs and Net `t/30timeout.t` and + `t/32remove.t` on both backends after each owner-path change. +4. Keep Cookie2 formatting and content-coding exception handling separate from + this ownership work. ### Open Questions -- Does the content-coding exception trace share a root cause with the remaining - Future compatibility work, or should it be tracked independently? -- Are the Net::Async::HTTP refcount expectations supported by the project's - current selective reference-count model? +- Which IO::Async scalar-store paths remain live after notifier removal, and + which corresponding release transitions are missing? diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java b/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java index 9858bc756..b5afdf669 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java @@ -444,13 +444,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 @@ -491,6 +495,8 @@ public static RuntimeList jperlReferenceByAddress(RuntimeArray args, int ctx) { *
  • {@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
  • * */ public static RuntimeList jperl_refstate(RuntimeArray args, int ctx) { @@ -503,6 +509,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"; diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java index a03ac5fda..e8b685fbb 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java @@ -243,6 +243,11 @@ public boolean hasSemanticCaptureOwner() { return semanticCaptureOwners != null && !semanticCaptureOwners.isEmpty(); } + /** Number of distinct captured pad cells that currently own this referent. */ + public int semanticCaptureOwnerCount() { + return semanticCaptureOwners == null ? 0 : semanticCaptureOwners.size(); + } + // Conservative gate for the tied-handler reachability fallback. Once a // base has appeared in a tie handler's strong object graph it remains // marked; a stale true only permits the exact walker to run, while false diff --git a/src/test/resources/unit/refcount/b_refcount_fresh_lexical.t b/src/test/resources/unit/refcount/b_refcount_fresh_lexical.t new file mode 100644 index 000000000..2de535c91 --- /dev/null +++ b/src/test/resources/unit/refcount/b_refcount_fresh_lexical.t @@ -0,0 +1,23 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use Test::More; +use Test2::Tools::Refcount qw(is_oneref); +use B qw(svref_2object); + +{ + package BRefcountFreshLexical; + sub new { bless {}, shift } +} + +# Keep this in its own test process. B::SV must not depend on unrelated +# earlier activity registering a live lexical with the reachability walker. +my $object = BRefcountFreshLexical->new; + +is(svref_2object($object)->REFCNT, 1, + 'B reports the single lexical owner in a fresh runtime'); +is_oneref($object, + 'Test2 observes the same single owner through B'); + +done_testing; From d9cc13d49b2566f0a298d4a3f89ee58cf9190dd4 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 17:03:42 +0200 Subject: [PATCH 07/18] wip: snapshot before continuing owner ledger design --- dev/design/refcount-owner-ledger.md | 30 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index 4d23aceb9..4b70809ca 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -345,17 +345,27 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: `12needs_all`, `13needs_any`, and `25retain` now pass on the JVM. - [x] `Internals::jperl_refstate` reports active scalar-store and semantic captured-pad owner counts for focused ownership diagnosis. +- [x] Reproduced the remaining Net::Async::HTTP exact-count failures in the + distribution's normal socket-enabled environment. `t/30timeout.t` reports + 3 rather than 1 references at EOF; after the script scope drains, the raw + `$http` count is 2, so the `B::REFCNT` probe supplies the third reference. + `t/32remove.t` similarly reports 7 rather than 4 and 4 rather than 1; its + raw connection count is 3 after script-scope cleanup. +- [x] Scoped `PJ_REFCOUNT_TRACE` confirms these residual counts are not + semantic captured-pad owners. It also exposed a diagnostic gap: the trace + removes a scalar's provenance when `deferDecrementIfTracked()` queues its + decrement, before the deferred work is applied. A residual raw count can + therefore outlive all entries in the shutdown owner dump without revealing + the originating scalar-store path. ### Next Steps -1. Continue the exact-owner migration for Net::Async::HTTP. `t/30timeout.t` - still has two surplus raw `$http` owners after removal (B reports 3 instead - of 1); the diagnostic shows one active scalar-store owner and zero semantic - capture owners. `t/32remove.t` still reports a connection at 7/4 instead of - 4/1. -2. Use the targeted `PJ_REFCOUNT_TRACE=1 PJ_REFCOUNT_TRACE_CLASS=Net::Async::HTTP` - trace together with `jperl_refstate` to identify the remaining ordinary - scalar-store owners. Do not alter capture accounting to compensate for them. +1. Extend the owner trace so a queued deferred decrement retains immutable + provenance until it is applied or cancelled. Include the source scalar, + referent generation, and queue/release site in the shutdown report. +2. Use that retained trace with `jperl_refstate` to identify the two surplus + raw `$http` owners and the three surplus connection owners. Do not alter + capture accounting to compensate for them. 3. Re-run the Future exact-count programs and Net `t/30timeout.t` and `t/32remove.t` on both backends after each owner-path change. 4. Keep Cookie2 formatting and content-coding exception handling separate from @@ -363,5 +373,5 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: ### Open Questions -- Which IO::Async scalar-store paths remain live after notifier removal, and - which corresponding release transitions are missing? +- Which scalar-store or deferred-release paths leave the two `$http` and three + connection counts unbalanced after notifier removal? From 0300ece64cfc494e58a2a47b9174b166b350f77c Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 17:25:22 +0200 Subject: [PATCH 08/18] feat: retain deferred owner trace provenance Keep trace-only source provenance for deferred scalar releases until drain or cancellation, and record the completed diagnostic step in the owner-ledger design. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- dev/design/refcount-owner-ledger.md | 22 ++-- .../runtimetypes/LifecycleRuntimeState.java | 11 ++ .../runtime/runtimetypes/MortalList.java | 34 ++++-- .../runtime/runtimetypes/RuntimeBase.java | 106 ++++++++++++++++-- 4 files changed, 148 insertions(+), 25 deletions(-) diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index 4b70809ca..dba5b9c8f 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -317,7 +317,7 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: ## Progress Tracking -### Current Status: Issue #1132 lifetime paths complete; exact owner migration remains in progress +### Current Status: Deferred-release provenance is available; exact owner migration remains in progress ### Completed Work @@ -357,21 +357,27 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: decrement, before the deferred work is applied. A residual raw count can therefore outlive all entries in the shutdown owner dump without revealing the originating scalar-store path. +- [x] Deferred owner-release tracing now retains immutable provenance from + `deferDecrementIfTracked()` queueing until the matching drain or explicit + runtime-state cancellation; a still-pending record is included in the + shutdown report. The record includes the source scalar + identity, stable referent generation, acquisition site, queue site, and + drain site. The parallel queue metadata is trace-only, so it creates no + runtime owner or reachability edge. Files: `RuntimeBase.java`, + `LifecycleRuntimeState.java`, and `MortalList.java`. `make` passes; a + `PJ_REFCOUNT_TRACE` smoke run reports the retained pending provenance. ### Next Steps -1. Extend the owner trace so a queued deferred decrement retains immutable - provenance until it is applied or cancelled. Include the source scalar, - referent generation, and queue/release site in the shutdown report. -2. Use that retained trace with `jperl_refstate` to identify the two surplus +1. Use the retained trace with `jperl_refstate` to identify the two surplus raw `$http` owners and the three surplus connection owners. Do not alter capture accounting to compensate for them. -3. Re-run the Future exact-count programs and Net `t/30timeout.t` and +2. Re-run the Future exact-count programs and Net `t/30timeout.t` and `t/32remove.t` on both backends after each owner-path change. -4. Keep Cookie2 formatting and content-coding exception handling separate from +3. Keep Cookie2 formatting and content-coding exception handling separate from this ownership work. ### Open Questions -- Which scalar-store or deferred-release paths leave the two `$http` and three +- Which acquisition sites in the retained trace leave the two `$http` and three connection counts unbalanced after notifier removal? diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/LifecycleRuntimeState.java b/src/main/java/org/perlonjava/runtime/runtimetypes/LifecycleRuntimeState.java index f6cd2dccc..066bcc1dd 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/LifecycleRuntimeState.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/LifecycleRuntimeState.java @@ -17,6 +17,9 @@ final class LifecycleRuntimeState { final AtomicBoolean boundaryWorkRegistered = new AtomicBoolean(); boolean mortalActive = true; final ArrayList 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 pendingOwnerReleases = new ArrayList<>(); final ArrayList pendingTiedReleases = new ArrayList<>(); final ArrayList pendingIoReleases = new ArrayList<>(); final ArrayList deferredCaptures = new ArrayList<>(); @@ -65,7 +68,15 @@ 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.clear(); + pendingOwnerReleases.clear(); pendingTiedReleases.clear(); pendingIoReleases.clear(); deferredCaptures.clear(); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java index 43c931d44..7bb84d1d3 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java @@ -161,7 +161,13 @@ public static void deferDecrement(RuntimeBase base) { } LifecycleRuntimeState state = state(); markBoundaryWork(state); + queueDeferredBase(state, base, null); + } + + private static void queueDeferredBase(LifecycleRuntimeState state, RuntimeBase base, + RuntimeBase.PendingOwnerRelease ownerRelease) { state.pending.add(base); + state.pendingOwnerReleases.add(ownerRelease); } public static void deferTiedObjectRelease(TiedVariableBase tiedVariable) { @@ -343,12 +349,13 @@ public static void deferDecrementIfTracked(RuntimeScalar scalar) { scalar.refCountOwned = false; if (base.refCountTrace) { base.traceRefCount(0, "MortalList.deferDecrementIfTracked (queued, scalar.refCountOwned->false)"); - base.releaseOwner(scalar, "deferDecrementIfTracked"); } base.releaseActiveOwner(scalar); LifecycleRuntimeState state = state(); markBoundaryWork(state); - state.pending.add(base); + RuntimeBase.PendingOwnerRelease ownerRelease = base.queueOwnerRelease( + scalar, "MortalList.deferDecrementIfTracked"); + queueDeferredBase(state, base, ownerRelease); } else if (base.refCount == 0 && base.clearedOwnedAggregateElement && WeakRefRegistry.hasWeakRefsTo(base)) { @@ -798,7 +805,7 @@ private static void deferDecrementRecursive(RuntimeScalar scalar) { releasingLastOwner = base.refCount == 1; LifecycleRuntimeState state = state(); markBoundaryWork(state); - state.pending.add(base); + queueDeferredBase(state, base, null); } else if (base.refCount == 0) { if (base.refCountTrace) { base.traceRefCount(+1, "MortalList.deferDecrementRecursive (blessed never-stored bump+queue)"); @@ -806,7 +813,7 @@ private static void deferDecrementRecursive(RuntimeScalar scalar) { base.refCount = 1; LifecycleRuntimeState state = state(); markBoundaryWork(state); - state.pending.add(base); + queueDeferredBase(state, base, null); // A zero-count blessed container returned from a helper // has no counted owner to release, but its fields still // disappear when this temporary dies. @@ -841,7 +848,7 @@ private static void deferDecrementRecursive(RuntimeScalar scalar) { base.releaseActiveOwner(s); LifecycleRuntimeState state = state(); markBoundaryWork(state); - state.pending.add(base); + queueDeferredBase(state, base, null); if (!WeakRefRegistry.weakRefsExist() && base.refCount > 1) { continue; } @@ -968,7 +975,7 @@ public static void mortalizeForVoidDiscard(RuntimeList result) { base.releaseActiveOwner(scalar); LifecycleRuntimeState state = state(); markBoundaryWork(state); - state.pending.add(base); + queueDeferredBase(state, base, null); } else if (base.refCount == 0 && (base.blessId != 0 || base instanceof RuntimeHash @@ -980,7 +987,7 @@ public static void mortalizeForVoidDiscard(RuntimeList result) { base.refCount = 1; LifecycleRuntimeState state = state(); markBoundaryWork(state); - state.pending.add(base); + queueDeferredBase(state, base, null); } } } @@ -1005,7 +1012,9 @@ private static void processDeferredEntriesFrom( state.pendingTiedReleases.get(tiedReleaseIdx++).releaseTiedObject(); } while (pendingIdx < state.pending.size()) { - processDeferredBase(state.pending.get(pendingIdx++), false); + RuntimeBase.PendingOwnerRelease ownerRelease = + state.pendingOwnerReleases.get(pendingIdx); + processDeferredBase(state.pending.get(pendingIdx++), false, ownerRelease); } while (ioReleaseIdx < state.pendingIoReleases.size()) { RuntimeScalar.releaseIoOwner(state.pendingIoReleases.get(ioReleaseIdx++)); @@ -1226,7 +1235,9 @@ private static boolean isReachableFromNonLexicalRootForCaptureRelease(RuntimeBas return state.externalRootSnapshot.isReachableFromNonLexicalRoot(base); } - private static void processDeferredBase(RuntimeBase base, boolean clearWeakRefsForLocalBinding) { + private static void processDeferredBase(RuntimeBase base, boolean clearWeakRefsForLocalBinding, + RuntimeBase.PendingOwnerRelease ownerRelease) { + base.completeQueuedOwnerRelease(ownerRelease, "MortalList.processDeferredBase"); boolean hasWeakRefs = WeakRefRegistry.hasWeakRefsTo(base); if (base.refCount > 0) { base.traceRefCount(-1, "MortalList.flush (deferred decrement)"); @@ -1415,6 +1426,7 @@ public static void flush() { try { processDeferredEntriesFrom(0, 0, 0); state.pending.clear(); + state.pendingOwnerReleases.clear(); state.pendingTiedReleases.clear(); state.pendingIoReleases.clear(); state.marks.clear(); // All entries drained; marks are meaningless now @@ -1540,7 +1552,8 @@ public static void drainPendingSince(int startIdx) { int i = startIdx; try { while (i < state.pending.size()) { - processDeferredBase(state.pending.get(i), true); + processDeferredBase(state.pending.get(i), true, + state.pendingOwnerReleases.get(i)); i++; } } finally { @@ -1550,6 +1563,7 @@ public static void drainPendingSince(int startIdx) { // as processed. Outer flush won't re-process them. while (state.pending.size() > startIdx) { state.pending.remove(state.pending.size() - 1); + state.pendingOwnerReleases.remove(state.pendingOwnerReleases.size() - 1); } } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java index e8b685fbb..1fd37f824 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java @@ -397,14 +397,59 @@ public void traceRefCount(int delta, String reason) { // expected (e.g. metaclass should have 1 owner = $METAS slot but // shows 0), we know the underflow site by elimination. // ───────────────────────────────────────────────────────────────────── - private static final java.util.Map> traceOwners + private static final java.util.concurrent.atomic.AtomicLong nextTraceReferentGeneration = + new java.util.concurrent.atomic.AtomicLong(); + // Trace-only identity: keep it out of every RuntimeBase instance when + // PJ_REFCOUNT_TRACE is disabled. + private static final java.util.Map traceReferentGenerations = + new java.util.IdentityHashMap<>(); + + private static final java.util.Map> traceOwners = new java.util.IdentityHashMap<>(); + private static final java.util.Map> + pendingTraceOwnerReleases = new java.util.IdentityHashMap<>(); + + private static final class OwnerTrace { + final int scalarIdentity; + final String acquireSite; + + OwnerTrace(RuntimeScalar scalar, String acquireSite) { + this.scalarIdentity = System.identityHashCode(scalar); + this.acquireSite = acquireSite; + } + } + + /** + * Immutable trace record retained between queueing and draining an owned + * decrement. It deliberately does not retain the scalar itself: source + * identity and acquisition site are enough for shutdown diagnosis, and a + * trace facility must not change Perl/JVM reachability. + */ + static final class PendingOwnerRelease { + final int scalarIdentity; + final long referentGeneration; + final String acquireSite; + final String queueSite; + + PendingOwnerRelease(int scalarIdentity, long referentGeneration, + String acquireSite, String queueSite) { + this.scalarIdentity = scalarIdentity; + this.referentGeneration = referentGeneration; + this.acquireSite = acquireSite; + this.queueSite = queueSite; + } + } + + private static long traceReferentGeneration(RuntimeBase base) { + return traceReferentGenerations.computeIfAbsent(base, + ignored -> nextTraceReferentGeneration.incrementAndGet()); + } public synchronized void recordOwner(RuntimeScalar owner, String site) { if (!refCountTrace || !REFCOUNT_TRACE_ENV) return; traceOwners .computeIfAbsent(this, k -> new java.util.LinkedHashMap<>()) - .put(System.identityHashCode(owner), site); + .put(System.identityHashCode(owner), new OwnerTrace(owner, site)); StackTraceElement[] st = new Throwable().getStackTrace(); StringBuilder sb = new StringBuilder(); sb.append("[REFCOUNT-RECORD] base=").append(System.identityHashCode(this)) @@ -418,9 +463,9 @@ public synchronized void recordOwner(RuntimeScalar owner, String site) { public synchronized void releaseOwner(RuntimeScalar owner, String site) { if (!refCountTrace || !REFCOUNT_TRACE_ENV) return; - java.util.LinkedHashMap owners = traceOwners.get(this); + java.util.LinkedHashMap owners = traceOwners.get(this); if (owners == null) return; - String prev = owners.remove(System.identityHashCode(owner)); + OwnerTrace prev = owners.remove(System.identityHashCode(owner)); if (prev == null) { System.err.println("[REFCOUNT-OWNER] *** UNPAIRED RELEASE *** base=" + System.identityHashCode(this) @@ -430,9 +475,44 @@ public synchronized void releaseOwner(RuntimeScalar owner, String site) { } } + /** Move an active owner trace into the deferred-decrement queue. */ + public synchronized PendingOwnerRelease queueOwnerRelease(RuntimeScalar owner, String queueSite) { + if (!refCountTrace || !REFCOUNT_TRACE_ENV) return null; + int scalarIdentity = System.identityHashCode(owner); + java.util.LinkedHashMap owners = traceOwners.get(this); + OwnerTrace ownerTrace = owners == null ? null : owners.remove(scalarIdentity); + String acquireSite = ownerTrace == null ? "" : ownerTrace.acquireSite; + PendingOwnerRelease release = new PendingOwnerRelease( + scalarIdentity, traceReferentGeneration(this), acquireSite, queueSite); + pendingTraceOwnerReleases + .computeIfAbsent(this, k -> new java.util.ArrayList<>()) + .add(release); + return release; + } + + /** Mark the exact queued trace record as drained without relying on scalar state. */ + public synchronized void completeQueuedOwnerRelease(PendingOwnerRelease release, String releaseSite) { + if (release == null || !REFCOUNT_TRACE_ENV) return; + java.util.ArrayList releases = pendingTraceOwnerReleases.get(this); + if (releases != null) { + releases.remove(release); + if (releases.isEmpty()) pendingTraceOwnerReleases.remove(this); + } + System.err.println("[REFCOUNT-OWNER-RELEASE] base=" + System.identityHashCode(this) + + " scalar=" + release.scalarIdentity + + " referent-generation=" + release.referentGeneration + + " acquire-site=" + release.acquireSite + + " queue-site=" + release.queueSite + + " release-site=" + releaseSite); + } + + public void cancelQueuedOwnerRelease(PendingOwnerRelease release, String cancelSite) { + completeQueuedOwnerRelease(release, cancelSite + " (cancelled)"); + } + public static void dumpTraceOwners() { if (!REFCOUNT_TRACE_ENV) return; - for (java.util.Map.Entry> e + for (java.util.Map.Entry> e : traceOwners.entrySet()) { RuntimeBase b = e.getKey(); if (e.getValue().isEmpty()) continue; @@ -441,8 +521,20 @@ public static void dumpTraceOwners() { + " blessId=" + b.blessId + " refCount=" + b.refCount + " owners=" + e.getValue().size()); - for (java.util.Map.Entry own : e.getValue().entrySet()) { - System.err.println(" owner=" + own.getKey() + " from " + own.getValue()); + for (java.util.Map.Entry own : e.getValue().entrySet()) { + System.err.println(" owner=" + own.getKey() + " from " + own.getValue().acquireSite); + } + } + for (java.util.Map.Entry> e + : pendingTraceOwnerReleases.entrySet()) { + RuntimeBase b = e.getKey(); + for (PendingOwnerRelease release : e.getValue()) { + System.err.println("[REFCOUNT-PENDING-OWNER] base=" + System.identityHashCode(b) + + " scalar=" + release.scalarIdentity + + " referent-generation=" + release.referentGeneration + + " acquire-site=" + release.acquireSite + + " queue-site=" + release.queueSite + + " release-site="); } } } From 453a885e21be967e6a936e0c7a8fb4123f59825d Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 18:17:01 +0200 Subject: [PATCH 09/18] wip: snapshot before owner-ledger work --- dev/design/refcount-owner-ledger.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index dba5b9c8f..2ead4a766 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -366,12 +366,20 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: runtime owner or reachability edge. Files: `RuntimeBase.java`, `LifecycleRuntimeState.java`, and `MortalList.java`. `make` passes; a `PJ_REFCOUNT_TRACE` smoke run reports the retained pending provenance. +- [x] Rebuilt the provenance instrumentation and ran the normal socket-enabled + `Net::Async::HTTP` `t/30timeout.t` with a scoped trace. The final assertion + still observes three refs where Perl expects one; after the script-scope + token drains, `$http` has the known raw count of two. Neither surplus is an + active scalar-store owner. The shutdown report currently includes a large + number of unrelated queued releases, so the next diagnostic step needs an + assertion-boundary snapshot filtered to the selected referent rather than a + broader trace or any capture-accounting adjustment. ### Next Steps -1. Use the retained trace with `jperl_refstate` to identify the two surplus - raw `$http` owners and the three surplus connection owners. Do not alter - capture accounting to compensate for them. +1. Add a referent-filtered, assertion-boundary owner snapshot to identify the + two surplus raw `$http` owners and the three surplus connection owners. + Do not alter capture accounting to compensate for them. 2. Re-run the Future exact-count programs and Net `t/30timeout.t` and `t/32remove.t` on both backends after each owner-path change. 3. Keep Cookie2 formatting and content-coding exception handling separate from From 2d7a8da533eed608c5e51c8f0861c7bc7f7954f2 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 18:29:34 +0200 Subject: [PATCH 10/18] feat: add assertion-boundary owner trace snapshots Expose active and queued owner provenance for a selected referent without creating diagnostic reachability, and record the next Net exact-count step. Generated with Codex (https://openai.com/codex) Co-Authored-By: Codex --- dev/design/refcount-owner-ledger.md | 17 ++++-- .../runtime/perlmodule/Internals.java | 16 ++++++ .../runtime/runtimetypes/RuntimeBase.java | 57 +++++++++++++++++++ .../unit/refcount/owner_trace_snapshot.t | 16 ++++++ 4 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 src/test/resources/unit/refcount/owner_trace_snapshot.t diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index 2ead4a766..9cb66f613 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -317,7 +317,7 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: ## Progress Tracking -### Current Status: Deferred-release provenance is available; exact owner migration remains in progress +### Current Status: Assertion-boundary owner snapshots are available; exact owner migration remains in progress ### Completed Work @@ -366,6 +366,14 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: runtime owner or reachability edge. Files: `RuntimeBase.java`, `LifecycleRuntimeState.java`, and `MortalList.java`. `make` passes; a `PJ_REFCOUNT_TRACE` smoke run reports the retained pending provenance. +- [x] Added `Internals::jperl_owner_trace($referent)`, a referent-filtered + assertion-boundary snapshot of active scalar-store tokens and queued + deferred releases. It is observational when `PJ_REFCOUNT_TRACE` is absent; + when tracing is enabled it reports the source scalar identity, referent + generation, acquisition site, and queue site without retaining runtime + objects. `unit/refcount/owner_trace_snapshot.t` passes on system Perl + (guarded skip), JVM, and interpreter. Files: `RuntimeBase.java`, + `Internals.java`, and `owner_trace_snapshot.t`. - [x] Rebuilt the provenance instrumentation and ran the normal socket-enabled `Net::Async::HTTP` `t/30timeout.t` with a scoped trace. The final assertion still observes three refs where Perl expects one; after the script-scope @@ -377,9 +385,10 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: ### Next Steps -1. Add a referent-filtered, assertion-boundary owner snapshot to identify the - two surplus raw `$http` owners and the three surplus connection owners. - Do not alter capture accounting to compensate for them. +1. Run `Internals::jperl_owner_trace` at the failing Net assertion boundaries + with `PJ_REFCOUNT_TRACE=1` to identify the two surplus raw `$http` owners + and the three surplus connection owners. Do not alter capture accounting to + compensate for them. 2. Re-run the Future exact-count programs and Net `t/30timeout.t` and `t/32remove.t` on both backends after each owner-path change. 3. Keep Cookie2 formatting and content-coding exception handling separate from diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java b/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java index b5afdf669..e89ea559e 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java @@ -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 @@ -558,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 diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java index 1fd37f824..3470b9511 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java @@ -510,6 +510,63 @@ public void cancelQueuedOwnerRelease(PendingOwnerRelease release, String cancelS completeQueuedOwnerRelease(release, cancelSite + " (cancelled)"); } + /** + * Return an assertion-boundary view of the trace-only owner ledger for + * this referent. This is intentionally a string rather than a graph of + * runtime objects: diagnostics must not keep an owner, a pad, or a + * deferred scalar alive merely by inspecting it. + * + *

    The view is useful while a Perl test is still at the assertion that + * observed an unexpected count. Shutdown dumps are too late because a + * queued decrement may already have drained and unrelated referents make + * the output difficult to attribute. Active entries are scalar-store + * tokens; pending entries are the immutable provenance retained after a + * token is queued for deferred release.

    + */ + public synchronized String ownerTraceSnapshot() { + StringBuilder result = new StringBuilder(); + result.append("base=").append(System.identityHashCode(this)) + // Do not register a static trace identity merely because a + // program queried this diagnostic with tracing disabled. + // The diagnostic itself must remain observational in normal + // runtimes. + .append(" generation=").append(REFCOUNT_TRACE_ENV + ? traceReferentGeneration(this) : 0) + .append(" refCount=").append(refCount) + .append(" active="); + java.util.LinkedHashMap owners = traceOwners.get(this); + if (owners == null || owners.isEmpty()) { + result.append("[]"); + } else { + result.append('['); + boolean first = true; + for (OwnerTrace owner : owners.values()) { + if (!first) result.append(", "); + first = false; + result.append("scalar=").append(owner.scalarIdentity) + .append(" acquire=").append(owner.acquireSite); + } + result.append(']'); + } + result.append(" pending="); + java.util.ArrayList pending = pendingTraceOwnerReleases.get(this); + if (pending == null || pending.isEmpty()) { + result.append("[]"); + } else { + result.append('['); + for (int i = 0; i < pending.size(); i++) { + if (i != 0) result.append(", "); + PendingOwnerRelease release = pending.get(i); + result.append("scalar=").append(release.scalarIdentity) + .append(" generation=").append(release.referentGeneration) + .append(" acquire=").append(release.acquireSite) + .append(" queued=").append(release.queueSite); + } + result.append(']'); + } + return result.toString(); + } + public static void dumpTraceOwners() { if (!REFCOUNT_TRACE_ENV) return; for (java.util.Map.Entry> e diff --git a/src/test/resources/unit/refcount/owner_trace_snapshot.t b/src/test/resources/unit/refcount/owner_trace_snapshot.t new file mode 100644 index 000000000..69e874b81 --- /dev/null +++ b/src/test/resources/unit/refcount/owner_trace_snapshot.t @@ -0,0 +1,16 @@ +use strict; +use warnings; + +use Test::More; + +plan skip_all => 'PerlOnJava owner-trace diagnostic' unless defined &Internals::jperl_owner_trace; + +my $object = bless {}, 'OwnerTraceSnapshot'; +my $snapshot = Internals::jperl_owner_trace($object); + +like($snapshot, qr/\Abase=\d+ generation=\d+ refCount=-?\d+ active=\[/, + 'snapshot identifies the selected referent and active owner set'); +like($snapshot, qr/ pending=\[\]\z/, + 'fresh assertion-boundary snapshot has no deferred release provenance'); + +done_testing; From 3ef8100934a655734780a3961504f64278b0eaa8 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 18:40:15 +0200 Subject: [PATCH 11/18] docs: record Net owner-ledger trace findings Document that the remaining Net exact-count surplus is not captured-pad, scalar-store, or missed-boundary ownership. Generated with Codex (https://openai.com/codex) Co-Authored-By: Codex --- dev/design/refcount-owner-ledger.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index 9cb66f613..ba138094f 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -382,12 +382,22 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: number of unrelated queued releases, so the next diagnostic step needs an assertion-boundary snapshot filtered to the selected referent rather than a broader trace or any capture-accounting adjustment. +- [x] Ran the filtered snapshot at the normal socket-enabled Net assertion + boundaries. `t/30timeout.t` shows one active scalar-store token and no + semantic capture owner for `$http`; `t/32remove.t` shows the same for + `$conn`. An explicit `jperl_freetmps()` does not reduce either failing + count. The surplus is therefore not a missed boundary drain, captured pad, + or currently ledgered scalar-store token. Historic queued-release trace + records also do not account for the raw count and must not be treated as + live owners. ### Next Steps -1. Run `Internals::jperl_owner_trace` at the failing Net assertion boundaries - with `PJ_REFCOUNT_TRACE=1` to identify the two surplus raw `$http` owners - and the three surplus connection owners. Do not alter capture accounting to +1. Add authoritative ledger entries for the remaining non-scalar owner + sources (in particular method-invocant holds, initial blessing temporaries, + aggregate stores, and tie/runtime wrappers), then use the Net assertion + snapshots to identify which leaves the two surplus raw `$http` owners and + three surplus connection owners. Do not alter capture accounting to compensate for them. 2. Re-run the Future exact-count programs and Net `t/30timeout.t` and `t/32remove.t` on both backends after each owner-path change. From 8c56a6d5a999f2b75d1ee269e58dbd53df84aba5 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 19:29:16 +0200 Subject: [PATCH 12/18] feat: trace transient refcount owners Track method-invocant and blessing-temporary holds in the owner trace, and keep parallel deferred trace metadata aligned with scoped queue drains. Generated with Codex (https://openai.com/codex) Co-Authored-By: Codex --- dev/design/refcount-owner-ledger.md | 23 +++++--- .../runtime/operators/ReferenceOperators.java | 8 ++- .../runtimetypes/LifecycleRuntimeState.java | 6 +++ .../runtime/runtimetypes/MortalList.java | 32 ++++++++++-- .../runtime/runtimetypes/RuntimeBase.java | 52 +++++++++++++++++++ .../runtime/runtimetypes/RuntimeCode.java | 5 ++ .../unit/refcount/owner_trace_snapshot.t | 4 +- 7 files changed, 116 insertions(+), 14 deletions(-) diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index ba138094f..b52830fbe 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -390,15 +390,26 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: or currently ledgered scalar-store token. Historic queued-release trace records also do not account for the raw count and must not be treated as live owners. +- [x] Added trace-only non-scalar owner kinds for method-invocant holds and + first-bless mortal temporaries. Parallel owner metadata is now removed in + lockstep with deferred entries during `flushAboveMark()` and + `popAndFlush()`, eliminating false unpaired trace releases. The full + `make` gate passes. Net tracing shows `transient=[]` at the failing boundary: + those two owner kinds balance correctly. +- [x] Mapped `$http` through the Net timeout lifecycle: it has one active + scalar-store token after construction, two after loop registration, four + before loop removal (while still showing only those two active tokens), and + three after removal (one active token). The extra two counts are introduced + by request processing, not loop registration/removal, captured pads, + method holds, or bless temporaries. ### Next Steps -1. Add authoritative ledger entries for the remaining non-scalar owner - sources (in particular method-invocant holds, initial blessing temporaries, - aggregate stores, and tie/runtime wrappers), then use the Net assertion - snapshots to identify which leaves the two surplus raw `$http` owners and - three surplus connection owners. Do not alter capture accounting to - compensate for them. +1. Trace and ledger every remaining direct `refCount` mutation in the request + path, beginning with scalar-reference contents, weak-reference promotion, + aggregate reconstruction, and tie/runtime wrapper holds. Identify which + acquirements leave the two surplus raw `$http` owners and three surplus + connection owners. Do not alter capture accounting to compensate for them. 2. Re-run the Future exact-count programs and Net `t/30timeout.t` and `t/32remove.t` on both backends after each owner-path change. 3. Keep Cookie2 formatting and content-coding exception handling separate from diff --git a/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java b/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java index ec3806373..862b32aa1 100644 --- a/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java @@ -100,7 +100,9 @@ public static RuntimeScalar bless(RuntimeScalar runtimeScalar, RuntimeScalar cla // correct count. referent.setBlessId(newBlessId); 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); @@ -175,7 +177,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); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/LifecycleRuntimeState.java b/src/main/java/org/perlonjava/runtime/runtimetypes/LifecycleRuntimeState.java index 066bcc1dd..12128ae0d 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/LifecycleRuntimeState.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/LifecycleRuntimeState.java @@ -20,6 +20,9 @@ final class LifecycleRuntimeState { // 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 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 pendingTransientOwnerKinds = new ArrayList<>(); final ArrayList pendingTiedReleases = new ArrayList<>(); final ArrayList pendingIoReleases = new ArrayList<>(); final ArrayList deferredCaptures = new ArrayList<>(); @@ -74,9 +77,12 @@ void clear() { 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(); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java index 7bb84d1d3..1dce4bdd2 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java @@ -156,18 +156,30 @@ public static boolean isDeferredCapture(RuntimeScalar scalar) { * from a container. */ public static void deferDecrement(RuntimeBase base) { + deferDecrement(base, null); + } + + /** Queue a decrement paired with a trace-only non-scalar owner kind. */ + public static void deferDecrement(RuntimeBase base, String transientOwnerKind) { if (base.refCountTrace) { base.traceRefCount(0, "MortalList.deferDecrement (queued)"); } LifecycleRuntimeState state = state(); markBoundaryWork(state); - queueDeferredBase(state, base, null); + queueDeferredBase(state, base, null, transientOwnerKind); } private static void queueDeferredBase(LifecycleRuntimeState state, RuntimeBase base, RuntimeBase.PendingOwnerRelease ownerRelease) { + queueDeferredBase(state, base, ownerRelease, null); + } + + private static void queueDeferredBase(LifecycleRuntimeState state, RuntimeBase base, + RuntimeBase.PendingOwnerRelease ownerRelease, + String transientOwnerKind) { state.pending.add(base); state.pendingOwnerReleases.add(ownerRelease); + state.pendingTransientOwnerKinds.add(transientOwnerKind); } public static void deferTiedObjectRelease(TiedVariableBase tiedVariable) { @@ -1014,7 +1026,9 @@ private static void processDeferredEntriesFrom( while (pendingIdx < state.pending.size()) { RuntimeBase.PendingOwnerRelease ownerRelease = state.pendingOwnerReleases.get(pendingIdx); - processDeferredBase(state.pending.get(pendingIdx++), false, ownerRelease); + String transientOwnerKind = state.pendingTransientOwnerKinds.get(pendingIdx); + processDeferredBase(state.pending.get(pendingIdx++), false, ownerRelease, + transientOwnerKind); } while (ioReleaseIdx < state.pendingIoReleases.size()) { RuntimeScalar.releaseIoOwner(state.pendingIoReleases.get(ioReleaseIdx++)); @@ -1236,8 +1250,11 @@ private static boolean isReachableFromNonLexicalRootForCaptureRelease(RuntimeBas } private static void processDeferredBase(RuntimeBase base, boolean clearWeakRefsForLocalBinding, - RuntimeBase.PendingOwnerRelease ownerRelease) { + RuntimeBase.PendingOwnerRelease ownerRelease, + String transientOwnerKind) { base.completeQueuedOwnerRelease(ownerRelease, "MortalList.processDeferredBase"); + base.releaseTransientTraceOwner(transientOwnerKind, + "MortalList.processDeferredBase"); boolean hasWeakRefs = WeakRefRegistry.hasWeakRefsTo(base); if (base.refCount > 0) { base.traceRefCount(-1, "MortalList.flush (deferred decrement)"); @@ -1427,6 +1444,7 @@ public static void flush() { processDeferredEntriesFrom(0, 0, 0); state.pending.clear(); state.pendingOwnerReleases.clear(); + state.pendingTransientOwnerKinds.clear(); state.pendingTiedReleases.clear(); state.pendingIoReleases.clear(); state.marks.clear(); // All entries drained; marks are meaningless now @@ -1553,7 +1571,8 @@ public static void drainPendingSince(int startIdx) { try { while (i < state.pending.size()) { processDeferredBase(state.pending.get(i), true, - state.pendingOwnerReleases.get(i)); + state.pendingOwnerReleases.get(i), + state.pendingTransientOwnerKinds.get(i)); i++; } } finally { @@ -1564,6 +1583,7 @@ public static void drainPendingSince(int startIdx) { while (state.pending.size() > startIdx) { state.pending.remove(state.pending.size() - 1); state.pendingOwnerReleases.remove(state.pendingOwnerReleases.size() - 1); + state.pendingTransientOwnerKinds.remove(state.pendingTransientOwnerKinds.size() - 1); } } @@ -1644,6 +1664,8 @@ public static void flushAboveMark() { // Remove only entries above the mark while (state.pending.size() > mark) { state.pending.removeLast(); + state.pendingOwnerReleases.removeLast(); + state.pendingTransientOwnerKinds.removeLast(); } while (state.pendingTiedReleases.size() > tiedMark) { state.pendingTiedReleases.removeLast(); @@ -1688,6 +1710,8 @@ public static void popAndFlush() { // Remove only the entries we processed (keep entries before mark) while (state.pending.size() > mark) { state.pending.removeLast(); + state.pendingOwnerReleases.removeLast(); + state.pendingTransientOwnerKinds.removeLast(); } while (state.pendingTiedReleases.size() > tiedMark) { state.pendingTiedReleases.removeLast(); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java index 3470b9511..d917c35d9 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java @@ -408,6 +408,12 @@ public void traceRefCount(int delta, String reason) { = new java.util.IdentityHashMap<>(); private static final java.util.Map> pendingTraceOwnerReleases = new java.util.IdentityHashMap<>(); + // Non-scalar holds (method dispatch, blessing temporaries, tie wrappers, + // and similar runtime edges) have no RuntimeScalar identity. Keep their + // trace-only balance separately so a selected snapshot can account for + // every direct refCount increment while tracing is enabled. + private static final java.util.Map> + transientTraceOwners = new java.util.IdentityHashMap<>(); private static final class OwnerTrace { final int scalarIdentity; @@ -510,6 +516,38 @@ public void cancelQueuedOwnerRelease(PendingOwnerRelease release, String cancelS completeQueuedOwnerRelease(release, cancelSite + " (cancelled)"); } + /** Record a non-scalar owner token for trace attribution only. */ + public synchronized void acquireTransientTraceOwner(String kind, String site) { + if (kind == null || !refCountTrace || !REFCOUNT_TRACE_ENV) return; + transientTraceOwners.computeIfAbsent(this, ignored -> new java.util.LinkedHashMap<>()) + .merge(kind + " @ " + site, 1, Integer::sum); + } + + /** Release the matching trace-only non-scalar owner token. */ + public synchronized void releaseTransientTraceOwner(String kind, String site) { + if (kind == null || !refCountTrace || !REFCOUNT_TRACE_ENV) return; + java.util.LinkedHashMap owners = transientTraceOwners.get(this); + if (owners == null) return; + String prefix = kind + " @ "; + String matchingKey = null; + for (String key : owners.keySet()) { + if (key.startsWith(prefix)) { + matchingKey = key; + break; + } + } + if (matchingKey == null) { + System.err.println("[REFCOUNT-TRANSIENT] *** UNPAIRED RELEASE *** base=" + + System.identityHashCode(this) + " kind=" + kind + + " release-site=" + site); + return; + } + int count = owners.get(matchingKey); + if (count == 1) owners.remove(matchingKey); + else owners.put(matchingKey, count - 1); + if (owners.isEmpty()) transientTraceOwners.remove(this); + } + /** * Return an assertion-boundary view of the trace-only owner ledger for * this referent. This is intentionally a string rather than a graph of @@ -564,6 +602,20 @@ public synchronized String ownerTraceSnapshot() { } result.append(']'); } + result.append(" transient="); + java.util.LinkedHashMap transientOwners = transientTraceOwners.get(this); + if (transientOwners == null || transientOwners.isEmpty()) { + result.append("[]"); + } else { + result.append('['); + boolean first = true; + for (java.util.Map.Entry owner : transientOwners.entrySet()) { + if (!first) result.append(", "); + first = false; + result.append(owner.getKey()).append(" count=").append(owner.getValue()); + } + result.append(']'); + } return result.toString(); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index 445bd3335..f8777687a 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -984,6 +984,7 @@ public static RuntimeBase acquireMethodInvocantHold(RuntimeScalar runtimeScalar) } base.traceRefCount(+1, "RuntimeCode.method invocant hold (+1)"); base.refCount++; + base.acquireTransientTraceOwner("method invocant hold", "RuntimeCode.acquireMethodInvocantHold"); return base; } @@ -993,6 +994,8 @@ public static void releaseMethodInvocantHold(RuntimeBase holdBase) { return; } holdBase.traceRefCount(-1, "RuntimeCode.method invocant hold release (-1)"); + holdBase.releaseTransientTraceOwner("method invocant hold", + "RuntimeCode.releaseMethodInvocantHold"); if (holdBase.refCount > 0 && holdBase.refCount != Integer.MIN_VALUE && !holdBase.currentlyDestroying) { if (holdBase.refCount == 1) { // Keep the invocant alive until the caller has had a chance to @@ -1015,6 +1018,8 @@ public static void releaseAbandonedMethodInvocantHold(RuntimeBase holdBase) { return; } holdBase.traceRefCount(-1, "RuntimeCode.abandoned method invocant hold release (-1)"); + holdBase.releaseTransientTraceOwner("method invocant hold", + "RuntimeCode.releaseAbandonedMethodInvocantHold"); if (holdBase.refCount > 0 && holdBase.refCount != Integer.MIN_VALUE && !holdBase.currentlyDestroying diff --git a/src/test/resources/unit/refcount/owner_trace_snapshot.t b/src/test/resources/unit/refcount/owner_trace_snapshot.t index 69e874b81..12c051fd1 100644 --- a/src/test/resources/unit/refcount/owner_trace_snapshot.t +++ b/src/test/resources/unit/refcount/owner_trace_snapshot.t @@ -10,7 +10,7 @@ my $snapshot = Internals::jperl_owner_trace($object); like($snapshot, qr/\Abase=\d+ generation=\d+ refCount=-?\d+ active=\[/, 'snapshot identifies the selected referent and active owner set'); -like($snapshot, qr/ pending=\[\]\z/, - 'fresh assertion-boundary snapshot has no deferred release provenance'); +like($snapshot, qr/ pending=\[\] transient=\[\]\z/, + 'fresh assertion-boundary snapshot has no deferred or transient ownership'); done_testing; From 84d7e97c6aef54749df40880fe687a16190504bb Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 19:54:10 +0200 Subject: [PATCH 13/18] feat: trace remaining transient refcount owners Record scalar-reference, weak-promotion, closure-capture, and tie-wrapper holds in assertion-boundary owner traces. Document that the JVM-only Net timeout excess remains outside these paths. Generated with Codex (https://openai.com/codex) Co-Authored-By: Codex --- dev/design/refcount-owner-ledger.md | 24 +++++++++++++++---- .../runtime/runtimetypes/RuntimeScalar.java | 9 +++++-- .../runtime/runtimetypes/TieArray.java | 2 ++ .../runtime/runtimetypes/TieHandle.java | 2 ++ .../runtime/runtimetypes/TieHash.java | 2 ++ .../runtimetypes/TiedVariableBase.java | 2 ++ .../runtime/runtimetypes/WeakRefRegistry.java | 4 ++++ .../unit/refcount/owner_trace_snapshot.t | 8 +++++++ 8 files changed, 46 insertions(+), 7 deletions(-) diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index b52830fbe..6c87ac7b3 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -402,14 +402,28 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: three after removal (one active token). The extra two counts are introduced by request processing, not loop registration/removal, captured pads, method holds, or bless temporaries. +- [x] Added balanced trace-only tokens for scalar-reference contents, + weak-reference promotion, closure-capture referents, and all tie-wrapper + holds (`TiedVariableBase`, `TieHash`, `TieArray`, and `TieHandle`). The + scalar-reference regression now enables tracing before the hold is acquired + and verifies that its release leaves `transient=[]`; it passes on both + execution backends. Full `make` passes. +- [x] Re-ran normal socket-enabled `Net::Async::HTTP` `t/30timeout.t` with + the expanded ledger. On the JVM, the failing boundary still has raw count 3 + with one scalar-store token and `transient=[]`; no `unweaken` event or + closure-capture/tie token was acquired for that referent. The interpreter + remains balanced (two owners before loop removal, one after) and exits 0. + These direct mutation paths are therefore not the JVM's two surplus + request-path owners. ### Next Steps -1. Trace and ledger every remaining direct `refCount` mutation in the request - path, beginning with scalar-reference contents, weak-reference promotion, - aggregate reconstruction, and tie/runtime wrapper holds. Identify which - acquirements leave the two surplus raw `$http` owners and three surplus - connection owners. Do not alter capture accounting to compensate for them. +1. Trace and ledger the remaining direct owner classes that can occur in a + request: regex executable callbacks, PerlIO `ViaLayer` handler holds, + destroy/rescue transitions, and any aggregate reconstruction path not + represented by a scalar-store token. Identify which acquirements leave the + two surplus raw `$http` owners and three surplus connection owners. Do not + alter capture accounting to compensate for them. 2. Re-run the Future exact-count programs and Net `t/30timeout.t` and `t/32remove.t` on both backends after each owner-path change. 3. Keep Cookie2 formatting and content-coding exception handling separate from diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index 8149cd0ee..b2bb29ea4 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -356,6 +356,7 @@ private void retainClosureCaptureReferent() { base.refCount++; base.hadCountedReference = true; captureRefCountOwned++; + base.acquireTransientTraceOwner("closure capture", "RuntimeScalar.closureCapture"); base.acquireSemanticCaptureOwner(this); } @@ -416,7 +417,7 @@ private void releaseOneClosureCaptureReferent() { if ((type & RuntimeScalarType.REFERENCE_BIT) != 0 && value instanceof RuntimeBase base && base.refCount > 0) { - MortalList.deferDecrement(base); + MortalList.deferDecrement(base, "closure capture"); } captureRefCountOwned--; } @@ -424,7 +425,7 @@ private void releaseOneClosureCaptureReferent() { private void releaseAllClosureCaptureReferents(RuntimeBase oldBase) { while (captureRefCountOwned > 0) { if (oldBase != null && oldBase.refCount > 0) { - MortalList.deferDecrement(oldBase); + MortalList.deferDecrement(oldBase, "closure capture"); } captureRefCountOwned--; } @@ -1632,6 +1633,8 @@ private void retainScalarReferenceContents(RuntimeScalar value) { inner.traceRefCount(+1, "RuntimeScalar.retainScalarReferenceContents"); inner.refCount++; inner.hadCountedReference = true; + inner.acquireTransientTraceOwner("scalar-reference contents", + "RuntimeScalar.scalarReferenceContents"); this.ownsScalarReferenceContents = true; } @@ -1653,6 +1656,8 @@ public static void releaseScalarReferenceContents(RuntimeScalar scalarReferent) return; } inner.traceRefCount(-1, "RuntimeScalar.releaseScalarReferenceContents"); + inner.releaseTransientTraceOwner("scalar-reference contents", + "RuntimeScalar.scalarReferenceContents"); if (--inner.refCount == 0) { inner.refCount = Integer.MIN_VALUE; DestroyDispatch.callDestroy(inner); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/TieArray.java b/src/main/java/org/perlonjava/runtime/runtimetypes/TieArray.java index 65d3dc73e..7fa633f5a 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/TieArray.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/TieArray.java @@ -69,6 +69,7 @@ public TieArray(String tiedPackage, RuntimeArray previousValue, RuntimeScalar se && self.value instanceof RuntimeBase base && base.refCount >= 0) { base.refCount++; + base.acquireTransientTraceOwner("tie wrapper", "TieArray"); } } @@ -291,6 +292,7 @@ public void releaseTiedObject() { if (self == null) return; if ((self.type & RuntimeScalarType.REFERENCE_BIT) != 0 && self.value instanceof RuntimeBase base) { + base.releaseTransientTraceOwner("tie wrapper", "TieArray"); if (base.refCount > 0 && --base.refCount == 0) { base.refCount = Integer.MIN_VALUE; DestroyDispatch.callDestroy(base); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/TieHandle.java b/src/main/java/org/perlonjava/runtime/runtimetypes/TieHandle.java index 4b25292c8..9220f854d 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/TieHandle.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/TieHandle.java @@ -68,6 +68,7 @@ public TieHandle(String tiedPackage, RuntimeIO previousValue, RuntimeScalar self && self.value instanceof RuntimeBase base && base.refCount >= 0) { base.refCount++; + base.acquireTransientTraceOwner("tie wrapper", "TieHandle"); } } @@ -257,6 +258,7 @@ public void releaseTiedObject() { } if ((self.type & RuntimeScalarType.REFERENCE_BIT) != 0 && self.value instanceof RuntimeBase base) { + base.releaseTransientTraceOwner("tie wrapper", "TieHandle"); if (base.refCount > 0 && --base.refCount == 0) { base.refCount = Integer.MIN_VALUE; DestroyDispatch.callDestroy(base); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/TieHash.java b/src/main/java/org/perlonjava/runtime/runtimetypes/TieHash.java index cd491538f..643374486 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/TieHash.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/TieHash.java @@ -60,6 +60,7 @@ public TieHash(String tiedPackage, RuntimeHash previousValue, RuntimeScalar self && self.value instanceof RuntimeBase base && base.refCount >= 0) { base.refCount++; + base.acquireTransientTraceOwner("tie wrapper", "TieHash"); } if (self != null && (self.type & RuntimeScalarType.REFERENCE_BIT) != 0 && self.value instanceof RuntimeBase base) { @@ -219,6 +220,7 @@ public void releaseTiedObject() { if (self == null) return; if ((self.type & RuntimeScalarType.REFERENCE_BIT) != 0 && self.value instanceof RuntimeBase base) { + base.releaseTransientTraceOwner("tie wrapper", "TieHash"); if (base.refCount > 0 && --base.refCount == 0) { base.refCount = Integer.MIN_VALUE; DestroyDispatch.callDestroy(base); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/TiedVariableBase.java b/src/main/java/org/perlonjava/runtime/runtimetypes/TiedVariableBase.java index ac88abe37..21e909d6c 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/TiedVariableBase.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/TiedVariableBase.java @@ -47,6 +47,7 @@ public TiedVariableBase(RuntimeScalar tiedObject, String tiedPackage) { && tiedObject.value instanceof RuntimeBase base && base.refCount >= 0) { base.refCount++; + base.acquireTransientTraceOwner("tie wrapper", "TiedVariableBase"); } } @@ -216,6 +217,7 @@ public void releaseTiedObject() { if (self == null) return; if ((self.type & RuntimeScalarType.REFERENCE_BIT) != 0 && self.value instanceof RuntimeBase base) { + base.releaseTransientTraceOwner("tie wrapper", "TiedVariableBase"); if (base.refCount > 0 && --base.refCount == 0) { base.refCount = Integer.MIN_VALUE; DestroyDispatch.callDestroy(base); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java b/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java index 22419e76e..50b507e13 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java @@ -296,8 +296,12 @@ public static void unweaken(RuntimeScalar ref) { if (weakRefs.isEmpty()) state.referentToWeakRefs.remove(base); } if (base.refCount >= 0) { + base.traceRefCount(+1, "WeakRefRegistry.unweaken (restore strong count)"); base.refCount++; // restore strong count ref.refCountOwned = true; // restore ownership + base.hadCountedReference = true; + base.recordOwner(ref, "WeakRefRegistry.unweaken"); + base.recordActiveOwner(ref); } // Note: if MIN_VALUE, object already destroyed — unweaken is a no-op } diff --git a/src/test/resources/unit/refcount/owner_trace_snapshot.t b/src/test/resources/unit/refcount/owner_trace_snapshot.t index 12c051fd1..e71c25596 100644 --- a/src/test/resources/unit/refcount/owner_trace_snapshot.t +++ b/src/test/resources/unit/refcount/owner_trace_snapshot.t @@ -13,4 +13,12 @@ like($snapshot, qr/\Abase=\d+ generation=\d+ refCount=-?\d+ active=\[/, like($snapshot, qr/ pending=\[\] transient=\[\]\z/, 'fresh assertion-boundary snapshot has no deferred or transient ownership'); +my $nested = bless {}, 'OwnerTraceNested'; +Internals::jperl_owner_trace($nested); # enable trace collection before the transient hold +my $scalar_ref = \$nested; +$scalar_ref = undef; +my $nested_snapshot = Internals::jperl_owner_trace($nested); +like($nested_snapshot, qr/ transient=\[\]\z/, + 'scalar-reference contents hold is balanced after release'); + done_testing; From 6e2487306771475683fd2c796a947c0a67aaf2b2 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 20:25:53 +0200 Subject: [PATCH 14/18] feat: expose semantic capture ownership in owner traces Include semantic captured-pad owner counts in assertion-boundary snapshots and record the JVM-only Net::Async::HTTP investigation handoff. Generated with Codex (https://openai.com/codex) Co-Authored-By: Codex --- dev/design/refcount-owner-ledger.md | 19 +++++++++++++------ .../runtime/runtimetypes/RuntimeBase.java | 1 + .../unit/refcount/owner_trace_snapshot.t | 4 ++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index 6c87ac7b3..ea835891c 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -415,15 +415,22 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: remains balanced (two owners before loop removal, one after) and exits 0. These direct mutation paths are therefore not the JVM's two surplus request-path owners. +- [x] Added the existing semantic captured-pad owner count to the + assertion-boundary snapshot. The focused diagnostic passes on JVM and + interpreter; the JVM Net boundary reports `semanticCaptureOwners=0` both + before and after loop removal. Captured-pad transfer cannot account for the + two surplus raw counts. Focused JVM/interpreter tests and `make check-links` + pass. Full `make` is deliberately handed to the receiving worker because a + concurrent external workload caused the local full-gate attempt to time out + after its unit shards completed. ### Next Steps -1. Trace and ledger the remaining direct owner classes that can occur in a - request: regex executable callbacks, PerlIO `ViaLayer` handler holds, - destroy/rescue transitions, and any aggregate reconstruction path not - represented by a scalar-store token. Identify which acquirements leave the - two surplus raw `$http` owners and three surplus connection owners. Do not - alter capture accounting to compensate for them. +1. Compare JVM and interpreter cleanup of ledgered `setLargeRefCounted` + temporary stores through the Net notifier-removal call chain. The surplus + has no active, pending, transient, or semantic-capture token, so identify + the JVM path that clears a scalar token without applying its decrement. + Do not alter capture accounting to compensate for it. 2. Re-run the Future exact-count programs and Net `t/30timeout.t` and `t/32remove.t` on both backends after each owner-path change. 3. Keep Cookie2 formatting and content-coding exception handling separate from diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java index d917c35d9..8ef9a6546 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java @@ -616,6 +616,7 @@ public synchronized String ownerTraceSnapshot() { } result.append(']'); } + result.append(" semanticCaptureOwners=").append(semanticCaptureOwnerCount()); return result.toString(); } diff --git a/src/test/resources/unit/refcount/owner_trace_snapshot.t b/src/test/resources/unit/refcount/owner_trace_snapshot.t index e71c25596..aa7ef4708 100644 --- a/src/test/resources/unit/refcount/owner_trace_snapshot.t +++ b/src/test/resources/unit/refcount/owner_trace_snapshot.t @@ -10,7 +10,7 @@ my $snapshot = Internals::jperl_owner_trace($object); like($snapshot, qr/\Abase=\d+ generation=\d+ refCount=-?\d+ active=\[/, 'snapshot identifies the selected referent and active owner set'); -like($snapshot, qr/ pending=\[\] transient=\[\]\z/, +like($snapshot, qr/ pending=\[\] transient=\[\] semanticCaptureOwners=0\z/, 'fresh assertion-boundary snapshot has no deferred or transient ownership'); my $nested = bless {}, 'OwnerTraceNested'; @@ -18,7 +18,7 @@ Internals::jperl_owner_trace($nested); # enable trace collection before the tran my $scalar_ref = \$nested; $scalar_ref = undef; my $nested_snapshot = Internals::jperl_owner_trace($nested); -like($nested_snapshot, qr/ transient=\[\]\z/, +like($nested_snapshot, qr/ transient=\[\] semanticCaptureOwners=0\z/, 'scalar-reference contents hold is balanced after release'); done_testing; From fc2c9681d0c49b86b29513073f9c15c510a8ac87 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 20:28:11 +0200 Subject: [PATCH 15/18] docs: make issue 1132 handoff explicit Record PR 1204 as the required continuation target, the exact diagnostic state, retained commits, and receiving-worker validation responsibility. Generated with Codex (https://openai.com/codex) Co-Authored-By: Codex --- dev/design/refcount-owner-ledger.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index ea835891c..a8ea8e575 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -424,6 +424,23 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: concurrent external workload caused the local full-gate attempt to time out after its unit shards completed. +### Handoff: issue #1132 / PR #1204 + +This work must be continued on the issue-linked PR +[#1204](https://github.com/fglock/PerlOnJava/pull/1204), branch +`fix/issue-1132-closure-lifetime` (not on a new owner-ledger PR). The commits +to retain are `5b693177f`, `1702edb70`, `4832540d6`, `9fcab6e81`, and +`87b4fdee3`. The receiving worker must run a clean full `make` before further +source changes or PR completion; the prior full gate was invalidated only by +an external concurrent workload and timed out after test shards completed. + +At handoff, JVM `Net::Async::HTTP` `t/30timeout.t` still reports raw `$http` +count 3 instead of 1, whereas the interpreter passes with count 1. The JVM +snapshot has one active scalar-store owner and zero pending, transient, and +semantic-capture owners. Next, compare JVM and interpreter cleanup of +`setLargeRefCounted` temporaries through the notifier-removal call chain; do +not compensate by changing capture accounting. + ### Next Steps 1. Compare JVM and interpreter cleanup of ledgered `setLargeRefCounted` From 5889a105e5aeb4d3492b5df41698a31f91e300b0 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 22:18:27 +0200 Subject: [PATCH 16/18] fix: release captured closure owners after global removal Always schedule the captured scalar's normal deferred decrement. The former reachability-based transfer discarded a capture count while an IO::Async loop temporarily owned the object and never restored it after notifier removal. Add a system-Perl-validated exact-refcount regression and document the Net::Async::HTTP 0.50 t/30timeout.t JVM/interpreter verification. Generated with [OpenAI Codex](https://openai.com/codex/) Co-Authored-By: OpenAI Codex --- dev/design/refcount-owner-ledger.md | 63 ++++++++++++++----- docs/about/changelog.md | 3 + .../runtime/operators/ReferenceOperators.java | 1 + .../runtime/runtimetypes/MortalList.java | 34 +--------- .../runtime/runtimetypes/RuntimeScalar.java | 1 + .../refcount/captured_global_owner_release.t | 27 ++++++++ 6 files changed, 80 insertions(+), 49 deletions(-) create mode 100644 src/test/resources/unit/refcount/captured_global_owner_release.t diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index a8ea8e575..d8139bcb8 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -317,7 +317,14 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: ## Progress Tracking -### Current Status: Assertion-boundary owner snapshots are available; exact owner migration remains in progress +### Current Status: Net::Async::HTTP captured-owner transfer fixed; exact owner migration remains in progress + +The designated issue branch, `fix/issue-1132-closure-lifetime`, includes the +handoff commits below. On 2026-09-01, the required pre-change `make` rebuilt +the classes and JAR but its four unit-test workers were SIGKILLed (exit 137) +while concurrent PerlOnJava worktrees exhausted host resources. This is an +invalid gate, not a test assertion failure; rerun a clean full gate after the +external workload has drained before changing runtime source. ### Completed Work @@ -423,6 +430,34 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: pass. Full `make` is deliberately handed to the receiving worker because a concurrent external workload caused the local full-gate attempt to time out after its unit shards completed. +- [x] Installed `Net::Async::HTTP` 0.50 and its CPAN dependency chain through + `jcpan`. In the socket-enabled distribution environment, JVM + `t/30timeout.t` reproduces the documented final `$http` count of 3 rather + than 1; the interpreter reaches 1. At the exact assertion, both backends + have one active scalar-store owner and no pending, transient, or semantic + capture owner. The JVM's two surplus counts therefore originate in compiled + `IO::Async::Loop`/`Notifier` cleanup before the final top-level flush, not + captured pads or method-invocant holds. `t/32remove.t` also has broader + connection-count drift in this newly installed environment (JVM 7/4 and + interpreter 8/5), which remains a separate investigation. +- [x] Tested and rejected two JVM-only cleanup hypotheses against clean full + `make` gates: including compiler-marked captures at ordinary block exit and + flushing untracked compiled-sub return values. Neither changed the JVM + `t/30timeout.t` count of 3. The surplus is not a deferred mortal awaiting a + block or return boundary; continue from direct unledgered increment and + release paths rather than widening capture or flush behavior. +- [x] Direct increment tracing found two JVM-only + `releaseCapturedDecrement()` ownership transfers for the failing `$http`. + Root-path tracing showed that the temporary global + `$IO::Async::Loop::ONE_TRUE_LOOP->{notifiers}` registration triggered each + transfer. The loop later removes that entry correctly, but the discarded + capture token was never restored, leaving two surplus counts. Captured + owners now always schedule their ordinary deferred decrement. Added + `unit/refcount/captured_global_owner_release.t`: the test passes on system + Perl and both PerlOnJava backends, while the unfixed implementation reported + two references. The normal socket-enabled Net::Async::HTTP `t/30timeout.t` + now passes all 25 assertions on JVM and interpreter. Full `make` passed in + 6m 08s before the final source-comment cleanup. ### Handoff: issue #1132 / PR #1204 @@ -434,26 +469,22 @@ to retain are `5b693177f`, `1702edb70`, `4832540d6`, `9fcab6e81`, and source changes or PR completion; the prior full gate was invalidated only by an external concurrent workload and timed out after test shards completed. -At handoff, JVM `Net::Async::HTTP` `t/30timeout.t` still reports raw `$http` -count 3 instead of 1, whereas the interpreter passes with count 1. The JVM -snapshot has one active scalar-store owner and zero pending, transient, and -semantic-capture owners. Next, compare JVM and interpreter cleanup of -`setLargeRefCounted` temporaries through the notifier-removal call chain; do -not compensate by changing capture accounting. +The Net::Async::HTTP 0.50 distribution is installed through `jcpan` in the +local CPAN cache. JVM and interpreter `t/30timeout.t` both now pass. Preserve +the focused regression and rerun `t/32remove.t` separately: it had broader +connection-count drift on both backends and is not part of this resolved +captured-owner transfer. ### Next Steps -1. Compare JVM and interpreter cleanup of ledgered `setLargeRefCounted` - temporary stores through the Net notifier-removal call chain. The surplus - has no active, pending, transient, or semantic-capture token, so identify - the JVM path that clears a scalar token without applying its decrement. - Do not alter capture accounting to compensate for it. -2. Re-run the Future exact-count programs and Net `t/30timeout.t` and - `t/32remove.t` on both backends after each owner-path change. +1. Run Net::Async::HTTP `t/32remove.t` on both backends and classify its + broader connection-count drift independently from issue #1132. +2. Continue the owner-source migration inventory and remove only diagnosed + ownership heuristics with permanent system-Perl-validated regressions. 3. Keep Cookie2 formatting and content-coding exception handling separate from this ownership work. ### Open Questions -- Which acquisition sites in the retained trace leave the two `$http` and three - connection counts unbalanced after notifier removal? +- What owns the remaining connection-count difference in Net::Async::HTTP + `t/32remove.t` on both backends? diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 876e5e028..d45649b7c 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -4,6 +4,9 @@ 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. - 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 diff --git a/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java b/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java index 862b32aa1..97bc5d5a5 100644 --- a/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java @@ -99,6 +99,7 @@ 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) referent.acquireTransientTraceOwner("bless mortal temporary", "ReferenceOperators.bless tracked referent"); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java index 1dce4bdd2..ea3e3355d 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java @@ -393,31 +393,10 @@ public static void deferDecrementIfTracked(RuntimeScalar scalar) { } } - /** - * Release a scope-exited closure capture. This is normally the same as - * {@link #deferDecrementIfTracked}, but DBIC's leak tracer can wrap - * Try::Tiny blocks with {@code goto} and weak refs, making a captured - * temporary consume the counted owner of package-global metadata. In that - * case, transfer ownership only when the referent is still reachable from a - * non-lexical root; stack-local temporaries must release normally so - * DESTROY fires at lexical scope exit. - */ + /** Release the tracked owner held by a scope-exited closure capture. */ public static void releaseCapturedDecrement(RuntimeScalar scalar) { if (!isActive() || scalar == null) return; if (!scalar.refCountOwned) return; - if ((scalar.type & RuntimeScalarType.REFERENCE_BIT) != 0 - && scalar.value instanceof RuntimeBase base - && base.blessId != 0 - && WeakRefRegistry.hasWeakRefsTo(base) - && isReachableFromNonLexicalRootForCaptureRelease(base)) { - scalar.refCountOwned = false; - if (base.refCountTrace) { - base.traceRefCount(0, "MortalList.releaseCapturedDecrement (transferred to live scalar)"); - base.releaseOwner(scalar, "releaseCapturedDecrement transfer"); - } - base.releaseActiveOwner(scalar); - return; - } deferDecrementIfTracked(scalar); } @@ -1238,17 +1217,6 @@ private static boolean isReachableThroughTiedHashCached(RuntimeBase base) { return state.flushTiedReachableCache.contains(base); } - private static boolean isReachableFromNonLexicalRootForCaptureRelease(RuntimeBase base) { - if (ReachabilityWalker.isReachableFromTemporaryRoots(base)) { - return true; - } - LifecycleRuntimeState state = state(); - if (state.externalRootSnapshot == null) { - state.externalRootSnapshot = new ReachabilityWalker.ExternalRootSnapshot(); - } - return state.externalRootSnapshot.isReachableFromNonLexicalRoot(base); - } - private static void processDeferredBase(RuntimeBase base, boolean clearWeakRefsForLocalBinding, RuntimeBase.PendingOwnerRelease ownerRelease, String transientOwnerKind) { diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index b2bb29ea4..c15c2fd68 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -1571,6 +1571,7 @@ public static void incrementRefCountForContainerStore(RuntimeScalar scalar) { } base.refCount = 0; } + base.traceRefCount(+1, "RuntimeScalar.incrementRefCountForContainerStore"); base.refCount++; base.hadCountedReference = true; base.recordOwner(scalar, "incrementRefCountForContainerStore"); diff --git a/src/test/resources/unit/refcount/captured_global_owner_release.t b/src/test/resources/unit/refcount/captured_global_owner_release.t new file mode 100644 index 000000000..460310151 --- /dev/null +++ b/src/test/resources/unit/refcount/captured_global_owner_release.t @@ -0,0 +1,27 @@ +use strict; +use warnings; + +use Test2::V0; +use Test2::Tools::Refcount qw(is_oneref); +use Scalar::Util qw(weaken); + +our %ROOT; + +my $object = bless {}, 'CapturedGlobalOwnerRelease'; +my $weak_object = $object; +weaken($weak_object); + +$ROOT{object} = $object; + +my $callback = do { + my $captured = $object; + sub { $captured }; +}; + +undef $callback; +delete $ROOT{object}; + +is_oneref($object, + 'released callback capture does not outlive a deleted global owner'); + +done_testing; From d6a489cf4d062f4b748255fed2c5d7e3a62ec2cd Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 1 Sep 2026 23:33:38 +0200 Subject: [PATCH 17/18] fix: release interpreter hash-slice temporary owners Transfer compiler-created RHS and staging scalar-store owners after HASH_SLICE_SET creates durable hash slots. This fixes the interpreter-only Net::Async::HTTP t/32remove exact-refcount drift and adds permanent coverage. Refs: dev/design/refcount-owner-ledger.md Generated with [OpenAI Codex](https://openai.com/codex/) Co-Authored-By: OpenAI Codex --- dev/design/refcount-owner-ledger.md | 28 +++++++++++-------- docs/about/changelog.md | 2 ++ .../backend/bytecode/SlowOpcodeHandler.java | 26 +++++++++++++++++ ...interpreter_hash_slice_staging_ownership.t | 24 ++++++++++++++++ 4 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 src/test/resources/unit/refcount/interpreter_hash_slice_staging_ownership.t diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index d8139bcb8..806a63d00 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -317,7 +317,7 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: ## Progress Tracking -### Current Status: Net::Async::HTTP captured-owner transfer fixed; exact owner migration remains in progress +### Current Status: Net::Async::HTTP captured-owner and interpreter hash-slice fixes complete The designated issue branch, `fix/issue-1132-closure-lifetime`, includes the handoff commits below. On 2026-09-01, the required pre-change `make` rebuilt @@ -458,6 +458,16 @@ external workload has drained before changing runtime source. two references. The normal socket-enabled Net::Async::HTTP `t/30timeout.t` now passes all 25 assertions on JVM and interpreter. Full `make` passed in 6m 08s before the final source-comment cleanup. +- [x] The remaining interpreter-only `Net::Async::HTTP` `t/32remove.t` + difference was traced to `HASH_SLICE_SET`: the compiler-created RHS array + and its `addToArray()` staging copies each retained a scalar-store owner + after `RuntimeHash.setSlice()` had created the durable slot. The interpreter + now transfers both temporary owners immediately. Added + `unit/refcount/interpreter_hash_slice_staging_ownership.t`; it passes on + system Perl and both PerlOnJava backends, while the unfixed interpreter + reported 3 then 2 references instead of 2 then 1. `t/32remove.t` now passes + its exact 4 then 1 checks on JVM and interpreter, and `t/30timeout.t` passes + all 25 assertions on both backends. Full `make` passed on 2026-09-01. ### Handoff: issue #1132 / PR #1204 @@ -470,21 +480,17 @@ source changes or PR completion; the prior full gate was invalidated only by an external concurrent workload and timed out after test shards completed. The Net::Async::HTTP 0.50 distribution is installed through `jcpan` in the -local CPAN cache. JVM and interpreter `t/30timeout.t` both now pass. Preserve -the focused regression and rerun `t/32remove.t` separately: it had broader -connection-count drift on both backends and is not part of this resolved -captured-owner transfer. +local CPAN cache. JVM and interpreter `t/30timeout.t` and `t/32remove.t` now +pass. Preserve both focused ownership regressions when evolving the ledger. ### Next Steps -1. Run Net::Async::HTTP `t/32remove.t` on both backends and classify its - broader connection-count drift independently from issue #1132. -2. Continue the owner-source migration inventory and remove only diagnosed +1. Continue the owner-source migration inventory and remove only diagnosed ownership heuristics with permanent system-Perl-validated regressions. -3. Keep Cookie2 formatting and content-coding exception handling separate from +2. Keep Cookie2 formatting and content-coding exception handling separate from this ownership work. ### Open Questions -- What owns the remaining connection-count difference in Net::Async::HTTP - `t/32remove.t` on both backends? +- Can additional compiler-created aggregate temporaries be made explicit in + the owner ledger, rather than relying on opcode-specific transfers? diff --git a/docs/about/changelog.md b/docs/about/changelog.md index d45649b7c..b7b448848 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -7,6 +7,8 @@ Release history of PerlOnJava. See [Roadmap](roadmap.md) for future plans. - 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. +- 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 diff --git a/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java b/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java index bfb84bcf2..f54f6fb4b 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java @@ -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 diff --git a/src/test/resources/unit/refcount/interpreter_hash_slice_staging_ownership.t b/src/test/resources/unit/refcount/interpreter_hash_slice_staging_ownership.t new file mode 100644 index 000000000..5320f7f46 --- /dev/null +++ b/src/test/resources/unit/refcount/interpreter_hash_slice_staging_ownership.t @@ -0,0 +1,24 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use Test::More; +use Test2::Tools::Refcount qw(is_refcount is_oneref); + +{ + package InterpreterHashSliceStagingTarget; + sub DESTROY { } +} + +my $target = bless {}, 'InterpreterHashSliceStagingTarget'; +my %slots; + +@slots{'future'} = ($target); +is_refcount($target, 2, + 'hash-slice assignment has one lexical and one durable hash owner'); + +%slots = (); +is_oneref($target, + 'clearing a hash-slice destination releases its only non-lexical owner'); + +done_testing; From 4ee07f6cb335b01a43366a66ad9a40ef316783b1 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Wed, 2 Sep 2026 09:49:06 +0200 Subject: [PATCH 18/18] fix: retire unreachable eval capture ownership Release a scope-exited captured pad's semantic owner at the pre-END boundary when no END block can reach it. This restores DESTROY for discarded eval captures and returns Perl core run/fresh_perl.t to its 73/91 baseline. Refs: dev/design/refcount-owner-ledger.md Generated with [OpenAI Codex](https://openai.com/codex/) Co-Authored-By: OpenAI Codex --- dev/design/refcount-owner-ledger.md | 9 ++++++++- docs/about/changelog.md | 3 ++- .../perlonjava/runtime/runtimetypes/MortalList.java | 5 +++++ .../runtime/runtimetypes/RuntimeScalar.java | 12 ++++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/dev/design/refcount-owner-ledger.md b/dev/design/refcount-owner-ledger.md index 806a63d00..fb27154b9 100644 --- a/dev/design/refcount-owner-ledger.md +++ b/dev/design/refcount-owner-ledger.md @@ -317,7 +317,7 @@ Run on JVM and interpreter backends with `timeout` and complete output logs: ## Progress Tracking -### Current Status: Net::Async::HTTP captured-owner and interpreter hash-slice fixes complete +### Current Status: Net::Async::HTTP captured-owner, hash-slice, and eval-capture finalization fixes complete The designated issue branch, `fix/issue-1132-closure-lifetime`, includes the handoff commits below. On 2026-09-01, the required pre-change `make` rebuilt @@ -468,6 +468,13 @@ external workload has drained before changing runtime source. reported 3 then 2 references instead of 2 then 1. `t/32remove.t` now passes its exact 4 then 1 checks on JVM and interpreter, and `t/30timeout.t` passes all 25 assertions on both backends. Full `make` passed on 2026-09-01. +- [x] A scope-exited typed lexical captured only by a discarded `eval STRING` + no longer retains a stale semantic capture owner through global destruction. + The pre-`END` reachability pass now releases that owner only after proving + that no `END` block can reach the capture. Existing permanent core coverage + in `perl5_t/t/run/fresh_perl.t` test 75 now passes, restoring the baseline + 73/91 result; the other 18 historical failures are unchanged. Full `make` + passed in the isolated PR worktree on 2026-09-02. ### Handoff: issue #1132 / PR #1204 diff --git a/docs/about/changelog.md b/docs/about/changelog.md index b7b448848..396e95c32 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -6,7 +6,8 @@ Release history of PerlOnJava. See [Roadmap](roadmap.md) for future plans. - 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. + 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 diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java index ea3e3355d..e7a795e8c 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java @@ -332,6 +332,11 @@ public static void flushDeferredCapturesBeforeEnd() { || (scalar.value instanceof RuntimeBase base && endReachable.contains(base)); if (retained) continue; + // This scope-exited pad was captured by eval STRING, but no END + // block can reach it. Its semantic capture edge must not keep a + // blessed referent alive through global destruction merely because + // the compiled eval object is still Java-reachable. + scalar.releaseUnreachableSemanticCaptureOwner(); deferDecrementIfTracked(scalar); state.deferredCaptures.remove(i); removeFromDeferredSet(scalar); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index c15c2fd68..848748d0d 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -349,6 +349,18 @@ public void releaseClosureCapture() { } } + /** + * Retire this pad cell's semantic edge once its declaring scope has exited + * and the pre-END reachability walk has established that no END block can + * reach it. The capture count intentionally remains intact: the compiled + * eval code may still be Java-reachable, but it can no longer represent a + * Perl lifetime owner at global-destruction time. + */ + public void releaseUnreachableSemanticCaptureOwner() { + RuntimeBase base = semanticCaptureReferent(); + if (base != null) base.releaseSemanticCaptureOwner(this); + } + private void retainClosureCaptureReferent() { RuntimeBase base = closureCaptureReferent(); if (base == null) return;