diff --git a/Makefile b/Makefile index 677fff3a85..b46120d5c5 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,14 @@ .PHONY: all clean test test-unit test-cpan-tester test-interpreter check-thread-test-sources check-thread-core-test-sources check-thread-ecosystem-test-sources check-thread-regex-test-sources test-thread-tooling test-threads test-threads-core test-threads-core-platform test-threads-core-mode test-threads-windows test-threads-regex test-threads-release test-threads-ecosystem test-bundled-modules test-cpan-distroprefs test-cpan-release-acceptance test-exiftool test-all test-gradle test-gradle-unit test-gradle-all test-gradle-parallel test-maven-parallel build run wrapper check-java-gradle dev ci sbom sbom-java sbom-perl sbom-clean check-links perl5-update perl5-sync perl5-sync-check PERL ?= perl -GRADLE_ARGS ?= +ifeq ($(OS),Windows_NT) +# The Windows unit shards each take longer than the 75-minute CI step budget +# when only two Gradle workers are available. Run the five balanced shards +# concurrently on the hosted runner while keeping the existing per-worker heap cap. +GRADLE_ARGS ?= --max-workers=4 +else +GRADLE_ARGS ?= --max-workers=2 +endif # A persistent daemon keeps the priority from the invocation that first # started it. Use a single-use Gradle process so workers inherit the # launcher's niceness (for example, `nice -n 19 make`). @@ -123,13 +130,23 @@ perl5-sync-check: $(PERL) dev/import-perl5/update_perl5.pl --sync --verify-idempotent $(if $(FILTER),--filter "$(FILTER)",) # CI build - optimized for CI/CD environments -ci: check-java-gradle +ci: prepare-windows-posix-tmp check-java-gradle ifeq ($(OS),Windows_NT) gradlew.bat $(GRADLE_LAUNCH_ARGS) build -x test testUnitParallel --parallel --stacktrace else ./gradlew $(GRADLE_LAUNCH_ARGS) build -x test testUnitParallel --parallel --stacktrace endif +# Some Perl unit scripts use POSIX /tmp paths. On Windows, Java resolves +# these as root-relative paths on the current drive; create that directory on +# the runner before compiling and executing the suite. +prepare-windows-posix-tmp: +ifeq ($(OS),Windows_NT) + powershell -NoProfile -NonInteractive -Command "New-Item -ItemType Directory -Force -Path (Join-Path ([System.IO.Path]::GetPathRoot((Get-Location).Path)) 'tmp') | Out-Null" +else + @: +endif + # Check Java/Gradle compatibility and fix if needed # For Java 25+, we need Gradle 9.1.0+ (see https://docs.gradle.org/current/userguide/compatibility.html) # Note: On Windows CI, Make uses Git Bash, so we use bash-compatible syntax throughout diff --git a/build.gradle b/build.gradle index 465e8c328b..9d6675934f 100644 --- a/build.gradle +++ b/build.gradle @@ -350,10 +350,10 @@ tasks.withType(Test).configureEach { // deprecation banner while retaining the tested Netty allocation path. jvmArgs += '--sun-misc-unsafe-memory-access=allow' // The Perl test corpus loads sizeable bundled tables (for example - // I18N::LangTags via Test2). A 1 GiB Gradle worker can be terminated by - // the host before it reports an OOME; keep it below the command-line - // default while providing enough headroom for one shard. - maxHeapSize = '2g' + // I18N::LangTags via Test2). A 1 GiB Gradle worker can be terminated by + // the host before it reports an OOME. The expanded lifetime regression + // suite also needs room for shard 0's in-process tests. + maxHeapSize = '3g' } // Enable native access for all Java execution tasks @@ -833,10 +833,36 @@ def homeIntegrationTask = tasks.register('testPerlOnJavaHomeIntegration', Test) mustRunAfter tasks.named('testJoni') } +def websocketLifetimeTask = tasks.register('testUnitWebsocketLifetime', Test) { + group = 'verification' + description = 'Runs the isolated Mojolicious WebSocket lifetime regression' + + useJUnitPlatform { + includeTags 'unit' + excludeTags 'full' + } + + systemProperty 'test.shard.index', parallelShards + systemProperty 'test.shard.total', parallelShards + 1 + + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + include '**/PerlScriptExecutionTest.class' + + mustRunAfter unitShardTasks.collect { tasks.named(it) } + onlyIf { + jperlTestShardProp.get().isEmpty() || jperlTestShardProp.get() == "$parallelShards" + } +} + +homeIntegrationTask.configure { + mustRunAfter websocketLifetimeTask +} + tasks.register('testUnitParallel') { group = 'verification' description = 'Runs unit tests in parallel across multiple JVMs. Usage: gradle testUnitParallel --parallel' - dependsOn unitShardTasks + dependsOn unitShardTasks + [websocketLifetimeTask] dependsOn testJoni dependsOn verifyJoniPackaging dependsOn homeIntegrationTask diff --git a/dev/design/perl-ownership-rewrite.md b/dev/design/perl-ownership-rewrite.md index 6759cfe64f..7af83148bf 100644 --- a/dev/design/perl-ownership-rewrite.md +++ b/dev/design/perl-ownership-rewrite.md @@ -1,6 +1,6 @@ # Perl-compatible ownership rewrite -**Status:** Phase 1 closure-pad path is implemented and validated on both backends. Full UAT comparison, local gates, selected CPAN workloads, and PR CI pass. PR #1653 remains a draft pending review. +**Status:** Phase 1 closure-pad path is complete. Phase 2 remains in progress for [draft PR #1816](https://github.com/PerlOnJava/pull/1816), last reconciled with `origin/master` through `aeda28344`. PR head `69a0ab706` passes the full local `nice -n 19 make` gate (19m49s); no hosted checks are reported yet. Owner slots still bridge to `RuntimeBase.refCount` for dispatch, and default owner-slot accounting remains incomplete. **Tracking:** [#1649](https://github.com/fglock/PerlOnJava/issues/1649) @@ -27,6 +27,14 @@ slots, closure pads, and mortal temporaries have different ownership rules. Every path must state whether it creates an owner, borrows a cell, transfers an owner, or creates a deferred mortal owner. +During migration, `activeOwnerCount()` and `activeOwnerSnapshot()` describe +only the compatibility `activeOwners` registry. They must not be widened to +include explicit slots: several recovery and diagnostics paths need to tell +whether the legacy registry itself recorded an owner. Use +`countedScalarOwnerCount()` / `countedScalarOwnerSnapshot()` when the caller +needs the identity-deduplicated union of legacy entries and `SCALAR_VALUE` +slots. Keep that distinction until the compatibility registry is removed. + ## Baseline ownership map | Concern | Current implementation | Phase 1 implication | @@ -54,8 +62,69 @@ destruction while a legacy owner still exists. This finding rules out treating a scalar-only migration as a fix for #1618. The reduced reproducer crosses closure pads and aggregate slots, so Phase 2 -must cover those boundaries before HTML::Tree can pass. `undef $sub` is a -scalar slot overwrite, not the separate `undef &sub` CV-body operation. +must cover those boundaries in the ownership model. The current branch now +passes HTML::Tree's integration tests with the existing selective-count and +reachability machinery; that result is useful compatibility evidence, but it +does not prove the planned owner-slot migration. `undef $sub` is a scalar slot +overwrite, not the separate `undef &sub` CV-body operation. + +### Current ownership source/sink audit (2026-10-25) + +This table records the accounting paths relevant to the remaining aggregate +recovery scan. “Counted slot” means it changes the legacy referent count through +an explicit token; an identity-only slot is currently evidence for recovery, +not an independent count. + +| Perl ownership edge | Acquisition or transfer | Release | Current authority and recovery interaction | +|---|---|---|---| +| Direct scalar reference value | `RuntimeScalar.setLargeRefCounted` acquires a `SCALAR_VALUE` slot for a tracked store; `PerlOwnerSlot.acquireScalarValueCount` increments `refCount` | Scalar overwrite/undefine retires that slot; deferred scope and container paths queue its `PendingRelease` through `MortalList` | Counted slot and legacy count are bridged. When an unrelated decrement reaches zero, the scalar caller can still invoke aggregate recovery before lifecycle dispatch. | +| Hash entry | `RuntimeHash.updateHashEntryOwner` attaches a `HASH_ENTRY` slot to the stable entry and scalar cell | Key replacement/deletion and hash cleanup release that identity slot; the scalar cell's value slot is released separately | `HASH_ENTRY` is identity-only for ordinary non-shared hashes. Recovery proves the owning hash is rooted and counts the cell's still-live value edge. Shared hashes retain a separate shared-slot count. | +| Array element | `RuntimeArray.syncArrayElementOwnerSlot` / reconciliation attaches an `ARRAY_ELEMENT` slot when `ownsElement` is true | Array deletion, clear, replacement, and cleanup release the structural slot and release/queue the scalar value owner according to owned-versus-borrowed state | `ARRAY_ELEMENT` is identity-only; `SCALAR_VALUE` or the legacy bridge supplies the count. Borrowed aliases must not be promoted to an element owner. | +| Scalar-reference contents | `PerlOwnerSlot.acquireScalarReferenceContents` records a `SCALAR_REFERENCE_CONTENTS` edge; it counts separately when a reference cell owns a distinct contents edge, and records a non-counting identity when it shares the cell's existing value owner | Retargeting or releasing the final strong alias releases the counted contents slot or allows the shared cell owner to retire | Reachable contents slots restore a live contents owner after a decrement. Aggregate recovery now counts distinct reachable contents cells alongside hash/array owners; weak aliases are excluded. | +| Tied wrapper | Tie installation attaches a counted `TIE_WRAPPER` slot when the handler is tracked; scalar-store and first-bless promotion also count an existing wrapper slot when its owner container is rooted | Untie/replacement and wrapper cleanup release the slot and its count | Recovery still reconstructs tied-wrapper counts for mixed states not yet covered by a direct tracking transition. | +| Closure-pad capture | `RuntimeBase.retainClosureCaptureOwner` creates a typed capture slot; aggregate captures can be native counted slots, scalar captures use a legacy bridge | Scope exit, closure release, and capture replacement retire the slot and capture state | Aggregate capture slots have direct semantic counts; scalar capture bridges still use the selective counter. The recovery scan is not their normal release authority. | +| Deferred temporary or lifecycle hold | Blessing and method dispatch acquire a temporary count; an active END callback is rescued from zero to one by `DestroyDispatch`; closure aggregates normally acquire native capture slots | `MortalList.processDeferredBase` decrements queued counts at a cleanup boundary; abandoned method holds release immediately; END completion queues the rescued count | These generic releases carry no `PendingRelease` token. The normal bless, method-hold, and END paths each pair one temporary count with one release. The closure aggregate defer is a legacy fallback when its capture slot is absent. The focused #1794/#1795 no-recovery runs did not demonstrate a lost owner on these paths. | + +The scan repairs a mixed-state mismatch: it runs only after a deferred release +or scalar overwrite has brought the selective count to zero, then reconstructs +the count from rooted aggregate identities, scalar-value cells, tied wrappers, +and reachable counted scalar owners. The current Java tests that fail when the +scan is bypassed deliberately start from inconsistent counts or queue more +generic decrements than they acquired; they prove the recovery helper's +synthetic contract, not a production mutation that loses an owner. + +The source audit covered all five production `MortalList.deferDecrement` +invocations: two blessing paths, normal method-hold release, active END-block +completion, and the aggregate-capture compatibility fallback. The first four +have a paired temporary count or zero-to-one lifecycle rescue in the adjacent +producer; ordinary aggregate capture construction and ithread cloning install +native capture slots. The remaining uncertainty is not an unmatched +`deferDecrement` call, but whether arbitrary scalar overwrites or queued owner +releases can consume a different still-live selective owner before either +production scan caller runs. + +A mixed-owner unit probe found one recovery omission in that accounting: when a +rooted hash value and a distinct counted scalar-reference contents edge both +owned the referent, aggregate recovery restored the hash cell but ignored the +contents cell. The scalar-overwrite caller's later contents fallback could not +repair this case because aggregate recovery had already returned success. The +recovery scan now counts rooted `SCALAR_REFERENCE_CONTENTS` cells once per +underlying scalar cell, excluding non-counting aliases and weak references. A +focused Java regression covers the mixed state. This fixes a recovery-ledger +gap; it does not yet identify which production operation can create the +undercounted state, so neither scan caller is removed. + +Both production scan callers now have filtered release-provenance and owner +slot traces. In the Catalyst tempfile case, the root queries for the +recoverable `File::Temp` object's hash/array containers completed below their +budgets and returned false; recovery came from reachable scalar-owner cells. +A temporary exact-class bypass passed the focused #1794/#1795, alias-owner, +WebSocket, and DESTROY regressions, so those cases do not identify a missing +Perl owner acquisition. The bypass has been removed. The next audit target is +to correlate scalar-overwrite and queued legacy releases with the counted +scalar-owner cells they decrement, and find a production case where one +transition consumes a different still-live owner. Keep both scan callers until +that path has a focused regression and direct accounting. The first verified fix is at the reachability boundary. The interpreter's `capturedVars` array is execution metadata and can still contain a pad after @@ -100,12 +169,662 @@ Phase numbers are delivery boundaries, not estimates of the total project. The 20–36 engineer-week estimate remains provisional until the ownership audit and first path establish implementation cost. +Tracked package-global CODE refs (`refCount >= 0`) receive `SCALAR_VALUE` slots +through scalar assignment; untracked CODE refs (`refCount == -1`) correctly +remain outside counted ownership. `RuntimeScalar.undefine` now releases the +tracked slot when present and retains the legacy bridge for older or untracked +cells. The global CODE path performs this release before its early return +through `RuntimeCode.undefineCodeReference`; saved aliases still observe the +undefined CV. A focused Java regression verifies the final tracked CODE owner +retires its slot and still dispatches lifecycle cleanup. `nice -n 19 make` +passes in 13m26s on this batch. + ## Progress tracking -### Current status: Phase 1 complete; merged in PR #1653 as `84f32be179bdc2b351c3eaae1cb26df39964ab6b` (2026-10-07) +### Current status: Phase 2 in progress; Phase 1 merged in PR #1653 as `84f32be179bdc2b351c3eaae1cb26df39964ab6b` (2026-10-07) + +The #1794/#1795 fixes and #1649 work remain in [draft PR #1816](https://github.com/PerlOnJava/pull/1816). The branch includes the current-master merge at `aeda28344`; PR head `69a0ab706` is open and draft. Focused Mojolicious controller/WebSocket and Catalyst tempfile regressions pass on both backends with loopback access. On the tail-call candidate, the interpreter tempfile test exceeded a 180-second run but completed 24/24 within a 240-second JFR run; a later 240-second profile after owner-proof caching emitted no TAP, while an unprofiled run completed 24/24 within the 600-second limit. Sampled `isRootedAggregateOwner` frames declined from 517 to 190, with 112 cache-hit frames; the profiled runs had different total sample counts, so wall-clock attribution remains inconclusive. HTML::Tree 5.07 passes all 23 files and 989 tests on both backends on the preceding ownership candidate. Phase 2 remains incomplete: `refCount` still controls lifecycle dispatch, and the slot ledger is not yet the default accounting source across general scalar/proxy stores, aliases, and cleanup. Distinct scalar-reference aliases keep separate reachability identities while contributing one referent count per scalar cell. The HTML-Tree result is compatibility evidence, not the Phase 2 owner-slot exit proof. + +### Current batch + +- Postfix increment/decrement now transfers the stable `SCALAR_VALUE` slot from + the mutated lvalue to its returned scalar. Assignment of that result releases + the temporary through the slot API after acquiring the destination owner. + New Java coverage checks slot identity and verifies one owner is represented + by exactly one live slot for both `++` and `--`. This closes a ledger mismatch + where the old implementation manually decremented `refCount` but left the + temporary's owner slot active. Full `nice -n 19 make` passes in 14m45s; both + focused Java cases pass. +- `Storable.releaseApplyArgs()` now releases materialized argument owners through + `SCALAR_VALUE` slots when available, retaining the legacy fallback for older + cells. Its existing Java regression retires the legacy ownership flag before + release and verifies the slot and count both clear; this now exercises the + direct slot-release API rather than detaching the slot after a manual count + decrement. Full `nice -n 19 make` passes in 15m05s; the focused test passes + with zero failures. +- `RuntimeGlob.dynamicRestoreState()` now retires a tracked temporary CODE + scalar through its `SCALAR_VALUE` slot before dispatching final cleanup, with + the legacy bridge retained for older cells. `RuntimeGlobCodeOwnerSlotTest` + verifies the slot and count both retire. Full `nice -n 19 make` passes in + 13m21s on this batch. +- On 2026-10-24, tied scalar, array, hash, and handle wrappers now record + handler ownership in distinct `TIE_WRAPPER` slots. A wrapper installed while + its handler has the untracked sentinel remains uncounted until rooted owner + recovery; the existing Java regression requires this so the wrapper cannot + consume a later scalar owner's count. Root recovery includes live wrapper + slots from rooted owner containers. The wrapper-slot batch passed full + `nice -n 19 make` in 27m21s. +- On 2026-10-25, deleting a non-final array element now defers release of its + tracked scalar-value and I/O owners after removing the structural array slot. + A new DESTROY/weak-reference regression failed on the pre-fix JVM and passes + under system Perl, JVM, and interpreter. The fix preserves a captured delete + result until its caller can retain it. Full `nice -n 19 make` passes in + 14m53s; `make check-links` passes. Recovery still has the two production + callers described below; this fix closes one concrete array deletion edge + without establishing direct owner-slot accounting across aggregates. The + focused #1794 transaction-controller and WebSocket tests, and #1795 Catalyst + tempfile test, also pass on JVM and interpreter; the WebSocket test required + loopback-enabled extended access and passes all six assertions on both. +- The middle-delete release was then guarded by `ownsElement`: deleting a + borrowed middle argument from `@_` had prematurely destroyed the caller's + object. The new regression failed on both backends before the guard and passes + on system Perl, JVM, and interpreter afterward. The middle-delete and + borrowed-alias regressions pass together in the full gate (`14m15s`) and in + focused runs on both backends. +- A related localized-delete path restored a borrowed `@_` slot through the + ordinary aliased-array setter, which marked it as owned. Argument-frame + cleanup then decremented the caller's object after the subroutine returned. + `RuntimeArray.deleteLocal` now snapshots and restores the slot's prior + ownership state. The regression failed on the pre-fix JVM and passes on + system Perl, JVM, and interpreter. The new change passes full + `nice -n 19 make` (`13m42s`); all three array-delete ownership regressions + pass focused on both backends. A companion owned-slot regression confirms + restoration still retains a genuinely owning array element, and clearing the + restored slot then releases it; system Perl and both backends pass all five + assertions. The combined full gate including this control passes in 13m21s. +- The temporary `perlonjava.test.disableAggregateOwnerRecovery` diagnostic was + removed after the no-recovery audit. The two production call sites in + `MortalList.processDeferredBase` and `RuntimeScalar.setLargeRefCounted` still + use recovery: six Java tests fail with the bypass because they directly test + recovery from deliberately undercounted synthetic graphs. The root-chain + and lifecycle tests directly construct scalar cells, mark them owned, then + force the referent count below the live owners; the reachability-cost tests + queue more decrements than they acquired owners. These fixtures do not + identify a missing Perl store operation. Concretely, the six are + `MortalListAggregateOwnerSlotRootChainTest` (two cases), + `MortalListAggregateOwnerSlotTest.rootedHashEntrySlotRestoresAnUnderflowedLegacyCountUntilEntryRemoval`, + `ReachabilityQueryCostTest.deferredOwnerChecksShareOneRootSnapshotPerDrain`, + and two `UnblessedAggregateReachabilitySnapshotTest` cases. The first three + assign an artificially low `refCount` after constructing active owner slots; + the last three add three counted owners and then queue three generic + referent decrements without releasing those owners. The JVM + global-destruction wrapper timed out its nested child at 300 seconds, while + the underlying Catalyst lifetime test passed directly and the wrapper passed + on the interpreter; that wrapper result is inconclusive. + + The recovery scan has two production callers. In + `MortalList.processDeferredBase`, a queued slot release is completed, any + transient trace owner is retired, and the legacy `refCount` is decremented; + if it reaches zero, recovery rebuilds the count from rooted aggregate slots, + scalar-value slots, tie wrappers, and reachable counted scalar owners before + lifecycle dispatch. In `RuntimeScalar.setLargeRefCounted`, overwriting a + scalar releases the displaced scalar-value owner and decrements the old + referent; at zero, the same recovery protects any still-rooted aggregate + owners. The two production scan callers remain in + `MortalList.processDeferredBase` and `RuntimeScalar.setLargeRefCounted`. + Removing the scan is blocked on real operations that can produce these + mismatches and on direct accounting for aggregate slots and + scalar-reference contents; the current synthetic tests are not sufficient + evidence to replace the scan. After the diagnostic review, default behavior + passes full `nice -n 19 make` in 39m43s; offline documentation link checking + also passes. + Aggregate slots are currently reachability identities rather than count + owners: `RuntimeHash.updateHashEntryOwner` and array reconciliation attach + `HASH_ENTRY`/`ARRAY_ELEMENT` slots without changing `refCount`, while the + contained scalar cell generally owns the count through `SCALAR_VALUE`. + Removing recovery requires moving acquisition and release together across + aggregate insertion, replacement, deletion, aliases, and borrowed scalar + references. Counting structural slots before retiring the scalar-cell edge + would double-count ordinary copied SVs. A source audit found generic + deferred decrements that do not carry a semantic owner slot. The fallback in + `RuntimeScalar.deferOwnedScalarReferenceContents` was unreachable: it + required an active contents slot after the method had already returned + through that slot's deferred-release path. Removed the dead generic decrement + and added Java coverage that verifies the contents slot remains active until + drain, then releases exactly once. `RuntimeCode.releaseMethodInvocantHold` + queues a generic deferred decrement. Its ordinary path is balanced: + acquisition adds one count, and release either subtracts it or queues exactly + one decrement for the boundary drain. The interpreter tracks holds by frame + and abandons them on exceptional/non-local exits through + `releaseAbandonedMethodInvocantHold`, which decrements directly and dispatches + `DESTROY` at zero without consulting owner recovery. The project-owned + `issue_1649_abandoned_method_invocant_hold.t` now verifies the critical + ordering: an argument exception abandons the call while a hash still owns the + invocant, and deleting the hash entry later fires `DESTROY`. It passes 3/3 on + system Perl, JVM, and interpreter. No accounting mismatch is reproduced, so + retain the current lifecycle decision while migrating transient holds to + explicit slots as part of the broader ownership phase. +- On 2026-10-25, a focused recovery-bypass audit passed the #1794 controller + and WebSocket tests (four files), #1795 tempfile tests (two files), plus + selected scalar-reference, hash-element, array-delete, #1649, and DESTROY + regressions. A filtered no-recovery `make` reached 708 Java tests and failed + six tests whose simulated release paths still rely on recovery or whose + measured work includes the scan; three tests were skipped. The bypass was + removed after the focused paths passed. +- A new system-Perl-validated regression exposed a real scalar-cell ownership + gap: clearing or deleting a hash/array entry must preserve the cell's value + while a strong refalias still points to that cell, then release it when the + final alias disappears. `PerlOwnerSlot` now records a non-counting + `SCALAR_REFERENCE_CONTENTS` identity when the cell already owns the value; + aggregate removal preserves the cell's `SCALAR_VALUE` owner, and final alias + release retires that owner. Weak aliases do not retain the value. The new + regression passes 18/18 on system Perl and both PerlOnJava backends, with + aggregate-owner recovery disabled, and the hash/array refcount, weak-alias, + and borrowed-array deletion regressions pass on both backends. A filtered + `nice -n 19 make` passes. The recovery scan remains because six synthetic + release/query tests still exercise undercounted states, and its two + production callers remain `MortalList.processDeferredBase` and + `RuntimeScalar.setLargeRefCounted` pending direct-accounting proof there. +- The recovery-disabled audit had exposed an interpreter regression in + `issue_1649_recursive_builder_pad_release.t`: the interpreter retained all + five weakly observed nodes after dropping the root (expected two), even with + recovery enabled. Commit `9adc112aa` preserves captured lexical owners across + return cleanup. On 2026-10-25, the permanent regression passed all 9 + assertions on system Perl, JVM, and interpreter, confirming the root release + and subsequent escaped-subtree release. This closes that interpreter + blocker; it does not establish that aggregate-owner recovery is removable. +- On 2026-10-25, a two-node reducer narrowed a related interpreter failure to + replacing the final lexical root with `undef` when the child has a weak + parent edge. System Perl passes, and lexical scope exit releases the same + tree under the interpreter; explicit assignment leaves both weak observers + alive. The failure also reproduces with aggregate-owner recovery disabled. + Existing `weak_blessed_tree_descendant_cleanup.t` and `weaken_destroy.t` + exercise scope exit, so the new project-owned regression covers explicit + replacement. A refstate diagnostic call changes the timing and makes the + test pass, so its count output is not a reliable oracle for the missing edge. + Invalidating cached reachability for the displaced referent as well as its + scalar cell fixes the focused reducer and the existing recursive-builder + regression. Both pass on JVM and interpreter with aggregate-owner recovery + disabled; system Perl passes the new four-assertion reducer. Full + `nice -n 19 make` passes in 13m09s. The #1794 controller-owner regression + passes 3/3 and the #1795 Catalyst tempfile regression passes 24/24 on both + backends. The WebSocket integration test skipped because loopback sockets + were unavailable in the sandbox, then passed with extended access: the + WebSocket controller test passes 6/6 and callback/long-poll lifetime passes + 29/29 on both backends. With aggregate-owner recovery disabled, the #1794 + controller, both WebSocket tests, #1795 tempfile test, void-context hash + owner test, captured scalar/array argument test, and aggregate-capture owner + test all pass on both backends. Recovery remains enabled by default; its two + production callers still need independent direct-accounting proof, and the + synthetic Java tests still directly require the recovery behavior. +- The full no-recovery audit reached 1,300 tests in shard 0 and failed six Java + assertions that directly require `restoreReachableAggregateOwnerCount` to + rebuild an artificially undercounted referent; the other 1,294 passed, with + three skipped. Since Gradle stopped at shard 0, shards 1–4 were run + separately with recovery disabled and all passed. Focused #1794/#1795 + integration tests and selected #1649 lifetime tests also pass on both + backends with recovery disabled. The diagnostic switch is now removed; keep + the recovery method and its two production callers until direct accounting + proves those paths no longer need it. +- On 2026-10-25, added a source/sink inventory for scalar values, hash entries, + array elements, scalar-reference contents, tied wrappers, closure captures, + and generic deferred holds. It records which slots change `refCount` and + which are currently identity-only recovery evidence. The next audit traces + every remaining generic `deferDecrement` producer to its matching acquisition; + no producer is changed until that trace identifies a real Perl-visible + mismatch. `make check-links` passes for the updated design document (787 + links checked, zero errors). +- A mixed-owner recovery test exposed that `restoreReachableAggregateOwnerCount` + could count a rooted hash value but omit a distinct reachable + `SCALAR_REFERENCE_CONTENTS` owner. `RuntimeScalar.setLargeRefCounted` could + not repair the omission because aggregate recovery returned success first. + Recovery now adds one owner per rooted underlying contents cell, skipping + weak and non-counting aliases. The new Java regression passes in the full + `nice -n 19 make` gate (15m51s, `EXIT: 0`). The #1794 controller, both + WebSocket lifetime tests, both #1795 Catalyst tempfile tests, the new + 18-assertion refalias regression, and the #1649 abandoned-method-hold test + pass on the interpreter backend; the full gate also passes their JVM cases. + This repairs mixed recovery accounting but does not yet identify the + production operation that can produce an undercount, so both scan callers + remain. +- The initial `PJ_REFCOUNT_TRACE_CLASS=File::Temp` trace was inconclusive, but + a class-filtered owner/count trace later isolated an actual blessed + `RuntimeGlob` `File::Temp` referent. During the Catalyst regression, + `ReachabilityWalker` reported it unreachable at `refCount=2`, while the + aggregate recovery scan found two rooted owners from one hash entry, one + array element, and two scalar-value slots. The pending release drained while + `DESTROY` was running. This is a disagreement between weak-walk reachability + and the explicit slot proof; it does not identify a missing count acquisition + or justify removing recovery. A follow-up should trace the owning containers + and reconcile the two root proofs before changing either path. +- Thread-cloned scalar references and blessed glob aliases now acquire their + tracked `SCALAR_VALUE` owner through the slot API, keeping count acquisition + and slot identity together. The glob path retains its legacy fallback for + untracked referents. Focused Java tests verify the new owners release through + their slots; `nice -n 19 make` passes in 18m37s. This advances scalar-owner + migration but does not change lifecycle dispatch authority or remove either + aggregate-recovery caller. +- A tie wrapper installed before its handler is tracked now gains its count + when a scalar store or first bless promotes the handler, provided the tied + owner container is rooted. The focused regression now checks the count before + any recovery call. `TieWrapperOwnerSlotTest` passes 5/5, and the full + `nice -n 19 make` passes in 17m32s. Recovery remains for other mixed owner + transitions, and both production callers remain under audit. +- On 2026-10-25, added opt-in `PJ_OWNER_RECOVERY_TRACE_CLASS` logging around + both production recovery callers. It records the pending release provenance, + rooted slot kinds, and whether recovery restored a count. The full + `nice -n 19 make` gate passes in 17m01s. The socket-independent #1794 + transaction/controller ownership test passes 3/3 on system Perl and JVM; + its controller release reaches recovery with no rooted owner slots, while + the transaction overwrite has a rooted hash slot but does not need a count + restoration. With extended loopback access, the controller-finish, + WebSocket-controller, WebSocket-callback, and Catalyst tempfile regressions + pass on both backends (5/5, 6/6, 29/29, and 24/24); the dedicated Catalyst + global-destruction wrapper passes 3/3 on both. Five focused #1649 ownership + regressions pass 22/22 on system Perl and pass individually on JVM and + interpreter. The logging is disabled unless its class filter is set. The + root-proof discrepancy above remains under investigation, and the broader + owner-slot migration remains incomplete. +- A follow-up slot-detail trace shows the two recovered tempfile owners are + already represented by owned `GLOBREFERENCE` cells with active + `SCALAR_VALUE` slots. One cell is present in both a hash and an array; the + second is present in an array. Recovery runs with `currentlyDestroying=true`. + The current evidence therefore does not show a missing retain; it shows the + weak walker marking an object unreachable despite these counted owner cells. + Trace the parent container chain to reconcile that root proof before changing + the owner ledger or the weak sweep. +- A second focused reducer, + `issue_1649_captured_scalar_array_argument.t`, isolates the same class of + lifetime error: a closure writes a blessed object to a captured scalar while + receiving an array-reference argument, then the closure and returned owner + are released. System Perl and the JVM pass both assertions; the interpreter + failed the weak-observer release assertion with aggregate recovery both on + and off, while `DESTROY` fired once. A temporary `Internals::SvREFCNT` probe + showed Perl reporting 2 owners after the closure call and 1 after closure + release; the interpreter reported 0 at both points. The trace narrowed the + transition to return cleanup emitted for a captured lexical register. A + permanent test now reproduces this on the unfixed interpreter parent and + passes on system Perl and the JVM. A runtime frame-mark guard around return + cleanup also makes the focused interpreter test and the 24-assertion + Catalyst tempfile interpreter test pass, and its consolidated + `nice -n 19 make` passed in 35m17s, including the Mojolicious lifetime tests. + The first guard marked compiler register-index metadata as captured, but that + metadata also contains non-capture entries. It failed the interpreter's + recursive-builder release check (five weakly observed nodes remained instead + of two). Limiting metadata to the actual capture count compiled and passed + the full `make`, but still missed both interpreter reproductions because + captured registers are filled later by `withCapturedVars`. Marking those + registers when the closure copy receives its capture array fixes the scalar + regression. This all-type frame-aware patch passed full `make` in 14m27s, the + scalar interpreter test, and Catalyst 24/24. The recursive-builder + interpreter test still fails (five nodes remain rather than two) with + recovery enabled and disabled, so it is a separate existing failure. A + scalar-only variant retained the scalar and Catalyst passes but made + `catalyst_request_body_tempfile_global_destroy.t` exceed its 300-second child + timeout; that variant was discarded. The all-type fix was committed as + `9adc112aa`; its final `nice -n 19 make` passed in 31m15s. The permanent + regression passes with system Perl and the interpreter, and Catalyst passes + 24/24 on the interpreter. The recursive-builder failure remains open. The + earlier static filter also caused unrelated cleanup failures. Aggregate + recovery remains enabled while its two production callers await direct + ownership accounting. +- On 2026-10-24, the temporary scalar owner installed for `DESTROY`'s self + argument now balances through the `SCALAR_VALUE` slot API without invoking + destruction recursively inside the active DESTROY frame. A focused Java test + checks slot and compatibility-registry retirement when the legacy flag has + already cleared. Full `nice -n 19 make` passes in 14m on `108c06094`. +- On 2026-10-24, tracked scalar deletion from a stash now releases the scalar + owner through its `SCALAR_VALUE` slot, keeping the legacy bridge for cells + without explicit slots. The existing retired-flag regression now also checks + that the compatibility active-owner registry and slot ledger both clear. + Full `nice -n 19 make` passes in 14m53s on `54ca5153f`. +- On 2026-10-24, materialized scalar-copy cleanup in list assignment now uses + the `SCALAR_VALUE` release API, keeping the legacy bridge for cells without + explicit slots. The shared helper covers discarded values assigned to + scalar, array, and hash targets, including the undefined-target and simple + scalar-assignment paths. A Java regression verifies slot retirement after + the legacy flag is cleared. Full `nice -n 19 make` passes in 16m53s on + `e9c32a68e`. +- On 2026-10-24, removal of newly localized hash and array proxy entries now + releases the contained scalar's direct referent through its `SCALAR_VALUE` + slot, retaining the legacy fallback for cells without explicit slots. Two + Java regressions retire the legacy flag while keeping each slot active, then + verify restoration removes both scalar and aggregate owner slots and destroys + the displaced referent. Full `nice -n 19 make` passes in 13m28s on + `a0ff791ea`. +- On 2026-10-24, global `local` restoration now releases a displaced direct + scalar owner through its `SCALAR_VALUE` slot, while retaining the legacy + fallback and the existing last-owner `DESTROY` decision after the global + binding is restored. The regression keeps the slot active after retiring + `refCountOwned` and verifies the localized referent is destroyed while the + original global remains owned. Full `nice -n 19 make` passes in 14m47s on + `a547b89b8`, including the new regression. +- On 2026-10-24, `dynamicRestoreState()` now retires a displaced localized + referent through its `SCALAR_VALUE` owner slot before restoring the saved + scalar state, while preserving post-restore `DESTROY` dispatch. A focused + Java regression covers a retired legacy `refCountOwned` flag with an active + slot and verifies that the restored referent remains owned. Full + `nice -n 19 make` passes in 17m on this batch. The Catalyst tempfile file + passes 24/24, the #1794 controller-owner file 3/3, and the Mojolicious + WebSocket release file 6/6 on both JVM and interpreter. The prior ownership + cases and lifecycle bridge remain open; Phase 2 is incomplete. +- Array and hash tails in nested list assignment transfer materialized cells + through their owning-store paths. This releases displaced values, acquires + scalar ownership idempotently, and registers structural slots on the + destination's owner-aware container. Tied and autovivifying hashes use their + normal assignment path instead of adopting a temporary container. Java and + Perl-level regressions cover both transfer paths. The hash-tail regression + passes 4/4 on system Perl, JVM, and interpreter, and fails all four assertions + on the unfixed JVM and interpreter parent. Immutable code commit + `85daddac0` passes full `nice -n 19 make` in 17m20s. +- The hash-tail validation record was pushed in `993d5875e`. Both JVM and + interpreter regressions pass 4/4 after the fix; the unfixed parent fails + 4/4 on each backend. The latest PR head has no hosted checks attached yet. +- Explicit `undef` now releases a counted direct scalar owner through its + `SCALAR_VALUE` slot, decrementing the bridged count in the same operation; + `undefine()` still owns the local-binding and lifecycle decision. The Java + regression verifies that both the slot and count retire. Full + `nice -n 19 make` passes in 23m10s on the source committed as `29372cac0`. + The #1794 controller/WebSocket regressions pass 5/5, 6/6, 6/6, and 29/29 on + both backends with loopback access. Catalyst tempfile regressions pass 24/24 + and 3/3 on both backends; the controller-owner release regression passes + 3/3 on both. +- Deferred scalar cleanup now carries `SCALAR_VALUE` slot identity into the + mortal queue for ordinary scope release, recursive blessed/unblessed + container cleanup, and `pop`/`shift`. Slot retirement and count decrement + remain ordered around the existing mortal drain; cells predating explicit + slots keep the legacy queue path. A Java regression checks token identity + and delayed decrement. Full `nice -n 19 make` passes in 16m10s on + `2c05509e6`. The Mojolicious, Catalyst tempfile, and controller-owner + lifetime files pass on JVM and interpreter after this change. +- Immediate tail-call cleanup now uses the scalar slot release API for both + argument cells and CODE references. Existing stash, local-binding, + reachability, and DESTROY decisions remain in `MortalList`; a Java regression + covers final CODE release. Full `nice -n 19 make` passes in 18m28s on + `fad2199eb`. The focused lifetime files pass on both backends, with the + interpreter Catalyst tempfile file requiring a 240-second limit after a + 180-second run stopped at assertion 13. Its JFR samples show external-root + snapshot construction and aggregate-owner recovery as the dominant work. +- Aggregate owner recovery now memoizes root proofs by owner identity for the + duration of one restore call. The same excluded referent and graph remain + fixed throughout that scan. A deterministic test with 64 hash-entry slots + verifies that all 64 owners are restored with one proof and 63 cache hits. + Full `nice -n 19 make` passes in 28m41s on `ca58da7c4`. The interpreter + Catalyst tempfile regression passes 24/24 without profiling. JFR sampled + `isRootedAggregateOwner` 517 times before and 190 times after the change, + plus 112 cache-hit frames; the profiled after-run hit its 240-second limit + before TAP output, so these samples do not establish a wall-clock speedup. +- On 2026-10-24, aggregate-owner recovery now takes one owner-slot snapshot and + classifies each slot in a single pass, combining hash, array, and scalar-value + recovery. A deterministic counter verifies all 128 slots in the mixed test + graph are inspected once, replacing three full scans. Full `nice -n 19 make` + passes in 15m56s on `6730377d1`. The Catalyst tempfile test passes 24/24 and + controller-owner release passes 3/3 on both backends; the WebSocket + controller-release regression passes 6/6 on both backends with loopback + access. This reduces snapshot allocation and slot traversal; it does not + replace the legacy `refCount` dispatch bridge. +- On 2026-10-24, `WeakRefRegistry.weaken()` now consumes an active + `SCALAR_VALUE` slot through `releaseScalarValueOwnerCount()`; cells without an + explicit slot retain the legacy decrement path. The existing regression + explicitly retires `refCountOwned` before weakening and verifies that the + slot is detached and the count is decremented exactly once. Full + `nice -n 19 make` passes in 31m48s on `c21116699`. Catalyst tempfile (24/24), + controller-owner release (3/3), and WebSocket controller release (6/6) pass + on both backends; the WebSocket run used loopback access. +- On 2026-10-24, `WeakRefRegistry.unweaken()` now restores the strong count and + owner ledger through the scalar-value slot acquisition API. The new Java + regression covers the full weaken/unweaken cycle, including count restoration + and slot reactivation. Full `nice -n 19 make` passes in 26m14s on + `6a4d65840`. The bridge still updates `refCount` because lifecycle dispatch + has not yet moved to the explicit owner ledger. +- On 2026-10-24, `incrementRefCountForContainerStore()` now acquires the direct + scalar count and owner registries through the `SCALAR_VALUE` slot API. Existing + aggregate ownership tests verify separate copied cells still acquire distinct + owners and active slots are not double-counted. Full `nice -n 19 make` passes + in 23m54s on `09988c53e`. +- On 2026-10-24, the tracked scalar-store retain now uses the same slot API, + installing the new scalar owner before releasing the displaced referent. A + self-assignment regression checks that the count and legacy owner registry + remain at one. Full `nice -n 19 make` passes in 16m12s on `2a2b9fe3b`. The + Catalyst tempfile, controller-owner, and WebSocket controller-release files + pass on both backends; WebSocket ran with loopback access. +- On 2026-10-24, scalar overwrite now releases the displaced direct owner + through its `SCALAR_VALUE` slot, then reuses the stable slot for the new + referent. Lifecycle dispatch remains after the assignment's existing rescue + and reachability checks. A Java regression retires the legacy Boolean before + overwrite and verifies old/new counts, registries, and slot identity. Full + `nice -n 19 make` passes in 20m05s; Catalyst tempfile, controller-owner, and + WebSocket controller-release regressions pass on both backends. +- The preceding array-tail fix routes `removeFirst()` and `removeLast()` through + structural mutation hooks so `shift` and `pop` retire array slots. Its + immutable code commit `ecc5efd37` passes full `nice -n 19 make` in 13m34s, + and the Perl-level regression passes on system Perl and both backends. +- Followed live `SCALAR_VALUE` and `SCALAR_REFERENCE_CONTENTS` slots from an + aggregate toward their containing aggregate or lexical root. Deferred + aggregate-owner recovery now avoids whole-root walks through these scalar + owner paths. Java regressions prove exact count restoration with zero root + queries and graph snapshots. Full `nice -n 19 make` passes in 21m54s; the + root-chain test class passes 3/3. Hosted CI for head `27d6df476` passes on + Linux and Windows, including the platform thread gates. +- Rebased PR #1816 onto master `5a6cf5ed6` before this batch's one full build. + This keeps the validation aligned with current master and avoids rebuilding + an obsolete candidate. +- On 2026-10-23, allowed scalar owner-slot identity to supply rooted array/hash + recovery when the legacy ownership flag is absent, while retaining the flag + for cells with no explicit slot. The Java regression covers array and hash + owners, restoration, and release after removing their semantic slots. Full + `nice -n 19 make` passes in 21m46s. The first hosted run had one failure in + `dbic_deep_cycle_weak_shift_regression.t` (assertion 10) in shard 3. The + regression passes in the filtered DBI/DBIC make and in shard 3's full normal + order (14m34s); hosted rerun `39861290618` passes on Linux and Windows, + including the platform thread gates. +- On 2026-10-23, direct scalar-owner snapshots began trusting the active + `SCALAR_VALUE` slot when the legacy Boolean has retired. The Java regression + checks the legacy registry and slot union, then verifies that releasing the + slot removes the counted owner. Full `nice -n 19 make` passes in 22m49s; + hosted run `39880315002` passes on Linux and Windows, including both thread + compatibility gates. +- The next cleanup tranche lets deferred scalar release, captured release, and + array `pop`/`shift` recognize the explicit scalar slot after the legacy flag + retires. Java regressions verify slot retirement and the deferred legacy + bridge decrement for each cleanup path. Full `nice -n 19 make` passes in + 22m25s on `759302bb7`; hosted run `39895645097` passes on Linux and Windows, + including both thread compatibility gates. +- The following tranche extends the same scalar-slot predicate through + recursive container clear, tail-call argument release, and void-result + cleanup paths. Java regressions cover recursive and tail-call release; + full `nice -n 19 make` passes in 33m11s on `5578d44bd`; hosted validation is + pending. +- Lexical scope exit now recognizes an active scalar-value owner slot after + `refCountOwned` retires. The focused Java regression passes as part of full + `nice -n 19 make` (23m57s); documentation links pass. A follow-up inspection + found the same retired-flag gap in explicit `undef`, including named CODE + slots. That path now checks the scalar slot before clearing the cell, with a + Java regression; full `nice -n 19 make` passes in 15m40s. The next bundled + tranche extends slot authority through scalar overwrite, container-copy + accounting, scalar-reference contents, closure-capture acquisition, and + deferred captured cleanup. Java regressions cover these boundaries. Its + full validation is pending. This branch began at `origin/master` commit + `5a6cf5ed6`; `origin/master` advanced to `aeda28344`, including the Catalyst + upload cleanup for #1795. The master changes were merged without rewriting + the published ownership commits. The overlapping reachability changes were + combined so statement-boundary candidates and active owner-slot roots both + participate in capture pruning; the broader multipart and request-context + regression remains in place. Full `nice -n 19 make` passes on the merged + candidate in 20m47s, including the Catalyst lifetime test (1 test, 0 failures) + and all owner-slot regressions. `make check-links` passes; hosted validation + is pending. +- Follow-up batches now use explicit scalar slots through weak release, + reachability, stash deletion, graph cloning, DESTROY argument balancing, + blessed glob retention, list-copy cleanup, localized globals, Storable + arguments, bytecode temporaries, dynamic state save/restore, and GLOB scope + exit. Focused Java regressions cover retired-flag cases. The full local Make + gate passes on candidate `9c93d5922` in 19m53s; hosted validation for that + head is pending. The slot ledger remains a bridge to selective `refCount`, + so Phase 2 is not complete. +- On 2026-10-25, the File::Temp owner-container root query completed below its + node and edge limits and returned false. The recovery trace still restored + two scalar owners from reachable scalar cells, while the corresponding + aggregate containers had no rooted owner path. A temporary exact-class scan + bypass then passed the dedicated #1794 controller test (3/3), the controller + finish and WebSocket regressions (5/5, 6/6, 29/29, and 6/6), the #1795 + Catalyst tempfile and global-destruction tests (24/24 and 3/3), cross- + container alias ownership (22/22), and basic DESTROY timing (18/18). These + focused tests did not expose a missing owner acquisition. The bypass was + removed after the audit; the default scan remains because its two production + callers and synthetic undercount-recovery Java tests still require it. The + `nice -n 19 make` gate passes on this candidate in 18m06s. The capped-root + trace remains available behind `PJ_OWNER_RECOVERY_TRACE_CLASS`; broader + Phase 2 owner-slot migration is still open. + +The earlier candidate was based on `origin/master` (`ede542b29`) and passed the full local make in 22m55s, Ubuntu `make ci`, the Ubuntu Perl thread compatibility gate, SBOM generation, Windows `make ci` in about 52 minutes, and the focused Windows thread gate. Windows CI exposed two existing unit tests with POSIX `/tmp` paths; `make ci` now creates the runner's root-relative `tmp` directory before building. On blead Perl 5.45.4, the controller, WebSocket, Catalyst tempfile, owner-release, and writable `Internals::SvREFCNT` regressions pass. The controller regression passes 5/5 on JVM and interpreter; the WebSocket regression passes 6/6 on both; the Catalyst tempfile regression passes 24/24 on both; `refcount/issue_1794_controller_owner_release.t` passes 3/3 on both; and `internals_svrefcnt_write.t` passes 6/6 on both after failing all six assertions on its unfixed parent. The alias-count follow-up rebases onto `origin/master` (`18323a1ac`). Its focused make passes in 50s and its full local make passes in 31m30s on code commit `15e362ddb`. ### Completed +- [x] On 2026-10-22, synchronized the per-scalar reverse index of destroyable + closure owners. Ithread cloning and parent-side temporary closure release + could concurrently mutate its `ArrayList`, producing an intermittent + `ConcurrentModificationException` in `threads-shared/t/object.t`. A focused + Java regression races reverse-index releases. The external test passed 28/28 + when rerun alone. The bundled Java regression passes in full `nice -n 19 + make` (15m59s); `make test-threads` passes JVM virtual 1925/1925, interpreter + virtual 1925/1925, and platform-focused 485/485. This batch is pending + hosted CI. + +- [x] On 2026-10-22, synchronized mutation and snapshot operations on the + legacy direct-scalar-owner registry and changed `reachableOwnerCount()` to + snapshot before graph walks. The new Java concurrency regression passes in + the full `nice -n 19 make` (22m39s). The same candidate passes `make + test-threads` in all three configurations: JVM virtual 1925/1925, + interpreter virtual 1925/1925, and platform-focused 485/485. The first local + thread-gate attempt lacked write access to the external Perl checkout; the + passing rerun used extended access. The published PR head's CI rerun also + passes on both platforms; this local batch still needs hosted validation. + +- [x] On 2026-10-22, used the scalar-owner identity union in global + destruction owner-delta checks and `MortalList` deferred-count restoration. + The new Java regression proves that a package-rooted scalar slot still + counts when its legacy registry entry is missing. Full `nice -n 19 make` + passes in 56m43s; the global-destruction, tempfile, and controller-owner + regressions pass 36/36 on both JVM and interpreter backends. + +- [x] On 2026-10-22, unified direct scalar-owner queries across explicit + `SCALAR_VALUE` slots and the legacy active-owner registry, deduplicating by + scalar-cell identity. Cleanup guards, weak CODE checks, and localized CODE + cleanup now see owners that predate legacy tracking. The two focused Java + tests pass in the full `nice -n 19 make`; `make test-threads` passes JVM + virtual 1925/1925, interpreter virtual 1925/1925, and platform-focused + 485/485. Eight focused lifetime regressions pass 79/79 on JVM and 73/73 on + the interpreter. + +- [x] On 2026-10-22, restore undercounted referents from a reachable standalone + `SCALAR_VALUE` slot even when the scalar predates activation of the legacy + `activeOwners` registry. The Java regression proves count restoration from + the slot with no active-owner entry. Full `nice -n 19 make` passes in 19m26s. + The lifecycle bridge still dispatches through `refCount`. + +- [x] On 2026-10-22, made the targeted weak-release direct-owner proof consult + explicit `SCALAR_VALUE` slots before the lazily populated scalar registry. + A new Java regression creates the strong scalar owner before the first + `weaken()` and verifies the slot preserves its referent without a root + snapshot. Full `nice -n 19 make` passes in 18m12s. Local + `nice -n 19 timeout 1800 make test-threads` passes all three configurations: + JVM virtual 1925/1925, interpreter virtual 1925/1925, and platform-focused + 485/485. Hosted CI for the preceding commit had one `free2.t` failure; the + follow-up hosted run is pending. + +- [x] On 2026-10-22, added stable `SCALAR_VALUE` owner slots for direct scalar + referents and synchronized them through assignment, postfix ownership + transfer, dynamic localization, and release. Reachable-owner recovery and + targeted weak sweeps recognize the new slot while preserving legacy + refcount dispatch and aggregate root-query fast paths. Two focused Java tests + cover stable slot transfer and localized-value restoration. Full + `nice -n 19 make` passes in 20m56s; the currently published PR head's Linux, + Windows, and thread compatibility checks pass. The default slot migration + remains incomplete. + +- [x] On 2026-10-21, corrected the array pop/shift guard after the rebased + aggregate-alias change exposed false retention in HTML::Tree cleanup. Only + Perl array lvalue aliases now suppress the removed slot's deferred + decrement; internal work-array copies still release it. The existing + project-owned HTML::Tree reproducers pass on system Perl, JVM, and + interpreter; the full local `make` passes in 21m34s, and HTML::Tree 5.07 + passes all 23 files and 989 tests on both backends. + +- [x] On 2026-10-20, rebased the #1794/#1795 candidate onto current master, + fixed the remaining interpreter closure-capture and cleanup behavior, and + validated the controller (5/5), WebSocket (6/6), Catalyst tempfile (24/24), + and owner-release (3/3) regressions on both backends. The full `make` gate + passes with the new two-worker and 3 GiB worker settings. Standard Perl + 5.45.4 passes the Perl-level regressions. Published the rebased candidate to + PR #1816 as a draft; its Linux and Windows CI jobs are pending. +- [x] On 2026-10-20, added writable `Internals::SvREFCNT` behavior with a + separate B::SV query path, plus a six-assertion Perl regression that passes + on blead Perl 5.45.4 and both backends and fails on the unfixed parent. The + complete local `make` gate passes on `dd368a80a`. Windows CI found two + existing unit scripts that require `/tmp`; the PR prepares the ephemeral + Windows runner path without changing those tests. This initial hosted run + exposed the workflow's step-order contract and a Windows time-budget issue; + both are resolved in the 2026-10-21 entry below. +- [x] On 2026-10-21, kept the CI workflow's fixed step contract, moved the + Windows root-relative temp-directory setup into `make ci`, and configured + four Gradle workers only on Windows after the two-worker gate exceeded its + 75-minute budget. The full local `make` passed in 22m55s. Ubuntu and Windows + make gates, the Ubuntu thread compatibility gate, the Windows focused thread + gate, and Ubuntu SBOM generation all passed on PR head `8a6c5a365`. +- [x] On 2026-10-21, followed typed hash/array owner slots through nested + aggregate roots during deferred-release recovery and `DESTROY` resurrection + checks. Two Java regressions assert the ownership result with zero root + queries and snapshots. The full local `nice -n 19 make` passes on + `ea7b77164` in 21m11s; hosted CI for this head is queued. +- [x] On 2026-10-21, added an explicit scalar-reference contents owner slot + that follows a referenced scalar cell when it is overwritten. The Perl + regression verifies replacement and final `DESTROY`/weak clearing; the Java + test covers slot accounting. Testing exposed and fixed a duplicate legacy + direct-store count on the same cell. The focused refcount gate and the full + local `nice -n 19 make` pass on `f0f10ed0e` (27m56s); hosted Ubuntu and + Windows CI are running. +- [x] Re-ran the unchanged HTML::Tree 5.07 suite on code head `f0f10ed0e`: + all 23 files and 989 tests pass, including `t/refloop.t`. This confirms the + ownership fix preserves the existing integration behavior; the suite still + exercises legacy refcount/reachability paths and does not complete the + planned default owner-slot migration. +- [x] On 2026-10-21, ran HTML::Tree 5.07 under the interpreter backend on code + head `f0f10ed0e`; all 23 files and 989 tests pass, including `t/refloop.t`. + PR #1816 remains open as a draft at `4eebdf5c4`, with hosted Ubuntu and + Windows checks still in progress. +- [x] On 2026-10-21, added a 12-assertion scalar-reference alias test. B::SV + reports one contents owner for two references to the same scalar cell on + system Perl 5.34.1 and blead Perl 5.45.4. The pre-fix parent reports two on + both backends; the rebased fix passes all 12 assertions on JVM and + interpreter. The owner-slot Java tests cover alias acquisition, idempotent + acquisition, retargeting, reachable-owner deduplication, and deferred and + final release. Full `nice -n 19 make` passes in 31m30s on code commit + `15e362ddb`; unchanged HTML::Tree 5.07 passes all 23 files and 989 tests on + both backends, including `t/refloop.t`. +- [x] Revalidated `ordinary_aggregate_element_owner_refcount.t`: all 11 + standard-Perl count/destruction assertions pass on blead Perl 5.45.4, JVM, + and interpreter. This covers scalar ownership through array/hash insertion, + push, shift, delete, replacement, aggregate clear, and final release; Phase 2 + still requires replacing the legacy counters with default owner-slot paths. +- [x] On 2026-10-21, kept a referent alive when `pop`, `shift`, or hash deletion + removes one owning aggregate slot while another array/hash slot still aliases + the same scalar cell. Its 22-assertion Perl regression covers those removals + and array/hash clear with a second owning aggregate. It passes on system Perl + 5.34.1, blead Perl 5.45.4, JVM, and interpreter; the failing pop/delete cases + were reproduced on the unfixed parent on both backends. After rebasing onto + `162d8eb84`, full `nice -n 19 make` passes in 31m15s on code commit + `69d30792f`. Legacy + `refCount` dispatch remains; the next work is default owner-slot accounting + across ordinary aggregate stores and cleanup. +- [x] On 2026-10-22, fixed interpreter WebSocket callback lifetime after the + tracked 29-assertion regression showed that an active callback's controller + was destroyed early. External-root snapshots now follow persistent scalar, + array, and hash state on installed CVs and continue through nested callback + code to its captured values. The new Java regression covers that full owner + chain; blead Perl 5.45.4 and both PerlOnJava backends pass the WebSocket test. + Full `nice -n 19 make` passes in 19m15s, and HTML::Tree 5.07 passes all 989 + tests on both backends on code commit `05ca274a6`. - [x] Re-read the parent issue's implementation contract and reviewed the independent lifecycle candidate handoff. Historical candidate test results are not treated as current Phase 1 evidence. @@ -298,16 +1017,20 @@ and first path establish implementation cost. ### Next steps -1. Extend the ownership path through scalar proxies and ordinary array/hash - slots in Phase 2, then resolve the HTML::Tree teardown failures and verify - unchanged `t/refloop.t`. -2. Resolve the strict target failures in the fresh-home CPAN release +1. Keep PR #1816 open/draft while continuing the Phase 2 owner-accounting work. +2. Continue Phase 2 from reachability recovery to default owner-slot + accounting for ordinary array/hash element stores and scalar proxies. Cover + acquisition, transfer, replacement, deletion, aliases, and final release + with oracle-checked refcount, weak-reference, and DESTROY assertions. Keep + legacy dispatch and reachability bridges only at explicit unmigrated + boundaries; use unchanged HTML-Tree as the integration guard. +3. Resolve the strict target failures in the fresh-home CPAN release acceptance, then rerun that gate on the final candidate. The current run distinguishes prerequisite/setup timeouts and missing dependencies from runtime assertions; those outcomes do not yet satisfy #1642's acceptance. -3. Rerun Moo and DBIx::Class after their separate tracked fixes land; classify +4. Rerun Moo and DBIx::Class after their separate tracked fixes land; classify the two DBIx::Class assertions independently. -4. Run each repository gate from an immutable commit and record before/after +5. Run each repository gate from an immutable commit and record before/after evidence for each enabled path. ### Open questions and blockers @@ -339,10 +1062,15 @@ and first path establish implementation cost. `cpan/Scalar-List-Utils/lib` added to `@INC`; the checkout remains untouched. The prior Perl 5.44.0 build remains the oracle for historical HTML-Tree integration results. -- The focused `new_from_lol` regression now passes, but HTML-Tree 5.07's - content and incremental parser cases still retain two and three elements at - the immediate release assertion on both PerlOnJava backends; Perl 5.44 passes - all cases. The new parser lifecycle reproducer records this open failure. +- On commit `4cad02e3b`, the focused `new_from_lol` and content/incremental + parser lifecycle reproducers pass on system Perl, JVM, and interpreter. The + upstream `timeout 1800 ./jcpan -t HTML::Tree` run passes all 23 files and 989 + tests, including `t/refloop.t`; standalone system Perl 5.42.2 passes the + unchanged `t/refloop.t` 8/8. This closes the observed HTML-Tree behavior + failure on this candidate, but does not complete Phase 2: owner slots still + cover closure pads only, and cleanup still relies on selective counts and + reachability walks. Revalidate the upstream suite after the scalar/proxy and + array/hash ownership path is migrated. - The Dancer2-Plugin-DBIx-Class `t/01_both_using_rsnames.t` workload now passes all six assertions and exits on both backends. The interpreter first exposed a `caller->isa` scalar-context bug, fixed in this follow-up, then exceeded a diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 562c41ce4f..66ac929dcc 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -13,7 +13,9 @@ priorities and future plans. - Apply source filters installed through `Test::More::use_ok` to the caller's remaining source and preserve `print FILEHANDLE (LIST) x N` output (#1375, #1441). - Cover `Test::Needs` prerequisite scanning of quoted module names and Perl version literals (#1396). - Preserve reference-valued FETCH results from tied-hash `values()` and retain scalar-reference envelopes in Storable round trips (#1554, #1468). +- Preserve tied-handler lifetimes when wrappers are created before selective reference counting begins. - Release temporary files once an exited closure capture retaining a Catalyst request context no longer has a live Perl root. +- Release tracked reference owners after array deletion while preserving returned-value lifetimes and borrowed aliases, including localized `@_` slots. - Ignore commented-out code when checking lexical constant closures for later mutations, preserving BEGIN-time generated constants (#1785). - Pack the method-dispatch inline-cache guard key into one primitive value while preserving class and method invalidation checks. - Keep watcher maps and live `substr`-lvalue observers in lazy scalar extension state, reducing the common `RuntimeScalar` footprint. @@ -21,6 +23,13 @@ priorities and future plans. - Avoid retaining transient arrays and hashes through shared immutable scalar literals while preserving reference ownership tracking. - Run standard Perl's `cpan -t Module` for CPAN oracle checks and keep download failures inconclusive instead of reporting missing local modules as Perl failures. - Keep lexical-registration membership checks constant-time, avoiding quadratic CPAN index parsing as cleanup stacks grow. +- Keep scalar-slot owners authoritative across stores, container copies, scalar references, closure captures, lexical scope exit, overwrites, and explicit `undef` after legacy tracking retires; preserve aggregate scalar-cell values through strong refaliases after clear or delete (#1649). +- Include distinct reachable scalar-reference contents owners when aggregate recovery reconstructs a mixed owner count (#1649). +- Preserve package-rooted aggregate scalar owners when discarding a void-context subroutine result (#1649). +- Release weakly linked aggregate trees when their last lexical root is overwritten with `undef` (#1649). +- Transfer array and hash list-assignment tails through normal owner-slot and displaced-value cleanup paths, including `pop`, `shift`, and `delete` release (#1649). +- Synchronize the destroyable-closure reverse index so ithread capture cloning and release can update it safely. +- Follow scalar owner slots during aggregate lifetime recovery, avoiding a whole-root walk for nested scalar-owned containers. - Preserve numeric types of cached read-only scalars in ranges, rejecting unsigned endpoints outside the supported integer range. - Improve JVM execution performance for the bit-packed Life example, ordinary scalar assignments, and common blessing lookups. - Reuse private one-scalar wrappers when materializing scalar returns, reducing result-list allocations across JVM calls. @@ -43,6 +52,9 @@ priorities and future plans. - Preserve inherited method dispatch in child threads. - Prefer exact root-module package declarations over earlier helper packages, and warn for undefined hash keys used by `exists` and `delete` (#1820, #1645). - Add a reproducible, warmup-aware Perl versus PerlOnJava performance runner (#1196). +- Preserve CODE callback captures while Perl container slots still own the callback; restore rooted hash-entry and package-rooted, registered lexical, or closure-captured array-element owners when a legacy deferred decrement undercounts them; register array-element owners after lvalue-proxy stores, scalar and aggregate refaliasing, and scalar-reference creation into new arrays; count scalar-reference contents once when multiple references alias the same scalar cell; release closure-captured request graphs once their scopes end, even when large root walks exceed the cached-snapshot limit; avoid false global-destruction resurrection warnings when bounded owner proofs scan large package graphs; keep stat's filehandle cache from extending temporary glob lifetimes (#1649, #1794, #1795). +- Serialize legacy scalar-owner registry updates and snapshots so concurrent thread cleanup cannot invalidate owner-ledger iteration (#1649). +- Implement Perl's writable `Internals::SvREFCNT` form while keeping B::SV's private query mode separate (#1649). - Cover FormState session restoration across requests and HTTP response conversion from callback and streaming PSGI responses; refresh HTTP::Message and LWP compatibility status (#1650, #1651, #1652). - Preserve the awaited Future value when its source scalar or aggregate slot changes during suspension, covering PAGI::Server request-body receives (#1818). - Preserve escaped byte strings in interpreter closures and `bytes::substr`, honor requested CRLF output in `MIME::QuotedPrint`, and cover URI Basic Auth byte-string userinfo (#1275, #1313, #1341). diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java index e64b5a131b..73ad46d200 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java @@ -1635,6 +1635,13 @@ public InterpretedCode compile(Node node, EmitterContext ctx) { evalSitePragmaFlags.isEmpty() ? null : evalSitePragmaFlags, warningBitsString ); + if (capturedVars != null && capturedVars.length > 0) { + BitSet capturedRegisters = new BitSet(); + for (int index = 0; index < capturedVars.length; index++) { + capturedRegisters.set(3 + index); + } + code.setCapturedVarRegisters(capturedRegisters); + } if (metadataScope != null) { code.setLexicalDisabledWarningCategories( metadataScope.getDisabledWarningCategories()); diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java index eb98acc7c9..8463727a03 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java @@ -132,20 +132,26 @@ private static boolean runtimeBaseIsTrackedReference(RuntimeBase elem) { return elem.blessId != 0 || elem.refCount >= 0; } + private static boolean capturedLexicalOwnerStillRegistered( + InterpretedCode code, int register, RuntimeBase slot, int frameCleanupMark) { + return slot != null && code.isCapturedVarRegister(register) + && MyVarCleanupStack.isRegisteredBefore(frameCleanupMark, slot); + } + private static void releaseConsumedTemp(RuntimeBase temp, RuntimeBase target) { if (!(temp instanceof RuntimeScalar tempScalar) || !(target instanceof RuntimeScalar targetScalar) || tempScalar == targetScalar - || !tempScalar.refCountOwned || !RuntimeScalarType.isReference(tempScalar) || tempScalar.type != targetScalar.type || tempScalar.value != targetScalar.value || !(tempScalar.value instanceof RuntimeBase base) + || !tempScalar.ownsCountedReferent(base) || base.refCount <= 0) { return; } - tempScalar.refCountOwned = false; + tempScalar.setRefCountOwned(false); if (base.refCountTrace) { base.traceRefCount(-1, "BytecodeInterpreter.releaseConsumedTemp"); base.releaseOwner(tempScalar, "releaseConsumedTemp"); @@ -167,6 +173,11 @@ private static void collectReturnedClosures(RuntimeBase value, java.util.Identit if (value instanceof RuntimeScalar scalar) { if (scalar.type == RuntimeScalarType.CODE && scalar.value instanceof RuntimeCode code) { closures.put(code, Boolean.TRUE); + } else if (scalar.type == RuntimeScalarType.REGEX + && scalar.value instanceof RuntimeRegex regex) { + for (RuntimeCode callback : regex.executableCallbackCodes()) { + closures.put(callback, Boolean.TRUE); + } } return; } @@ -190,7 +201,7 @@ private static void collectReturnedClosures(RuntimeBase value, java.util.Identit * @return RuntimeList containing the result (may be RuntimeControlFlowList) */ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int callContext) { - return execute(code, args, callContext, null); + return execute(code, args, callContext, null, MyVarCleanupStack.currentSize()); } /** @@ -203,9 +214,14 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c * @return RuntimeList containing the result (may be RuntimeControlFlowList) */ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int callContext, String subroutineName) { + return execute(code, args, callContext, subroutineName, MyVarCleanupStack.currentSize()); + } + + public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int callContext, + String subroutineName, int frameCleanupMark) { RuntimeBase[] registers = code.getRegisters(); SuspendedInterpreterFrame frame = new SuspendedInterpreterFrame( - code, registers, callContext, subroutineName); + code, registers, callContext, subroutineName, frameCleanupMark); // Initialize special registers (same as compiler). registers[0] = code; @@ -228,19 +244,23 @@ static void abandon(SuspendedInterpreterFrame frame) { frame.suspended = false; frame.suspendedRuntimeDisabledWarningCategories = null; - for (RuntimeCode closure : frame.createdClosures) { + InterpretedCode code = frame.code; + RuntimeBase[] registers = frame.registers; + int firstMyVarReg = 3 + (code.capturedVars != null ? code.capturedVars.length : 0); + java.util.List abandonedClosures = + new java.util.ArrayList<>(frame.createdClosures); + frame.createdClosures.clear(); + for (RuntimeCode closure : abandonedClosures) { + releaseCurrentFrameClosureSlots(registers, firstMyVarReg, closure); if ((closure.capturedScalars != null || closure.capturedAggregates != null) - && closure.refCount == 0 && closure.stashRefCount <= 0 + && !ReachabilityWalker.isReachableFromExternalRoot(closure) && (frame.returnedClosures == null || !frame.returnedClosures.contains(closure))) { closure.releaseCaptures(); } } - InterpretedCode code = frame.code; - RuntimeBase[] registers = frame.registers; - int firstMyVarReg = 3 + (code.capturedVars != null ? code.capturedVars.length : 0); BitSet myVars = code.myVarRegisters; boolean needsFlush = false; for (int i = myVars.nextSetBit(firstMyVarReg); @@ -261,12 +281,47 @@ static void abandon(SuspendedInterpreterFrame frame) { MyVarCleanupStack.unregister(reg); registers[i] = null; } + if (releaseTemporaryScalarReferenceContents(frame)) { + needsFlush = true; + } if (needsFlush) { MortalList.flush(); } code.releaseRegisters(); } + /** + * Release nested referent ownership held by interpreter-created scalar + * temporaries. These cells are intentionally not sent through full lexical + * cleanup: a temporary register can alias an lvalue or aggregate element. + * The nested owner is distinct and can be deferred safely so a returned + * scalar reference is retained by its caller before the decrement drains. + */ + private static boolean releaseTemporaryScalarReferenceContents( + SuspendedInterpreterFrame frame) { + boolean released = false; + for (RuntimeScalar scalar : frame.temporaryScalarOwners) { + if (!scalar.hasScalarReferenceContentsOwner()) continue; + scalar.deferOwnedScalarReferenceContents(); + released = true; + } + frame.temporaryScalarOwners.clear(); + return released; + } + + private static void trackTemporaryScalarReferenceContents( + SuspendedInterpreterFrame frame, InterpretedCode code, + int register, int firstMyVarReg, RuntimeScalar scalar) { + if (scalar == null || !scalar.hasScalarReferenceContentsOwner()) return; + boolean eligible = register >= firstMyVarReg + && !code.myVarRegisters.get(register) + && !scalar.isPackageGlobalRoot + && scalar.getContainerOwner() == null + && !(scalar instanceof RuntimeBaseProxy) + && !MyVarCleanupStack.isRegistered(scalar); + if (eligible) frame.temporaryScalarOwners.add(scalar); + } + /** * Result of a call whose non-local last/next/redo was consumed by an enclosing * labeled block. Control has left the block, so the marker must not stay in the @@ -358,6 +413,7 @@ private static int dispatchListOperatorControlFlow( private static RuntimeList execute(SuspendedInterpreterFrame frame) { InterpretedCode code = frame.code; int callContext = frame.callContext; + int frameCleanupMark = frame.frameCleanupMark; // Track interpreter state for stack traces String framePackageName = code.packageName != null ? code.packageName : "main"; if (code.isQuotedRegexCallback) { @@ -606,7 +662,9 @@ private static RuntimeList execute(SuspendedInterpreterFrame frame) { int reg = bytecode[pc++]; int returnReg = bytecode[pc++]; RuntimeBase slot = registers[reg]; - if (slot instanceof RuntimeScalar rs) { + if (slot instanceof RuntimeScalar rs + && !capturedLexicalOwnerStillRegistered( + code, reg, slot, frameCleanupMark)) { RuntimeScalar.releaseOrTransferSocketIoOwnerOnReturn( rs, registers[returnReg]); MortalList.deferDecrementIfNotCaptured(rs); @@ -699,7 +757,9 @@ private static RuntimeList execute(SuspendedInterpreterFrame frame) { case Opcodes.RETURN_SCOPE_CLEANUP_HASH -> { int reg = bytecode[pc++]; RuntimeBase slot = registers[reg]; - if (slot instanceof RuntimeHash rh) { + if (slot instanceof RuntimeHash rh + && !capturedLexicalOwnerStillRegistered( + code, reg, slot, frameCleanupMark)) { MortalList.scopeExitCleanupHash(rh); } } @@ -745,7 +805,9 @@ private static RuntimeList execute(SuspendedInterpreterFrame frame) { int reg = bytecode[pc++]; int returnReg = bytecode[pc++]; RuntimeBase slot = registers[reg]; - if (slot instanceof RuntimeArray ra) { + if (slot instanceof RuntimeArray ra + && !capturedLexicalOwnerStillRegistered( + code, reg, slot, frameCleanupMark)) { MortalList.scopeExitCleanupArray(ra, registers[returnReg]); } } @@ -1474,6 +1536,8 @@ private static RuntimeList execute(SuspendedInterpreterFrame frame) { } } RuntimeScalar.assignTo(registers[rs], rdScalar); + trackTemporaryScalarReferenceContents( + frame, code, rd, firstMyVarReg, rdScalar); } case Opcodes.COPY_DO_BLOCK_RESULT -> { @@ -3903,19 +3967,19 @@ private static RuntimeList execute(SuspendedInterpreterFrame frame) { releaseMethodInvocantHoldsAbove(methodInvocantHolds, 0); } - // Release captures for interpreter closures created in this frame - // that were never stored via set() (refCount stayed at 0). - // This handles eval STRING map/grep block closures that over-capture - // all visible variables but are temporary and should release captures. - // Closures stored via set() have refCount > 0 and are skipped. - // This matches the JVM-compiled path where scopeExitCleanup releases - // captures for CODE refs with refCount=0 (see RuntimeScalar.java - // scopeExitCleanup special case for CODE refs). + // Release captures for closures created here that were not stored + // outside the frame. Clean their lexical slots first so the active + // frame does not make a temporary closure appear externally rooted. if (!frame.suspended && !createdClosures.isEmpty()) { - for (RuntimeCode closure : createdClosures) { + java.util.List frameClosures = + new java.util.ArrayList<>(createdClosures); + createdClosures.clear(); + for (RuntimeCode closure : frameClosures) { + releaseCurrentFrameClosureSlots( + registers, firstMyVarReg, closure); if ((closure.capturedScalars != null || closure.capturedAggregates != null) - && closure.refCount == 0 && closure.stashRefCount <= 0 + && !ReachabilityWalker.isReachableFromExternalRoot(closure) && (frame.returnedClosures == null || !frame.returnedClosures.contains(closure))) { closure.releaseCaptures(); } @@ -3956,6 +4020,10 @@ private static RuntimeList execute(SuspendedInterpreterFrame frame) { } } + if (!frame.suspended) { + releaseTemporaryScalarReferenceContents(frame); + } + // Outer finally: restore interpreter state saved at method entry. // Unwinds all `local` variables pushed during this frame, restores // the current package, and pops the InterpreterState call stack. @@ -3989,6 +4057,28 @@ private static RuntimeList execute(SuspendedInterpreterFrame frame) { } } + /** Release lexical slots holding a temporary closure before its root check. */ + private static void releaseCurrentFrameClosureSlots( + RuntimeBase[] registers, int firstMyVarReg, RuntimeCode closure) { + for (int i = firstMyVarReg; i < registers.length; i++) { + RuntimeBase register = registers[i]; + if (!(register instanceof RuntimeScalar scalar) + || scalar.value != closure + || !MyVarCleanupStack.isRegistered(scalar)) continue; + if (scalar.ownsCountedReferent(closure)) { + RuntimeScalar.scopeExitCleanup(scalar); + } else { + // Non-owning CODE wrappers can still be the only local root + // seen by the conservative reachability walk. Mark the slot + // exited without releasing captures before the external-root + // check (for example, a RuntimeRegex may still own the CV). + scalar.scopeExited = true; + } + MyVarCleanupStack.unregister(scalar); + registers[i] = null; + } + } + private static void releaseMethodInvocantHoldsAbove( ArrayList methodInvocantHolds, int depth) { boolean released = false; diff --git a/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java b/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java index fcb6814c5e..3babec04a2 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java +++ b/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java @@ -55,6 +55,7 @@ public ScalarPin scalarPin(int nameIdx) { public final int maxRegisters; // Number of registers needed public final RuntimeBase[] capturedVars; // Closure support (captured from outer scope) public final Map variableRegistry; // Variable name → register index (for eval STRING) + private BitSet capturedVarRegisters = new BitSet(); public final List> evalSiteRegistries; // Per-eval-site variable registries public final List> evalSiteLexicalSubroutineBindings; public final List evalSitePragmaFlags; // [strict, features, evalbytes, warningBitsPoolIndex, regexDebug, enhancedXx] @@ -451,7 +452,7 @@ public RuntimeList apply(RuntimeArray args, int callContext) { // Preserve the declared sub name for interpreter stack traces while // retaining async initial-result wrapping from master. RuntimeList result = BytecodeInterpreter.execute( - this, args, effectiveContext, this.subName); + this, args, effectiveContext, this.subName, cleanupMark); result = RuntimeCode.handleEscapingLoopControl( result, generatedClassConstructor, classAdjustBlock); if (isSortComparator && result instanceof RuntimeControlFlowList flow) { @@ -540,7 +541,7 @@ public RuntimeList apply(String subroutineName, RuntimeArray args, int callConte try { validateNamedSignatureArguments(args); RuntimeList result = BytecodeInterpreter.execute( - this, args, effectiveContext, subroutineName); + this, args, effectiveContext, subroutineName, cleanupMark); if (futureAsyncAwaitSub) { return FutureAsyncAwaitRuntime.wrapInitialResult( effectiveContext, callContext, result, futureAsyncAwaitFutureClass); @@ -617,6 +618,12 @@ public InterpretedCode withCapturedVars(RuntimeBase[] capturedVars) { true, this.deparseSourceText ); + copy.capturedVarRegisters = (BitSet) this.capturedVarRegisters.clone(); + if (capturedVars != null) { + for (int index = 0; index < capturedVars.length; index++) { + copy.capturedVarRegisters.set(3 + index); + } + } copy.prototype = this.prototype; copy.isConstantCv = this.isConstantCv; copy.isLexicalConstantCv = this.isLexicalConstantCv; @@ -683,6 +690,14 @@ public InterpretedCode withCapturedVars(RuntimeBase[] capturedVars) { return copy; } + void setCapturedVarRegisters(BitSet registers) { + capturedVarRegisters = registers == null ? new BitSet() : (BitSet) registers.clone(); + } + + boolean isCapturedVarRegister(int register) { + return capturedVarRegisters.get(register); + } + private void validateAsyncSignature(RuntimeArray args) { if (!futureAsyncAwaitSub || signatureMinArgs < 0) { return; diff --git a/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java b/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java index b8249d1ef9..87f048c16c 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java +++ b/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java @@ -1107,11 +1107,11 @@ public static int executeCreateClosure(int[] bytecode, int pc, RuntimeBase[] reg if (captured instanceof RuntimeScalar s) { if (!seenScalars.add(s)) continue; capturedScalars.add(s); - s.retainClosureCapture(); + s.retainClosureCapture(closureCode); } else if (captured instanceof RuntimeArray || captured instanceof RuntimeHash) { if (!seenAggregates.add(captured)) continue; capturedAggregates.add(captured); - PerlOwnerSlot ownerSlot = captured.retainClosureCaptureOwner(); + PerlOwnerSlot ownerSlot = captured.retainClosureCaptureOwner(closureCode); capturedAggregateOwnerSlots.add(ownerSlot); } } diff --git a/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java b/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java index 4df3e1b7af..276ee91d55 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java @@ -1349,13 +1349,13 @@ public static int executeHashSliceSet( /** 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) { + if (value != null && RuntimeScalarType.isReference(value) + && value.value instanceof RuntimeBase base + && value.ownsCountedReferent(base) && base.refCount > 0) { base.releaseOwner(value, "HASH_SLICE_SET temporary owner transfer"); base.releaseActiveOwner(value); base.refCount--; - value.refCountOwned = false; + value.setRefCountOwned(false); } } diff --git a/src/main/java/org/perlonjava/backend/bytecode/SuspendedInterpreterFrame.java b/src/main/java/org/perlonjava/backend/bytecode/SuspendedInterpreterFrame.java index d9f60fa25b..9db5b9aef4 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/SuspendedInterpreterFrame.java +++ b/src/main/java/org/perlonjava/backend/bytecode/SuspendedInterpreterFrame.java @@ -3,10 +3,13 @@ import org.perlonjava.runtime.runtimetypes.RegexState; import org.perlonjava.runtime.runtimetypes.RuntimeBase; import org.perlonjava.runtime.runtimetypes.RuntimeCode; +import org.perlonjava.runtime.runtimetypes.RuntimeScalar; import org.perlonjava.runtime.runtimetypes.DynamicVariableManager; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.Set; import java.util.List; @@ -23,6 +26,7 @@ public final class SuspendedInterpreterFrame { final RuntimeBase[] registers; final int callContext; final String subroutineName; + final int frameCleanupMark; int pc; int virtualEvalFrameDepth; @@ -41,6 +45,11 @@ public final class SuspendedInterpreterFrame { final ArrayDeque regexStateStack = new ArrayDeque<>(); final ArrayList createdClosures = new ArrayList<>(); final ArrayList methodInvocantHolds = new ArrayList<>(); + // SET_SCALAR can allocate expression-only cells that own the referent + // inside a scalar reference. They are not lexical registers, so emitted + // SCOPE_EXIT_CLEANUP opcodes do not see them. + final Set temporaryScalarOwners = + Collections.newSetFromMap(new IdentityHashMap<>()); final ArrayDeque> scopeCleanupBatches = new ArrayDeque<>(); List suspendedDynamicStates; @@ -51,10 +60,12 @@ public final class SuspendedInterpreterFrame { Set suspendedRuntimeDisabledWarningCategories; SuspendedInterpreterFrame(InterpretedCode code, RuntimeBase[] registers, - int callContext, String subroutineName) { + int callContext, String subroutineName, + int frameCleanupMark) { this.code = code; this.registers = registers; this.callContext = callContext; this.subroutineName = subroutineName; + this.frameCleanupMark = frameCleanupMark; } } diff --git a/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java b/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java index cc85db15a5..df8a0328d3 100644 --- a/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java @@ -2761,11 +2761,11 @@ private static void installClosureCaptureMetadata( } if (value instanceof RuntimeScalar scalar) { capturedScalars.add(scalar); - scalar.retainClosureCapture(); + scalar.retainClosureCapture(code); } else if (value instanceof RuntimeArray || value instanceof RuntimeHash) { RuntimeBase aggregate = (RuntimeBase) value; capturedAggregates.add(aggregate); - capturedAggregateOwnerSlots.add(aggregate.retainClosureCaptureOwner()); + capturedAggregateOwnerSlots.add(aggregate.retainClosureCaptureOwner(code)); } } if (!capturedScalars.isEmpty()) { diff --git a/src/main/java/org/perlonjava/runtime/io/IORuntimeRegistryState.java b/src/main/java/org/perlonjava/runtime/io/IORuntimeRegistryState.java index 6dc13f1cb4..af2149e80c 100644 --- a/src/main/java/org/perlonjava/runtime/io/IORuntimeRegistryState.java +++ b/src/main/java/org/perlonjava/runtime/io/IORuntimeRegistryState.java @@ -6,6 +6,8 @@ import java.lang.ref.PhantomReference; import java.lang.ref.ReferenceQueue; import java.util.Map; +import java.util.Set; +import java.util.IdentityHashMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicInteger; @@ -21,6 +23,9 @@ public final class IORuntimeRegistryState { public final ReferenceQueue globGCQueue = new ReferenceQueue<>(); public final ConcurrentHashMap, RuntimeIO> phantomToIO = new ConcurrentHashMap<>(); + /** Every glob slot that currently keeps an anonymous IO handle alive. */ + public final Map>> globOwnersByIO = + new IdentityHashMap<>(); public final AtomicInteger nextFd = new AtomicInteger(3); public final ConcurrentHashMap fdToHandle = new ConcurrentHashMap<>(); public final ConcurrentHashMap handleToFd = new ConcurrentHashMap<>(); @@ -34,6 +39,9 @@ public void clear() { ioToFileno.clear(); recycledFds.clear(); phantomToIO.clear(); + synchronized (globOwnersByIO) { + globOwnersByIO.clear(); + } nextFd.set(3); fdToHandle.clear(); handleToFd.clear(); diff --git a/src/main/java/org/perlonjava/runtime/operators/FileTestOperator.java b/src/main/java/org/perlonjava/runtime/operators/FileTestOperator.java index 33821e95a2..e8f741b14f 100644 --- a/src/main/java/org/perlonjava/runtime/operators/FileTestOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/FileTestOperator.java @@ -6,6 +6,7 @@ import org.perlonjava.runtime.runtimetypes.*; import java.io.IOException; +import java.lang.ref.WeakReference; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.LinkOption; @@ -44,16 +45,58 @@ public class FileTestOperator { public static final class State { final RuntimeScalar lastFileHandle = new RuntimeScalar(); + WeakReference lastFileHandleReferent; + int lastFileHandleType; final RuntimeScalar lastOverloadedFileTestSubject = new RuntimeScalar(); boolean hasLastOverloadedFileTestSubject; String lastFileTestOperator; boolean lastStatOk; int lastStatErrno; final RuntimeScalar lastStatArg = new RuntimeScalar(); + WeakReference lastStatArgReferent; + int lastStatArgType; + boolean lastStatArgumentWasHandle; boolean lastStatWasLstat; BasicFileAttributes lastBasicAttr; PosixFileAttributes lastPosixAttr; Stat.NativeStatFields lastNativeStatFields; + + void setLastFileHandle(RuntimeScalar arg) { + if (isStatHandle(arg)) { + lastFileHandle.set(new RuntimeScalar()); + lastFileHandleType = arg.type; + lastFileHandleReferent = weakStatReferent(arg); + } else { + lastFileHandleReferent = null; + lastFileHandle.set(snapshotStatArgument(arg)); + } + } + + RuntimeScalar lastFileHandleValue() { + RuntimeBase referent = lastFileHandleReferent == null + ? null : lastFileHandleReferent.get(); + return referent != null ? statHandleSnapshot(lastFileHandleType, referent) + : lastFileHandle; + } + + void setLastStatArgument(RuntimeScalar arg) { + lastStatArgumentWasHandle = isStatHandle(arg); + if (lastStatArgumentWasHandle) { + lastStatArg.set(new RuntimeScalar()); + lastStatArgType = arg.type; + lastStatArgReferent = weakStatReferent(arg); + } else { + lastStatArgReferent = null; + lastStatArg.set(snapshotStatArgument(arg)); + } + } + + RuntimeScalar lastStatArgumentValue() { + RuntimeBase referent = lastStatArgReferent == null + ? null : lastStatArgReferent.get(); + return referent != null ? statHandleSnapshot(lastStatArgType, referent) + : lastStatArg; + } } static State state() { @@ -62,7 +105,7 @@ static State state() { static void updateLastStat(RuntimeScalar arg, boolean ok, int errno, boolean wasLstat) { State state = state(); - state.lastStatArg.set(snapshotStatArgument(arg)); + state.setLastStatArgument(arg); state.lastStatOk = ok; state.lastStatErrno = errno; state.lastStatWasLstat = wasLstat; @@ -79,12 +122,25 @@ static void updateLastStat(RuntimeScalar arg, boolean ok, int errno) { } private static RuntimeScalar snapshotStatArgument(RuntimeScalar arg) { - if (arg.type == RuntimeScalarType.GLOB || arg.type == RuntimeScalarType.GLOBREFERENCE) { - return arg; - } return new RuntimeScalar(arg.toString()); } + private static WeakReference weakStatReferent(RuntimeScalar arg) { + return arg.value instanceof RuntimeBase base ? new WeakReference<>(base) : null; + } + + private static RuntimeScalar statHandleSnapshot(int type, RuntimeBase referent) { + RuntimeScalar snapshot = new RuntimeScalar(); + snapshot.type = type; + snapshot.value = referent; + return snapshot; + } + + private static boolean isStatHandle(RuntimeScalar arg) { + return arg != null && (arg.type == RuntimeScalarType.GLOB + || arg.type == RuntimeScalarType.GLOBREFERENCE); + } + private static boolean warningsEnabled() { return getGlobalVariable("main::" + Character.toString('W' - 'A' + 1)).getBoolean() || Warnings.warningManager.isWarningEnabled("all"); @@ -180,12 +236,11 @@ private static RuntimeScalar fileTestFromLastStat(String operator) { // A fresh -e _ is a stat operation, not merely a query of an earlier // lstat buffer. This matters to a following -l _: Perl reports that // the preceding operation was stat rather than lstat. - if (operator.equals("-e") && state.lastStatArg.type != RuntimeScalarType.GLOB - && state.lastStatArg.type != RuntimeScalarType.GLOBREFERENCE) { + if (operator.equals("-e") && !state.lastStatArgumentWasHandle) { try { - Path path = resolvePath(state.lastStatArg.toString()); + Path path = resolvePath(state.lastStatArgumentValue().toString()); if (path != null) { - statForFileTest(state.lastStatArg, path, false); + statForFileTest(state.lastStatArgumentValue(), path, false); } } catch (InvalidPathException ignored) { // Preserve the existing cached failure below. @@ -198,7 +253,7 @@ private static RuntimeScalar fileTestFromLastStat(String operator) { // If lastBasicAttr is null (e.g., after testing a JAR path), fall back to re-testing if (state.lastBasicAttr == null) { - return fileTest(operator, state.lastFileHandle); + return fileTest(operator, state.lastFileHandleValue()); } return switch (operator) { @@ -220,7 +275,7 @@ private static RuntimeScalar fileTestFromLastStat(String operator) { // We need to resolve the path from the last stat argument and read file content // without calling fileTest() which would overwrite the stat buffer. try { - String filename = state.lastStatArg.toString(); + String filename = state.lastStatArgumentValue().toString(); Path path = resolvePath(filename); if (path == null || !Files.exists(path)) { getGlobalVariable("main::!").set(2); @@ -233,7 +288,7 @@ private static RuntimeScalar fileTestFromLastStat(String operator) { yield scalarUndef; } } - default -> fileTest(operator, state.lastFileHandle); + default -> fileTest(operator, state.lastFileHandleValue()); }; } @@ -279,7 +334,7 @@ public static RuntimeScalar fileTest(String operator, RuntimeScalar fileHandle) } fileHandle = RuntimeScalar.fetchTiedOnce(fileHandle); state().lastFileTestOperator = operator; - state().lastFileHandle.set(snapshotStatArgument(fileHandle)); + state().setLastFileHandle(fileHandle); // File tests participate in Perl's unary operator overloading. A // blessed object may provide a direct (-X method; otherwise the @@ -306,7 +361,7 @@ public static RuntimeScalar fileTest(String operator, RuntimeScalar fileHandle) // object as its subject. There is no native stat result // to cache, but the bytecode stack form consumes the // current file-test subject through the same cache slot. - state().lastFileHandle.set(fileHandle); + state().setLastFileHandle(fileHandle); updateLastStat(fileHandle, true, 0); state().lastOverloadedFileTestSubject.set(fileHandle); state().hasLastOverloadedFileTestSubject = true; diff --git a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java index a09874adca..af8c3be8ea 100644 --- a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java @@ -1098,7 +1098,8 @@ else if (secondArg.type == RuntimeScalarType.GLOB || secondArg.type == RuntimeSc if (lexicalName != null && lexicalName.startsWith("$")) { // A lexical filehandle is an anonymous GV, but Perl retains // its pad name for stringification across recursive calls. - anonGlob.setStringificationName("main::" + lexicalName); + anonGlob.setStringificationName( + RuntimeCode.getCurrentPackage() + lexicalName); } newGlob.value = anonGlob; // Register for GC-based fd recycling (mimics Perl's DESTROY on scope exit) @@ -2028,6 +2029,17 @@ public static RuntimeScalar sysopen(int ctx, RuntimeBase... args) { RuntimeScalar newGlob = new RuntimeScalar(); newGlob.type = RuntimeScalarType.GLOBREFERENCE; RuntimeGlob anonGlob = new RuntimeGlob(null).setIO(fh); + String lexicalName = RuntimeCode.findActiveLexicalName(fileHandle); + if (lexicalName == null) { + lexicalName = fileHandle.lexicalDisplayName; + } + if (lexicalName != null && lexicalName.startsWith("$")) { + // sysopen creates an anonymous GV for lexical filehandles just + // like open(). Preserve its lexical name so modules such as + // File::Temp can serialize and recreate the handle correctly. + anonGlob.setStringificationName( + RuntimeCode.getCurrentPackage() + lexicalName); + } newGlob.value = anonGlob; RuntimeIO.registerGlobForFdRecycling(anonGlob, fh); fileHandle.set(newGlob); diff --git a/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java b/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java index 6c9cf65989..7f66263b8b 100644 --- a/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/ReferenceOperators.java @@ -224,7 +224,7 @@ private static RuntimeScalar bless(RuntimeScalar runtimeScalar, RuntimeScalar cl // tracking wasn't active at assignment time). Count it as 1. referent.refCount = 1; referent.recordOwner(runtimeScalar, "rebless from untracked"); - runtimeScalar.refCountOwned = true; + runtimeScalar.setRefCountOwned(true); } else { // First bless: start at refCount=1 and add to MortalList. // The mortal entry will decrement back to 0 at the next @@ -255,9 +255,10 @@ private static RuntimeScalar bless(RuntimeScalar runtimeScalar, RuntimeScalar cl scalarReferent.localBindingExists = true; } referent.refCount = existingScalarOwner ? 2 : 1; + MortalList.promoteRootedTieWrapperOwners(referent); if (existingScalarOwner) { referent.recordOwner(runtimeScalar, "first bless of existing scalar ref"); - runtimeScalar.refCountOwned = true; + runtimeScalar.setRefCountOwned(true); } referent.acquireTransientTraceOwner("bless mortal temporary", "ReferenceOperators.first bless"); diff --git a/src/main/java/org/perlonjava/runtime/operators/Stat.java b/src/main/java/org/perlonjava/runtime/operators/Stat.java index 89be6c5290..ead7c1ed73 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Stat.java +++ b/src/main/java/org/perlonjava/runtime/operators/Stat.java @@ -199,7 +199,7 @@ public static RuntimeBase lstat(RuntimeScalar arg, int ctx) { public static RuntimeList stat(RuntimeScalar arg) { arg = dereferenceAndFetch(arg); - state().lastFileHandle.set(arg); + state().setLastFileHandle(arg); RuntimeList res = new RuntimeList(); if (arg.type == RuntimeScalarType.GLOB || arg.type == RuntimeScalarType.GLOBREFERENCE) { @@ -353,7 +353,7 @@ public static RuntimeList stat(RuntimeScalar arg) { public static RuntimeList lstat(RuntimeScalar arg) { arg = dereferenceAndFetch(arg); - state().lastFileHandle.set(arg); + state().setLastFileHandle(arg); RuntimeList res = new RuntimeList(); if (arg.type == RuntimeScalarType.GLOB || arg.type == RuntimeScalarType.GLOBREFERENCE) { diff --git a/src/main/java/org/perlonjava/runtime/operators/TieOperators.java b/src/main/java/org/perlonjava/runtime/operators/TieOperators.java index 9f65ac6b92..74f499c050 100644 --- a/src/main/java/org/perlonjava/runtime/operators/TieOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/TieOperators.java @@ -156,7 +156,7 @@ public static RuntimeScalar tie(int ctx, RuntimeBase... scalars) { hash.threadShared = false; hash.threadSharedIdentity = null; hash.type = TIED_HASH; - hash.elements = new TieHash(className, previousValue, self); + hash.elements = new TieHash(className, previousValue, self, hash); hash.resetIterator(); } case GLOBREFERENCE -> { @@ -166,7 +166,8 @@ public static RuntimeScalar tie(int ctx, RuntimeBase... scalars) { glob.IO.type = TIED_SCALAR; boolean selfIsTiedGlob = self != null && (self.value == glob || isSameHandle(self, previousValue)); - TieHandle tieHandle = new TieHandle(className, previousValue, self, !selfIsTiedGlob); + TieHandle tieHandle = new TieHandle( + className, previousValue, self, !selfIsTiedGlob, glob); // Propagate the glob name so select() returns the correct name // (e.g., "main::STDOUT") even when the handle is tied. if (previousValue != null) { diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Clone.java b/src/main/java/org/perlonjava/runtime/perlmodule/Clone.java index 8197eb0e4f..29c1a6c623 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Clone.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Clone.java @@ -86,7 +86,8 @@ private static RuntimeScalar deepClone(RuntimeScalar scalar, IdentityHashMap 0; + } else { + int nativeCaptureOwners = referent.nativeCaptureOwnerCount(); + boolean compileHintsHash = referent == GlobalVariable.getGlobalHash( + GlobalContext.encodeSpecialVar("H")); + int extra = (referent.localBindingExists && !compileHintsHash ? 1 : 0) + + referent.foreachAliasCount; + int adjust = compileHintsHash || !referent.localBindingExists ? -1 : 0; + int rawCount = requested - nativeCaptureOwners - extra - adjust; + referent.refCount = rawCount; + referent.hadCountedReference = rawCount > 0; + } + return new RuntimeScalar(requested).getList(); + } + return svRefcountValue(arg, false); + } + + /** Internal B::SV query mode; kept separate from Perl's public setter argument. */ + public static RuntimeList jperlBSvrefcnt(RuntimeArray args, int ctx) { + if (args.size() != 1) { + WarnDie.die(new RuntimeScalar("Usage: Internals::jperl_b_svrefcnt(SCALAR)"), + new RuntimeScalar("")); + } + return svRefcountValue(args.get(0), true); + } + + private static RuntimeList svRefcountValue(RuntimeScalar arg, boolean bObjectProbe) { RuntimeBase base = arg.value instanceof RuntimeBase value ? value : null; if (base == null && arg.firstClassRegexScalar && arg.firstClassRegexReferent != null) { base = arg.firstClassRegexReferent.get(); @@ -548,8 +582,7 @@ public static RuntimeList svRefcount(RuntimeArray args, int ctx) { int extra = (base.localBindingExists && !compileHintsHash ? 1 : 0) + base.foreachAliasCount; if (rc == 2 - && args.size() > 1 - && args.get(1).getBoolean() + && bObjectProbe && DestroyDispatch.isInsideDestroy() && !ReachabilityWalker.hasLiveStrongScalarReferentOtherThan(base, arg)) { // B::SV's private hash slot is one of the two selective @@ -577,9 +610,8 @@ public static RuntimeList svRefcount(RuntimeArray args, int ctx) { // selective refCount owner. Do not discount that real owner. boolean fieldOwnedMethodResult = rc == 1 && !base.localBindingExists - && base.hashSlotOwnerCount > 0 + && base.hashSlotOwnerCount() > 0 && !ReachabilityWalker.hasLiveStrongScalarReferentOtherThan(base, arg); - boolean bObjectProbe = args.size() > 1 && args.get(1).getBoolean(); int adjust = compileHintsHash ? -1 : base.localBindingExists || fieldOwnedMethodResult ? 0 : -1; @@ -593,7 +625,7 @@ public static RuntimeList svRefcount(RuntimeArray args, int ctx) { } return new RuntimeScalar(rc + extra + adjust).getList(); } - return new RuntimeScalar(1).getList(); + return new RuntimeScalar(arg.refCount >= 0 ? arg.refCount : 1).getList(); } /** Return a reference whose displayed address was observed in this runtime. */ @@ -808,6 +840,7 @@ public static RuntimeList jperl_trace_to(RuntimeArray args, int ctx) { } System.err.println(" total direct holders=" + matchIdx + " total lexicals scanned=" + totalLexIdx); + ReachabilityWalker.dumpCaptureOwnersForTrace(base); if (matchIdx == 0 && !parentScalars.isEmpty()) { System.err.println(" --- parent-holder candidates (" + parentScalars.size() + ") ---"); diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Storable.java b/src/main/java/org/perlonjava/runtime/perlmodule/Storable.java index c81c9751ef..bf8c037d5d 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Storable.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Storable.java @@ -347,12 +347,17 @@ public static void releaseApplyArgs(RuntimeArray args) { if (args == null || args.elements == null) return; for (RuntimeScalar elem : args.elements) { if (elem == null) continue; - if (elem.refCountOwned && elem.value instanceof RuntimeBase base + if (elem.value instanceof RuntimeBase base + && elem.ownsCountedReferent(base) && base.refCount > 0) { - base.releaseOwner(elem, "Storable.releaseApplyArgs"); - base.releaseActiveOwner(elem); - base.refCount--; - elem.refCountOwned = false; + if (!elem.releaseScalarValueOwnerCount(base, "Storable.releaseApplyArgs")) { + // Cells created before explicit scalar-value slots retain + // the legacy bridge until their owner path is migrated. + base.releaseOwner(elem, "Storable.releaseApplyArgs"); + base.releaseActiveOwner(elem); + base.refCount--; + elem.setRefCountOwned(false); + } } } args.elements.clear(); @@ -472,7 +477,8 @@ private static RuntimeScalar deepClone(RuntimeScalar scalar, CloneContext contex RuntimeHash previousValue = new RuntimeHash(); // Create new TieHash with cloned handler newHash.type = RuntimeHash.TIED_HASH; - newHash.elements = new TieHash(tieHash.getTiedPackage(), previousValue, clonedSelf); + newHash.elements = new TieHash( + tieHash.getTiedPackage(), previousValue, clonedSelf, newHash); // Initialize the backing value directly. Replaying through STORE // breaks read-only tie classes during dclone; the cloned handler // already carries the tie object's state. diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/storable/Misc.java b/src/main/java/org/perlonjava/runtime/perlmodule/storable/Misc.java index 0dcba2f26f..74b32ef807 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/storable/Misc.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/storable/Misc.java @@ -256,7 +256,7 @@ static void installTiedHash(RuntimeHash hv, RuntimeScalar tying) { String className = classnameOf(tying); RuntimeHash previousValue = RuntimeHash.createHash(hv); hv.type = RuntimeHash.TIED_HASH; - hv.elements = new TieHash(className, previousValue, tying); + hv.elements = new TieHash(className, previousValue, tying, hv); hv.resetIterator(); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java b/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java index 9b6049b2eb..3bf3218275 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java @@ -33,6 +33,15 @@ static RuntimeBase currentDestroyTarget() { return state().currentDestroyTarget; } + static boolean isInGlobalDestructionPhase() { + String phase = GlobalVariable.getGlobalVariable(GlobalContext.GLOBAL_PHASE).toString(); + return "DESTRUCT".equals(phase); + } + + static boolean isCheckingGlobalOwnerDelta() { + return isInGlobalDestructionPhase() || MortalList.isDrainingTopLevelTemporary(); + } + static void markDestroyTargetRescued() { LifecycleRuntimeState state = state(); state.destroyTargetRescued = true; @@ -416,15 +425,12 @@ private static void doCallDestroy(RuntimeBase referent, String className) { // can be distinguished from real resurrection during teardown. There // is intentionally no backfill: package roots are themselves being // dismantled, while any new owner created by DESTROY is recorded. - String globalPhase = GlobalVariable.getGlobalVariable( - GlobalContext.GLOBAL_PHASE).toString(); - boolean checkingGlobalOwnerDelta = "DESTRUCT".equals(globalPhase) - || MortalList.isDrainingTopLevelTemporary(); + boolean checkingGlobalOwnerDelta = isCheckingGlobalOwnerDelta(); Set activeOwnersAtStart = null; Set ownerSlotsAtStart = null; if (checkingGlobalOwnerDelta) { referent.activateOwnerTracking(); - activeOwnersAtStart = referent.activeOwnerSnapshot(); + activeOwnersAtStart = referent.countedScalarOwnerSnapshot(); ownerSlotsAtStart = referent.ownerSlotSnapshot(); } @@ -520,15 +526,10 @@ private static void doCallDestroy(RuntimeBase referent, String className) { // avoids recursively entering callDestroy while the same // referent is still in its DESTROY frame. for (RuntimeScalar elem : args.elements) { - if (elem != null && elem.refCountOwned - && elem.value instanceof RuntimeBase base + if (elem != null && elem.value instanceof RuntimeBase base + && elem.ownsCountedReferent(base) && base.refCount > 0) { - if (base.refCountTrace) { - base.releaseOwner(elem, "doCallDestroy args balance"); - } - base.releaseActiveOwner(elem); - base.refCount--; - elem.refCountOwned = false; + balanceDestroyArgumentOwner(elem, base); } } args.elements.clear(); @@ -691,6 +692,16 @@ private static void doCallDestroy(RuntimeBase referent, String className) { } } + /** Balance the temporary scalar owner installed by {@code args.push(self)}. */ + static void balanceDestroyArgumentOwner(RuntimeScalar scalar, RuntimeBase referent) { + String releaseSite = "doCallDestroy args balance"; + if (scalar.releaseScalarValueOwnerCount(referent, releaseSite)) return; + if (referent.refCountTrace) referent.releaseOwner(scalar, releaseSite); + referent.releaseActiveOwner(scalar); + referent.refCount--; + scalar.setRefCountOwned(false); + } + /** * Decide resurrection from Perl owner slots when the owner ledger is * active. During global destruction the legacy selective count can still @@ -698,23 +709,49 @@ private static void doCallDestroy(RuntimeBase referent, String className) { * those aliases are not resurrection. Objects which have not activated * owner tracking retain the legacy count-based behavior. */ - private static boolean hasEscapedPerlOwner(RuntimeBase referent, + static boolean hasEscapedPerlOwner(RuntimeBase referent, boolean checkingGlobalOwnerDelta, Set activeOwnersAtStart, Set ownerSlotsAtStart) { if (checkingGlobalOwnerDelta) { - for (RuntimeScalar owner : referent.activeOwnerSnapshot()) { + for (RuntimeScalar owner : referent.countedScalarOwnerSnapshot()) { // DESTROY commonly creates short-lived aliases while it calls // methods on $self. They are new ledger entries, but they do // not resurrect the object if their Perl slots are gone by the // time the destructor returns. During global teardown, package // roots are still available to the reachability walker, so // require a new scalar owner to remain rooted before warning. - if (!activeOwnersAtStart.contains(owner) - && ReachabilityWalker.isScalarReachable(owner)) return true; + if (owner.isRetiredBorrowedArrayAlias()) continue; + if (!activeOwnersAtStart.contains(owner)) { + boolean rooted = ReachabilityWalker.isScalarReachableFromPackageRoots( + owner, referent); + if (rooted) return true; + } } for (PerlOwnerSlot ownerSlot : referent.ownerSlotSnapshot()) { - if (!ownerSlotsAtStart.contains(ownerSlot)) return true; + if (ownerSlotsAtStart.contains(ownerSlot)) continue; + RuntimeBase ownerContainer = ownerSlot.ownerContainer(); + if (ownerContainer != null + && MortalList.isRootedOwnerContainer(ownerContainer, referent)) { + return true; + } + // Closure pads have no aggregate container. When capture + // provenance identifies the owning CODE, require that CODE to + // remain rooted before treating the new pad edge as escape. + if (ownerSlot.kind() == PerlOwnerSlot.Kind.CLOSURE_PAD) { + RuntimeCode closureOwner = ownerSlot.closureOwner(); + if (closureOwner == null + || ReachabilityWalker.isCodeReachableFromRoots(closureOwner)) { + if (closureOwner == null && ownerSlot.padCell() != null) { + if (ReachabilityWalker.isScalarReachableFromPackageRoots( + ownerSlot.padCell(), referent)) { + return true; + } + } else { + return true; + } + } + } } return false; } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalar.java index 61b73e7919..6612bcdade 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalar.java @@ -204,16 +204,18 @@ public void dynamicRestoreState() { GlobalVariable.clearForeachGlobalAlias(saved.fullName); RuntimeScalar localVar = saved.localizedVariable; RuntimeBase displacedBase = null; + boolean releasedDisplacedScalarSlot = false; RuntimeScalar scalarReferenceContents = null; + PerlOwnerSlot scalarContentsOwner = null; // Release container-owned values only when this localized // scalar is dropping the container's last counted owner. A // localized alias to a still-live request or cache must not // release its fields while another Perl owner can still use // them (Dancer2 uploads keep their File::Temp handle there). boolean releasesLastContainerOwner = localVar != null - && localVar.refCountOwned && (localVar.type & RuntimeScalarType.REFERENCE_BIT) != 0 && localVar.value instanceof RuntimeBase localBase + && localVar.ownsCountedReferent(localBase) && localBase.refCount == 1 && !localBase.localBindingExists; if (releasesLastContainerOwner && localVar.value instanceof RuntimeHash localHash) { @@ -222,19 +224,26 @@ public void dynamicRestoreState() { MortalList.deferDestroyForContainerClear(localArray.elements); } if (localVar != null - && localVar.refCountOwned && (localVar.type & RuntimeScalarType.REFERENCE_BIT) != 0 && localVar.value instanceof RuntimeBase base + && localVar.ownsCountedReferent(base) && base.refCount > 0) { displacedBase = base; - localVar.refCountOwned = false; - displacedBase.releaseActiveOwner(localVar); + releasedDisplacedScalarSlot = localVar.releaseScalarValueOwnerCount( + base, "GlobalRuntimeScalar.dynamicRestoreState"); + if (!releasedDisplacedScalarSlot) { + // Compatibility path for localized cells created + // before direct scalar owner slots were installed. + localVar.setRefCountOwned(false); + displacedBase.releaseActiveOwner(localVar); + } } if (localVar != null - && localVar.ownsScalarReferenceContents + && localVar.hasScalarReferenceContentsOwner() && localVar.type == RuntimeScalarType.REFERENCE && localVar.value instanceof RuntimeScalar scalarReferent) { scalarReferenceContents = scalarReferent; + scalarContentsOwner = localVar.scalarReferenceContentsOwner; } // Restore the internal separator values if this was an output separator variable @@ -262,12 +271,14 @@ public void dynamicRestoreState() { // Decrement refCount of the displaced local value after restoring // the global slot, so DESTROY sees the restored variable state. boolean displacedWillDestroy = false; - if (displacedBase != null && displacedBase.refCount == 1) { + if (displacedBase != null + && (releasedDisplacedScalarSlot + ? displacedBase.refCount == 0 : displacedBase.refCount == 1)) { String className = NameNormalizer.getBlessStr(displacedBase.blessId); displacedWillDestroy = className != null && !className.isEmpty() && DestroyDispatch.classHasDestroy(displacedBase.blessId, className); } - if ((displacedWillDestroy || scalarReferenceContents != null) + if ((displacedWillDestroy || scalarContentsOwner != null) && localVar != null && localVar != saved.originalVariable) { // Compiled code may still hold the localized scalar object // directly. Mirror the restored value into it just before @@ -281,14 +292,21 @@ public void dynamicRestoreState() { localVar.value = saved.originalVariable.value; localVar.blessId = saved.originalVariable.blessId; } - localVar.refCountOwned = false; + localVar.setRefCountOwned(false); } if (localVar != null) { - localVar.ownsScalarReferenceContents = false; + localVar.scalarReferenceContentsOwner = null; + } + if (scalarContentsOwner != null) { + scalarContentsOwner.releaseScalarReferenceContents(); + } else { + RuntimeScalar.releaseScalarReferenceContents(scalarReferenceContents); } - RuntimeScalar.releaseScalarReferenceContents(scalarReferenceContents); - if (displacedBase != null && displacedBase.refCount > 0 - && --displacedBase.refCount == 0) { + boolean displacedReferentReachedZero = displacedBase != null + && (releasedDisplacedScalarSlot + ? displacedBase.refCount == 0 + : displacedBase.refCount > 0 && --displacedBase.refCount == 0); + if (displacedReferentReachedZero) { displacedBase.refCount = Integer.MIN_VALUE; DestroyDispatch.callDestroy(displacedBase); } @@ -301,6 +319,13 @@ public Object dynamicSuspendState() { RuntimeScalar activeVariable = GlobalVariable.getGlobalVariable(fullName); RuntimeScalar activeState = activeVariable != null ? new RuntimeScalar(activeVariable) : new RuntimeScalar(); + if (activeVariable != null) { + activeState.scalarReferenceContentsOwner = activeVariable.scalarReferenceContentsOwner; + if (activeState.scalarReferenceContentsOwner != null) { + activeState.scalarReferenceContentsOwner.transferScalarReferenceContentsCell(activeState); + } + activeVariable.scalarReferenceContentsOwner = null; + } boolean tied = activeVariable != null && activeVariable.type == RuntimeScalarType.TIED_SCALAR; RuntimeScalar tiedValue = tied ? activeVariable.tiedFetch() : null; @@ -322,7 +347,12 @@ public void dynamicResumeState(Object token) { activeVariable.type = activeState.type; activeVariable.value = activeState.value; activeVariable.blessId = activeState.blessId; - activeVariable.ownsScalarReferenceContents = activeState.ownsScalarReferenceContents; + activeVariable.scalarReferenceContentsOwner = activeState.scalarReferenceContentsOwner; + activeState.scalarReferenceContentsOwner = null; + if (activeVariable.scalarReferenceContentsOwner != null) { + activeVariable.scalarReferenceContentsOwner + .transferScalarReferenceContentsCell(activeVariable); + } activeVariable.referencedByScalarReference = activeState.referencedByScalarReference; activeVariable.tainted = activeState.tainted; activeVariable.numericLiteralText = activeState.numericLiteralText; diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java index 9488f1261e..98fb708313 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java @@ -5,6 +5,7 @@ import java.util.Collections; import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; @@ -21,6 +22,8 @@ public class MortalList { private static final int MAX_UNBLESSED_AGGREGATE_DRAIN_ROOTS = 50_000; private static final AtomicInteger runtimesWithBoundaryWork = new AtomicInteger(); + private static final String OWNER_RECOVERY_TRACE_CLASS = + System.getenv("PJ_OWNER_RECOVERY_TRACE_CLASS"); static final class ReachabilityQueryMeasurement implements AutoCloseable { private final LifecycleRuntimeState state; @@ -113,7 +116,13 @@ public static void setActive(boolean active) { public static void pushTemporaryRoot(RuntimeBase root) { if (root != null) { - state().temporaryRoots.push(root); + ArrayDeque roots = state().temporaryRoots; + if (root instanceof RuntimeList) { + for (RuntimeBase existing : roots) { + if (existing == root) return; + } + } + roots.push(root); } } @@ -232,16 +241,34 @@ public static void deferDecrement(RuntimeBase base, String transientOwnerKind) { /** Queue the deferred release together with the array scalar that owned it. */ public static void deferDecrementFromArraySlot( RuntimeBase base, RuntimeScalar ownerScalar, String releaseSite) { - if (base.refCountTrace) { - base.traceRefCount(0, releaseSite + " (array-slot decrement queued)"); - } LifecycleRuntimeState state = state(); markBoundaryWork(state); RuntimeBase.PendingOwnerRelease ownerRelease = - base.queueOwnerRelease(ownerScalar, releaseSite); + queueScalarOwnerRelease(base, ownerScalar, releaseSite); queueDeferredBase(state, base, ownerRelease, null, ownerScalar); } + /** Detach a scalar owner and queue its count release with slot provenance. */ + private static RuntimeBase.PendingOwnerRelease queueScalarOwnerRelease( + RuntimeBase base, RuntimeScalar scalar, String releaseSite) { + PerlOwnerSlot.PendingRelease slotRelease = + scalar.deferScalarValueOwnerCount(base); + if (slotRelease != null) { + base.traceRefCount(0, releaseSite + " (scalar-value slot decrement queued)"); + base.releaseActiveOwner(scalar); + return base.queueOwnerSlotRelease(slotRelease); + } + + // Compatibility path for a count that predates explicit SCALAR_VALUE + // slots. The slot path above retires the identity itself and does not + // depend on refCountOwned to decide which scalar owns this release. + scalar.setRefCountOwned(false); + base.traceRefCount(0, releaseSite + " (scalar decrement queued)"); + base.releaseOwner(scalar, releaseSite); + base.releaseActiveOwner(scalar); + return base.queueOwnerRelease(scalar, releaseSite); + } + /** Queue a closure-capture bridge decrement with owner-slot provenance. */ static void deferOwnerSlotDecrement(RuntimeBase base, String transientOwnerKind, PerlOwnerSlot.PendingRelease slotRelease) { @@ -318,6 +345,16 @@ public static void noteDeferredScopeExitCaptureCandidate(RuntimeScalar scalar) { noteDeferredScopeExitCaptureCandidate(state(), scalar); } + /** Recheck a scope-exited referent when its final closure owner is released. */ + public static void closureCaptureReleased(RuntimeScalar scalar) { + if (scalar == null || !scalar.scopeExited + || (scalar.type & RuntimeScalarType.REFERENCE_BIT) == 0 + || !(scalar.value instanceof RuntimeBase base) + || !isDeferredScopeExitCandidate(base)) return; + noteDeferredScopeExitCaptureCandidate(state(), scalar); + requestImmediateWeakSweep(); + } + private static void noteDeferredScopeExitCaptureCandidate( LifecycleRuntimeState state, RuntimeScalar scalar) { if (scalar.value instanceof RuntimeBase base && isDeferredScopeExitCandidate(base)) { @@ -332,6 +369,19 @@ private static boolean isDeferredScopeExitCandidate(RuntimeBase base) { return className != null && DestroyDispatch.classHasDestroy(base.blessId, className); } + private static boolean containerDirectlyOwnsReferent( + RuntimeBase container, RuntimeBase referent) { + if (container instanceof RuntimeArray array) { + return array.elements.stream().anyMatch(scalar -> scalar != null + && scalar.value == referent); + } + if (container instanceof RuntimeHash hash) { + return hash.elements.values().stream().anyMatch(scalar -> scalar != null + && scalar.value == referent); + } + return false; + } + /** * Release eval STRING over-captures whose declaring scope just exited and * which cannot be reached by live code or an END block. Reachability sweeps @@ -342,12 +392,33 @@ private static boolean isDeferredScopeExitCandidate(RuntimeBase base) { private static void processUnreachableScopeExitCaptureCandidates( LifecycleRuntimeState state) { if (state.deferredScopeExitCaptureCandidates.isEmpty()) return; - + // Nested Perl frames can still own transient callback and request + // state through active call arguments. Keep candidates queued until + // an outer statement boundary can inspect the completed owner graph. + if (RuntimeCode.argsStackDepth() > 1) return; Set endReachable = ReachabilityWalker.walkEndBlockRoots(); Set cycleProtected = ReachabilityWalker.strongCycleProtectedReferents(); + Set candidateReferents = + Collections.newSetFromMap(new IdentityHashMap<>()); + for (RuntimeScalar scalar : state.deferredScopeExitCaptureCandidates) { + if (scalar.scopeExited && scalar.value instanceof RuntimeBase base) { + candidateReferents.add(base); + } + } + List captureOwnerReferents = new ArrayList<>(candidateReferents); + for (RuntimeBase ownerReferent : PerlRuntime.snapshotOwnerSlotReferents()) { + if (candidateReferents.stream().anyMatch( + candidate -> containerDirectlyOwnsReferent(ownerReferent, candidate))) { + captureOwnerReferents.add(ownerReferent); + } + } + ReachabilityWalker.releaseUnreachableScopeExitedCaptureOwners( + captureOwnerReferents, endReachable); boolean found = false; for (RuntimeScalar scalar : new ArrayList<>(state.deferredScopeExitCaptureCandidates)) { - if (!scalar.scopeExited) continue; + if (!scalar.scopeExited) { + continue; + } RuntimeBase base = scalar.value instanceof RuntimeBase value ? value : null; boolean scalarReachable = ReachabilityWalker.isScalarReachable(scalar) || ReachabilityWalker.isReachableFromRoots(scalar); @@ -518,20 +589,15 @@ public static void flushDeferredCapturesBeforeEnd() { */ public static void deferDecrementIfTracked(RuntimeScalar scalar) { if (!isActive() || scalar == null) return; - if (!scalar.refCountOwned) return; if (((scalar.type & RuntimeScalarType.REFERENCE_BIT) != 0 || scalar.type == RuntimeScalarType.GLOB) && scalar.value instanceof RuntimeBase base) { + if (!scalar.ownsCountedReferent(base)) return; if (base.refCount > 0) { - scalar.refCountOwned = false; - if (base.refCountTrace) { - base.traceRefCount(0, "MortalList.deferDecrementIfTracked (queued, scalar.refCountOwned->false)"); - } - base.releaseActiveOwner(scalar); LifecycleRuntimeState state = state(); markBoundaryWork(state); - RuntimeBase.PendingOwnerRelease ownerRelease = base.queueOwnerRelease( - scalar, "MortalList.deferDecrementIfTracked"); + RuntimeBase.PendingOwnerRelease ownerRelease = queueScalarOwnerRelease( + base, scalar, "MortalList.deferDecrementIfTracked"); queueDeferredBase(state, base, ownerRelease, null, scalar); } else if (base.refCount == 0 && base.clearedOwnedAggregateElement @@ -542,7 +608,7 @@ public static void deferDecrementIfTracked(RuntimeScalar scalar) { // that stale ownership marker and ask the reachability walker to // decide at the next safe (non-nested) boundary whether the weak // observers should be cleared. - scalar.refCountOwned = false; + scalar.setRefCountOwned(false); if (base.refCountTrace) { base.releaseOwner(scalar, "deferDecrementIfTracked zero-count owner"); } @@ -561,7 +627,6 @@ public static void deferDecrementIfTracked(RuntimeScalar scalar) { /** 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; deferDecrementIfTracked(scalar); } @@ -589,13 +654,20 @@ public static void deferDecrementIfNotCaptured(RuntimeScalar scalar) { /** * Defer DESTROY for tracked blessed refs in a collection being cleared. *

- * Only decrements elements that own a refCount (refCountOwned == true). - * Elements stored via copy constructor (no setLarge) are skipped. + * Only decrements elements that own a counted reference, through either + * the legacy flag or an explicit scalar-value owner slot. Non-owning + * copies remain skipped. * Never-stored blessed objects (refCount == 0) are bumped to ensure DESTROY fires. */ public static void deferDestroyForContainerClear(Iterable elements) { if (!isActive()) return; for (RuntimeScalar scalar : elements) { + if (scalar != null && scalar.hasStrongScalarReferenceAlias()) { + // A refalias keeps the scalar cell itself alive after the + // aggregate slot is removed. Its SCALAR_VALUE owner must move + // with the cell and is released when the final alias goes. + continue; + } // Use the recursive path so a discarded wrapper/container also // releases references stored in its fields. Test::Deep's // temporary comparator is a blessed hash whose `val` field can @@ -662,7 +734,10 @@ public static void scopeExitCleanupHash(RuntimeHash hash) { // data), we must still cascade decrements so their weak-ref entries // can be cleared when the referent's refCount reaches 0. if (!RuntimeBase.blessedObjectExists() && !WeakRefRegistry.weakRefsExist() - && !RuntimeScalar.watcherCleanupNeeded()) return; + && !RuntimeScalar.watcherCleanupNeeded()) { + hash.releaseHashEntryOwnerSlots(); + return; + } // If the hash has outstanding references (e.g., from \%hash stored elsewhere), // do NOT clean up elements — the hash is still alive and its elements are // accessible through the reference. Cleanup will happen when the last @@ -678,7 +753,7 @@ public static void scopeExitCleanupHash(RuntimeHash hash) { for (RuntimeScalar val : hash.elements.values()) { if (val != null && (val.type & RuntimeScalarType.REFERENCE_BIT) != 0 && val.value instanceof RuntimeBase rb) { - if (rb.blessId != 0 || rb.refCount >= 0) { + if (val.ownsCountedReferent(rb) || rb.blessId != 0 || rb.refCount >= 0) { needsWalk = true; break; } @@ -701,7 +776,10 @@ public static void scopeExitCleanupHash(RuntimeHash hash) { if (needsWalk) break; } } - if (!needsWalk) return; + if (!needsWalk) { + hash.releaseHashEntryOwnerSlots(); + return; + } // Refcount drift can miss an escaped hash reference. DBIC's // source_registrations hash is assigned into the live schema while the // original hash later exits; walking it here would destroy ResultSources @@ -715,6 +793,7 @@ public static void scopeExitCleanupHash(RuntimeHash hash) { for (RuntimeScalar val : valuesInDestructionOrder) { deferDecrementRecursive(val); } + hash.releaseHashEntryOwnerSlots(); } /** @@ -754,6 +833,7 @@ public static void scopeExitCleanupArray(RuntimeArray arr, RuntimeBase returned) // must not retain this Java RuntimeArray merely through its backing // reference. A real escaped \@array has refCount > 0 above. arr.orphanArraySizeLvalues(); + arr.releaseArrayElementOwnerSlots(); if (!arr.elementsAliased) { for (RuntimeScalar elem : arr.elements) { if (returned == null) RuntimeScalar.releaseIoOwner(elem); @@ -792,7 +872,7 @@ public static void scopeExitCleanupArray(RuntimeArray arr, RuntimeBase returned) return; } // Quick scan: check if any element either: - // 1. Owns a refCount (was assigned via setLarge with a tracked referent), OR + // 1. Owns a counted referent through a legacy flag or scalar-value slot, OR // 2. Is a direct blessed reference (blessId != 0), OR // 3. Is a container (array/hash ref) that might hold nested blessed refs // For case 3, we peek one level deep to avoid false positives for arrays @@ -800,9 +880,11 @@ public static void scopeExitCleanupArray(RuntimeArray arr, RuntimeBase returned) boolean needsWalk = RuntimeScalar.watcherCleanupNeeded(); for (RuntimeScalar elem : arr.elements) { if (elem == null || (elem.type & RuntimeScalarType.REFERENCE_BIT) == 0) continue; - // Fast check: refCountOwned means this ref was properly tracked via setLarge - if (elem.refCountOwned) { needsWalk = true; break; } if (!(elem.value instanceof RuntimeBase rb)) continue; + // The explicit scalar slot can outlive the legacy ownership flag + // during the bridge migration, so either identity source needs the + // recursive release walk. + if (elem.ownsCountedReferent(rb)) { needsWalk = true; break; } // Direct blessed ref if (rb.blessId != 0 || rb.refCount >= 0) { needsWalk = true; break; } // Container ref — peek at children for reference-type elements @@ -856,9 +938,9 @@ public static void releaseLastOwnedTailCallArgs(RuntimeArray args) { for (RuntimeScalar scalar : args.elements) { if (scalar == null || !args.ownsElement(scalar) - || !scalar.refCountOwned || (scalar.type & RuntimeScalarType.REFERENCE_BIT) == 0 || !(scalar.value instanceof RuntimeBase base) + || !scalar.ownsCountedReferent(base) || base.refCount != 1) { continue; } @@ -869,21 +951,15 @@ public static void releaseLastOwnedTailCallArgs(RuntimeArray args) { public static void releaseTailCallCodeRef(RuntimeScalar codeRef) { if (codeRef == null || !isActive() - || !codeRef.refCountOwned || codeRef.type != RuntimeScalarType.CODE || !(codeRef.value instanceof RuntimeCode code) + || !codeRef.ownsCountedReferent(code) || code.refCount <= 0) { return; } - codeRef.refCountOwned = false; - if (code.refCountTrace) { - code.traceRefCount(-1, "MortalList.releaseTailCallCodeRef"); - code.releaseOwner(codeRef, "releaseTailCallCodeRef"); - } - code.releaseActiveOwner(codeRef); - - if (--code.refCount > 0) return; + releaseScalarValueCount(code, codeRef, "MortalList.releaseTailCallCodeRef"); + if (code.refCount > 0) return; if (code.stashRefCount > 0) { code.refCount = 0; @@ -901,21 +977,15 @@ public static void releaseTailCallCodeRef(RuntimeScalar codeRef) { private static void releaseTailCallArgElement(RuntimeScalar scalar) { if (scalar == null - || !scalar.refCountOwned || (scalar.type & RuntimeScalarType.REFERENCE_BIT) == 0 || !(scalar.value instanceof RuntimeBase base) + || !scalar.ownsCountedReferent(base) || base.refCount <= 0) { return; } - scalar.refCountOwned = false; - if (base.refCountTrace) { - base.traceRefCount(-1, "MortalList.releaseTailCallArgElement"); - base.releaseOwner(scalar, "releaseTailCallArgElement"); - } - base.releaseActiveOwner(scalar); - - if (--base.refCount > 0) return; + releaseScalarValueCount(base, scalar, "MortalList.releaseTailCallArgElement"); + if (base.refCount > 0) return; if (base.localBindingExists || reachableTailCallArgReferent(base)) { base.refCount = 1; @@ -926,6 +996,19 @@ private static void releaseTailCallArgElement(RuntimeScalar scalar) { DestroyDispatch.callDestroy(base); } + /** Release an immediate scalar-value owner through its explicit slot when present. */ + private static void releaseScalarValueCount( + RuntimeBase base, RuntimeScalar scalar, String releaseSite) { + if (scalar.releaseScalarValueOwnerCount(base, releaseSite)) return; + scalar.setRefCountOwned(false); + if (base.refCountTrace) { + base.traceRefCount(-1, releaseSite); + base.releaseOwner(scalar, releaseSite); + } + base.releaseActiveOwner(scalar); + base.refCount--; + } + private static boolean reachableTailCallArgReferent(RuntimeBase base) { return WeakRefRegistry.hasWeakRefsTo(base) && (ReachabilityWalker.isReachableFromRoots(base) @@ -941,7 +1024,9 @@ private static boolean reachableTailCallArgReferent(RuntimeBase base) { * StackOverflowError on circular data structures like ExifTool's). */ private static void deferDecrementRecursive(RuntimeScalar scalar) { - if (scalar == null || (scalar.type & RuntimeScalarType.REFERENCE_BIT) == 0) return; + if (scalar == null) return; + scalar.deferOwnedScalarReferenceContents(); + if ((scalar.type & RuntimeScalarType.REFERENCE_BIT) == 0) return; if (!(scalar.value instanceof RuntimeBase topBase)) return; // Fast path for leaf references (the overwhelmingly common case): @@ -985,26 +1070,24 @@ private static void deferDecrementRecursive(RuntimeScalar scalar) { while (!work.isEmpty()) { RuntimeScalar s = work.poll(); - if (s == null || (s.type & RuntimeScalarType.REFERENCE_BIT) == 0) continue; + if (s == null) continue; + s.deferOwnedScalarReferenceContents(); + if ((s.type & RuntimeScalarType.REFERENCE_BIT) == 0) continue; if (!(s.value instanceof RuntimeBase base)) continue; if (!visited.add(base)) continue; // already visited — cycle if (base.blessId != 0) { boolean releasingLastOwner = false; - if (s.refCountOwned && base.refCount > 0) { - s.refCountOwned = false; - if (base.refCountTrace) { - base.traceRefCount(0, "MortalList.deferDecrementRecursive (blessed, queued)"); - base.releaseOwner(s, "deferDecrementRecursive blessed"); - } - base.releaseActiveOwner(s); + if (s.ownsCountedReferent(base) && base.refCount > 0) { + RuntimeBase.PendingOwnerRelease ownerRelease = queueScalarOwnerRelease( + base, s, "MortalList.deferDecrementRecursive (blessed)"); // refCount is the authoritative ownership count here. // activeOwners is diagnostic and can retain a stale // call-frame alias until the enclosing sub returns. releasingLastOwner = base.refCount == 1; LifecycleRuntimeState state = state(); markBoundaryWork(state); - queueDeferredBase(state, base, null, null, s); + queueDeferredBase(state, base, ownerRelease, null, s); } else if (base.refCount == 0) { if (base.refCountTrace) { base.traceRefCount(+1, "MortalList.deferDecrementRecursive (blessed never-stored bump+queue)"); @@ -1039,20 +1122,17 @@ private static void deferDecrementRecursive(RuntimeScalar scalar) { boolean hasDirectWeakElementRefs = containerHasWeakElementRefs(base); boolean hasCleanupTargets = hasDirectWeakElementRefs || containerMayContainCleanupTargets(base); - if (s.refCountOwned && base.refCount > 0) { - s.refCountOwned = false; - if (base.refCountTrace) { - base.traceRefCount(0, "MortalList.deferDecrementRecursive (unblessed container, queued)"); - } - base.releaseActiveOwner(s); + if (s.ownsCountedReferent(base) && base.refCount > 0) { + RuntimeBase.PendingOwnerRelease ownerRelease = queueScalarOwnerRelease( + base, s, "MortalList.deferDecrementRecursive (unblessed container)"); LifecycleRuntimeState state = state(); markBoundaryWork(state); - queueDeferredBase(state, base, null, null, s); + queueDeferredBase(state, base, ownerRelease, null, s); if (!WeakRefRegistry.weakRefsExist() && base.refCount > 1) { continue; } if (!hasCleanupTargets - || base.activeOwnerCount() > 0 + || base.countedScalarOwnerCount() > 0 || (base.refCount > 1 && !hasDirectWeakElementRefs && isReachableFromExternalRootCached(base))) { @@ -1067,7 +1147,7 @@ && isReachableFromExternalRootCached(base))) { continue; } else if (base.refCount > 0 && (!hasCleanupTargets - || base.activeOwnerCount() > 0 + || base.countedScalarOwnerCount() > 0 || (!hasDirectWeakElementRefs && isReachableFromExternalRootCached(base)))) { // This scalar is a non-owning copy of a still-live container. @@ -1146,10 +1226,10 @@ private static boolean containerHasWeakElementRefs(RuntimeBase base) { } /** - * Mortal-ize blessed refs with refCount==0 in a RuntimeList that will be - * discarded (void-context call result). Without this, objects that were - * blessed but never stored in a named variable would leak. - * Only processes elements with refCount==0 (never-stored objects). + * Release owned aggregate refs and mortal-ize never-stored blessed refs + * in a RuntimeList discarded by a void-context call. A returned scalar may + * still be the same Perl cell stored in a global, live lexical, or owning + * container; dropping the call result must not release that cell's owner. */ public static void mortalizeForVoidDiscard(RuntimeList result) { if (!isActive() || result == null) return; @@ -1164,9 +1244,11 @@ public static void mortalizeForVoidDiscard(RuntimeList result) { boolean reachableCodeRef = base instanceof RuntimeCode && scalar.isStoredInRegisteredContainerOwner() && ReachabilityWalker.isScalarReachable(scalar); - if (scalar.refCountOwned && base.refCount > 0 && unblessedDiscardableContainer - && !reachableCodeRef) { - scalar.refCountOwned = false; + boolean hasOtherPerlOwner = scalar.hasDurableLvalueStorage(); + if (scalar.ownsCountedReferent(base) && base.refCount > 0 + && unblessedDiscardableContainer + && !reachableCodeRef && !hasOtherPerlOwner) { + scalar.setRefCountOwned(false); if (base.refCountTrace) { base.traceRefCount(0, "MortalList.mortalizeForVoidDiscard (queued owned container discard)"); base.releaseOwner(scalar, "mortalizeForVoidDiscard container"); @@ -1616,7 +1698,8 @@ static boolean isScalarReachableThroughRootedContainer(RuntimeScalar scalar) { if (container instanceof RuntimeHash hash) { containsScalar = hash.elements.containsValue(scalar); } else if (container instanceof RuntimeArray array) { - containsScalar = array.elements.contains(scalar); + containsScalar = array.elements.contains(scalar) + && (!array.elementsAliased || array.ownsElement(scalar)); } else { containsScalar = false; } @@ -1716,6 +1799,26 @@ private static void processDeferredBase(RuntimeBase base, boolean clearWeakRefsF // have a legacy bridge count and retain the old compatibility // sentinel behavior. if (base.nativeCaptureOwnerCount() == 0) base.refCount = 1; + } else if (restoreReachableAggregateOwnerCount( + base, deferredReleaseProvenance(ownerRelease, transientOwnerKind))) { + // A hash-entry owner has a stable semantic slot identity even + // when an unrelated legacy scalar decrement consumes the + // selective count. Restore the count from independently owned + // hash slots that still lead from live Perl roots. Hash entries + // are copied SV slots; borrowed array aliases are deliberately + // excluded here and remain on their legacy path. + // + // This is a migration bridge: the owner slot is authoritative + // for whether the entry still exists, while refCount remains + // the dispatch counter until all aggregate stores are migrated. + } else if (base instanceof RuntimeCode code + && ReachabilityWalker.isCodeReachableFromRoots(code)) { + // A callback stored in a long-lived Perl container can have + // its selective CV count consumed by a temporary invocation + // alias. The container is still a strong owner even when no + // weak references point at the CODE object. Preserve the CV + // and its lexical captures until the last Perl root is gone. + base.refCount = 1; } else if (base.localBindingExists) { if (base instanceof RuntimeScalar scalar && scalar.referencedByScalarReference @@ -1894,6 +1997,362 @@ && isReachableCached(base)) { } } + /** Restore legacy count for aggregate owner slots still reachable from Perl roots. */ + static boolean restoreReachableAggregateOwnerCount(RuntimeBase referent) { + return restoreReachableAggregateOwnerCount(referent, "direct invocation"); + } + + static boolean restoreReachableAggregateOwnerCount( + RuntimeBase referent, String releaseProvenance) { + Set rootedHashCells = Collections.newSetFromMap(new IdentityHashMap<>()); + Set rootedScalarReferenceCells = + Collections.newSetFromMap(new IdentityHashMap<>()); + Set rootedTieWrappers = Collections.newSetFromMap(new IdentityHashMap<>()); + // The referent exclusion and Perl graph stay fixed throughout this + // synchronous scan, so multiple slots in one aggregate share a proof. + Map rootedAggregateOwners = new IdentityHashMap<>(); + int slotsWithoutCell = 0; + Set ownerSlots = referent.ownerSlotSnapshot(); + ReachabilityQueryStats stats = activeReachabilityQueryStats(); + for (PerlOwnerSlot slot : ownerSlots) { + if (stats != null) stats.aggregateOwnerSlotsInspected++; + if (slot.kind() == PerlOwnerSlot.Kind.HASH_ENTRY + || slot.kind() == PerlOwnerSlot.Kind.ARRAY_ELEMENT) { + // Excluding the referent prevents an owner proof through the + // object whose pending release triggered this recovery. + RuntimeBase owner = slot.ownerContainer(); + if (!isRootedAggregateOwnerCached(owner, referent, rootedAggregateOwners)) { + continue; + } + RuntimeScalar cell = slot.ownerCell(); + if (cell == null) { + slotsWithoutCell++; + } else if (cell.value == referent + && (cell.refCountOwned || cell.hasScalarValueOwnerSlotFor(referent)) + && !WeakRefRegistry.isweak(cell)) { + rootedHashCells.add(cell); + } + } else if (slot.kind() == PerlOwnerSlot.Kind.SCALAR_VALUE + && slot.referent() == referent) { + // Scalar-value slots can supply identity when the legacy + // ownership Boolean has already retired. Aggregate cells are + // handled by their structural slot above or legacy fallback. + RuntimeScalar cell = slot.ownerCell(); + if (cell != null && cell.containerOwner == null + && cell.value == referent + && (cell.refCountOwned || cell.hasScalarValueOwnerSlotFor(referent)) + && !WeakRefRegistry.isweak(cell) + && ReachabilityWalker.isScalarReachable(cell)) { + rootedHashCells.add(cell); + } + } else if (slot.kind() == PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS + && slot.referent() == referent) { + // A counted contents slot can be the only owner when its + // underlying scalar cell is borrowed or otherwise lacks its + // own SCALAR_VALUE count. Aliases to one cell share that + // count, so restore it once per reachable contents cell. + RuntimeScalar ownerCell = slot.ownerCell(); + RuntimeScalar contentsCell = slot.scalarReferenceCell(); + if (slot.scalarReferenceContentsCounted() + && ownerCell != null && contentsCell != null + && ownerCell.scalarReferenceContentsOwner == slot + && contentsCell.value == referent + && !WeakRefRegistry.isweak(ownerCell) + && !WeakRefRegistry.isweak(contentsCell) + && ReachabilityWalker.isScalarReachable(ownerCell)) { + rootedScalarReferenceCells.add(contentsCell); + } + } else if (slot.kind() == PerlOwnerSlot.Kind.TIE_WRAPPER + && slot.referent() == referent) { + RuntimeBase owner = slot.ownerContainer(); + if (owner != null && isRootedOwnerContainer(owner, referent)) { + slotsWithoutCell++; + rootedTieWrappers.add(slot); + } + } + } + int owners = slotsWithoutCell + rootedHashCells.size() + + rootedScalarReferenceCells.size(); + // Active scalar owners may include lexical RVs in addition to hash + // cells. Exclude cells already counted through their explicit slots, + // then add other still-reachable scalar owners to avoid losing them + // when restoring the mixed legacy/native count. + for (RuntimeScalar scalar : referent.countedScalarOwnerSnapshot()) { + if (scalar == null || rootedHashCells.contains(scalar) + || !scalar.ownsCountedReferent(referent) + || !ReachabilityWalker.isScalarReachable(scalar)) { + continue; + } + owners++; + } + if (owners == 0) { + traceAggregateOwnerRecovery(referent, releaseProvenance, false, 0, ownerSlots); + return false; + } + for (PerlOwnerSlot slot : rootedTieWrappers) { + slot.markTieWrapperCountRestored(); + } + referent.refCount = owners; + traceAggregateOwnerRecovery(referent, releaseProvenance, true, owners, ownerSlots); + return true; + } + + private static String deferredReleaseProvenance( + RuntimeBase.PendingOwnerRelease release, String transientOwnerKind) { + if (OWNER_RECOVERY_TRACE_CLASS == null || OWNER_RECOVERY_TRACE_CLASS.isEmpty()) { + return null; + } + if (release == null) { + return "MortalList.processDeferredBase transient=" + transientOwnerKind; + } + PerlOwnerSlot.PendingRelease slotRelease = release.ownerSlotRelease; + String slot = slotRelease == null ? "" : " slot=" + slotRelease.kind() + + ":" + slotRelease.ownerSlotIdentity() + + "/" + slotRelease.sequence(); + return "MortalList.processDeferredBase queue=" + release.queueSite + + " acquire=" + release.acquireSite + slot; + } + + private static void traceAggregateOwnerRecovery( + RuntimeBase referent, String releaseProvenance, boolean recovered, + int restoredOwners, Set ownerSlots) { + if (OWNER_RECOVERY_TRACE_CLASS == null || OWNER_RECOVERY_TRACE_CLASS.isEmpty()) return; + String className = referent.blessId == 0 + ? referent.getClass().getSimpleName() + : NameNormalizer.getBlessStr(referent.blessId); + if (className == null || !className.contains(OWNER_RECOVERY_TRACE_CLASS)) return; + Map slotCounts = new java.util.EnumMap<>(PerlOwnerSlot.Kind.class); + List slotDetails = new java.util.ArrayList<>(); + for (PerlOwnerSlot slot : ownerSlots) { + slotCounts.merge(slot.kind(), 1, Integer::sum); + RuntimeBase owner = slot.ownerContainer(); + RuntimeScalar cell = slot.ownerCell(); + String ownerLabel = describeRecoveryOwnerContainer(owner, referent); + String cellLabel = cell == null ? "none" + : "@" + System.identityHashCode(cell) + + ":type=" + cell.type + + ":valueMatches=" + (cell.value == referent) + + ":weak=" + WeakRefRegistry.isweak(cell) + + ":owned=" + cell.refCountOwned + + ":scalarSlot=" + cell.hasScalarValueOwnerSlotFor(referent) + + ":container=" + (cell.containerOwner == null ? "none" + : cell.containerOwner.getClass().getSimpleName() + "@" + + System.identityHashCode(cell.containerOwner)); + slotDetails.add(slot.kind() + "#" + slot.identity() + + " owner=" + ownerLabel + " cell=" + cellLabel); + } + System.err.println("[OWNER-RECOVERY] base=" + System.identityHashCode(referent) + + " class=" + className + + " recovered=" + recovered + + " restoredOwners=" + restoredOwners + + " slots=" + slotCounts + + " slotDetails=" + slotDetails + + " currentlyDestroying=" + referent.currentlyDestroying + + " release=" + releaseProvenance); + } + + private static String describeRecoveryOwnerContainer( + RuntimeBase owner, RuntimeBase excludedReferent) { + if (owner == null) return "none"; + StringBuilder detail = new StringBuilder(owner.getClass().getSimpleName()) + .append('@').append(System.identityHashCode(owner)) + .append("{inSweepLive=") + .append(state().weakSweepLiveReferents != null + && state().weakSweepLiveReferents.contains(owner)) + .append(",explicitRoot=").append(isExplicitAggregateRoot(owner)) + .append(",registered=").append(MyVarCleanupStack.isRegistered(owner)) + .append(",liveLexical=").append(MyVarCleanupStack.isLive(owner)) + .append(",scopeExited=").append(owner.scopeExited) + .append(",semanticCaptureOwners=").append(owner.semanticCaptureOwnerCount()); + if (owner instanceof RuntimeHash hash) { + detail.append(",packageRoot=").append(hash.isPackageRootedHash()) + .append(",specialElements=") + .append(hash.elements instanceof HashSpecialVariable); + } else if (owner instanceof RuntimeArray array) { + detail.append(",packageRoot=").append(array.isPackageGlobalRoot) + .append(",elementsAliased=").append(array.elementsAliased); + } + try (ReachabilityQueryMeasurement measurement = measureReachabilityQueries()) { + boolean reachable = ReachabilityWalker.isReachableFromRoots( + owner, true, excludedReferent); + ReachabilityQueryStats stats = measurement.stats(); + detail.append(",globalRootQuery=").append(reachable) + .append(":roots=").append(stats.rootsSeeded) + .append(":nodes=").append(stats.nodesVisited) + .append(":edges=").append(stats.edgesInspected) + .append(":capped=").append(stats.rootQueriesBudgetCapped); + } + List parentSlots = new java.util.ArrayList<>(); + for (PerlOwnerSlot parentSlot : owner.ownerSlotSnapshot()) { + if (parentSlot.referent() != owner) continue; + RuntimeBase parent = parentSlot.ownerContainer(); + RuntimeScalar parentCell = parentSlot.ownerCell(); + String parentLabel = parent == null ? "none" + : parent.getClass().getSimpleName() + "@" + + System.identityHashCode(parent); + String cellLabel = parentCell == null ? "none" + : "@" + System.identityHashCode(parentCell) + + ":type=" + parentCell.type + + ":valueMatches=" + (parentCell.value == owner) + + ":weak=" + WeakRefRegistry.isweak(parentCell) + + ":owned=" + parentCell.refCountOwned + + ":registered=" + MyVarCleanupStack.isRegistered(parentCell) + + ":live=" + MyVarCleanupStack.isLive(parentCell); + parentSlots.add(parentSlot.kind() + "#" + parentSlot.identity() + + " parent=" + parentLabel + " cell=" + cellLabel); + } + return detail.append(",parentSlots=").append(parentSlots).append('}').toString(); + } + + /** + * Promote rooted tie-wrapper edges when a previously untracked handler + * enters selective counting. This keeps the wrapper count direct instead + * of waiting for aggregate recovery to reconstruct it later. + */ + public static void promoteRootedTieWrapperOwners(RuntimeBase referent) { + if (referent == null || referent.refCount < 0) return; + Map rootedContainers = new IdentityHashMap<>(); + for (PerlOwnerSlot slot : referent.ownerSlotSnapshot()) { + if (slot.kind() != PerlOwnerSlot.Kind.TIE_WRAPPER + || slot.referent() != referent || slot.tieWrapperCounted()) { + continue; + } + RuntimeBase owner = slot.ownerContainer(); + if (owner == null) continue; + Boolean rooted = rootedContainers.get(owner); + if (rooted == null) { + rooted = isRootedOwnerContainer(owner, referent); + rootedContainers.put(owner, rooted); + } + if (rooted) { + slot.promoteTieWrapperCount( + referent, "MortalList.promoteRootedTieWrapperOwners"); + } + } + } + + private static boolean isRootedAggregateOwnerCached( + RuntimeBase owner, RuntimeBase excludedReferent, + Map rootedAggregateOwners) { + if (rootedAggregateOwners.containsKey(owner)) { + ReachabilityQueryStats stats = activeReachabilityQueryStats(); + if (stats != null) stats.aggregateOwnerRootProofCacheHits++; + return rootedAggregateOwners.get(owner); + } + ReachabilityQueryStats stats = activeReachabilityQueryStats(); + if (stats != null) stats.aggregateOwnerRootProofs++; + boolean rooted = isRootedAggregateOwner(owner, excludedReferent); + rootedAggregateOwners.put(owner, rooted); + return rooted; + } + + /** + * Prove aggregate ownership by following registered owner slots toward a + * Perl root. Nested hash/array stores and scalar-value slots record direct + * edges, so walking that owner chain avoids a whole-root scan for the + * common case. Fall back to the graph walker for legacy edges that do not + * yet have an explicit owner slot. + */ + private static boolean isRootedAggregateOwner(RuntimeBase owner, + RuntimeBase excludedReferent) { + if (!(owner instanceof RuntimeHash) && !(owner instanceof RuntimeArray)) return false; + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + ArrayDeque pending = new ArrayDeque<>(); + pending.add(owner); + while (!pending.isEmpty()) { + RuntimeBase current = pending.removeFirst(); + if (current == null || current == excludedReferent || !visited.add(current)) continue; + if (isExplicitAggregateRoot(current)) return true; + for (PerlOwnerSlot parentSlot : current.ownerSlotSnapshot()) { + if (parentSlot.referent() != current) continue; + if (parentSlot.kind() == PerlOwnerSlot.Kind.HASH_ENTRY + || parentSlot.kind() == PerlOwnerSlot.Kind.ARRAY_ELEMENT) { + RuntimeBase parent = parentSlot.ownerContainer(); + RuntimeScalar cell = parentSlot.ownerCell(); + if (isLiveAggregateOwnerEdge(parent, cell, current)) { + pending.addLast(parent); + } + } else if (parentSlot.kind() == PerlOwnerSlot.Kind.SCALAR_VALUE + || parentSlot.kind() == PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS) { + RuntimeScalar ownerCell = parentSlot.ownerCell(); + if (parentSlot.kind() == PerlOwnerSlot.Kind.SCALAR_VALUE) { + if (!isLiveScalarOwnerEdge(ownerCell, current)) continue; + } else if (!isLiveScalarReferenceContentsEdge( + parentSlot, ownerCell, current)) { + continue; + } + if (isExplicitScalarRoot(ownerCell)) return true; + RuntimeBase parent = ownerCell.containerOwner; + if (isLiveAggregateOwnerCell(parent, ownerCell)) { + pending.addLast(parent); + } + } + } + } + return ReachabilityWalker.isReachableFromRoots(owner, true, excludedReferent); + } + + private static boolean isLiveScalarOwnerEdge(RuntimeScalar cell, RuntimeBase referent) { + return cell != null && cell.value == referent && !WeakRefRegistry.isweak(cell); + } + + private static boolean isLiveScalarReferenceContentsEdge( + PerlOwnerSlot slot, RuntimeScalar ownerCell, RuntimeBase referent) { + RuntimeScalar contentsCell = slot.scalarReferenceCell(); + return ownerCell != null + && ownerCell.scalarReferenceContentsOwner == slot + && !WeakRefRegistry.isweak(ownerCell) + && contentsCell != null && contentsCell.value == referent + && !WeakRefRegistry.isweak(contentsCell); + } + + private static boolean isLiveAggregateOwnerCell(RuntimeBase parent, RuntimeScalar cell) { + if (parent instanceof RuntimeHash hash) { + return hash.elements.containsValue(cell); + } + if (parent instanceof RuntimeArray array) { + return array.elements.contains(cell) && array.ownsElement(cell); + } + return false; + } + + private static boolean isExplicitScalarRoot(RuntimeScalar cell) { + if (!cell.scopeExited && MyVarCleanupStack.isRegistered(cell)) return true; + if (cell.isStoredInRegisteredContainerOwner()) return true; + return cell.isPackageGlobalRoot + && (GlobalVariable.scalarSlots().containsValue(cell) + || GlobalVariable.globalCodeRefs.containsValue(cell)); + } + + static boolean isRootedOwnerContainer(RuntimeBase owner, + RuntimeBase excludedReferent) { + if (owner instanceof RuntimeHash || owner instanceof RuntimeArray) { + return isRootedAggregateOwner(owner, excludedReferent); + } + return ReachabilityWalker.isReachableFromRoots(owner, true, excludedReferent); + } + + private static boolean isExplicitAggregateRoot(RuntimeBase aggregate) { + if (aggregate.semanticCaptureOwnerCount() > 0) return true; + if (aggregate instanceof RuntimeHash hash && hash.isPackageRootedHash()) return true; + if (aggregate instanceof RuntimeArray && aggregate.isPackageGlobalRoot) return true; + return !aggregate.scopeExited && MyVarCleanupStack.isRegistered(aggregate); + } + + private static boolean isLiveAggregateOwnerEdge(RuntimeBase parent, + RuntimeScalar cell, + RuntimeBase referent) { + if (parent == null || cell == null || cell.value != referent + || WeakRefRegistry.isweak(cell)) return false; + if (parent instanceof RuntimeHash hash) { + return hash.elements.containsValue(cell); + } + if (parent instanceof RuntimeArray array) { + return array.elements.contains(cell) && array.ownsElement(cell); + } + return false; + } + private static boolean blessedClassHasDestroy(RuntimeBase base) { String className = NameNormalizer.getBlessStr(base.blessId); return className != null && DestroyDispatch.classHasDestroy(base.blessId, className); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/MyVarCleanupStack.java b/src/main/java/org/perlonjava/runtime/runtimetypes/MyVarCleanupStack.java index 807c62df40..8b50bc532a 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/MyVarCleanupStack.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/MyVarCleanupStack.java @@ -68,6 +68,21 @@ public static boolean isRegistered(Object var) { return registeredCounts().containsKey(var); } + /** True when {@code var} was registered before the current frame mark. */ + public static boolean isRegisteredBefore(int mark, Object var) { + if (var == null) return false; + ArrayList current = stack(); + int limit = Math.min(Math.max(mark, 0), current.size()); + for (int i = limit - 1; i >= 0; i--) { + if (current.get(i) == var) return true; + } + return false; + } + + public static int currentSize() { + return stack().size(); + } + /** * Snapshot the currently-live my-variables. Used by the * reachability walker's per-object query @@ -360,14 +375,20 @@ public static void retainBlessedGlobAliases(RuntimeGlob glob, RuntimeBase refere && (scalar.type == RuntimeScalarType.GLOB || scalar.type == RuntimeScalarType.GLOBREFERENCE) && scalar.value == glob - && !scalar.refCountOwned + && !scalar.ownsCountedReferent(referent) && seen.put(scalar, Boolean.TRUE) == null) { - scalar.refCountOwned = true; - referent.recordOwner(scalar, "blessed glob lexical alias"); - referent.recordActiveOwner(scalar); - referent.traceRefCount(+1, "MyVarCleanupStack.retainBlessedGlobAliases"); - referent.refCount++; - referent.hadCountedReference = true; + if (!scalar.acquireScalarValueOwnerCount( + referent, "MyVarCleanupStack.retainBlessedGlobAliases")) { + // Preserve the legacy count for a glob referent that has + // not entered tracked ownership yet. + scalar.setRefCountOwned(true); + referent.recordOwner(scalar, "blessed glob lexical alias"); + referent.recordActiveOwner(scalar); + referent.traceRefCount(+1, + "MyVarCleanupStack.retainBlessedGlobAliases"); + referent.refCount++; + referent.hadCountedReference = true; + } } } for (RuntimeScalar scalar : GlobalVariable.scalarSlots().values()) { @@ -375,14 +396,20 @@ public static void retainBlessedGlobAliases(RuntimeGlob glob, RuntimeBase refere && (scalar.type == RuntimeScalarType.GLOB || scalar.type == RuntimeScalarType.GLOBREFERENCE) && scalar.value == glob - && !scalar.refCountOwned + && !scalar.ownsCountedReferent(referent) && seen.put(scalar, Boolean.TRUE) == null) { - scalar.refCountOwned = true; - referent.recordOwner(scalar, "blessed glob package alias"); - referent.recordActiveOwner(scalar); - referent.traceRefCount(+1, "MyVarCleanupStack.retainBlessedGlobAliases"); - referent.refCount++; - referent.hadCountedReference = true; + if (!scalar.acquireScalarValueOwnerCount( + referent, "MyVarCleanupStack.retainBlessedGlobAliases")) { + // Preserve the legacy count for a glob referent that has + // not entered tracked ownership yet. + scalar.setRefCountOwned(true); + referent.recordOwner(scalar, "blessed glob package alias"); + referent.recordActiveOwner(scalar); + referent.traceRefCount(+1, + "MyVarCleanupStack.retainBlessedGlobAliases"); + referent.refCount++; + referent.hadCountedReference = true; + } } } } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlOwnerSlot.java b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlOwnerSlot.java index 5cf83c1ded..0166a80284 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlOwnerSlot.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlOwnerSlot.java @@ -3,6 +3,7 @@ import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; +import java.lang.ref.WeakReference; /** * Identity for one Perl strong-owner slot. @@ -17,13 +18,24 @@ public final class PerlOwnerSlot { private static final AtomicLong NEXT_ID = new AtomicLong(); public enum Kind { - CLOSURE_PAD + CLOSURE_PAD, + HASH_ENTRY, + ARRAY_ELEMENT, + SCALAR_REFERENCE_CONTENTS, + SCALAR_VALUE, + TIE_WRAPPER } private final Kind kind; private final RuntimeScalar padCell; + private final WeakReference ownerContainer; + private volatile WeakReference ownerCell; + private volatile WeakReference scalarReferenceCell; + private volatile RuntimeCode closureOwner; private final long identity = NEXT_ID.incrementAndGet(); private RuntimeBase referent; + private boolean tieWrapperCounted; + private boolean scalarReferenceContentsCounted; private PerlRuntime ownerRuntime; private LifecycleRuntimeState ownerRuntimeState; private int legacyCaptureCount; @@ -35,12 +47,19 @@ public static final class PendingRelease { private final long ownerSlotIdentity; private final long sequence; private final Kind kind; + private final PerlOwnerSlot detachOnCompletion; private final AtomicBoolean completed = new AtomicBoolean(); private PendingRelease(long ownerSlotIdentity, long sequence, Kind kind) { + this(ownerSlotIdentity, sequence, kind, null); + } + + private PendingRelease(long ownerSlotIdentity, long sequence, Kind kind, + PerlOwnerSlot detachOnCompletion) { this.ownerSlotIdentity = ownerSlotIdentity; this.sequence = sequence; this.kind = kind; + this.detachOnCompletion = detachOnCompletion; } public long ownerSlotIdentity() { @@ -60,17 +79,29 @@ public boolean isCompleted() { } void complete() { - completed.set(true); + if (!completed.compareAndSet(false, true)) return; + if (detachOnCompletion != null) detachOnCompletion.detachAfterDeferredRelease(); } } public PerlOwnerSlot(Kind kind) { - this(kind, null); + this(kind, null, null); } PerlOwnerSlot(Kind kind, RuntimeScalar padCell) { + this(kind, padCell, null); + } + + PerlOwnerSlot(Kind kind, RuntimeScalar padCell, RuntimeBase ownerContainer) { + this(kind, padCell, ownerContainer, null); + } + + PerlOwnerSlot(Kind kind, RuntimeScalar padCell, RuntimeBase ownerContainer, + RuntimeScalar ownerCell) { this.kind = Objects.requireNonNull(kind, "kind"); this.padCell = padCell; + this.ownerContainer = ownerContainer == null ? null : new WeakReference<>(ownerContainer); + this.ownerCell = ownerCell == null ? null : new WeakReference<>(ownerCell); } public Kind kind() { @@ -85,6 +116,20 @@ RuntimeScalar padCell() { return padCell; } + RuntimeBase ownerContainer() { + return ownerContainer == null ? null : ownerContainer.get(); + } + + RuntimeScalar ownerCell() { + WeakReference reference = ownerCell; + return reference == null ? null : reference.get(); + } + + RuntimeScalar scalarReferenceCell() { + WeakReference reference = scalarReferenceCell; + return reference == null ? null : reference.get(); + } + boolean isNativeCaptureOwner() { return nativeCaptureOwner; } @@ -101,6 +146,18 @@ public synchronized int legacyCaptureCount() { return legacyCaptureCount; } + synchronized boolean tieWrapperCounted() { + return tieWrapperCounted; + } + + synchronized boolean scalarReferenceContentsCounted() { + return scalarReferenceContentsCounted; + } + + RuntimeCode closureOwner() { + return closureOwner; + } + /** Acquire one closure-pad referent count through the legacy bridge. */ public synchronized void acquireLegacyCapture(RuntimeBase target) { Objects.requireNonNull(target, "target"); @@ -126,12 +183,418 @@ public synchronized void acquireLegacyCapture(RuntimeBase target) { /** Acquire a captured aggregate pad as an explicit owner slot. */ public synchronized void acquireCapture(RuntimeBase target) { + acquireCapture(target, null); + } + + /** Acquire a captured aggregate pad as an edge from its owning closure. */ + public synchronized void acquireCapture(RuntimeBase target, RuntimeCode owner) { Objects.requireNonNull(target, "target"); if (target.refCount == Integer.MIN_VALUE) { throw new IllegalStateException("cannot capture a destroyed referent"); } nativeCaptureOwner = true; + closureOwner = owner; + transferTo(target); + } + + /** Attach this stable hash-entry slot to its current direct referent. */ + public synchronized void acquireHashEntry(RuntimeBase target) { + acquireHashEntry(target, null); + } + + /** Attach this stable hash-entry slot to its current scalar cell and referent. */ + public synchronized void acquireHashEntry(RuntimeBase target, RuntimeScalar cell) { + if (kind != Kind.HASH_ENTRY) { + throw new IllegalStateException("hash-entry acquisition requires a HASH_ENTRY slot"); + } + ownerCell = cell == null ? null : new WeakReference<>(cell); + transferTo(target); + } + + /** Attach this stable array-element slot to its current direct referent. */ + public synchronized void acquireArrayElement(RuntimeBase target) { + acquireArrayElement(target, null); + } + + /** Attach this stable array-element slot to its current scalar cell and referent. */ + public synchronized void acquireArrayElement(RuntimeBase target, RuntimeScalar cell) { + if (kind != Kind.ARRAY_ELEMENT) { + throw new IllegalStateException("array-element acquisition requires an ARRAY_ELEMENT slot"); + } + ownerCell = cell == null ? null : new WeakReference<>(cell); + transferTo(target); + } + + /** + * Attach one scalar-reference alias slot to its cell's contents. + * Distinct aliases keep distinct reachability identities, while aliases + * to the same scalar cell share one referent count. + */ + public synchronized void acquireScalarReferenceContents(RuntimeBase target) { + acquireScalarReferenceContents(target, null); + } + + public synchronized void acquireScalarReferenceContents( + RuntimeBase target, RuntimeScalar referenceCell) { + if (kind != Kind.SCALAR_REFERENCE_CONTENTS) { + throw new IllegalStateException( + "scalar-reference acquisition requires a SCALAR_REFERENCE_CONTENTS slot"); + } + Objects.requireNonNull(target, "target"); + if (target.refCount < 0) { + throw new IllegalStateException( + "scalar-reference contents require a tracked referent"); + } + if (referent != null && referent != target) { + throw new IllegalStateException("cannot retarget an active scalar-reference owner"); + } + if (referent == target) { + RuntimeScalar currentCell = scalarReferenceCell(); + if (currentCell == referenceCell) return; + throw new IllegalStateException( + "an active scalar-reference owner cannot change cells during acquisition"); + } + boolean hasCountedSibling = hasCountedSiblingScalarReferenceContentsOwner( + referenceCell, target); + boolean cellOwnsContents = referenceCell != null + && referenceCell.ownsCountedReferent(target); + boolean countedContentsOwner = hasCountedSibling || !cellOwnsContents; + boolean acquireCount = countedContentsOwner && !hasCountedSibling; + if (acquireCount && target.refCount == Integer.MAX_VALUE) { + throw new IllegalStateException("scalar-reference contents count overflow"); + } + transferTo(target); + scalarReferenceContentsCounted = countedContentsOwner; + if (acquireCount) { + target.traceRefCount(+1, "PerlOwnerSlot.acquireScalarReferenceContents"); + target.refCount++; + target.hadCountedReference = true; + target.acquireTransientTraceOwner( + "scalar-reference contents", "PerlOwnerSlot.scalarReferenceContents"); + } + scalarReferenceCell = referenceCell == null ? null : new WeakReference<>(referenceCell); + if (referenceCell != null) referenceCell.registerScalarReferenceContentsOwner(this); + } + + /** Move the scalar cell that represents this contents owner. */ + public synchronized void transferScalarReferenceContentsCell(RuntimeScalar cell) { + if (kind != Kind.SCALAR_REFERENCE_CONTENTS) { + throw new IllegalStateException( + "scalar-reference cell transfer requires a SCALAR_REFERENCE_CONTENTS slot"); + } + if (referent == null) { + throw new IllegalStateException("cannot transfer an inactive scalar-reference owner"); + } + ownerCell = cell == null ? null : new WeakReference<>(cell); + } + + /** Release this scalar-reference contents owner and dispatch on final release. */ + public synchronized void releaseScalarReferenceContents() { + if (kind != Kind.SCALAR_REFERENCE_CONTENTS) { + throw new IllegalStateException( + "scalar-reference release requires a SCALAR_REFERENCE_CONTENTS slot"); + } + RuntimeBase target = referent; + if (target == null) return; + RuntimeScalar referenceCell = scalarReferenceCell == null ? null : scalarReferenceCell.get(); + boolean countedContentsOwner = scalarReferenceContentsCounted; + scalarReferenceContentsCounted = false; + scalarReferenceCell = null; + if (referenceCell != null) referenceCell.unregisterScalarReferenceContentsOwner(this); + transferTo(null); + if (hasSiblingScalarReferenceContentsOwner(referenceCell, target)) return; + if (!countedContentsOwner) { + releaseDetachedScalarCellContents(referenceCell, target); + return; + } + if (target.refCount <= 0) return; + target.traceRefCount(-1, "PerlOwnerSlot.releaseScalarReferenceContents"); + target.releaseTransientTraceOwner( + "scalar-reference contents", "PerlOwnerSlot.scalarReferenceContents"); + if (--target.refCount == 0) { + target.refCount = Integer.MIN_VALUE; + DestroyDispatch.callDestroy(target); + } + } + + /** Queue a scalar-reference contents decrement with stable slot provenance. */ + public synchronized PendingRelease deferScalarReferenceContentsRelease() { + if (kind != Kind.SCALAR_REFERENCE_CONTENTS) { + throw new IllegalStateException( + "scalar-reference release requires a SCALAR_REFERENCE_CONTENTS slot"); + } + RuntimeBase target = referent; + if (target == null) return null; + if (target.refCount <= 0) { + releaseScalarReferenceContents(); + target.releaseTransientTraceOwner( + "scalar-reference contents", "PerlOwnerSlot.deferScalarReferenceContentsRelease"); + return null; + } + RuntimeScalar referenceCell = scalarReferenceCell == null ? null : scalarReferenceCell.get(); + boolean countedContentsOwner = scalarReferenceContentsCounted; + scalarReferenceContentsCounted = false; + scalarReferenceCell = null; + if (referenceCell != null) referenceCell.unregisterScalarReferenceContentsOwner(this); + if (hasSiblingScalarReferenceContentsOwner(referenceCell, target)) { + transferTo(null); + return null; + } + if (!countedContentsOwner) { + transferTo(null); + releaseDetachedScalarCellContents(referenceCell, target); + return null; + } + PendingRelease release = new PendingRelease(identity, ++nextReleaseSequence, kind, this); + MortalList.deferOwnerSlotDecrement(target, "scalar-reference contents", release); + return release; + } + + /** Transfer the contents edge when its referenced scalar cell is overwritten. */ + public synchronized void retargetScalarReferenceContents(RuntimeBase nextTarget) { + if (kind != Kind.SCALAR_REFERENCE_CONTENTS) { + throw new IllegalStateException( + "scalar-reference retargeting requires a SCALAR_REFERENCE_CONTENTS slot"); + } + RuntimeBase previous = referent; + if (previous == nextTarget) return; + RuntimeScalar referenceCell = scalarReferenceCell == null ? null : scalarReferenceCell.get(); + boolean previousCounted = scalarReferenceContentsCounted; + boolean hasCountedSiblingForNext = nextTarget != null + && hasCountedSiblingScalarReferenceContentsOwner(referenceCell, nextTarget); + boolean nextCellOwnsContents = nextTarget != null && referenceCell != null + && referenceCell.ownsCountedReferent(nextTarget); + boolean nextCounted = nextTarget != null + && (hasCountedSiblingForNext || !nextCellOwnsContents); + boolean acquireNextCount = nextCounted && !hasCountedSiblingForNext; + if (nextTarget != null) { + if (nextTarget.refCount < 0) { + nextTarget = null; + nextCounted = false; + acquireNextCount = false; + } else if (acquireNextCount) { + if (nextTarget.refCount == Integer.MAX_VALUE) { + throw new IllegalStateException("scalar-reference contents count overflow"); + } + nextTarget.traceRefCount(+1, "PerlOwnerSlot.retargetScalarReferenceContents"); + nextTarget.refCount++; + nextTarget.hadCountedReference = true; + nextTarget.acquireTransientTraceOwner( + "scalar-reference contents", "PerlOwnerSlot.scalarReferenceContents"); + } + } + transferTo(nextTarget); + scalarReferenceContentsCounted = nextTarget != null && nextCounted; + if (nextTarget == null && scalarReferenceCell != null) { + scalarReferenceCell = null; + if (referenceCell != null) referenceCell.unregisterScalarReferenceContentsOwner(this); + } + if (previous == null + || hasSiblingScalarReferenceContentsOwner(referenceCell, previous) + || !previousCounted + || previous.refCount <= 0) return; + previous.traceRefCount(-1, "PerlOwnerSlot.retargetScalarReferenceContents"); + previous.releaseTransientTraceOwner( + "scalar-reference contents", "PerlOwnerSlot.scalarReferenceContents"); + if (--previous.refCount == 0) { + previous.refCount = Integer.MIN_VALUE; + DestroyDispatch.callDestroy(previous); + } + } + + /** Acquire one tracked direct-scalar owner through this slot. */ + synchronized boolean acquireScalarValueCount(RuntimeBase target, String acquireSite) { + if (kind != Kind.SCALAR_VALUE) { + throw new IllegalStateException( + "scalar-value acquisition requires a SCALAR_VALUE slot"); + } + Objects.requireNonNull(target, "target"); + if (referent == target) return true; + if (referent != null) { + throw new IllegalStateException("cannot retarget an active scalar-value owner"); + } + if (target.refCount < 0) return false; + if (target.refCount == Integer.MAX_VALUE) { + throw new IllegalStateException("scalar-value owner count overflow"); + } + target.traceRefCount(+1, acquireSite); + target.refCount++; + target.hadCountedReference = true; + RuntimeScalar scalar = ownerCell(); + if (scalar != null) { + scalar.refCountOwned = true; + target.recordOwner(scalar, acquireSite); + target.recordActiveOwner(scalar); + } + transferTo(target); + return true; + } + + /** Move the stable direct-scalar owner identity to a postfix result cell. */ + synchronized boolean transferScalarValueCell( + RuntimeScalar expectedOwner, RuntimeScalar nextOwner) { + if (kind != Kind.SCALAR_VALUE) { + throw new IllegalStateException( + "scalar-value transfer requires a SCALAR_VALUE slot"); + } + if (expectedOwner == null || nextOwner == null + || referent == null || ownerCell() != expectedOwner) { + return false; + } + ownerCell = new WeakReference<>(nextOwner); + expectedOwner.refCountOwned = false; + nextOwner.refCountOwned = true; + return true; + } + + /** + * Release the legacy counted edge represented by a direct scalar-value + * slot. The caller retains lifecycle policy (for example, whether a + * lexical binding suppresses DESTROY) after receiving the decremented + * count. This makes explicit slot identity the authority for the scalar + * owner's release while the referent counter remains the dispatch bridge. + */ + synchronized boolean releaseScalarValueCount(RuntimeBase expectedTarget, String releaseSite) { + if (kind != Kind.SCALAR_VALUE) { + throw new IllegalStateException( + "scalar-value release requires a SCALAR_VALUE slot"); + } + if (referent != expectedTarget || expectedTarget == null + || expectedTarget.refCount <= 0) { + return false; + } + RuntimeScalar scalar = ownerCell(); + transferTo(null); + if (scalar != null) scalar.refCountOwned = false; + expectedTarget.traceRefCount(-1, releaseSite); + if (scalar != null) { + expectedTarget.releaseOwner(scalar, releaseSite); + expectedTarget.releaseActiveOwner(scalar); + } + expectedTarget.refCount--; + return true; + } + + /** Acquire the tie wrapper's independent strong reference to its handler. */ + synchronized boolean acquireTieWrapperCount(RuntimeBase target, String acquireSite) { + if (kind != Kind.TIE_WRAPPER) { + throw new IllegalStateException("tie-wrapper acquisition requires a TIE_WRAPPER slot"); + } + Objects.requireNonNull(target, "target"); + if (referent == target) return true; + if (referent != null) { + throw new IllegalStateException("cannot retarget an active tie-wrapper owner"); + } + if (target.refCount < -1) return false; + if (target.refCount >= 0) { + if (target.refCount == Integer.MAX_VALUE) { + throw new IllegalStateException("tie-wrapper owner count overflow"); + } + target.traceRefCount(+1, acquireSite); + target.refCount++; + target.hadCountedReference = true; + tieWrapperCounted = true; + } transferTo(target); + return true; + } + + synchronized void markTieWrapperCountRestored() { + if (kind != Kind.TIE_WRAPPER || referent == null) { + throw new IllegalStateException("only an active tie-wrapper slot can be restored"); + } + tieWrapperCounted = true; + } + + /** Count a previously untracked wrapper owner when its rooted target is promoted. */ + synchronized boolean promoteTieWrapperCount(RuntimeBase expectedTarget, String acquireSite) { + if (kind != Kind.TIE_WRAPPER) { + throw new IllegalStateException( + "tie-wrapper promotion requires a TIE_WRAPPER slot"); + } + if (referent != expectedTarget || expectedTarget == null || tieWrapperCounted) { + return false; + } + if (expectedTarget.refCount < 0) return false; + if (expectedTarget.refCount == Integer.MAX_VALUE) { + throw new IllegalStateException("tie-wrapper owner count overflow"); + } + expectedTarget.traceRefCount(+1, acquireSite); + expectedTarget.refCount++; + expectedTarget.hadCountedReference = true; + tieWrapperCounted = true; + return true; + } + + /** Retire a tie-wrapper owner identity and decrement its legacy bridge count. */ + synchronized boolean releaseTieWrapperCount(RuntimeBase expectedTarget, String releaseSite) { + if (kind != Kind.TIE_WRAPPER) { + throw new IllegalStateException("tie-wrapper release requires a TIE_WRAPPER slot"); + } + if (referent != expectedTarget || expectedTarget == null) return false; + boolean counted = tieWrapperCounted; + tieWrapperCounted = false; + transferTo(null); + if (!counted) return true; + if (expectedTarget.refCount <= 0) return true; + expectedTarget.traceRefCount(-1, releaseSite); + expectedTarget.refCount--; + return true; + } + + /** Detach a scalar-value owner and preserve its identity for deferred release. */ + synchronized PendingRelease deferScalarValueCountRelease(RuntimeBase expectedTarget) { + if (kind != Kind.SCALAR_VALUE) { + throw new IllegalStateException( + "scalar-value release requires a SCALAR_VALUE slot"); + } + if (referent != expectedTarget || expectedTarget == null) return null; + PendingRelease release = new PendingRelease(identity, ++nextReleaseSequence, kind); + RuntimeScalar scalar = ownerCell(); + if (scalar != null) scalar.refCountOwned = false; + release(); + return release; + } + + private synchronized void detachAfterDeferredRelease() { + transferTo(null); + } + + private boolean hasSiblingScalarReferenceContentsOwner( + RuntimeScalar referenceCell, RuntimeBase target) { + if (referenceCell == null || target == null) return false; + for (PerlOwnerSlot slot : referenceCell.scalarReferenceContentsOwnersSnapshot()) { + if (slot != this && slot.referent() == target) return true; + } + return false; + } + + private boolean hasCountedSiblingScalarReferenceContentsOwner( + RuntimeScalar referenceCell, RuntimeBase target) { + if (referenceCell == null || target == null) return false; + for (PerlOwnerSlot slot : referenceCell.scalarReferenceContentsOwnersSnapshot()) { + if (slot != this && slot.referent() == target + && slot.scalarReferenceContentsCounted) return true; + } + return false; + } + + private static void releaseDetachedScalarCellContents( + RuntimeScalar referenceCell, RuntimeBase target) { + if (referenceCell != null && referenceCell.value == target + && !referenceCell.hasOwningAggregateSlot() + && referenceCell.ownsCountedReferent(target)) { + // The scalar cell held the sole SCALAR_VALUE count while it lived + // in an aggregate. A refalias keeps that same cell alive after + // deletion; release the cell's value owner when its final alias + // disappears instead of manufacturing a second count for it. + if (MortalList.isActive()) { + MortalList.deferDecrementIfTracked(referenceCell); + } else { + RuntimeScalar.releaseDynamicRestoreScalarValueOwner( + referenceCell, "PerlOwnerSlot.releaseDetachedScalarCellContents"); + } + } } /** Queue one closure-pad count release and retain its slot provenance. */ @@ -158,9 +621,14 @@ public synchronized void transferTo(RuntimeBase nextReferent) { RuntimeBase previous = referent; PerlRuntime previousRuntime = ownerRuntime; LifecycleRuntimeState previousState = ownerRuntimeState; - PerlRuntime nextRuntime = nextReferent == null + // Hash entry slots are owned by their hash, so retaining their + // referent from the runtime would turn an unrooted hash into a root. + // Closure-pad slots are runtime roots while their Perl closure lives. + PerlRuntime nextRuntime = kind != Kind.CLOSURE_PAD || nextReferent == null ? null : PerlRuntime.currentOrNull(); - if (nextReferent != null && nextRuntime == null) nextRuntime = previousRuntime; + if (kind == Kind.CLOSURE_PAD && nextReferent != null && nextRuntime == null) { + nextRuntime = previousRuntime; + } referent = nextReferent; ownerRuntime = nextRuntime; @@ -180,6 +648,7 @@ public synchronized void release() { referent = null; ownerRuntime = null; ownerRuntimeState = null; + ownerCell = null; if (previous != null) previous.removeOwnerSlot(this); if (previous != null) PerlRuntime.releaseOwnerSlotReferent(previousState, previous); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlRuntime.java b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlRuntime.java index 506b7e6636..af7c238114 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlRuntime.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlRuntime.java @@ -241,6 +241,22 @@ static java.util.List snapshotOwnerSlotReferents() { } } + /** + * Snapshot only legacy/unassociated capture pads that still need to act as + * conservative roots. Pads owned by a RuntimeCode are reached through that + * closure's capture edge, avoiding a false root from a closure to its own + * captured aggregate graph. + */ + static java.util.List snapshotOwnerSlotRootReferents() { + java.util.List referents = snapshotOwnerSlotReferents(); + if (referents.isEmpty()) return referents; + java.util.ArrayList roots = new java.util.ArrayList<>(referents.size()); + for (RuntimeBase referent : referents) { + if (referent.hasUnassociatedClosureCaptureOwner()) roots.add(referent); + } + return java.util.List.copyOf(roots); + } + /** Record the address exposed by Perl reference stringification for B introspection. */ public void registerReferenceAddress(RuntimeBase value) { registerReferenceAddress(referenceAddress(value), value); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityQueryStats.java b/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityQueryStats.java index 8938a4577a..d54c90b107 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityQueryStats.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityQueryStats.java @@ -3,6 +3,7 @@ /** Optional per-runtime counters for deterministic reachability cost tests. */ final class ReachabilityQueryStats { long rootQueries; + long rootQueriesBudgetCapped; long rootsSeeded; long edgesInspected; long nodesVisited; @@ -21,4 +22,7 @@ final class ReachabilityQueryStats { long unblessedAggregateDrainPositiveProofsCached; long scalarReachabilityQueries; long scalarReachabilityRootWalks; + long aggregateOwnerRootProofs; + long aggregateOwnerRootProofCacheHits; + long aggregateOwnerSlotsInspected; } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java b/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java index 37e74459ac..6d782bab05 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java @@ -139,7 +139,7 @@ private static boolean isNonOwningDebugArgsArray(String name) { private static boolean hasCapturedAggregateElementOwner(RuntimeBase referent) { for (RuntimeScalar owner : ScalarRefRegistry.snapshot()) { - if (owner == null || !owner.refCountOwned || owner.value != referent) continue; + if (owner == null || !owner.ownsCountedReferent(referent)) continue; RuntimeBase container = owner.containerOwner; if (container instanceof RuntimeArray array && array.captureCount > 0 && array.elements.contains(owner)) { @@ -664,17 +664,9 @@ private static java.util.List findPathTo( for (Map.Entry e : GlobalVariable.globalCodeRefs.entrySet()) { seedPath(e.getValue(), "&" + e.getKey(), howReached, todo); } - int activeCodeIdx = 0; - for (RuntimeCode active : PerlRuntime.current().executionState().activeCodeStack) { - String label = ""; - if (howReached.putIfAbsent(active, label) == null) todo.add(active); - } - int argsFrameIdx = 0; - for (List args : RuntimeCode.snapshotPristineArgsStack()) { - for (RuntimeScalar arg : args) { - seedPath(arg, "", howReached, todo); - } - argsFrameIdx++; + int codeEdgeIdx = 0; + for (RuntimeScalar codeRef : GlobalVariable.globalCodeRefEdgeRootsView()) { + seedPath(codeRef, "", howReached, todo); } int rescuedIdx = 0; for (RuntimeBase rescued : DestroyDispatch.snapshotRescuedForWalk()) { @@ -689,9 +681,25 @@ private static java.util.List findPathTo( for (RuntimeScalar sc : org.perlonjava.runtime.WarningBitsRegistry.snapshotHintHashStackScalars()) { seedPath(sc, "", howReached, todo); } - int suspendedIdx = 0; + int activeCodeIdx = 0; + for (RuntimeCode active : PerlRuntime.current().executionState().activeCodeStack) { + String label = ""; + if (howReached.putIfAbsent(active, label) == null) todo.add(active); + } + int activeArgsIdx = 0; + for (java.util.List args : RuntimeCode.snapshotPristineArgsStack()) { + int argIdx = 0; + for (RuntimeScalar arg : args) { + if (arg == null || WeakRefRegistry.isweak(arg)) continue; + seedPath(arg, "[" + argIdx++ + "]", + howReached, todo); + } + activeArgsIdx++; + } + int activeSuspendedIdx = 0; for (RuntimeBase suspended : MortalList.snapshotSuspendedRoots()) { - if (howReached.putIfAbsent(suspended, "") == null) { + if (howReached.putIfAbsent(suspended, + "") == null) { todo.add(suspended); } } @@ -747,6 +755,28 @@ private static java.util.List findPathTo( todo.add(tempRoot); } } + int ownerIdx = 0; + for (RuntimeBase ownerReferent : PerlRuntime.snapshotOwnerSlotReferents()) { + String ownerClass = ownerReferent.blessId == 0 + ? ownerReferent.getClass().getSimpleName() + : NameNormalizer.getBlessStr(ownerReferent.blessId); + String ownerLabel = ""; + if (ownerReferent instanceof RuntimeCode code) { + ownerLabel = ""; + } + if (howReached.putIfAbsent(ownerReferent, ownerLabel) == null) { + todo.add(ownerReferent); + } + } + int suspendedIdx = 0; + for (RuntimeBase suspended : MortalList.snapshotSuspendedRoots()) { + if (howReached.putIfAbsent(suspended, "") == null) { + todo.add(suspended); + } + } } while (!todo.isEmpty()) { RuntimeBase cur = todo.removeFirst(); @@ -771,6 +801,8 @@ private static java.util.List findPathTo( for (RuntimeScalar v : a.elements) { visitScalarPath(v, curPath + "[" + (idx++) + "]", howReached, todo); } + } else if (cur instanceof RuntimeScalar scalar) { + visitScalarPath(scalar, curPath, howReached, todo); } else if (cur instanceof RuntimeCode code) { if (code.closedOverVariables != null) { for (Map.Entry captured @@ -797,6 +829,20 @@ private static java.util.List findPathTo( String path = curPath + ""; if (howReached.putIfAbsent(state.getValue(), path) == null) todo.add(state.getValue()); } + if (code.closedOverVariables != null) { + int i = 0; + for (Map.Entry entry : code.closedOverVariables.entrySet()) { + RuntimeBase captured = entry.getValue(); + String path = curPath + ""; + if (captured instanceof RuntimeScalar scalar) { + visitScalarPath(scalar, path, howReached, todo); + } else if (captured != null + && howReached.putIfAbsent(captured, path) == null) { + todo.add(captured); + } + } + } // Phase I: mirror the main walker — follow closure captures // so findPathTo traces through the same graph as sweepWeakRefs. if (code.capturedScalars != null) { @@ -838,12 +884,113 @@ private static java.util.List findPathTo( for (RuntimeCode callback : regex.executableCallbackCodes()) { String path = curPath + ""; if (howReached.putIfAbsent(callback, path) == null) todo.add(callback); - } + } } } return null; } + /** Dump closure capture owners that can explain a retained target. */ + public static void dumpCaptureOwnersForTrace(RuntimeBase target) { + java.util.Set capturedAggregates = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + capturedAggregates.add(target); + int arrayIndex = 0; + for (RuntimeBase owner : PerlRuntime.snapshotOwnerSlotReferents()) { + if (!(owner instanceof RuntimeArray array)) continue; + boolean containsTarget = false; + for (RuntimeScalar element : array.elements) { + if (element != null && element.value == target) { + containsTarget = true; + break; + } + } + if (containsTarget) { + capturedAggregates.add(array); + System.err.println(" captured-array candidate #" + (arrayIndex++) + + " id=" + System.identityHashCode(array) + + " refCount=" + array.refCount + + " captureCount=" + array.captureCount + + " scopeExited=" + array.scopeExited + + " size=" + array.elements.size()); + } + } + + java.util.Set seenCodes = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + for (RuntimeScalar scalar : ScalarRefRegistry.snapshot()) { + if (scalar != null && scalar.value instanceof RuntimeCode code) { + seenCodes.add(code); + } + } + for (RuntimeScalar scalar : GlobalVariable.globalCodeRefValuesView()) { + if (scalar != null && scalar.value instanceof RuntimeCode code) { + seenCodes.add(code); + } + } + int codeIndex = 0; + for (RuntimeCode code : seenCodes) { + boolean capturesTarget = false; + if (code.capturedScalars != null) { + for (RuntimeScalar capture : code.capturedScalars) { + if (capture != null && capture.value == target) { + capturesTarget = true; + break; + } + } + } + if (!capturesTarget && code.capturedAggregates != null) { + for (RuntimeBase capture : code.capturedAggregates) { + if (capturedAggregates.contains(capture)) { + capturesTarget = true; + break; + } + } + } + if (capturesTarget) { + System.err.println(" capture-owner #" + (codeIndex++) + + " code=" + code.packageName + "::" + code.subName + + " at=" + code.cvStartFile + ":" + code.cvStartLine + + " refCount=" + code.refCount + + " stashRefCount=" + code.stashRefCount + + " active=" + RuntimeCode.isActiveCode(code) + + " scalarOwners=" + code.activeOwnerCount() + + " hashSlotOwners=" + code.hashSlotOwnerCount() + + " arraySlotOwners=" + code.arraySlotOwnerCount() + + " capturedScalars=" + (code.capturedScalars == null + ? 0 : code.capturedScalars.length) + + " capturedAggregates=" + (code.capturedAggregates == null + ? 0 : code.capturedAggregates.length) + + " closedOver=" + (code.closedOverVariables == null + ? "[]" : code.closedOverVariables.keySet())); + for (RuntimeScalar owner : ScalarRefRegistry.snapshot()) { + if (owner == null || owner.value != code) continue; + RuntimeBase container = owner.containerOwner; + System.err.println(" code-scalar id=" + System.identityHashCode(owner) + + " type=" + owner.type + + " refCountOwned=" + owner.refCountOwned + + " captureCount=" + owner.captureCount + + " scopeExited=" + owner.scopeExited + + " container=" + (container == null ? "none" + : container.getClass().getSimpleName() + "#" + + System.identityHashCode(container))); + if (container instanceof RuntimeArray array) { + String arrayPath = findPathTo(array) == null ? "" + : String.join(" -> ", findPathTo(array)); + System.err.println(" owner-array id=" + System.identityHashCode(array) + + " refCount=" + array.refCount + + " captureCount=" + array.captureCount + + " scopeExited=" + array.scopeExited + + " owned=" + array.elementsOwned + + " aliased=" + array.elementsAliased + + " size=" + array.elements.size() + + " path=" + arrayPath); + } + } + } + } + } + private static void seedPath(RuntimeScalar s, String label, java.util.IdentityHashMap howReached, java.util.ArrayDeque todo) { @@ -1227,16 +1374,23 @@ boolean isReachable(RuntimeBase target) { private static final class RootWalkBudget { private final int maxEdges; + private final ReachabilityQueryStats stats; private int edgesVisited; private boolean complete = true; private RootWalkBudget(int maxEdges) { + this(maxEdges, null); + } + + private RootWalkBudget(int maxEdges, ReachabilityQueryStats stats) { this.maxEdges = maxEdges; + this.stats = stats; } private boolean visitEdge() { if (edgesVisited >= maxEdges) { complete = false; + if (stats != null) stats.rootQueriesBudgetCapped++; return false; } edgesVisited++; @@ -1246,6 +1400,8 @@ private boolean visitEdge() { static final int MAX_ROOT_SNAPSHOT_EDGES = 50_000; static final int MAX_ROOT_SNAPSHOT_VISITS = 50_000; + private static final int MAX_DESTRUCTION_OWNER_ROOT_EDGES = 500_000; + private static final int MAX_DESTRUCTION_OWNER_ROOT_VISITS = 100_000; static Set reachableFromRootsSnapshot() { return reachableFromRootsSnapshotWithStatus().reachable(); @@ -1255,6 +1411,12 @@ static RootReachabilitySnapshot reachableFromRootsSnapshotWithStatus() { return reachableFromRootsSnapshotWithStatus(true); } + /** Root proof for a CODE referent without treating its own aggregate pads as roots. */ + static boolean isCodeReachableFromRoots(RuntimeCode code) { + return code != null + && reachableFromRootsSnapshotWithStatus(true, false).isReachable(code); + } + /** * Collect the bounded root graph needed to answer reachability for * unblessed aggregates. Installed CODE slot identities cannot own an @@ -1268,6 +1430,11 @@ static RootReachabilitySnapshot unblessedAggregateRootsSnapshotWithStatus() { private static RootReachabilitySnapshot reachableFromRootsSnapshotWithStatus( boolean includeInstalledCodeSlots) { + return reachableFromRootsSnapshotWithStatus(includeInstalledCodeSlots, true); + } + + private static RootReachabilitySnapshot reachableFromRootsSnapshotWithStatus( + boolean includeInstalledCodeSlots, boolean includeOwnedCapturePads) { ReachabilityQueryStats stats = MortalList.activeReachabilityQueryStats(); if (stats != null) stats.snapshotsBuilt++; final int maxVisits = MAX_ROOT_SNAPSHOT_VISITS; @@ -1279,7 +1446,14 @@ private static RootReachabilitySnapshot reachableFromRootsSnapshotWithStatus( if (includeInstalledCodeSlots) { for (RuntimeScalar codeRef : GlobalVariable.globalCodeRefValuesView()) { if (!budget.visitEdge()) break rootScan; - seedTarget(codeRef, null, seen, todo, stats); + seedTargetIncludingCode(codeRef, null, seen, todo, stats); + } + } + for (RuntimeCode activeCode : PerlRuntime.current().executionState().activeCodeStack) { + if (!budget.visitEdge()) break rootScan; + if (activeCode != null && seen.add(activeCode)) { + todo.addLast(activeCode); + if (stats != null) stats.rootsSeeded++; } } for (RuntimeScalar codeRef : GlobalVariable.globalCodeRefEdgeRootsView()) { @@ -1291,7 +1465,8 @@ private static RootReachabilitySnapshot reachableFromRootsSnapshotWithStatus( } for (RuntimeScalar scalar : GlobalVariable.globalVariableValuesView()) { if (!budget.visitEdge()) break rootScan; - seedTarget(scalar, null, seen, todo, stats); + if (includeOwnedCapturePads) seedTarget(scalar, null, seen, todo, stats); + else seedTargetIncludingCode(scalar, null, seen, todo, stats); } for (Map.Entry e : GlobalVariable.globalArrayEntriesView()) { if (!budget.visitEdge()) break rootScan; @@ -1312,15 +1487,18 @@ private static RootReachabilitySnapshot reachableFromRootsSnapshotWithStatus( for (RuntimeScalar scalar : ScalarRefRegistry.snapshot()) { if (!budget.visitEdge()) break rootScan; if (scalar == null || scalar.captureCount > 0 || WeakRefRegistry.isweak(scalar) - || (!MyVarCleanupStack.isLive(scalar) && !scalar.refCountOwned) + || (!MyVarCleanupStack.isLive(scalar) + && !scalar.ownsCurrentCountedReferent()) || scalar.scopeExited) continue; - seedTarget(scalar, null, seen, todo, stats); + if (includeOwnedCapturePads) seedTarget(scalar, null, seen, todo, stats); + else seedTargetIncludingCode(scalar, null, seen, todo, stats); } for (Object liveVar : MyVarCleanupStack.snapshotLiveVars()) { if (!budget.visitEdge()) break rootScan; if (liveVar instanceof RuntimeScalar scalar) { if (seen.add(scalar) && stats != null) stats.rootsSeeded++; - seedTarget(scalar, null, seen, todo, stats); + if (includeOwnedCapturePads) seedTarget(scalar, null, seen, todo, stats); + else seedTargetIncludingCode(scalar, null, seen, todo, stats); } else if (liveVar instanceof RuntimeBase base && seen.add(base)) { todo.addLast(base); if (stats != null) stats.rootsSeeded++; @@ -1332,7 +1510,8 @@ private static RootReachabilitySnapshot reachableFromRootsSnapshotWithStatus( && scalar.type == RuntimeScalarType.REGEX && !scalar.scopeExited && !WeakRefRegistry.isweak(scalar)) { - seedTarget(scalar, null, seen, todo, stats); + if (includeOwnedCapturePads) seedTarget(scalar, null, seen, todo, stats); + else seedTargetIncludingCode(scalar, null, seen, todo, stats); } } for (RuntimeBase rescued : DestroyDispatch.snapshotRescuedForWalk()) { @@ -1342,7 +1521,10 @@ private static RootReachabilitySnapshot reachableFromRootsSnapshotWithStatus( if (stats != null) stats.rootsSeeded++; } } - for (RuntimeBase ownerReferent : PerlRuntime.snapshotOwnerSlotReferents()) { + java.util.List ownerReferents = includeOwnedCapturePads + ? PerlRuntime.snapshotOwnerSlotReferents() + : PerlRuntime.snapshotOwnerSlotRootReferents(); + for (RuntimeBase ownerReferent : ownerReferents) { if (!budget.visitEdge()) break rootScan; if (seen.add(ownerReferent)) { todo.addLast(ownerReferent); @@ -1366,13 +1548,59 @@ private static RootReachabilitySnapshot reachableFromRootsSnapshotWithStatus( for (RuntimeScalar value : hash.elements.values()) { if (!budget.visitEdge()) break walk; if (stats != null) stats.edgesInspected++; - followScalar(value, null, seen, todo); + if (includeOwnedCapturePads) followScalar(value, null, seen, todo); + else followScalarIncludingCode(value, null, seen, todo); } } else if (cur instanceof RuntimeArray array) { for (RuntimeScalar value : array.elements) { if (!budget.visitEdge()) break walk; if (stats != null) stats.edgesInspected++; - followScalar(value, null, seen, todo); + if (includeOwnedCapturePads) followScalar(value, null, seen, todo); + else followScalarIncludingCode(value, null, seen, todo); + } + } else if (cur instanceof RuntimeCode code) { + // Persistent state is a Perl owner edge from an installed CV. + // In particular, singleton services such as Mojo::IOLoop keep + // their reactor (and its callback hashes) in a state variable. + for (RuntimeScalar state : code.stateVariable.values()) { + if (!budget.visitEdge()) break walk; + if (stats != null) stats.edgesInspected++; + followScalar(state, null, seen, todo); + } + for (RuntimeArray state : code.stateArray.values()) { + if (!budget.visitEdge()) break walk; + if (stats != null) stats.edgesInspected++; + if (state != null && seen.add(state)) todo.addLast(state); + } + for (RuntimeHash state : code.stateHash.values()) { + if (!budget.visitEdge()) break walk; + if (stats != null) stats.edgesInspected++; + if (state != null && seen.add(state)) todo.addLast(state); + } + if (!includeOwnedCapturePads && code.capturedScalars != null) { + for (RuntimeScalar captured : code.capturedScalars) { + if (!budget.visitEdge()) break walk; + if (stats != null) stats.edgesInspected++; + followScalarIncludingCode(captured, null, seen, todo); + } + } + if (!includeOwnedCapturePads && code.capturedAggregates != null) { + for (RuntimeBase captured : code.capturedAggregates) { + if (!budget.visitEdge()) break walk; + if (stats != null) stats.edgesInspected++; + if (captured != null && seen.add(captured)) todo.addLast(captured); + } + } + if (!includeOwnedCapturePads && code.closedOverVariables != null) { + for (RuntimeBase captured : code.closedOverVariables.values()) { + if (!budget.visitEdge()) break walk; + if (stats != null) stats.edgesInspected++; + if (captured instanceof RuntimeScalar scalar) { + followScalarIncludingCode(scalar, null, seen, todo); + } else if (captured != null && seen.add(captured)) { + todo.addLast(captured); + } + } } } else if (cur instanceof RuntimeRegex regex) { for (RuntimeCode callback : regex.executableCallbackCodes()) { @@ -1534,7 +1762,8 @@ public static boolean isScalarReachable(RuntimeScalar target) { } if (container instanceof RuntimeArray array && array.isPackageGlobalRoot - && array.elements.contains(target)) { + && array.elements.contains(target) + && (!array.elementsAliased || array.ownsElement(target))) { return true; } if (target.isPackageGlobalRoot @@ -1547,7 +1776,9 @@ public static boolean isScalarReachable(RuntimeScalar target) { } Boolean drainSnapshotResult = MortalList.isScalarReachableFromDrainSnapshot(target); - if (drainSnapshotResult != null) return drainSnapshotResult; + if (drainSnapshotResult != null) { + return drainSnapshotResult; + } if (stats != null) stats.scalarReachabilityRootWalks++; final int MAX_VISITS = MAX_ROOT_SNAPSHOT_VISITS; @@ -1594,6 +1825,9 @@ public static boolean isScalarReachable(RuntimeScalar target) { // here; transient method-call argument scalars are NOT // (they get unwound when the method returns). for (Object liveVar : MyVarCleanupStack.snapshotLiveVars()) { + if (!MyVarCleanupStack.isRegistered(liveVar)) continue; + if (liveVar instanceof RuntimeScalar scalar && scalar.scopeExited) continue; + if (liveVar instanceof RuntimeBase runtimeBase && runtimeBase.scopeExited) continue; if (liveVar == target) return true; if (liveVar instanceof RuntimeBase rb && seen.add(rb)) todo.addLast(rb); } @@ -1628,14 +1862,19 @@ public static boolean isScalarReachable(RuntimeScalar target) { if (val == null) continue; // Skip weak refs — they don't keep their referent alive. if (WeakRefRegistry.isweak(val)) continue; - if (val == target) return true; + if (val == target) { + return true; + } if (seen.add(val)) todo.addLast(val); } } else if (cur instanceof RuntimeArray arr) { for (RuntimeScalar elem : arr.elements) { if (elem == null) continue; + if (arr.elementsAliased && !arr.ownsElement(elem)) continue; if (WeakRefRegistry.isweak(elem)) continue; - if (elem == target) return true; + if (elem == target) { + return true; + } if (seen.add(elem)) todo.addLast(elem); } } else if (cur instanceof RuntimeScalar sc) { @@ -1651,7 +1890,9 @@ public static boolean isScalarReachable(RuntimeScalar target) { && WeakRefRegistry.isweak(scalar)) { continue; } - if (captured == target) return true; + if (captured == target) { + return true; + } if (seen.add(captured)) todo.addLast(captured); } } @@ -1659,13 +1900,17 @@ public static boolean isScalarReachable(RuntimeScalar target) { for (RuntimeScalar cap : code.capturedScalars) { if (cap == null) continue; if (WeakRefRegistry.isweak(cap)) continue; - if (cap == target) return true; + if (cap == target) { + return true; + } if (seen.add(cap)) todo.addLast(cap); } } if (code.capturedAggregates != null) { for (RuntimeBase cap : code.capturedAggregates) { - if (cap == target) return true; + if (cap == target) { + return true; + } if (seen.add(cap)) todo.addLast(cap); } } @@ -1702,11 +1947,14 @@ public static boolean isScalarReachable(RuntimeScalar target) { */ static final class ScalarRootReachabilitySnapshot { private final Set reachable; + private final Set reachableBases; private final boolean complete; private ScalarRootReachabilitySnapshot(Set reachable, + Set reachableBases, boolean complete) { this.reachable = reachable; + this.reachableBases = reachableBases; this.complete = complete; } @@ -1716,12 +1964,51 @@ boolean isReachable(RuntimeScalar target) { return target != null && (!complete || reachable.contains(target)); } + boolean isReachable(RuntimeBase target) { + return target != null && (!complete || reachableBases.contains(target)); + } + boolean complete() { return complete; } } static ScalarRootReachabilitySnapshot scalarReachabilitySnapshot() { + return scalarReachabilitySnapshot(MAX_ROOT_SNAPSHOT_VISITS, false, true); + } + + /** + * Bounded scalar-owner proof from package roots only. Destruction-time + * resurrection checks must not treat a still-active local cleanup frame + * as a persistent Perl owner. + */ + static boolean isScalarReachableFromPackageRoots(RuntimeScalar target, + RuntimeBase excludedReferent) { + if (target == null || target == excludedReferent) return false; + return scalarReachabilitySnapshot( + excludedReferent == null ? MAX_ROOT_SNAPSHOT_VISITS + : MAX_DESTRUCTION_OWNER_ROOT_VISITS, + false, false, excludedReferent) + .isReachable(target); + } + + /** + * Complete root walk for closure-owner release. A capped snapshot cannot + * distinguish a real owner from a capture cycle beyond its visit limit. + */ + static ScalarRootReachabilitySnapshot completeScalarReachabilitySnapshot() { + return scalarReachabilitySnapshot(Integer.MAX_VALUE, true, true); + } + + private static ScalarRootReachabilitySnapshot scalarReachabilitySnapshot( + int maxVisits, boolean includeTransientRoots, boolean includeLiveVars) { + return scalarReachabilitySnapshot( + maxVisits, includeTransientRoots, includeLiveVars, null); + } + + private static ScalarRootReachabilitySnapshot scalarReachabilitySnapshot( + int maxVisits, boolean includeTransientRoots, boolean includeLiveVars, + RuntimeBase excludedReferent) { ReachabilityQueryStats stats = MortalList.activeReachabilityQueryStats(); if (stats != null) { stats.snapshotsBuilt++; @@ -1733,25 +2020,61 @@ static ScalarRootReachabilitySnapshot scalarReachabilitySnapshot() { java.util.ArrayDeque todo = new java.util.ArrayDeque<>(); for (RuntimeScalar scalar : GlobalVariable.globalCodeRefValuesView()) { - addScalarReachabilityRoot(scalar, seen, reachableScalars, todo, stats); + addScalarReachabilityRoot(scalar, seen, reachableScalars, todo, stats, + excludedReferent); + } + for (RuntimeScalar scalar : GlobalVariable.globalCodeRefEdgeRootsView()) { + addScalarReachabilityRoot(scalar, seen, reachableScalars, todo, stats, + excludedReferent); + } + if (includeTransientRoots) { + for (RuntimeCode active : PerlRuntime.current().executionState().activeCodeStack) { + addScalarReachabilityRoot(active, seen, reachableScalars, todo, stats, + excludedReferent); + } + for (java.util.List args : RuntimeCode.snapshotPristineArgsStack()) { + for (RuntimeScalar arg : args) { + if (arg != null && !WeakRefRegistry.isweak(arg)) { + addScalarReachabilityRoot(arg, seen, reachableScalars, todo, stats, + excludedReferent); + } + } + } + for (RuntimeArray args : RuntimeCode.snapshotArgsStack()) { + addScalarReachabilityRoot(args, seen, reachableScalars, todo, stats, + excludedReferent); + } + for (RuntimeBase suspended : MortalList.snapshotSuspendedRoots()) { + addScalarReachabilityRoot(suspended, seen, reachableScalars, todo, stats, + excludedReferent); + } + for (RuntimeBase temporary : MortalList.snapshotTemporaryRoots()) { + addScalarReachabilityRoot(temporary, seen, reachableScalars, todo, stats, + excludedReferent); + } } for (RuntimeScalar scalar : GlobalVariable.globalVariableValuesView()) { - addScalarReachabilityRoot(scalar, seen, reachableScalars, todo, stats); + addScalarReachabilityRoot(scalar, seen, reachableScalars, todo, stats, + excludedReferent); } for (Map.Entry entry : GlobalVariable.globalArrayEntriesView()) { if (isNonOwningDebugArgsArray(entry.getKey())) continue; - addScalarReachabilityRoot(entry.getValue(), seen, reachableScalars, todo, stats); + addScalarReachabilityRoot(entry.getValue(), seen, reachableScalars, todo, stats, + excludedReferent); } for (RuntimeHash hash : GlobalVariable.globalHashValuesView()) { - addScalarReachabilityRoot(hash, seen, reachableScalars, todo, stats); + addScalarReachabilityRoot(hash, seen, reachableScalars, todo, stats, + excludedReferent); } - for (Object liveVar : MyVarCleanupStack.snapshotLiveVars()) { - if (liveVar instanceof RuntimeBase base) { - addScalarReachabilityRoot(base, seen, reachableScalars, todo, stats); + if (includeLiveVars) { + for (Object liveVar : MyVarCleanupStack.snapshotLiveVars()) { + if (liveVar instanceof RuntimeBase base) { + addScalarReachabilityRoot(base, seen, reachableScalars, todo, stats, + excludedReferent); + } } } - final int maxVisits = MAX_ROOT_SNAPSHOT_VISITS; int visits = 0; while (!todo.isEmpty() && visits < maxVisits) { RuntimeBase current = todo.removeFirst(); @@ -1763,16 +2086,17 @@ static ScalarRootReachabilitySnapshot scalarReachabilitySnapshot() { if (hash.elements instanceof HashSpecialVariable) continue; if (hash.elements instanceof TieHash tieHash) { addScalarReachabilityEdge(tieHash.getSelf(), seen, - reachableScalars, todo, stats, true, true); + reachableScalars, todo, stats, true, true, excludedReferent); } for (RuntimeScalar value : hash.elements.values()) { addScalarReachabilityEdge(value, seen, - reachableScalars, todo, stats, true, false); + reachableScalars, todo, stats, true, false, excludedReferent); } } else if (current instanceof RuntimeArray array) { for (RuntimeScalar value : array.elements) { + if (array.elementsAliased && !array.ownsElement(value)) continue; addScalarReachabilityEdge(value, seen, - reachableScalars, todo, stats, true, false); + reachableScalars, todo, stats, true, false, excludedReferent); } } else if (current instanceof RuntimeScalar scalar) { if (stats != null) stats.edgesInspected++; @@ -1782,51 +2106,77 @@ static ScalarRootReachabilitySnapshot scalarReachabilitySnapshot() { if (base instanceof RuntimeScalar referencedScalar) { reachableScalars.add(referencedScalar); } - if (seen.add(base)) todo.addLast(base); + if (base != excludedReferent && seen.add(base)) todo.addLast(base); } } else if (current instanceof RuntimeCode code) { + if (code.padConstants != null) { + for (RuntimeBase constant : code.padConstants) { + addScalarReachabilityEdge(constant, seen, + reachableScalars, todo, stats, true, false, excludedReferent); + } + } + for (RuntimeScalar state : code.stateVariable.values()) { + addScalarReachabilityEdge(state, seen, + reachableScalars, todo, stats, true, false, excludedReferent); + } + for (RuntimeArray state : code.stateArray.values()) { + addScalarReachabilityEdge(state, seen, + reachableScalars, todo, stats, true, false, excludedReferent); + } + for (RuntimeHash state : code.stateHash.values()) { + addScalarReachabilityEdge(state, seen, + reachableScalars, todo, stats, true, false, excludedReferent); + } if (code.closedOverVariables != null) { for (RuntimeBase captured : code.closedOverVariables.values()) { if (stats != null) stats.edgesInspected++; if (captured instanceof RuntimeScalar scalar && WeakRefRegistry.isweak(scalar)) continue; addScalarReachabilityEdge(captured, seen, - reachableScalars, todo, null, true, false); + reachableScalars, todo, null, true, false, excludedReferent); } } if (code.capturedScalars != null) { for (RuntimeScalar captured : code.capturedScalars) { addScalarReachabilityEdge(captured, seen, - reachableScalars, todo, stats, true, false); + reachableScalars, todo, stats, true, false, excludedReferent); } } if (code.capturedAggregates != null) { for (RuntimeBase captured : code.capturedAggregates) { addScalarReachabilityEdge(captured, seen, - reachableScalars, todo, stats, true, true); + reachableScalars, todo, stats, true, true, excludedReferent); } } visitReflectiveCodeScalars(code, captured -> addScalarReachabilityEdge(captured, seen, - reachableScalars, todo, stats, true, false)); + reachableScalars, todo, stats, true, false, excludedReferent)); visitReflectiveCodeBases(code, captured -> addScalarReachabilityEdge(captured, seen, - reachableScalars, todo, stats, false, false)); + reachableScalars, todo, stats, false, false, excludedReferent)); } else if (current instanceof RuntimeRegex regex) { for (RuntimeCode callback : regex.executableCallbackCodes()) { addScalarReachabilityEdge(callback, seen, - reachableScalars, todo, stats, false, false); + reachableScalars, todo, stats, false, false, excludedReferent); } } } - return new ScalarRootReachabilitySnapshot(reachableScalars, todo.isEmpty()); + return new ScalarRootReachabilitySnapshot( + reachableScalars, seen, todo.isEmpty()); } private static void addScalarReachabilityRoot( RuntimeBase base, Set seen, Set reachableScalars, java.util.ArrayDeque todo, ReachabilityQueryStats stats) { + addScalarReachabilityRoot(base, seen, reachableScalars, todo, stats, null); + } + + private static void addScalarReachabilityRoot( + RuntimeBase base, Set seen, Set reachableScalars, + java.util.ArrayDeque todo, ReachabilityQueryStats stats, + RuntimeBase excludedReferent) { if (base instanceof RuntimeScalar scalar && WeakRefRegistry.isweak(scalar)) return; - if (base != null && seen.add(base)) { + if (base != null && base != excludedReferent && seen.add(base)) { todo.addLast(base); if (stats != null) stats.rootsSeeded++; } @@ -1837,6 +2187,15 @@ private static void addScalarReachabilityEdge( RuntimeBase base, Set seen, Set reachableScalars, java.util.ArrayDeque todo, ReachabilityQueryStats stats, boolean trackScalarIdentity, boolean includeWeakIdentity) { + addScalarReachabilityEdge(base, seen, reachableScalars, todo, stats, + trackScalarIdentity, includeWeakIdentity, null); + } + + private static void addScalarReachabilityEdge( + RuntimeBase base, Set seen, Set reachableScalars, + java.util.ArrayDeque todo, ReachabilityQueryStats stats, + boolean trackScalarIdentity, boolean includeWeakIdentity, + RuntimeBase excludedReferent) { if (stats != null) stats.edgesInspected++; if (base instanceof RuntimeScalar scalar) { boolean weak = WeakRefRegistry.isweak(scalar); @@ -1845,7 +2204,7 @@ private static void addScalarReachabilityEdge( } if (weak) return; } - if (base != null && seen.add(base)) todo.addLast(base); + if (base != null && base != excludedReferent && seen.add(base)) todo.addLast(base); } /** @@ -1866,13 +2225,25 @@ private static void addScalarReachabilityEdge( * diagnostic / debugging callers. */ public static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnly) { - if (target == null) return false; + return isReachableFromRoots(target, globalOnly, null); + } + + /** + * Test a package-root path while refusing to pass through one referent. + * Destruction-time resurrection checks use this to distinguish a new owner + * retained by the dying object's own graph from one that escaped elsewhere. + */ + static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnly, + RuntimeBase excludedReferent) { + if (target == null || target == excludedReferent) return false; ReachabilityQueryStats stats = MortalList.activeReachabilityQueryStats(); if (stats != null) stats.rootQueries++; // Node and edge caps bound both graph traversal and root seeding. An // incomplete proof conservatively retains an unresolved target. - final int MAX_VISITS = MAX_ROOT_SNAPSHOT_VISITS; - RootWalkBudget budget = new RootWalkBudget(MAX_ROOT_SNAPSHOT_EDGES); + final int MAX_VISITS = excludedReferent == null + ? MAX_ROOT_SNAPSHOT_VISITS : MAX_DESTRUCTION_OWNER_ROOT_VISITS; + RootWalkBudget budget = new RootWalkBudget(excludedReferent == null + ? MAX_ROOT_SNAPSHOT_EDGES : MAX_DESTRUCTION_OWNER_ROOT_EDGES, stats); Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); java.util.ArrayDeque todo = new java.util.ArrayDeque<>(); @@ -1880,7 +2251,7 @@ public static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnl // Seed: package globals (scalars, arrays, hashes, code refs). for (RuntimeScalar codeRef : GlobalVariable.globalCodeRefValuesView()) { if (!budget.visitEdge()) return true; - seedTarget(codeRef, target, seen, todo, stats); + seedTargetExcluding(codeRef, target, seen, todo, stats, excludedReferent); if (seen.contains(target)) return true; if (!globalOnly && codeRef != null @@ -1891,7 +2262,7 @@ public static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnl } for (RuntimeScalar scalar : GlobalVariable.globalVariableValuesView()) { if (!budget.visitEdge()) return true; - seedTarget(scalar, target, seen, todo, stats); + seedTargetExcluding(scalar, target, seen, todo, stats, excludedReferent); if (seen.contains(target)) return true; } for (Map.Entry e : GlobalVariable.globalArrayEntriesView()) { @@ -1901,7 +2272,8 @@ public static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnl if (stats != null) stats.rootsSeeded++; return true; } - if (e.getValue() != null && seen.add(e.getValue())) { + if (e.getValue() != null && e.getValue() != excludedReferent + && seen.add(e.getValue())) { todo.addLast(e.getValue()); if (stats != null) stats.rootsSeeded++; } @@ -1912,7 +2284,7 @@ public static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnl if (stats != null) stats.rootsSeeded++; return true; } - if (hash != null && seen.add(hash)) { + if (hash != null && hash != excludedReferent && seen.add(hash)) { todo.addLast(hash); if (stats != null) stats.rootsSeeded++; } @@ -1920,7 +2292,8 @@ public static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnl for (RuntimeBase ownerReferent : PerlRuntime.snapshotOwnerSlotReferents()) { if (!budget.visitEdge()) return true; if (ownerReferent == target) return true; - if (ownerReferent != null && seen.add(ownerReferent)) { + if (ownerReferent != null && ownerReferent != excludedReferent + && seen.add(ownerReferent)) { todo.addLast(ownerReferent); if (stats != null) stats.rootsSeeded++; } @@ -1941,9 +2314,9 @@ public static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnl if (sc == null) continue; if (sc.captureCount > 0) continue; if (WeakRefRegistry.isweak(sc)) continue; - if (!MyVarCleanupStack.isLive(sc) && !sc.refCountOwned) continue; + if (!MyVarCleanupStack.isLive(sc) && !sc.ownsCurrentCountedReferent()) continue; if (sc.scopeExited) continue; - seedTarget(sc, target, seen, todo, stats); + seedTargetExcluding(sc, target, seen, todo, stats, excludedReferent); if (seen.contains(target)) return true; } // Seed: live my-vars themselves (RuntimeHash / RuntimeArray / @@ -1964,14 +2337,14 @@ public static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnl if (stats != null) stats.rootsSeeded++; return true; } - seedTarget(sc, target, seen, todo, stats); + seedTargetExcluding(sc, target, seen, todo, stats, excludedReferent); if (seen.contains(target)) return true; } else if (liveVar instanceof RuntimeBase rb) { if (rb == target) { if (stats != null) stats.rootsSeeded++; return true; } - if (seen.add(rb)) { + if (rb != excludedReferent && seen.add(rb)) { todo.addLast(rb); if (stats != null) stats.rootsSeeded++; } @@ -1988,7 +2361,7 @@ public static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnl || scalar.type != RuntimeScalarType.REGEX || scalar.scopeExited || WeakRefRegistry.isweak(scalar)) continue; - seedTarget(scalar, target, seen, todo, stats); + seedTargetExcluding(scalar, target, seen, todo, stats, excludedReferent); if (seen.contains(target)) return true; } } @@ -1999,7 +2372,7 @@ public static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnl if (stats != null) stats.rootsSeeded++; return true; } - if (seen.add(rescued)) { + if (rescued != excludedReferent && seen.add(rescued)) { todo.addLast(rescued); if (stats != null) stats.rootsSeeded++; } @@ -2012,6 +2385,7 @@ public static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnl visits++; if (stats != null) stats.nodesVisited++; if (cur == target) return true; + if (cur == excludedReferent) continue; // Phase D-W2 (perf): skip RuntimeStash — see bfs(). if (cur instanceof RuntimeStash) continue; if (cur instanceof RuntimeHash h) { @@ -2020,19 +2394,20 @@ public static boolean isReachableFromRoots(RuntimeBase target, boolean globalOnl if (stats != null) stats.edgesInspected++; } if (h.elements instanceof TieHash tieHash - && followScalar(tieHash.getSelf(), target, seen, todo)) { + && followScalarExcluding( + tieHash.getSelf(), target, seen, todo, excludedReferent)) { return true; } for (RuntimeScalar v : h.elements.values()) { if (!budget.visitEdge()) break walk; if (stats != null) stats.edgesInspected++; - if (followScalar(v, target, seen, todo)) return true; + if (followScalarExcluding(v, target, seen, todo, excludedReferent)) return true; } } else if (cur instanceof RuntimeArray a) { for (RuntimeScalar v : a.elements) { if (!budget.visitEdge()) break walk; if (stats != null) stats.edgesInspected++; - if (followScalar(v, target, seen, todo)) return true; + if (followScalarExcluding(v, target, seen, todo, excludedReferent)) return true; } } else if (cur instanceof RuntimeRegex regex) { // Executable regex callbacks are strong owners of their @@ -2059,20 +2434,16 @@ && followScalar(tieHash.getSelf(), target, seen, todo)) { if (!budget.complete) break walk; } } - // Note: we deliberately don't follow RuntimeCode.capturedScalars - // here — closure captures are NOT considered strong reachability - // edges for this query (matches the default of - // ReachabilityWalker.walk() which has walkCodeCaptures=false - // for the second-phase BFS). Without this discipline, DBIC's - // leak detector (t/52leaks.t) reports false positives because - // the walker would keep things alive that user code released. - // - // For Class::MOP's %METAS hash (which we also need to find for - // the Moose bootstrap), we don't need closure-capture walking - // because %METAS is declared `our %METAS` (package global) so - // it appears directly in GlobalVariable.globalHashes. + // Closure captures remain opaque in the conservative refcount + // query; CODE-specific owner rescue uses the explicit owner-aware + // root proof above instead. } - return !budget.complete || !todo.isEmpty(); + boolean incomplete = !budget.complete || !todo.isEmpty(); + if (incomplete && budget.complete && stats != null) { + // The node limit ended the walk with unexplored roots remaining. + stats.rootQueriesBudgetCapped++; + } + return incomplete; } /** @@ -2259,7 +2630,7 @@ && followGlobalCodeCaptures(code, target, seen, todo)) { if (sc.captureCount > 0) continue; if (WeakRefRegistry.isweak(sc)) continue; if (sc.scopeExited) continue; - if (!MyVarCleanupStack.isLive(sc) && !sc.refCountOwned) continue; + if (!MyVarCleanupStack.isLive(sc) && !sc.ownsCurrentCountedReferent()) continue; if (followScalar(sc, target, seen, todo)) return true; } for (Object liveVar : MyVarCleanupStack.snapshotLiveVars()) { @@ -2410,6 +2781,13 @@ private void walkSnapshotNode(RuntimeBase cur, } } else if (cur instanceof RuntimeScalar sc) { seedNonLexicalScalar(sc, nonLexicalTodo); + } else if (cur instanceof RuntimeCode code) { + // Callbacks can be nested several levels below a globally + // installed CV's persistent state (for example, a reactor + // timer wrapper captures the user callback). Follow the + // active semantic owner edges on every reached CV, not only + // on the initially installed one. + seedGlobalCodeCaptures(code, nonLexicalTodo); } } @@ -2436,6 +2814,24 @@ private boolean visitExternalEdge() { private void seedGlobalCodeCaptures(RuntimeCode code, java.util.ArrayDeque todo) { + // A globally installed CV owns its persistent state variables. + // Mojo::IOLoop keeps the singleton reactor in `state $loop`; the + // reactor then owns timer records and their callbacks. Omitting + // these edges makes an interpreter callback look unrooted when + // its creating frame exits, even though the installed CV's state + // still reaches it. + for (RuntimeScalar state : code.stateVariable.values()) { + seedNonLexicalScalar(state, todo); + if (!complete) return; + } + for (RuntimeArray state : code.stateArray.values()) { + if (!visitExternalEdge()) return; + addNonLexical(state, todo); + } + for (RuntimeHash state : code.stateHash.values()) { + if (!visitExternalEdge()) return; + addNonLexical(state, todo); + } if (code.capturedScalars != null) { for (RuntimeScalar cap : code.capturedScalars) { seedNonLexicalScalar(cap, todo); @@ -2611,6 +3007,40 @@ private static void seedTarget(RuntimeScalar s, RuntimeBase target, } } + private static void seedTargetExcluding(RuntimeScalar scalar, RuntimeBase target, + Set seen, java.util.ArrayDeque todo, + ReachabilityQueryStats stats, RuntimeBase excludedReferent) { + if (excludedReferent == null) { + seedTarget(scalar, target, seen, todo, stats); + return; + } + if (scalar == null) return; + if (stats != null) stats.edgesInspected++; + if (WeakRefRegistry.isweak(scalar)) return; + if ((scalar.type & RuntimeScalarType.REFERENCE_BIT) != 0 + && scalar.value instanceof RuntimeBase base) { + if (base == target) { + if (seen.add(target) && stats != null) stats.rootsSeeded++; + return; + } + if (base != excludedReferent && seen.add(base)) { + todo.addLast(base); + if (stats != null) stats.rootsSeeded++; + } + } + } + + private static void seedTargetIncludingCode(RuntimeScalar scalar, RuntimeBase target, + Set seen, java.util.ArrayDeque todo, + ReachabilityQueryStats stats) { + seedTarget(scalar, target, seen, todo, stats); + if (scalar != null && !WeakRefRegistry.isweak(scalar) + && scalar.value instanceof RuntimeCode code) { + if (code == target) seen.add(code); + else if (seen.add(code)) todo.addLast(code); + } + } + private static boolean followScalar(RuntimeScalar s, RuntimeBase target, Set seen, java.util.ArrayDeque todo) { @@ -2624,6 +3054,29 @@ private static boolean followScalar(RuntimeScalar s, RuntimeBase target, return false; } + private static boolean followScalarExcluding(RuntimeScalar scalar, RuntimeBase target, + Set seen, java.util.ArrayDeque todo, + RuntimeBase excludedReferent) { + if (excludedReferent == null) return followScalar(scalar, target, seen, todo); + if (scalar == null || WeakRefRegistry.isweak(scalar)) return false; + if ((scalar.type & RuntimeScalarType.REFERENCE_BIT) != 0 + && scalar.value instanceof RuntimeBase base) { + if (base == target) return true; + if (base != excludedReferent && seen.add(base)) todo.addLast(base); + } + return false; + } + + private static boolean followScalarIncludingCode(RuntimeScalar scalar, RuntimeBase target, + Set seen, java.util.ArrayDeque todo) { + if (followScalar(scalar, target, seen, todo)) return true; + if (scalar == null || WeakRefRegistry.isweak(scalar) + || !(scalar.value instanceof RuntimeCode code)) return false; + if (code == target) return true; + if (seen.add(code)) todo.addLast(code); + return false; + } + private static boolean followGlobalCodeCaptures(RuntimeCode code, RuntimeBase target, Set seen, java.util.ArrayDeque todo) { @@ -3172,7 +3625,7 @@ private static void releaseUnreachableScopeExitedCaptureOwners( if (retainedByEnd == null) retainedByEnd = walkEndBlockRoots(); boolean padInEnd = retainedByEnd.contains(padCell); boolean referentInEnd = retainedByEnd.contains(referent); - if (scalarRoots == null) scalarRoots = scalarReachabilitySnapshot(); + if (scalarRoots == null) scalarRoots = completeScalarReachabilitySnapshot(); boolean padInScalarRoots = scalarRoots.isReachable(padCell); if (padInScalarRoots && statementBoundaryCandidates != null && containsAnyCandidate(referent, statementBoundaryCandidates) @@ -3180,6 +3633,7 @@ && findPathTo(padCell, false, false) == null) { padInScalarRoots = false; } if (padInEnd || referentInEnd || padInScalarRoots) continue; + padCell.releaseUnreachableDestroyableClosureOwners(scalarRoots); if (WALKER_TRACE_CLASS != null && !WALKER_TRACE_CLASS.isEmpty()) { String className = NameNormalizer.getBlessStr(referent.blessId); if (className != null && className.contains(WALKER_TRACE_CLASS)) { @@ -3303,18 +3757,29 @@ public static ReleasedWeakSweepResult sweepReleasedWeakReferents( Collections.emptySet(), 0); } - // A registered, live, nonweak scalar with an owned direct reference is - // itself a root in walk(). If every released target has such an owner, - // no graph traversal is needed to preserve those targets. This is a - // deliberately narrow proof: nested container slots, exited captures, - // and uncounted references still use the full reachability walk below. + // A live, nonweak scalar value slot is itself a direct owner. Check + // explicit slots first so owners created before the first weaken() do + // not depend on ScalarRefRegistry's intentionally lazy registration. + // The registry remains a migration fallback for older ownership paths. Set directlyOwned = Collections.newSetFromMap(new IdentityHashMap<>()); + for (RuntimeBase referent : pending) { + for (PerlOwnerSlot slot : referent.ownerSlotSnapshot()) { + if (slot.kind() != PerlOwnerSlot.Kind.SCALAR_VALUE + || slot.referent() != referent) continue; + RuntimeScalar owner = slot.ownerCell(); + if (owner == null || !owner.ownsCountedReferent(referent) || owner.scopeExited + || !MyVarCleanupStack.isLive(owner) || WeakRefRegistry.isweak(owner) + || (owner.type & RuntimeScalarType.REFERENCE_BIT) == 0 + || owner.value != referent) continue; + directlyOwned.add(referent); + } + } for (RuntimeScalar owner : ScalarRefRegistry.snapshot()) { - if (owner == null || !owner.refCountOwned || owner.scopeExited + if (owner == null || !(owner.value instanceof RuntimeBase target) + || !owner.ownsCountedReferent(target) || owner.scopeExited || !MyVarCleanupStack.isLive(owner) || WeakRefRegistry.isweak(owner) - || (owner.type & RuntimeScalarType.REFERENCE_BIT) == 0 - || !(owner.value instanceof RuntimeBase target)) { + || (owner.type & RuntimeScalarType.REFERENCE_BIT) == 0) { continue; } if (pending.contains(target)) directlyOwned.add(target); @@ -3361,7 +3826,9 @@ public static ReleasedWeakSweepResult sweepReleasedWeakReferents( Set countedStrongOwners = Collections.newSetFromMap(new IdentityHashMap<>()); for (RuntimeScalar owner : ScalarRefRegistry.snapshot()) { - if (owner == null || !owner.refCountOwned || WeakRefRegistry.isweak(owner)) continue; + if (owner == null || !(owner.value instanceof RuntimeBase referent) + || !owner.ownsCountedReferent(referent) + || WeakRefRegistry.isweak(owner)) continue; // Hash/array slots in a discarded aggregate are internal owners of // that graph, not independent roots. Keep their referents only when // the containing aggregate is still reachable or was not part of @@ -3371,7 +3838,27 @@ public static ReleasedWeakSweepResult sweepReleasedWeakReferents( && !live.contains(owner.containerOwner)) { continue; } - if (owner.value instanceof RuntimeBase referent && referents.contains(referent)) { + if (referents.contains(referent)) { + countedStrongOwners.add(referent); + } + } + // Scalar-reference contents use a distinct owner slot from the + // scalar's direct referent field. When an explicit release triggers a + // targeted weak sweep, retain the nested referent if that owner cell + // is still a Perl root. Slots whose owner cells live inside the graph + // being discarded are internal edges, not independent roots. + for (RuntimeBase referent : referents) { + for (PerlOwnerSlot slot : referent.ownerSlotSnapshot()) { + if ((slot.kind() != PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS + && slot.kind() != PerlOwnerSlot.Kind.SCALAR_VALUE) + || slot.referent() != referent) continue; + RuntimeScalar ownerCell = slot.ownerCell(); + if (ownerCell == null || !live.contains(ownerCell)) continue; + if (ownerCell.containerOwner != null + && referents.contains(ownerCell.containerOwner) + && !live.contains(ownerCell.containerOwner)) { + continue; + } countedStrongOwners.add(referent); } } @@ -3470,10 +3957,18 @@ static int sweepStatementBoundaryDestroyableObjects() { java.util.List candidates = DestroyDispatch.snapshotStatementBoundaryDestroyableObjects(); if (candidates.isEmpty()) return 0; + // A scope-exited closure pad can be the only remaining bookkeeping + // owner for a destroyable referent after its callback container has + // gone away. Retire such dead capture edges before the root walk so + // statement-boundary DESTROY matches Perl without waiting for a weak + // reference sweep or an explicit jperl_gc(). Set candidateSet = Collections.newSetFromMap(new IdentityHashMap<>()); candidateSet.addAll(candidates); + Set weakReferentSet = Collections.newSetFromMap(new IdentityHashMap<>()); + weakReferentSet.addAll(PerlRuntime.snapshotOwnerSlotReferents()); + weakReferentSet.addAll(candidates); releaseUnreachableScopeExitedCaptureOwners( - PerlRuntime.snapshotOwnerSlotReferents(), null, candidateSet); + new ArrayList<>(weakReferentSet), null, candidateSet); Set live = new ReachabilityWalker().walk(); return destroyUnreachableDestroyables(candidates, live); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArray.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArray.java index 369ecc7732..c7d3035932 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArray.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArray.java @@ -26,7 +26,6 @@ * using a list of RuntimeScalar objects, which can hold any type of Perl scalar value. */ public class RuntimeArray extends RuntimeBase implements RuntimeScalarReference, DynamicState { - /** Return an empty lexical cell when control flow skipped its declaration. */ public static RuntimeArray materializeLexicalCell(RuntimeArray array) { return array != null ? array : new RuntimeArray(); @@ -116,6 +115,8 @@ void endScalarLocalElement() { // while mutating ops such as unshift can insert new counted elements that // this array must release during tail-call/scope cleanup. public Set ownedAliasElements; + /** Stable semantic identities for direct referents held by owning elements. */ + private List arrayElementOwnerSlots; // Internal one-shot array used to materialize the RHS of list assignment. // Its elements are expression temporaries, not durable array-owned slots. boolean transientListAssignmentRhs; @@ -175,6 +176,7 @@ private RuntimeArrayElementList newElementList(List values) { } void resetElementListAfterAutovivification() { + releaseArrayElementOwnerSlots(); elements = newElementList(); } @@ -190,6 +192,7 @@ Object snapshotRegexMutationState() { void restoreRegexMutationState(Object token) { if (!(token instanceof RegexMutationState state)) return; deferElementsAddedSince(state.elements); + releaseArrayElementOwnerSlots(); elements = newElementList(state.elements); type = state.type; strictAutovivify = state.strictAutovivify; @@ -199,6 +202,7 @@ void restoreRegexMutationState(Object token) { ownedAliasElements = state.ownedAliasElements == null ? null : Collections.newSetFromMap(new IdentityHashMap<>()); if (ownedAliasElements != null) ownedAliasElements.addAll(state.ownedAliasElements); + reconcileArrayElementOwnerSlots(); blessId = state.blessId; markPackageRootedValues(0); } @@ -248,6 +252,7 @@ public boolean add(RuntimeScalar value) { if (value != null) value.markContainerOwner(owner); owner.markPackageRootedValue(value); boolean changed = super.add(value); + if (changed) owner.arrayElementAdded(size() - 1, value); owner.validateIsaMutation(); return changed; } @@ -263,6 +268,7 @@ public void add(int index, RuntimeScalar element) { if (element != null) element.markContainerOwner(owner); owner.markPackageRootedValue(element); super.add(index, element); + owner.arrayElementAdded(index, element); owner.validateIsaMutation(); } @@ -271,11 +277,17 @@ public boolean addAll(java.util.Collection c) { if (!owner.threadShared) { if (!c.isEmpty()) owner.noteIsaMutation(); owner.notePackageRootMutationIf(owner.hasRootEdge(c)); - for (RuntimeScalar value : c) { + List added = new ArrayList<>(c); + for (RuntimeScalar value : added) { if (value != null) value.markContainerOwner(owner); owner.markPackageRootedValue(value); } - boolean changed = super.addAll(c); + boolean changed = super.addAll(added); + if (changed) { + int start = size() - added.size(); + int index = start; + for (RuntimeScalar value : added) owner.arrayElementAdded(index++, value); + } if (changed) owner.validateIsaMutation(); return changed; } @@ -292,11 +304,16 @@ public boolean addAll(int index, java.util.Collection c if (!owner.threadShared) { if (!c.isEmpty()) owner.noteIsaMutation(); owner.notePackageRootMutationIf(owner.hasRootEdge(c)); - for (RuntimeScalar value : c) { + List added = new ArrayList<>(c); + for (RuntimeScalar value : added) { if (value != null) value.markContainerOwner(owner); owner.markPackageRootedValue(value); } - boolean changed = super.addAll(index, c); + boolean changed = super.addAll(index, added); + if (changed) { + int insertAt = index; + for (RuntimeScalar value : added) owner.arrayElementAdded(insertAt++, value); + } if (changed) owner.validateIsaMutation(); return changed; } @@ -321,6 +338,13 @@ public RuntimeScalar set(int index, RuntimeScalar element) { if (element != null) element.markContainerOwner(owner); owner.markPackageRootedValue(element); RuntimeScalar result = super.set(index, element); + owner.arrayElementReplaced(index, previous, element); + if (owner.elementsAliased && element != null) { + // A write into an aliased array slot replaces the borrowed + // slot with a value owned by this array. Keep that value + // reachable even after the original argument frame exits. + owner.markOwnedAliasElement(element); + } owner.validateIsaMutation(); return result; } @@ -328,21 +352,31 @@ public RuntimeScalar set(int index, RuntimeScalar element) { @Override public RuntimeScalar remove(int index) { RuntimeScalar previous = super.remove(index); + owner.arrayElementRemoved(index, previous); owner.noteIsaMutation(); owner.notePackageRootMutation(previous, null); owner.validateIsaMutation(); return previous; } + @Override + public RuntimeScalar removeFirst() { + if (isEmpty()) throw new java.util.NoSuchElementException(); + return remove(0); + } + + @Override + public RuntimeScalar removeLast() { + if (isEmpty()) throw new java.util.NoSuchElementException(); + return remove(size() - 1); + } + @Override public boolean remove(Object o) { - boolean removed = super.remove(o); - if (removed && o instanceof RuntimeScalar scalar) { - owner.noteIsaMutation(); - owner.notePackageRootMutation(scalar, null); - owner.validateIsaMutation(); - } - return removed; + int index = indexOf(o); + if (index < 0) return false; + remove(index); + return true; } @Override @@ -350,10 +384,59 @@ public void clear() { if (!isEmpty()) { owner.noteIsaMutation(); owner.notePackageRootClear(this); + owner.arrayElementsCleared(this); } super.clear(); owner.validateIsaMutation(); } + + @Override + protected void removeRange(int fromIndex, int toIndex) { + if (fromIndex == toIndex) return; + List removed = new ArrayList<>(subList(fromIndex, toIndex)); + owner.noteIsaMutation(); + owner.notePackageRootClear(removed); + super.removeRange(fromIndex, toIndex); + owner.arrayElementsRemoved(fromIndex, removed); + owner.validateIsaMutation(); + } + + @Override + public boolean removeIf(java.util.function.Predicate filter) { + Objects.requireNonNull(filter); + boolean changed = false; + for (int i = size() - 1; i >= 0; i--) { + if (filter.test(get(i))) { + remove(i); + changed = true; + } + } + return changed; + } + + @Override + public boolean removeAll(java.util.Collection values) { + Objects.requireNonNull(values); + return removeIf(values::contains); + } + + @Override + public boolean retainAll(java.util.Collection values) { + Objects.requireNonNull(values); + return removeIf(value -> !values.contains(value)); + } + + @Override + public void replaceAll(java.util.function.UnaryOperator operator) { + Objects.requireNonNull(operator); + for (int i = 0; i < size(); i++) set(i, operator.apply(get(i))); + } + + @Override + public void sort(java.util.Comparator comparator) { + super.sort(comparator); + owner.reconcileArrayElementOwnerSlots(); + } } /** Compact backing for the common one-argument method call plus invocant. */ @@ -609,6 +692,7 @@ public void markOwnedAliasElement(RuntimeScalar scalar) { } ownedAliasElements.add(scalar); elementsOwned = true; + reconcileArrayElementOwnerSlots(); } public boolean ownsElement(RuntimeScalar scalar) { @@ -617,6 +701,132 @@ public boolean ownsElement(RuntimeScalar scalar) { return ownedAliasElements != null && ownedAliasElements.contains(scalar); } + private static RuntimeBase directReferent(RuntimeScalar scalar) { + return scalar != null && scalar.value instanceof RuntimeBase base ? base : null; + } + + private boolean containsElementIdentity(RuntimeScalar scalar) { + for (RuntimeScalar element : elements) if (element == scalar) return true; + return false; + } + + private void syncArrayElementOwnerSlot(int index) { + if (threadShared || index < 0 || index >= elements.size()) return; + if (arrayElementOwnerSlots == null) { + if (ownsElement(elements.get(index))) reconcileArrayElementOwnerSlots(); + return; + } + if (index >= arrayElementOwnerSlots.size()) return; + RuntimeScalar scalar = elements.get(index); + RuntimeBase referent = ownsElement(scalar) ? directReferent(scalar) : null; + PerlOwnerSlot slot = arrayElementOwnerSlots.get(index); + if (referent == null) { + if (slot != null) slot.release(); + arrayElementOwnerSlots.set(index, null); + } else { + if (slot == null) { + slot = new PerlOwnerSlot(PerlOwnerSlot.Kind.ARRAY_ELEMENT, null, this, scalar); + arrayElementOwnerSlots.set(index, slot); + } + slot.acquireArrayElement(referent, scalar); + } + } + + void reconcileArrayElementOwnerSlots() { + if (threadShared || !elementsOwned) { + releaseArrayElementOwnerSlots(); + return; + } + if (arrayElementOwnerSlots == null) { + arrayElementOwnerSlots = new ArrayList<>(Collections.nCopies(elements.size(), null)); + } else if (arrayElementOwnerSlots.size() < elements.size()) { + arrayElementOwnerSlots.addAll(Collections.nCopies( + elements.size() - arrayElementOwnerSlots.size(), null)); + } else if (arrayElementOwnerSlots.size() > elements.size()) { + for (int i = arrayElementOwnerSlots.size() - 1; i >= elements.size(); i--) { + PerlOwnerSlot slot = arrayElementOwnerSlots.remove(i); + if (slot != null) slot.release(); + } + } + for (int i = 0; i < elements.size(); i++) syncArrayElementOwnerSlot(i); + } + + private void arrayElementAdded(int index, RuntimeScalar scalar) { + if (scalar != null) scalar.markArrayContainerOwner(this); + if (threadShared) return; + if (arrayElementOwnerSlots == null) { + if (ownsElement(scalar)) reconcileArrayElementOwnerSlots(); + return; + } + arrayElementOwnerSlots.add(index, null); + syncArrayElementOwnerSlot(index); + } + + private void arrayElementReplaced(int index, RuntimeScalar previous, RuntimeScalar scalar) { + if (scalar != null) scalar.markArrayContainerOwner(this); + if (previous != null && previous != scalar && !containsElementIdentity(previous)) { + previous.unmarkArrayContainerOwner(this); + } + syncArrayElementOwnerSlot(index); + } + + private void arrayElementRemoved(int index, RuntimeScalar previous) { + if (arrayElementOwnerSlots != null && !threadShared + && index >= 0 && index < arrayElementOwnerSlots.size()) { + PerlOwnerSlot slot = arrayElementOwnerSlots.remove(index); + if (slot != null) slot.release(); + } + if (previous != null && !containsElementIdentity(previous)) { + previous.unmarkArrayContainerOwner(this); + } + } + + private void arrayElementsRemoved(int startIndex, List removed) { + if (arrayElementOwnerSlots != null && !threadShared) { + for (int i = removed.size() - 1; i >= 0; i--) { + int index = startIndex + i; + if (index < arrayElementOwnerSlots.size()) { + PerlOwnerSlot slot = arrayElementOwnerSlots.remove(index); + if (slot != null) slot.release(); + } + } + } + for (RuntimeScalar scalar : removed) { + if (scalar != null && !containsElementIdentity(scalar)) { + scalar.unmarkArrayContainerOwner(this); + } + } + } + + private void arrayElementsCleared(List removed) { + releaseArrayElementOwnerSlots(); + Set unique = Collections.newSetFromMap(new IdentityHashMap<>()); + unique.addAll(removed); + for (RuntimeScalar scalar : unique) { + if (scalar != null) scalar.unmarkArrayContainerOwner(this); + } + } + + void updateArrayElementOwnersForScalar(RuntimeScalar scalar) { + if (threadShared || arrayElementOwnerSlots == null) return; + for (int i = 0; i < elements.size(); i++) { + if (elements.get(i) == scalar) syncArrayElementOwnerSlot(i); + } + } + + void releaseArrayElementOwnerSlots() { + if (arrayElementOwnerSlots == null) return; + for (PerlOwnerSlot slot : arrayElementOwnerSlots) { + if (slot != null) slot.release(); + } + arrayElementOwnerSlots = null; + } + + PerlOwnerSlot arrayElementOwnerSlot(int index) { + return arrayElementOwnerSlots == null || index < 0 || index >= arrayElementOwnerSlots.size() + ? null : arrayElementOwnerSlots.get(index); + } + public void forgetOwnedAliasElement(RuntimeScalar scalar) { if (ownedAliasElements == null || scalar == null) return; ownedAliasElements.remove(scalar); @@ -626,6 +836,7 @@ public void forgetOwnedAliasElement(RuntimeScalar scalar) { elementsOwned = false; } } + reconcileArrayElementOwnerSlots(); } /** @@ -694,13 +905,14 @@ public static RuntimeScalar pop(RuntimeArray runtimeArray) { // @_ uses aliasing (setArrayOfAlias) without refCount increments, // so its elements must NOT be mortal-ized on shift/pop — doing so // would corrupt the caller's refCount tracking. - if (runtimeArray.ownsElement(result) && result.refCountOwned + if (runtimeArray.ownsElement(result) + && !result.hasOwningAggregateSlotForLvalueAlias() + && !result.hasStrongScalarReferenceAlias() && (result.type & RuntimeScalarType.REFERENCE_BIT) != 0 && result.value instanceof RuntimeBase base + && result.ownsCountedReferent(base) && base.refCount > 0) { - result.refCountOwned = false; runtimeArray.forgetOwnedAliasElement(result); - base.releaseActiveOwner(result); MortalList.requestWeakSweepsForDestroyedContainer(base); MortalList.deferDecrementFromArraySlot( base, result, "RuntimeArray.pop"); @@ -737,13 +949,14 @@ public static RuntimeScalar shift(RuntimeArray runtimeArray) { if (result != null) { // If this element owned a refCount, defer the decrement. // See pop() for rationale and elementsOwned guard. - if (runtimeArray.ownsElement(result) && result.refCountOwned + if (runtimeArray.ownsElement(result) + && !result.hasOwningAggregateSlotForLvalueAlias() + && !result.hasStrongScalarReferenceAlias() && (result.type & RuntimeScalarType.REFERENCE_BIT) != 0 && result.value instanceof RuntimeBase base + && result.ownsCountedReferent(base) && base.refCount > 0) { - result.refCountOwned = false; runtimeArray.forgetOwnedAliasElement(result); - base.releaseActiveOwner(result); MortalList.requestWeakSweepsForDestroyedContainer(base); MortalList.deferDecrementFromArraySlot( base, result, "RuntimeArray.shift"); @@ -811,6 +1024,7 @@ public static RuntimeScalar push(RuntimeArray runtimeArray, RuntimeBase value) { runtimeArray.elementsAliased = false; runtimeArray.ownedAliasElements = null; } + runtimeArray.reconcileArrayElementOwnerSlots(); yield getScalarInt(runtimeArray.elements.size()); } case AUTOVIVIFY_ARRAY -> { @@ -856,6 +1070,7 @@ public static RuntimeScalar unshift(RuntimeArray runtimeArray, RuntimeBase value runtimeArray.elementsAliased = false; runtimeArray.ownedAliasElements = null; } + runtimeArray.reconcileArrayElementOwnerSlots(); yield getScalarInt(runtimeArray.elements.size()); } case AUTOVIVIFY_ARRAY -> { @@ -1096,6 +1311,15 @@ public RuntimeScalar delete(int index) { } RuntimeScalar previous = this.get(index); this.elements.set(index, null); + // A middle delete leaves the scalar cell available as the + // operator result, but the removed cell no longer owns this + // reference through an owning array. Borrowed aliases such as + // @_ must not release the caller's scalar owner. Defer the + // owner release so assignment can first retain the result. + if (ownsElement(previous) && !previous.hasAggregateOrScalarReferenceOwner()) { + MortalList.deferDecrementIfTracked(previous); + MortalList.deferIoOwnerRelease(previous); + } yield previous; } case AUTOVIVIFY_ARRAY -> scalarUndef; @@ -1249,6 +1473,7 @@ public void dynamicRestoreState() { ? elements.get(index) : null; boolean existed = existingElement != null && !(existingElement instanceof RuntimeArrayProxyEntry proxy && !proxy.hasLvalue()); + boolean savedOwnedElement = existed && ownsElement(existingElement); // local must restore the original slot, not a scalar copy of it. An // array entry can be ref-aliased (for example, `$a[0]` aliased to // `$_`), and copying the wrapper here loses that alias identity when @@ -1310,6 +1535,14 @@ && isEmptyLocalPlaceholder(self.elements.getLast())) { if (existed) { if (idx < self.elements.size()) { self.elements.set(idx, savedValue); + if (!savedOwnedElement) { + // Setting an element on an aliased array normally + // marks it as a newly owned slot. Restoration must + // recover the borrowed/owned status from before + // localization, or @_ cleanup can release the + // caller's scalar owner. + self.forgetOwnedAliasElement(savedValue); + } } } else if (idx >= 0 && idx < self.elements.size()) { self.elements.set(idx, null); @@ -1602,6 +1835,9 @@ public RuntimeArray set(RuntimeScalar value) { this.ownedAliasElements = null; this.elementsAliased = false; this.elements.add(value); + this.elementsOwned = true; + this.elementsAliased = false; + reconcileArrayElementOwnerSlots(); markPackageRootedValue(value); MortalList.flush(); return this; @@ -1664,6 +1900,7 @@ public RuntimeArray setFromList(RuntimeList list) { this.elementsOwned = true; this.elementsAliased = false; this.ownedAliasElements = null; + reconcileArrayElementOwnerSlots(); // Create a new array with scalarContextSize set for assignment return value // This is needed for eval context where assignment should return element count @@ -1721,6 +1958,34 @@ public RuntimeArray setFromList(RuntimeList list) { }; } + /** + * Replace a plain array with already-materialized list-assignment cells. + * Normalize each scalar owner idempotently, then transfer the cells using + * the normal mutation hooks so displaced values and structural slots are + * released consistently. + */ + void replaceWithTransferredListAssignmentCells(List replacements) { + if (type != PLAIN_ARRAY) { + throw new IllegalStateException("transferred list-assignment cells require a plain array"); + } + eachIteratorIndex = null; + notePackageRootMutation(); + MortalList.deferDestroyForContainerClear(elements); + elements.clear(); + elementsOwned = true; + elementsAliased = false; + ownedAliasElements = null; + elements.addAll(replacements); + for (RuntimeScalar element : replacements) { + markPackageRootedValue(element); + if (hasDurableElementLifetime()) { + RuntimeScalar.retainUnstashedIoForDurableSlot(element); + } + RuntimeScalar.incrementRefCountForContainerStore(element); + } + reconcileArrayElementOwnerSlots(); + } + /** * Set this array's contents from a list without incrementing the * referents' refCounts — i.e., the stored elements are aliases, @@ -1757,12 +2022,13 @@ public RuntimeArray setFromListAliased(RuntimeList list) { // overwrites will still work correctly because setLarge checks // refCountOwned before decrementing. for (RuntimeScalar elem : this.elements) { - if (elem != null) elem.refCountOwned = false; + if (elem != null) elem.setRefCountOwned(false); markPackageRootedValue(elem); } this.elementsOwned = false; this.elementsAliased = true; this.ownedAliasElements = null; + reconcileArrayElementOwnerSlots(); return this; } @@ -1788,11 +2054,12 @@ public RuntimeArray setFromReferenceList(RuntimeList list) { RuntimeScalar referent = reference.refAliasScalarReference(); this.elements.add(referent); markPackageRootedValue(referent); - referent.refCountOwned = false; + referent.setRefCountOwned(false); } this.elementsOwned = false; this.elementsAliased = true; this.ownedAliasElements = null; + reconcileArrayElementOwnerSlots(); return this; } @@ -1876,6 +2143,7 @@ public RuntimeScalar createReferenceWithTrackedElements() { this.elementsOwned = true; this.elementsAliased = false; this.ownedAliasElements = null; + reconcileArrayElementOwnerSlots(); RuntimeScalar result = new RuntimeScalar(); result.type = RuntimeScalarType.ARRAYREFERENCE; result.value = this; @@ -2207,6 +2475,7 @@ public RuntimeArray setArrayOfAlias(RuntimeArray arr) { arr.elements.add(element); } arr.elementsAliased = true; + arr.reconcileArrayElementOwnerSlots(); return arr; } @@ -2232,6 +2501,7 @@ public RuntimeArray setArrayOfAlias(RuntimeArray arr) { } arr.elementsAliased = true; arr.elementsOwned = arr.ownedAliasElements != null && !arr.ownedAliasElements.isEmpty(); + arr.reconcileArrayElementOwnerSlots(); return arr; } @@ -2260,6 +2530,7 @@ public RuntimeArray getTailCallArrayOfAlias() { } } this.elementsOwned = false; + reconcileArrayElementOwnerSlots(); return arr; } @@ -2288,6 +2559,8 @@ public RuntimeArray takeTailCallOwnership() { this.elementsOwned = false; carrier.elementsAliased = true; carrier.elementsOwned = carrier.ownedAliasElements != null && !carrier.ownedAliasElements.isEmpty(); + reconcileArrayElementOwnerSlots(); + carrier.reconcileArrayElementOwnerSlots(); return carrier; } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArrayProxyEntry.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArrayProxyEntry.java index 3f6f97691f..dbcecba6ba 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArrayProxyEntry.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArrayProxyEntry.java @@ -79,6 +79,11 @@ public RuntimeScalar set(RuntimeScalar value) { RuntimeScalar result = super.set(value); if (!parent.elementsAliased) { parent.elementsOwned = true; + // Vivification can insert the lvalue while this array is still + // marked non-owning. The element-list mutation then cannot create + // an ARRAY_ELEMENT owner slot, so reconcile after the proxy store + // promotes the array to an owning aggregate. + parent.reconcileArrayElementOwnerSlots(); } return result; } @@ -105,6 +110,7 @@ public RuntimeScalar aliasToReference(RuntimeScalar reference) { this.firstClassRegexValue = referent.firstClassRegexValue; if (!parent.elementsAliased) { parent.elementsOwned = true; + parent.reconcileArrayElementOwnerSlots(); } return referent; } @@ -124,6 +130,10 @@ public RuntimeScalar aliasToAggregateReference(RuntimeScalar reference) { this.lvalue = reference; this.type = reference.type; this.value = reference.value; + if (!parent.elementsAliased) { + parent.elementsOwned = true; + parent.reconcileArrayElementOwnerSlots(); + } return reference; } @@ -155,7 +165,15 @@ public RuntimeScalar createReference() { vivify(); } } - return lvalue.createReference(); + RuntimeScalar reference = lvalue.createReference(); + if (!parent.elementsAliased) { + // Taking a reference vivifies an ordinary array slot without + // going through set(). Promote the parent now so later writes + // through the scalar reference update an ARRAY_ELEMENT owner slot. + parent.elementsOwned = true; + parent.reconcileArrayElementOwnerSlots(); + } + return reference; } /** @@ -266,13 +284,8 @@ public void dynamicRestoreState() { parent.notePackageRootMutation(); // Element didn't exist before. // Decrement refCount of the current value being displaced. - if (this.lvalue != null - && (this.lvalue.type & RuntimeScalarType.REFERENCE_BIT) != 0 - && this.lvalue.value instanceof RuntimeBase displacedBase - && displacedBase.refCount > 0 && --displacedBase.refCount == 0) { - displacedBase.refCount = Integer.MIN_VALUE; - DestroyDispatch.callDestroy(displacedBase); - } + RuntimeScalar.releaseDynamicRestoreScalarValueOwner( + this.lvalue, "RuntimeArrayProxyEntry.dynamicRestoreState"); this.lvalue = null; this.type = RuntimeScalarType.UNDEF; this.value = null; diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java index 553ac10c8b..1dd44afcc6 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java @@ -1,7 +1,10 @@ package org.perlonjava.runtime.runtimetypes; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.Iterator; import java.util.Objects; +import java.util.Set; import static org.perlonjava.runtime.runtimetypes.RuntimeScalarCache.scalarUndef; @@ -191,14 +194,6 @@ boolean deferSharedDestroy() { */ public boolean storedInPackageGlobal = false; - /** - * Number of live hash-element slots that directly own this referent. - * This is separate from selective refCount because a returned method - * value can be detached from its slot while the hash remains an owner. - */ - public int hashSlotOwnerCount = 0; - - /** * Marks a scalar/container whose contents are reachable from package * globals. Mutating such an object can change cached root-reachability @@ -237,6 +232,11 @@ public void retainClosureCapture() { /** Retain one aggregate pad capture through an explicit owner slot. */ public PerlOwnerSlot retainClosureCaptureOwner() { + return retainClosureCaptureOwner(null); + } + + /** Retain this aggregate pad through the semantic edge from its closure. */ + public PerlOwnerSlot retainClosureCaptureOwner(RuntimeCode closureOwner) { // A closure-pad slot is a semantic Perl owner even though it does not // contribute to the legacy selective refcount. A targeted weak sweep // can therefore leave an unblessed aggregate's legacy count at the @@ -248,7 +248,7 @@ public PerlOwnerSlot retainClosureCaptureOwner() { refCount = 0; } PerlOwnerSlot ownerSlot = new PerlOwnerSlot(PerlOwnerSlot.Kind.CLOSURE_PAD); - ownerSlot.acquireCapture(this); + ownerSlot.acquireCapture(this, closureOwner); retainClosureCapture(); return ownerSlot; } @@ -315,6 +315,8 @@ private void releaseClosureCapture(boolean nativeOwnerSlot) { /** Identity ledger for migrated slots; selective refCount remains the * lifecycle bridge while owner families move to this API. */ private java.util.Set ownerSlots = null; + /** Legacy-only hash slots shared across runtime wrappers. */ + private int sharedHashSlotOwnerCount; synchronized void addOwnerSlot(PerlOwnerSlot slot) { if (ownerSlots == null) { @@ -347,6 +349,20 @@ public int semanticCaptureOwnerCount() { return ownerSlotCount(PerlOwnerSlot.Kind.CLOSURE_PAD); } + /** Number of live ordinary hash-entry slots that directly own this referent. */ + public int hashSlotOwnerCount() { + return ownerSlotCount(PerlOwnerSlot.Kind.HASH_ENTRY) + sharedHashSlotOwnerCount; + } + + /** Number of live ordinary array-element slots that directly own this referent. */ + public int arraySlotOwnerCount() { + return ownerSlotCount(PerlOwnerSlot.Kind.ARRAY_ELEMENT); + } + + synchronized void adjustSharedHashSlotOwnerCount(int delta) { + sharedHashSlotOwnerCount = Math.max(0, sharedHashSlotOwnerCount + delta); + } + /** Count captured aggregate pads whose lifetime is owned natively by slots. */ public synchronized int nativeCaptureOwnerCount() { if (ownerSlots == null) return 0; @@ -360,6 +376,17 @@ public synchronized int nativeCaptureOwnerCount() { return count; } + /** Whether an active captured-pad slot has no owning closure edge recorded. */ + synchronized boolean hasUnassociatedClosureCaptureOwner() { + if (ownerSlots == null) return false; + for (PerlOwnerSlot slot : ownerSlots) { + if (slot.kind() == PerlOwnerSlot.Kind.CLOSURE_PAD + && slot.isNativeCaptureOwner() + && slot.closureOwner() == null) return true; + } + return false; + } + // 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 @@ -401,7 +428,7 @@ private static void addTiedHandlerReferent( } } - public void activateOwnerTracking() { + public synchronized void activateOwnerTracking() { if (activeOwners == null) { activeOwners = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); // Note: backfilling from ScalarRefRegistry.snapshot() was @@ -415,19 +442,38 @@ public void activateOwnerTracking() { } } - public void recordActiveOwner(RuntimeScalar scalar) { + public synchronized void recordActiveOwner(RuntimeScalar scalar) { if (activeOwners != null) { activeOwners.add(scalar); } } - public void releaseActiveOwner(RuntimeScalar scalar) { + public synchronized void releaseActiveOwner(RuntimeScalar scalar) { if (activeOwners != null) { activeOwners.remove(scalar); } } - public int activeOwnerCount() { + /** Transfer legacy owner diagnostics along with a migrated scalar-value slot. */ + synchronized void transferScalarOwner(RuntimeScalar previous, RuntimeScalar next, + String transferSite) { + if (previous == null || next == null) return; + if (refCountTrace && REFCOUNT_TRACE_ENV) { + java.util.LinkedHashMap owners = traceOwners.get(this); + if (owners != null) { + OwnerTrace prior = owners.remove(System.identityHashCode(previous)); + String acquireSite = prior == null + ? "" + : prior.acquireSite; + owners.put(System.identityHashCode(next), new OwnerTrace(next, acquireSite)); + } + } + if (activeOwners != null && activeOwners.remove(previous)) { + activeOwners.add(next); + } + } + + public synchronized int activeOwnerCount() { if (activeOwners == null) return 0; // Filter for actual still-owning scalars: refCountOwned=true and // value==this. Stale entries (overwritten without going through a @@ -447,7 +493,7 @@ public int activeOwnerCount() { } /** Snapshot the currently live scalar owner slots by identity. */ - public java.util.Set activeOwnerSnapshot() { + public synchronized java.util.Set activeOwnerSnapshot() { if (activeOwnerCount() == 0) return java.util.Collections.emptySet(); java.util.Set snapshot = java.util.Collections.newSetFromMap( new java.util.IdentityHashMap<>()); @@ -455,6 +501,31 @@ public java.util.Set activeOwnerSnapshot() { return snapshot; } + /** + * Snapshot direct scalar owners from both the legacy registry and explicit + * scalar-value slots. During migration, a scalar can be present in either + * source or both, so the identity set forms their union. + */ + public java.util.Set countedScalarOwnerSnapshot() { + java.util.Set snapshot = java.util.Collections.newSetFromMap( + new java.util.IdentityHashMap<>()); + snapshot.addAll(activeOwnerSnapshot()); + for (PerlOwnerSlot slot : ownerSlotSnapshot()) { + if (slot.kind() != PerlOwnerSlot.Kind.SCALAR_VALUE + || slot.referent() != this) continue; + RuntimeScalar owner = slot.ownerCell(); + if (owner != null && owner.value == this) { + snapshot.add(owner); + } + } + return snapshot; + } + + /** Count direct scalar owners across the legacy and explicit ledgers. */ + public int countedScalarOwnerCount() { + return countedScalarOwnerSnapshot().size(); + } + /** Snapshot semantic owner-slot identities, including closure pads. */ public synchronized java.util.Set ownerSlotSnapshot() { if (ownerSlots == null || ownerSlots.isEmpty()) { @@ -467,8 +538,8 @@ public synchronized java.util.Set ownerSlotSnapshot() { } /** - * D-W6.14: count owners that are reachable from package globals or - * live my-vars. This is the strict version used by the production + * D-W6.14: count scalar and scalar-content-slot owners that are reachable + * from package globals or live my-vars. This is the strict version used by the production * rescue at MortalList.flush refCount→0: only objects whose owners * can be reached from real roots are kept alive. Phantom owners * (scalars that should be dead but haven't been released yet) are @@ -477,16 +548,59 @@ public synchronized java.util.Set ownerSlotSnapshot() { * resolved by user weaken()). */ public int reachableOwnerCount() { - if (activeOwners == null) return 0; int count = 0; - java.util.Iterator it = activeOwners.iterator(); - while (it.hasNext()) { - RuntimeScalar sc = it.next(); + Set scalarValueCells = + Collections.newSetFromMap(new IdentityHashMap<>()); + for (PerlOwnerSlot slot : ownerSlotSnapshot()) { + if (slot.kind() == PerlOwnerSlot.Kind.SCALAR_VALUE + && slot.referent() == this && slot.ownerCell() != null) { + scalarValueCells.add(slot.ownerCell()); + } + } + // Snapshot under the same monitor used by register/release operations. + // Reachability queries can walk large graphs, so do them after dropping + // the monitor; this avoids both fail-fast iteration and lock inversion + // between concurrently cleaned referents. + java.util.List legacyOwners; + synchronized (this) { + legacyOwners = activeOwners == null + ? java.util.List.of() : new java.util.ArrayList<>(activeOwners); + } + for (RuntimeScalar sc : legacyOwners) { if (sc != null && sc.refCountOwned && sc.value == this && ReachabilityWalker.isScalarReachable(sc)) { - count++; + // The explicit scalar value slot is authoritative when + // present; retain activeOwners only as a migration fallback. + if (!scalarValueCells.contains(sc)) count++; } else if (sc == null || !sc.refCountOwned || sc.value != this) { - it.remove(); + synchronized (this) { + if (activeOwners != null) activeOwners.remove(sc); + } + } + } + // Several RV wrappers can alias one scalar cell. They keep separate + // slot identities for root proofs, but the cell owns its contents only + // once, so only one reachable alias contributes to the count. + Set countedScalarReferenceCells = + Collections.newSetFromMap(new IdentityHashMap<>()); + for (PerlOwnerSlot slot : ownerSlotSnapshot()) { + if ((slot.kind() != PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS + && slot.kind() != PerlOwnerSlot.Kind.SCALAR_VALUE) + || slot.referent() != this) continue; + RuntimeScalar ownerCell = slot.ownerCell(); + if (ownerCell == null || !ReachabilityWalker.isScalarReachable(ownerCell)) continue; + RuntimeScalar referenceCell = slot.kind() == PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS + ? slot.scalarReferenceCell() : ownerCell; + Object ownershipKey = referenceCell == null ? slot : referenceCell; + if (countedScalarReferenceCells.add(ownershipKey)) count++; + } + for (PerlOwnerSlot slot : ownerSlotSnapshot()) { + RuntimeBase owner = slot.ownerContainer(); + if (slot.kind() == PerlOwnerSlot.Kind.TIE_WRAPPER + && slot.referent() == this + && owner != null + && MortalList.isRootedOwnerContainer(owner, this)) { + count++; } } return count; @@ -806,7 +920,9 @@ public synchronized String ownerTraceSnapshot() { } result.append(']'); } - result.append(" semanticCaptureOwners=").append(semanticCaptureOwnerCount()); + result.append(" semanticCaptureOwners=").append(semanticCaptureOwnerCount()) + .append(" hashEntryOwners=").append(hashSlotOwnerCount()) + .append(" arrayElementOwners=").append(arraySlotOwnerCount()); 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 b5e7f670f5..1b4c222dbe 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -250,6 +250,7 @@ private static void popJvmClosureFrame(JvmClosureFrame frame) { && closure.refCount == 0 && closure.stashRefCount <= 0 && !closure.localBindingExists + && !ReachabilityWalker.isReachableFromRoots(closure) && (frame.returned == null || !frame.returned.containsKey(closure))) { closure.releaseCaptures(); } @@ -2871,7 +2872,7 @@ public void releaseCaptures() { RuntimeScalar[] scalars = capturedScalars; capturedScalars = null; // null out first to prevent re-entry for (RuntimeScalar s : scalars) { - s.releaseClosureCapture(); + s.releaseClosureCapture(this); if (s.captureCount == 0) { // If the captured scalar itself holds an unowned CODE ref with // captures, release those recursively (handles nested closures). @@ -5368,7 +5369,7 @@ public static RuntimeScalar makeCodeObject( } code.closedOverVariables.put(f.getName(), capturedVar); captured.add(capturedVar); - capturedVar.retainClosureCapture(); + capturedVar.retainClosureCapture(code); } } else if (f.getType() == RuntimeArray.class || f.getType() == RuntimeHash.class) { RuntimeBase capturedAggregate = (RuntimeBase) f.get(codeObject); @@ -5379,7 +5380,7 @@ public static RuntimeScalar makeCodeObject( } code.closedOverVariables.put(f.getName(), capturedAggregate); capturedAggregates.add(capturedAggregate); - PerlOwnerSlot ownerSlot = capturedAggregate.retainClosureCaptureOwner(); + PerlOwnerSlot ownerSlot = capturedAggregate.retainClosureCaptureOwner(code); capturedAggregateOwnerSlots.add(ownerSlot); } } @@ -6043,7 +6044,7 @@ private static RuntimeList bindReturnedMethodCoderef( bound.refCount = 0; bound.capturedScalars = new RuntimeScalar[] { new RuntimeScalar(codeRef), invocant }; for (RuntimeScalar captured : bound.capturedScalars) { - captured.retainClosureCapture(); + captured.retainClosureCapture(bound); } registerJvmClosure(bound); return new RuntimeScalar(bound).getList(); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java index 26280eb418..fb912b4030 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java @@ -113,11 +113,6 @@ private static boolean isUnderscoreGlobName(String name) { // while ordinary stringification reports the assigned source GV. private String stringificationName; public RuntimeScalar IO; - // Keep an anonymous source glob alive when its IO slot is assigned into a - // named glob (for example local *STDIN = $tempfile). The named glob owns - // the IO slot but must also retain the source until the assignment scope - // ends, otherwise phantom fd recycling can close the live handle. - private RuntimeGlob retainedIoGlob; // The IO slot displaced by local *GLOB. A subsequent selective FORMAT // assignment must retain it: `local *FH = *OTHER{FORMAT}` does not replace // FH's filehandle. @@ -1044,8 +1039,16 @@ public RuntimeScalar set(RuntimeGlob value) { oldRuntimeIO = rio; } + RuntimeIO assignedRuntimeIO = value.IO != null + && value.IO.value instanceof RuntimeIO runtimeIO ? runtimeIO : null; this.IO = value.IO; - this.retainedIoGlob = value; + if (assignedRuntimeIO != null) { + // The receiving glob becomes another owner of the IO handle. + // Track this slot even when value is a detached wrapper around + // an anonymous source glob, so that source can run DESTROY + // without closing a handle that is still installed here. + RuntimeIO.registerGlobForFdRecycling(this, assignedRuntimeIO); + } // Also update the global IO entry for this glob if (this.globName != null) { RuntimeGlob targetIO = GlobalVariable.getGlobalIO(this.globName); @@ -1439,10 +1442,13 @@ public RuntimeScalar getGlobSlot(RuntimeScalar index) { } // For anonymous globs (null globName), use local arraySlot if (this.globName == null) { - if (this.arraySlot == null) { - this.arraySlot = new RuntimeArray(); - } - yield this.arraySlot.createReference(); + // *glob{ARRAY} inspects an existing slot; it must not + // materialize one as a side effect. Ordinary array + // dereference uses getGlobArray(), which still vivifies + // the anonymous slot when needed. + yield this.arraySlot != null + ? this.arraySlot.createReference() + : new RuntimeScalar(); } if (this.slotSnapshot) { yield this.arraySlot != null @@ -2304,6 +2310,8 @@ && hasDestroyMethod(displacedBase)) { RuntimeScalar localCode = GlobalVariable.globalCodeRefs.get(snap.globName); RuntimeCode removedLocalCodeForReachabilityCheck = null; if (localCode != null && (localCode.type & REFERENCE_BIT) != 0 && localCode.value instanceof RuntimeBase localBase) { + boolean releasedScalarOwnerSlot = localCode.releaseScalarValueOwnerCount( + localBase, "RuntimeGlob.dynamicRestoreState local CODE release"); boolean removedLastStashRef = false; // Decrement stashRefCount on the local scope's CODE ref being removed if (localBase instanceof RuntimeCode localCodeObj) { @@ -2312,7 +2320,11 @@ && hasDestroyMethod(displacedBase)) { removedLastStashRef = localCodeObj.stashRefCount == 0; } } - if (localBase.refCount > 0 && --localBase.refCount == 0) { + if (!releasedScalarOwnerSlot && localBase.refCount > 0) { + localCode.setRefCountOwned(false); + localBase.releaseActiveOwner(localCode); + } + if (!releasedScalarOwnerSlot && localBase.refCount > 0 && --localBase.refCount == 0) { localBase.refCount = Integer.MIN_VALUE; DestroyDispatch.callDestroy(localBase); } else if (localBase.refCount == 0 && localBase instanceof RuntimeCode) { @@ -2334,7 +2346,7 @@ && hasDestroyMethod(displacedBase)) { } GlobalVariable.invalidatePackageRootSnapshot(); if (removedLocalCodeForReachabilityCheck != null - && removedLocalCodeForReachabilityCheck.activeOwnerCount() == 0 + && removedLocalCodeForReachabilityCheck.countedScalarOwnerCount() == 0 && !ReachabilityWalker.isReachableFromLiveScalarRegistry(removedLocalCodeForReachabilityCheck) && !ReachabilityWalker.isReachableFromLiveCodeCaptures(removedLocalCodeForReachabilityCheck) && !ReachabilityWalker.isReachableFromRoots(removedLocalCodeForReachabilityCheck)) { diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java index d7f02304e2..954465acb0 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java @@ -439,7 +439,7 @@ private void copyCodeMetadata(RuntimeCode source, RuntimeCode target) { } try (PerlRuntime.Binding ignored = targetRuntime.bind()) { for (RuntimeScalar captured : target.capturedScalars) { - captured.retainThreadCloneClosureCapture(); + captured.retainThreadCloneClosureCapture(target); } } } @@ -453,7 +453,7 @@ private void copyCodeMetadata(RuntimeCode source, RuntimeCode target) { new PerlOwnerSlot[target.capturedAggregates.length]; for (int i = 0; i < target.capturedAggregates.length; i++) { target.capturedAggregateOwnerSlots[i] = - target.capturedAggregates[i].retainClosureCaptureOwner(); + target.capturedAggregates[i].retainClosureCaptureOwner(target); } } } @@ -580,7 +580,8 @@ private RuntimeScalar cloneScalar(RuntimeScalar source) { } private void restoreScalarReferenceOwnership(RuntimeScalar source, RuntimeScalar target) { - if (!source.refCountOwned + if (!(source.value instanceof RuntimeBase sourceReferent) + || !source.ownsCountedReferent(sourceReferent) || (target.type & RuntimeScalarType.REFERENCE_BIT) == 0 || !(target.value instanceof RuntimeBase referent)) { return; @@ -591,12 +592,10 @@ private void restoreScalarReferenceOwnership(RuntimeScalar source, RuntimeScalar } if (referent.refCount < 0) return; if (referent.refCount == 0) referent.registerSharedFetchedView(); - referent.traceRefCount(+1, "RuntimeGraphCloner.restoreScalarReferenceOwnership"); - referent.recordOwner(target, "thread clone scalar owner"); - referent.recordActiveOwner(target); - referent.refCount++; - referent.hadCountedReference = true; - target.refCountOwned = true; + if (!target.acquireScalarValueOwnerCount( + referent, "RuntimeGraphCloner.restoreScalarReferenceOwnership")) { + return; + } try (PerlRuntime.Binding ignored = targetRuntime.bind()) { ScalarRefRegistry.registerRef(target); } @@ -650,7 +649,7 @@ private RuntimeHash cloneHash(RuntimeHash source) { } else if (source.type == RuntimeHash.TIED_HASH && source.elements instanceof TieHash tie) { target.elements = new TieHash(tie.getTiedPackage(), (RuntimeHash) cloneValue(tie.getPreviousValue()), - (RuntimeScalar) cloneValue(tie.getSelf())); + (RuntimeScalar) cloneValue(tie.getSelf()), target); } else { try (PerlRuntime.Binding ignored = targetRuntime.bind()) { for (Map.Entry entry : source.elements.entrySet()) { diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java index c9155e2621..3cd6f764c6 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java @@ -41,6 +41,8 @@ private static Stack dynamicStateStack() { public int type; // Map to store the elements of the hash public Map elements; + /** Stable semantic owner identity for each ordinary hash entry. */ + private final Map hashEntryOwnerSlots = new HashMap<>(); /** True only for Perl's compile-time %^H hash. */ public boolean isHintHash; /** True for Perl's runtime %{^HOOK} hash. */ @@ -106,6 +108,37 @@ private RuntimeHashElementMap newElementMap(Map values) { return map; } + /** + * Transfer an already-built plain-hash assignment into this hash while + * keeping this instance's owner-aware map and slot identities. Values are + * already counted by the temporary assignment hash, so only the + * structural HASH_ENTRY slots move here. + */ + void replaceWithTransferredAssignmentHash(RuntimeHash replacement) { + if (type != PLAIN_HASH || replacement == this || replacement.type != PLAIN_HASH) { + throw new IllegalStateException("transferred assignment requires distinct plain hashes"); + } + Set replacementByteKeys = replacement.byteKeys == null + ? null : new HashSet<>(replacement.byteKeys); + notePackageRootMutation(); + MortalList.deferDestroyForContainerClear(elements.values()); + elements.clear(); + if (byteKeys != null) byteKeys.clear(); + hashIterator = null; + + // This map belongs to the destination, so putAll establishes the + // correct per-entry callbacks, package-root marks, and owner slots. + // The scalar cells already own their referents; do not increment them. + elements.putAll(replacement.elements); + byteKeys = replacementByteKeys; + + // The cells now belong to this hash. Retire only the temporary hash's + // structural slots, leaving the transferred scalar owners intact. + replacement.elements.clear(); + replacement.byteKeys = null; + replacement.hashIterator = null; + } + void resetElementMapAfterAutovivification() { elements = newElementMap(); } @@ -120,6 +153,16 @@ Object snapshotRegexMutationState() { void restoreRegexMutationState(Object token) { if (!(token instanceof RegexMutationState state)) return; deferValuesAddedSince(state.elements.values()); + // Release the current map's element ownership before building the + // restored map. The new map acquires one slot for each saved entry; + // replacing the map reference directly would leave both the old and + // new slot accounting attached to the referents. + suppressHintHashLifecycleCleanup = true; + try { + elements.clear(); + } finally { + suppressHintHashLifecycleCleanup = false; + } elements = newElementMap(state.elements); byteKeys = state.byteKeys == null ? null : new HashSet<>(state.byteKeys); type = state.type; @@ -182,28 +225,32 @@ public RuntimeScalar put(String key, RuntimeScalar value) { } owner.markPackageRootedValue(value); RuntimeScalar result = super.put(key, value); - updateReferentHashSlotOwnership(previous, value); + owner.updateHashEntryOwner(key, previous, value); + if (previous != value) owner.detachHashCellIfAbsent(previous); return result; } @Override public void putAll(Map m) { + Map updates = new LinkedHashMap<>(); + m.forEach(updates::put); if (owner.threadShared) { - for (Map.Entry entry - : new StableHashMap<>(m).entrySet()) { + for (Map.Entry entry : updates.entrySet()) { put(entry.getKey(), entry.getValue()); } return; } + Map previousValues = new HashMap<>(); boolean hasReferenceEdge = false; - for (Map.Entry e : m.entrySet()) { + for (Map.Entry e : updates.entrySet()) { + RuntimeScalar previous = super.get(e.getKey()); + previousValues.put(e.getKey(), previous); if (rootEdge(super.get(e.getKey())) || rootEdge(e.getValue())) { hasReferenceEdge = true; - break; } } owner.notePackageRootMutationIf(hasReferenceEdge); - for (RuntimeScalar value : m.values()) { + for (RuntimeScalar value : updates.values()) { if (owner.threadShared) SharedPerlStorage.publishBlessing(value); if (value != null) { value.markContainerOwner(owner); @@ -213,12 +260,23 @@ public void putAll(Map m) { } owner.markPackageRootedValue(value); } - super.putAll(m); + super.putAll(updates); + for (Map.Entry e : updates.entrySet()) { + owner.updateHashEntryOwner(e.getKey(), previousValues.get(e.getKey()), e.getValue()); + } + for (RuntimeScalar previous : previousValues.values()) { + owner.detachHashCellIfAbsent(previous); + } } @Override public RuntimeScalar remove(Object key) { + boolean existed = super.containsKey(key); RuntimeScalar previous = super.remove(key); + if (existed && key instanceof String stringKey) { + owner.releaseHashEntryOwner(stringKey, previous); + } + owner.detachHashCellIfAbsent(previous); if (previous != null) { if (owner.isHintHash && !owner.suppressHintHashLifecycleCleanup) { MortalList.deferDestroyForContainerClear(java.util.Collections.singletonList(previous)); @@ -235,6 +293,14 @@ public void clear() { MortalList.deferDestroyForContainerClear(values()); } owner.notePackageRootClear(values()); + Set previousValues = Collections.newSetFromMap(new IdentityHashMap<>()); + previousValues.addAll(values()); + for (Map.Entry entry : new ArrayList<>(entrySet())) { + owner.releaseHashEntryOwner(entry.getKey(), entry.getValue()); + } + super.clear(); + for (RuntimeScalar previous : previousValues) owner.detachHashCellIfAbsent(previous); + return; } super.clear(); } @@ -556,6 +622,7 @@ public RuntimeArray setFromList(RuntimeList value) { // tied outer hint hash; it must not dispatch CLEAR on the // outer tie object (which may deliberately omit CLEAR). if (isHintHash && materializedList.elements.isEmpty()) { + releaseHashEntryOwnerSlots(); elements = newElementMap(); type = PLAIN_HASH; yield new RuntimeArray(); @@ -649,16 +716,67 @@ private static RuntimeBase directReferent(RuntimeScalar scalar) { return scalar != null && scalar.value instanceof RuntimeBase base ? base : null; } - private static void updateReferentHashSlotOwnership( - RuntimeScalar oldValue, RuntimeScalar newValue) { - RuntimeBase oldReferent = directReferent(oldValue); - RuntimeBase newReferent = directReferent(newValue); + private void updateHashEntryOwner(String key, RuntimeScalar previous, RuntimeScalar value) { + // Shared hashes still use their canonical legacy accounting; an owner + // slot belongs to one PerlRuntime and cannot represent shared storage. + if (threadShared) { + adjustSharedHashSlotOwnerCount(directReferent(previous), directReferent(value)); + return; + } + PerlOwnerSlot slot = hashEntryOwnerSlots.computeIfAbsent(key, + ignored -> new PerlOwnerSlot(PerlOwnerSlot.Kind.HASH_ENTRY, null, this, value)); + slot.acquireHashEntry(directReferent(value), value); + } + + private void detachHashCellIfAbsent(RuntimeScalar scalar) { + if (scalar != null && !elements.containsValue(scalar)) { + scalar.unmarkHashContainerOwner(this); + } + } + + private void releaseHashEntryOwner(String key, RuntimeScalar previous) { + if (threadShared) { + adjustSharedHashSlotOwnerCount(directReferent(previous), null); + return; + } + PerlOwnerSlot removed = hashEntryOwnerSlots.remove(key); + if (removed != null) removed.release(); + } + + private static void adjustSharedHashSlotOwnerCount(RuntimeBase oldReferent, + RuntimeBase newReferent) { if (oldReferent == newReferent) return; - if (oldReferent != null && oldReferent.hashSlotOwnerCount > 0) { - oldReferent.hashSlotOwnerCount--; + if (oldReferent != null) oldReferent.adjustSharedHashSlotOwnerCount(-1); + if (newReferent != null) newReferent.adjustSharedHashSlotOwnerCount(1); + } + + PerlOwnerSlot hashEntryOwnerSlot(String key) { + return hashEntryOwnerSlots.get(key); + } + + void releaseHashEntryOwnerSlots() { + if (threadShared) return; + Set cells = Collections.newSetFromMap(new IdentityHashMap<>()); + cells.addAll(elements.values()); + for (RuntimeScalar cell : cells) { + if (cell != null) cell.unmarkHashContainerOwner(this); } - if (newReferent != null) { - newReferent.hashSlotOwnerCount++; + for (PerlOwnerSlot slot : hashEntryOwnerSlots.values()) slot.release(); + hashEntryOwnerSlots.clear(); + } + + /** Update every entry that aliases a scalar cell whose referent changed. */ + void updateHashEntryOwnersForScalar(RuntimeScalar scalar, RuntimeBase oldReferent, + RuntimeBase nextReferent) { + for (Map.Entry entry : elements.entrySet()) { + if (entry.getValue() != scalar) continue; + if (threadShared) { + adjustSharedHashSlotOwnerCount(oldReferent, nextReferent); + continue; + } + PerlOwnerSlot slot = hashEntryOwnerSlots.computeIfAbsent(entry.getKey(), + ignored -> new PerlOwnerSlot(PerlOwnerSlot.Kind.HASH_ENTRY, null, this, scalar)); + slot.acquireHashEntry(nextReferent, scalar); } } @@ -1064,8 +1182,10 @@ public RuntimeScalar deleteInContext(RuntimeScalar key, int callContext) { // Schedule deferred refCount decrement — fires at next safe point // (setLarge or RuntimeCode.apply). This prevents premature DESTROY // when the caller captures the return value. - MortalList.deferDecrementIfTracked(value); - MortalList.deferIoOwnerRelease(value); + if (!value.hasAggregateOrScalarReferenceOwner()) { + MortalList.deferDecrementIfTracked(value); + MortalList.deferIoOwnerRelease(value); + } // The deleted SV is a loose lvalue. This is observable when // the slot aliases another scalar and is passed as the // first argument of four-argument substr. @@ -1112,8 +1232,10 @@ public RuntimeScalar deleteInContext(String key, int callContext) { yield fetchedValue; } // Schedule deferred refCount decrement (see delete(RuntimeScalar) above) - MortalList.deferDecrementIfTracked(value); - MortalList.deferIoOwnerRelease(value); + if (!value.hasAggregateOrScalarReferenceOwner()) { + MortalList.deferDecrementIfTracked(value); + MortalList.deferIoOwnerRelease(value); + } yield value; } yield new RuntimeScalar(); @@ -1761,6 +1883,7 @@ public RuntimeHash undefine() { if (!hasDestroyableValues()) { MortalList.deferDestroyForContainerClear(this.elements.values()); if (this.type == PLAIN_HASH) { + releaseHashEntryOwnerSlots(); this.elements = newElementMap(); } else { this.elements.clear(); @@ -1784,6 +1907,8 @@ public RuntimeHash undefine() { String key = entry.getKey(); RuntimeScalar value = entry.getValue(); it.remove(); + releaseHashEntryOwner(key, value); + detachHashCellIfAbsent(value); notePackageRootMutation(value, null); if (this.byteKeys != null) this.byteKeys.remove(key); // Defer DESTROY for this one value, then flush so it runs now. @@ -1907,7 +2032,9 @@ public void dynamicRestoreState() { this.blessId = savedBlessId; } notePackageRootClear(this.elements.values()); - this.elements = previousState.elements; + releaseHashEntryOwnerSlots(); + this.elements = newElementMap(previousState.elements); + previousState.releaseHashEntryOwnerSlots(); notePackageRootClear(this.elements.values()); if (isPackageRootedHash()) { for (RuntimeScalar value : this.elements.values()) { @@ -1941,6 +2068,7 @@ public void dynamicResumeState(Object token) { this.blessId = activeState.blessId; this.byteKeys = activeState.byteKeys != null ? new HashSet<>(activeState.byteKeys) : null; this.elements.putAll(activeState.elements); + activeState.releaseHashEntryOwnerSlots(); } } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHashProxyEntry.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHashProxyEntry.java index a14e72a41b..4228d87dc4 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHashProxyEntry.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHashProxyEntry.java @@ -222,13 +222,8 @@ public void dynamicRestoreState() { // Re-fetch from parent in case hash was reassigned (setFromList clears elements). parent.notePackageRootMutation(); RuntimeScalar current = parent.elements.remove(key); - if (current != null - && (current.type & RuntimeScalarType.REFERENCE_BIT) != 0 - && current.value instanceof RuntimeBase displacedBase - && displacedBase.refCount > 0 && --displacedBase.refCount == 0) { - displacedBase.refCount = Integer.MIN_VALUE; - DestroyDispatch.callDestroy(displacedBase); - } + RuntimeScalar.releaseDynamicRestoreScalarValueOwner( + current, "RuntimeHashProxyEntry.dynamicRestoreState"); this.lvalue = null; this.type = RuntimeScalarType.UNDEF; this.value = null; @@ -248,7 +243,8 @@ public void dynamicRestoreState() { // reachable after the saved hash entry is restored. Release // it only after restoration, so a DESTROY method observes the // restored dynamic value (notably a restored %SIG handler). - if (displaced != null && displaced != previousState) { + if (displaced != null && displaced != previousState + && !displaced.hasStrongScalarReferenceAlias()) { // Replacing a localized element has the same ownership // effect as deleting a hash element. In particular, a // CODE slot can be the last counted owner of a closure; diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeIO.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeIO.java index 5a853045c9..f4f3d9d195 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeIO.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeIO.java @@ -286,9 +286,19 @@ public static void setSelectedHandleValue(RuntimeScalar value) { * @param io the RuntimeIO whose fd should be freed when the glob is collected */ public static void registerGlobForFdRecycling(RuntimeGlob glob, RuntimeIO io) { + if (glob == null || io == null) return; IORuntimeRegistryState registry = registry(); - PhantomReference phantom = new PhantomReference<>(glob, registry.globGCQueue); - registry.phantomToIO.put(phantom, io); + synchronized (registry.globOwnersByIO) { + Set> owners = + registry.globOwnersByIO.computeIfAbsent(io, ignored -> new HashSet<>()); + for (PhantomReference owner : owners) { + if (owner.get() == glob) return; + } + PhantomReference phantom = + new PhantomReference<>(glob, registry.globGCQueue); + owners.add(phantom); + registry.phantomToIO.put(phantom, io); + } } /** @@ -299,8 +309,23 @@ public static void processAbandonedGlobs() { Reference ref; IORuntimeRegistryState registry = registry(); while ((ref = registry.globGCQueue.poll()) != null) { - RuntimeIO io = registry.phantomToIO.remove(ref); - if (io != null && !(io.ioHandle instanceof ClosedIOHandle)) { + RuntimeIO io = null; + boolean closeWhenUnowned = false; + synchronized (registry.globOwnersByIO) { + io = registry.phantomToIO.remove(ref); + if (io != null) { + Set> owners = + registry.globOwnersByIO.get(io); + if (owners != null) { + owners.remove(ref); + if (owners.isEmpty()) { + registry.globOwnersByIO.remove(io); + closeWhenUnowned = true; + } + } + } + } + if (closeWhenUnowned && !(io.ioHandle instanceof ClosedIOHandle)) { io.close(); } } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeList.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeList.java index a08f033257..ee81688644 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeList.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeList.java @@ -781,18 +781,23 @@ public RuntimeArray setFromList(RuntimeList value) { result.elements.add(useAlias ? rhsAlias : rhsValue); if (rhsIndex < rhsSize) { + // The alias, when selected, is the original source cell. + // The materialized RHS copy is discarded and must release + // any nested owner held by a scalar reference. + if (useAlias && rhsValue != null) { + rhsValue.releaseOwnedScalarReferenceContents(); + } // Undo the materialized copy's refCount increment for the consumed // RHS value (mirrors the corresponding fix in the assigned-scalar // branch below). Without this, `my (undef, $name) = @_;` patterns // leak +1 refCount per call on the discarded first arg — visible // in op/inccode.t "no leaks" tests #61, #63 (bug perl #92252). - if (rhsValue != null && rhsValue.refCountOwned + if (rhsValue != null && (rhsValue.type & RuntimeScalarType.REFERENCE_BIT) != 0 - && rhsValue.value instanceof RuntimeBase base && base.refCount > 0) { - base.releaseOwner(rhsValue, "RuntimeList materialized-copy undo (undef-target)"); - base.releaseActiveOwner(rhsValue); - base.refCount--; - rhsValue.refCountOwned = false; + && rhsValue.value instanceof RuntimeBase base + && rhsValue.ownsCountedReferent(base) && base.refCount > 0) { + releaseMaterializedScalarOwner( + rhsValue, "RuntimeList materialized-copy undo (undef-target)"); } rhsIndex++; } @@ -803,6 +808,9 @@ public RuntimeArray setFromList(RuntimeList value) { RuntimeScalar assigned = (rhsIndex < rhsSize) ? rhsElements.get(rhsIndex++) : null; runtimeScalar.set(assigned != null ? assigned : new RuntimeScalar()); result.elements.add(runtimeScalar); // Add reference to the variable itself + // set() has acquired a new nested scalar-reference owner on the + // destination. The materialized RHS cell is discarded here. + if (assigned != null) assigned.releaseOwnedScalarReferenceContents(); // Undo the materialized copy's refCount increment. // The materialization (addToArray → addToScalar → set → setLarge) incremented // refCount on the copy. The target's set() above created its own increment. @@ -810,13 +818,12 @@ public RuntimeArray setFromList(RuntimeList value) { // sits in the local `rhs` array which is never scope-exit-cleaned). // Array/hash targets take direct ownership of materialized copies, so only // scalar targets need this correction. - if (assigned != null && assigned.refCountOwned + if (assigned != null && (assigned.type & RuntimeScalarType.REFERENCE_BIT) != 0 - && assigned.value instanceof RuntimeBase base && base.refCount > 0) { - base.releaseOwner(assigned, "RuntimeList materialized-copy undo (scalar-target)"); - base.releaseActiveOwner(assigned); - base.refCount--; - assigned.refCountOwned = false; + && assigned.value instanceof RuntimeBase base + && assigned.ownsCountedReferent(base) && base.refCount > 0) { + releaseMaterializedScalarOwner( + assigned, "RuntimeList materialized-copy undo (scalar-target)"); } } else if (elem instanceof RuntimeArray runtimeArray) { List remaining = (rhsIndex < rhsSize) @@ -837,18 +844,19 @@ public RuntimeArray setFromList(RuntimeList value) { // setFromList creates new copies via incrementRefCountForContainerStore; // the materialized rhs elements' increments are now redundant. for (RuntimeScalar r : remaining) { - if (r.refCountOwned && (r.type & RuntimeScalarType.REFERENCE_BIT) != 0 - && r.value instanceof RuntimeBase base && base.refCount > 0) { - base.releaseOwner(r, "RuntimeList materialized-copy undo (array-target)"); - base.releaseActiveOwner(r); - base.refCount--; - r.refCountOwned = false; + r.releaseOwnedScalarReferenceContents(); + if ((r.type & RuntimeScalarType.REFERENCE_BIT) != 0 + && r.value instanceof RuntimeBase base + && r.ownsCountedReferent(base) && base.refCount > 0) { + releaseMaterializedScalarOwner( + r, "RuntimeList materialized-copy undo (array-target)"); } } } else { - // Plain array: directly reuse the materialized copies. - // Their refCountOwned=true transfers to the array elements. - runtimeArray.elements = remaining; + // Plain arrays can take the materialized cells directly, + // but must transfer them through the array's owning-store + // path so old values are released and new slots are tracked. + runtimeArray.replaceWithTransferredListAssignmentCells(remaining); } result.elements.addAll(remaining); // Use original references rhsIndex = rhsSize; // Consume the rest @@ -858,26 +866,32 @@ public RuntimeArray setFromList(RuntimeList value) { ? new ArrayList<>(rhsElements.subList(rhsIndex, rhsSize)) : new ArrayList<>(); - RuntimeHash hash = RuntimeHash.createHashForAssignment(remainingArr); - runtimeHash.elements = hash.elements; - // Add lvalue references to hash elements for list assignment - for (Map.Entry entry : hash.elements.entrySet()) { - result.elements.add(new RuntimeScalar(entry.getKey())); - result.elements.add(entry.getValue()); // Add reference to hash value + if (runtimeHash.type == RuntimeHash.PLAIN_HASH) { + RuntimeHash hash = RuntimeHash.createHashForAssignment(remainingArr); + runtimeHash.replaceWithTransferredAssignmentHash(hash); + // Add lvalue references to hash elements for list assignment. + for (Map.Entry entry : runtimeHash.elements.entrySet()) { + result.elements.add(new RuntimeScalar(entry.getKey())); + result.elements.add(entry.getValue()); + } + } else { + // Tied and autovivifying hashes must keep their own map + // implementation and receive stores through setFromList. + RuntimeList remainingList = new RuntimeList(); + remainingList.elements.addAll(remainingArr.elements); + RuntimeArray assignedValues = runtimeHash.setFromList(remainingList); + result.elements.addAll(assignedValues.elements); } // Undo materialized copies' refCount increments. - // createHashForAssignment creates new RuntimeScalars for hash values - // and gives those hash slots their own container-store ownership. - // The original rhs elements' refCount increments (from materialization - // via addToArray -> setLarge) are now redundant and would leak since - // nobody decrements them. + // The destination hash has its own copied scalar cells; these + // materialized RHS cells are redundant after either transfer. for (RuntimeScalar r : remainingArr.elements) { - if (r.refCountOwned && (r.type & RuntimeScalarType.REFERENCE_BIT) != 0 - && r.value instanceof RuntimeBase base && base.refCount > 0) { - base.releaseOwner(r, "RuntimeList materialized-copy undo (hash-target)"); - base.releaseActiveOwner(r); - base.refCount--; - r.refCountOwned = false; + r.releaseOwnedScalarReferenceContents(); + if ((r.type & RuntimeScalarType.REFERENCE_BIT) != 0 + && r.value instanceof RuntimeBase base + && r.ownsCountedReferent(base) && base.refCount > 0) { + releaseMaterializedScalarOwner( + r, "RuntimeList materialized-copy undo (hash-target)"); } } rhsIndex = rhsSize; // Consume the rest @@ -947,6 +961,9 @@ private RuntimeArray assignSimpleScalarsFromArray(RuntimeArray rhsArray, boolean if (lhs.isKeysLvalue()) lhs.rejectKeysLvalueListAssignment(); RuntimeScalar rhs = i < rhsSize ? rhsElements.get(i) : null; lhs.set(rhs == null ? new RuntimeScalar() : rhs); + if (rhsArray.transientListAssignmentRhs && rhs != null) { + releaseDiscardedMaterializedScalar(rhs); + } if (result != null) result.elements.add(lhs); } return result; @@ -965,6 +982,9 @@ private RuntimeArray assignSimpleScalarsFromArray(RuntimeArray rhsArray, boolean RuntimeScalar lhs = (RuntimeScalar) elements.get(i); if (lhs.isKeysLvalue()) lhs.rejectKeysLvalueListAssignment(); lhs.set(i < rhsValues.length ? rhsValues[i] : new RuntimeScalar()); + if (rhsArray.transientListAssignmentRhs && i < rhsSize) { + releaseDiscardedMaterializedScalar(rhsElements.get(i)); + } if (result != null) result.elements.add(lhs); } return result; @@ -973,6 +993,29 @@ private RuntimeArray assignSimpleScalarsFromArray(RuntimeArray rhsArray, boolean } } + /** Release the ownership held by a materialized RHS cell after scalar assignment copied it. */ + static void releaseMaterializedScalarOwner(RuntimeScalar scalar, String releaseSite) { + if (scalar == null) return; + if ((scalar.type & RuntimeScalarType.REFERENCE_BIT) != 0 + && scalar.value instanceof RuntimeBase base + && scalar.ownsCountedReferent(base) + && base.refCount > 0) { + if (!scalar.releaseScalarValueOwnerCount(base, releaseSite)) { + base.releaseOwner(scalar, releaseSite); + base.releaseActiveOwner(scalar); + base.refCount--; + scalar.setRefCountOwned(false); + } + } + } + + private static void releaseDiscardedMaterializedScalar(RuntimeScalar scalar) { + if (scalar == null) return; + scalar.releaseOwnedScalarReferenceContents(); + releaseMaterializedScalarOwner( + scalar, "RuntimeList materialized-copy undo (simple-scalar-target)"); + } + /** * Converts the list to a string, concatenating all elements without separators. * diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index 393299abac..e0814f66a1 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -399,6 +399,14 @@ void clearForArraySlotRemoval() { /** Explicit identity for this pad cell's closure owner edge. */ private PerlOwnerSlot closureOwnerSlot; + /** + * Weak links to closures that capture a destroyable referent through this + * pad cell. The closure owns the pad strongly; this reverse index is only + * used to release captures when a complete Perl-root walk proves that the + * closure itself has become unreachable. + */ + private List> destroyableClosureOwners; + /** * Number of closure-capture owners currently retained on this scalar's * referent. This is separate from {@link #captureCount}: a weak scalar, an @@ -422,14 +430,16 @@ public int captureRefCountOwned() { // Last hash/array container that stored this scalar slot. Used only as a // narrow guard for live CODE refs whose captures must survive void discard. - RuntimeBase containerOwner; + volatile RuntimeBase containerOwner; + private List> hashContainerOwners; + private List> arrayContainerOwners; /** Return the aggregate slot that directly owns this scalar, if any. */ public RuntimeBase getContainerOwner() { return containerOwner; } - void markContainerOwner(RuntimeBase owner) { + synchronized void markContainerOwner(RuntimeBase owner) { // Immutable primitive literals are shared from the global scalar // cache. Their owner does not make them writable, but it would keep a // transient argument or container alive as long as the cache entry. @@ -443,9 +453,106 @@ void markContainerOwner(RuntimeBase owner) { return; } this.containerOwner = owner; + if (owner instanceof RuntimeHash hash) { + if (hashContainerOwners == null) { + hashContainerOwners = new ArrayList<>(); + } + boolean alreadyRegistered = hashContainerOwners.stream() + .anyMatch(reference -> reference.get() == hash); + if (!alreadyRegistered) hashContainerOwners.add(new WeakReference<>(hash)); + } propagateTiedHandlerMarkerToReferent(); } + synchronized List hashContainerOwnersSnapshot() { + if (hashContainerOwners == null) return List.of(); + List snapshot = new ArrayList<>(hashContainerOwners.size()); + hashContainerOwners.removeIf(reference -> reference.get() == null); + for (WeakReference reference : hashContainerOwners) { + RuntimeHash owner = reference.get(); + if (owner != null) snapshot.add(owner); + } + if (hashContainerOwners.isEmpty()) hashContainerOwners = null; + return snapshot; + } + + synchronized void unmarkHashContainerOwner(RuntimeHash owner) { + if (hashContainerOwners == null) return; + hashContainerOwners.removeIf(reference -> reference.get() == null || reference.get() == owner); + if (hashContainerOwners.isEmpty()) hashContainerOwners = null; + if (containerOwner == owner) { + List remaining = hashContainerOwnersSnapshot(); + containerOwner = remaining.isEmpty() ? null : remaining.get(0); + } + } + + synchronized void markArrayContainerOwner(RuntimeArray owner) { + if (arrayContainerOwners == null) arrayContainerOwners = new ArrayList<>(); + arrayContainerOwners.removeIf(reference -> reference.get() == null); + if (arrayContainerOwners.stream().noneMatch(reference -> reference.get() == owner)) { + arrayContainerOwners.add(new WeakReference<>(owner)); + } + } + + synchronized List arrayContainerOwnersSnapshot() { + if (arrayContainerOwners == null) return List.of(); + arrayContainerOwners.removeIf(reference -> reference.get() == null); + List snapshot = new ArrayList<>(arrayContainerOwners.size()); + for (WeakReference reference : arrayContainerOwners) { + RuntimeArray owner = reference.get(); + if (owner != null) snapshot.add(owner); + } + if (arrayContainerOwners.isEmpty()) arrayContainerOwners = null; + return snapshot; + } + + /** Whether an exited scalar remains only as a borrowed {@code @_}-style alias. */ + boolean isRetiredBorrowedArrayAlias() { + if (!scopeExited) return false; + for (RuntimeHash owner : hashContainerOwnersSnapshot()) { + if (owner.elements.containsValue(this)) return false; + } + boolean foundAlias = false; + for (RuntimeArray owner : arrayContainerOwnersSnapshot()) { + if (!owner.elements.contains(this)) continue; + foundAlias = true; + if (!owner.elementsAliased || owner.ownsElement(this)) return false; + } + return foundAlias; + } + + synchronized void unmarkArrayContainerOwner(RuntimeArray owner) { + if (arrayContainerOwners == null) return; + arrayContainerOwners.removeIf(reference -> reference.get() == null || reference.get() == owner); + if (arrayContainerOwners.isEmpty()) arrayContainerOwners = null; + } + + /** + * Whether this scalar cell still occupies a strong slot in an owning + * aggregate. Callers use this after removing one slot, when the owning + * container registry reflects the remaining slots. + */ + boolean hasOwningAggregateSlot() { + for (RuntimeHash owner : hashContainerOwnersSnapshot()) { + if (owner.elements.containsValue(this)) return true; + } + for (RuntimeArray owner : arrayContainerOwnersSnapshot()) { + if (owner.transientListAssignmentRhs) continue; + if (owner.elements.contains(this) && owner.ownsElement(this)) return true; + } + return false; + } + + /** + * Pop and shift may transfer an owned cell to another aggregate without + * decrementing its referent only when the cell was fetched as a Perl array + * lvalue. Internal work arrays also reuse scalar cells, but those are value + * copies and must not suppress the removed slot's deferred decrement. + */ + boolean hasOwningAggregateSlotForLvalueAlias() { + return localArrayOwner != null && hasOwningAggregateSlot(); + } + private void propagateTiedHandlerMarkerToReferent() { if (containerOwner != null && containerOwner.possiblyStoredInTiedHandler && (type & RuntimeScalarType.REFERENCE_BIT) != 0 @@ -457,22 +564,36 @@ private void propagateTiedHandlerMarkerToReferent() { boolean isStoredInRegisteredContainerOwner() { RuntimeBase owner = containerOwner; if (owner == null || !MyVarCleanupStack.isRegistered(owner)) return false; + if (owner.scopeExited && owner.semanticCaptureOwnerCount() == 0) return false; if (owner instanceof RuntimeHash hash) { return hash.elements.containsValue(this); } if (owner instanceof RuntimeArray array) { - return array.elements.contains(this); + return array.elements.contains(this) + && (!array.elementsAliased || array.ownsElement(this)); } return false; } /** Whether this scalar is backed by durable Perl storage for lvalue use. */ boolean hasDurableLvalueStorage() { - return isPackageGlobalRoot + if (isPackageGlobalRoot || MyVarCleanupStack.isRegistered(this) || isStoredInRegisteredContainerOwner() - || containerOwner == RuntimeCode.getCurrentArgs() - || this instanceof RuntimeBaseProxy; + || (containerOwner != null + && containerOwner == RuntimeCode.getCurrentArgs()) + || this instanceof RuntimeBaseProxy) { + return true; + } + RuntimeBase owner = containerOwner; + if (owner instanceof RuntimeHash hash) { + return hash.isPackageRootedHash() && hash.elements.containsValue(this); + } + if (owner instanceof RuntimeArray array) { + return array.isPackageGlobalRoot && array.elements.contains(this) + && (!array.elementsAliased || array.ownsElement(this)); + } + return false; } /** True when a container removal returned this scalar slot as a loose value. */ @@ -491,10 +612,14 @@ private boolean isDetachedFromContainerOwner() { } public void retainClosureCapture() { + retainClosureCapture(null); + } + + public void retainClosureCapture(RuntimeCode closureOwner) { boolean firstCapture = captureCount++ == 0; if (firstCapture && type == RuntimeScalarType.CODE) { retainClosureCaptureReferent(); - } else if (firstCapture && !refCountOwned) { + } else if (firstCapture && !ownsCurrentCountedReferent()) { // 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 @@ -510,15 +635,21 @@ public void retainClosureCapture() { RuntimeBase base = semanticCaptureReferent(); updateClosureOwnerSlot(base); } + recordDestroyableClosureOwner(closureOwner); } /** Reconstruct capture ownership for an independent ithread snapshot. */ void retainThreadCloneClosureCapture() { + retainThreadCloneClosureCapture(null); + } + + void retainThreadCloneClosureCapture(RuntimeCode closureOwner) { captureCount++; // An owning lexical slot already protects its referent. Borrowed // argument aliases do not, and the source runtime's other owners are // not a lifetime guarantee in the cloned runtime. - if (!refCountOwned) retainClosureCaptureReferent(); + if (!ownsCurrentCountedReferent()) retainClosureCaptureReferent(); + recordDestroyableClosureOwner(closureOwner); } public void releaseClosureCapture() { @@ -528,7 +659,65 @@ public void releaseClosureCapture() { if (captureCount == 0) { releaseOneClosureCaptureReferent(); releaseClosureOwnerSlot(); + if (scopeExited) MortalList.closureCaptureReleased(this); + } + } + + public void releaseClosureCapture(RuntimeCode closureOwner) { + forgetDestroyableClosureOwner(closureOwner); + releaseClosureCapture(); + } + + private synchronized void recordDestroyableClosureOwner(RuntimeCode closureOwner) { + if (closureOwner == null || WeakRefRegistry.isweak(this) + || !(value instanceof RuntimeBase referent) || referent.blessId == 0) { + return; + } + String className = NameNormalizer.getBlessStr(referent.blessId); + if (className == null || !DestroyDispatch.classHasDestroy(referent.blessId, className)) { + return; + } + if (destroyableClosureOwners == null) destroyableClosureOwners = new ArrayList<>(); + for (int i = destroyableClosureOwners.size() - 1; i >= 0; i--) { + RuntimeCode existing = destroyableClosureOwners.get(i).get(); + if (existing == null) { + destroyableClosureOwners.remove(i); + } else if (existing == closureOwner) { + return; + } } + destroyableClosureOwners.add(new WeakReference<>(closureOwner)); + } + + private synchronized void forgetDestroyableClosureOwner(RuntimeCode closureOwner) { + if (closureOwner == null || destroyableClosureOwners == null) return; + destroyableClosureOwners.removeIf(reference -> { + RuntimeCode existing = reference.get(); + return existing == null || existing == closureOwner; + }); + if (destroyableClosureOwners.isEmpty()) destroyableClosureOwners = null; + } + + boolean releaseUnreachableDestroyableClosureOwners( + ReachabilityWalker.ScalarRootReachabilitySnapshot roots) { + List unreachable = new ArrayList<>(); + synchronized (this) { + if (roots == null || !roots.complete() || destroyableClosureOwners == null) return false; + List> retained = new ArrayList<>(); + for (WeakReference reference : destroyableClosureOwners) { + RuntimeCode owner = reference.get(); + if (owner == null) continue; + if (roots.isReachable(owner)) retained.add(reference); + else unreachable.add(owner); + } + destroyableClosureOwners = retained.isEmpty() ? null : retained; + } + for (RuntimeCode owner : unreachable) { + if (owner.capturedScalars != null || owner.capturedAggregates != null) { + owner.releaseCaptures(); + } + } + return !unreachable.isEmpty(); } /** @@ -662,6 +851,7 @@ void retainClosureCaptureReferentsForUnweaken() { /** A postfix ++/-- result transfers, rather than duplicates, its reference owner on assignment. */ private boolean postfixReferenceOwnershipTransfer; + private boolean postfixReferenceOwnerSlotTransferred; /** * True for the synthetic {@code $_[0]} scalar passed to DESTROY. @@ -674,13 +864,136 @@ void retainClosureCaptureReferentsForUnweaken() { /** True after this scalar has acquired per-runtime {@code pos()} cache state. */ volatile boolean posCacheEntryCreated; + /** Stable owner identity for this scalar's counted scalar-reference contents edge. */ + public PerlOwnerSlot scalarReferenceContentsOwner; + private java.util.Set scalarReferenceContentsOwners; + + /** Explicit identity for this scalar cell's counted direct referent edge. */ + private PerlOwnerSlot scalarValueOwnerSlot; + + /** Stable slot used for the scalar cell's direct referent ownership. */ + public PerlOwnerSlot scalarValueOwnerSlot() { + return scalarValueOwnerSlot; + } + + boolean hasScalarValueOwnerSlotFor(RuntimeBase referent) { + return scalarValueOwnerSlot != null + && scalarValueOwnerSlot.referent() == referent; + } + + /** Whether the legacy count bridge or its explicit slot still owns a referent. */ + public synchronized boolean ownsCountedReferent(RuntimeBase referent) { + return value == referent + && (refCountOwned || hasScalarValueOwnerSlotFor(referent)); + } + + boolean ownsCurrentCountedReferent() { + return value instanceof RuntimeBase base && ownsCountedReferent(base); + } + /** - * True when this scalar owns a counted reference through a scalar - * reference, e.g. the outer variable in {@code $x = \ bless {}, "X"}. - * The counted referent is inside the RuntimeScalar pointed to by this - * scalar, so regular {@code refCountOwned} cannot describe it. + * Keep the owner ledger paired with the legacy scalar ownership bit while + * the lifecycle bridge is being migrated. This records the scalar-cell + * edge without changing the bridge's reference count or dispatch behavior. */ - public boolean ownsScalarReferenceContents; + public synchronized void setRefCountOwned(boolean owned) { + refCountOwned = owned; + syncScalarValueOwnerSlot(owned); + } + + private void syncScalarValueOwnerSlot(boolean owned) { + RuntimeBase referent = owned + && ((type & RuntimeScalarType.REFERENCE_BIT) != 0 + || type == RuntimeScalarType.GLOB) + && value instanceof RuntimeBase base ? base : null; + if (referent != null) { + if (scalarValueOwnerSlot == null) { + scalarValueOwnerSlot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_VALUE, null, null, this); + } + scalarValueOwnerSlot.transferTo(referent); + } else if (scalarValueOwnerSlot != null) { + scalarValueOwnerSlot.transferTo(null); + } + } + + /** Release this cell's direct counted owner through its explicit slot when available. */ + public boolean releaseScalarValueOwnerCount(RuntimeBase expectedReferent, String releaseSite) { + PerlOwnerSlot slot = scalarValueOwnerSlot; + return slot != null && slot.releaseScalarValueCount(expectedReferent, releaseSite); + } + + /** Release a localized scalar's direct owner, retaining the legacy bridge fallback. */ + static void releaseDynamicRestoreScalarValueOwner(RuntimeScalar cell, String releaseSite) { + if (cell == null + || (cell.type & RuntimeScalarType.REFERENCE_BIT) == 0 + || !(cell.value instanceof RuntimeBase referent) + || referent.refCount <= 0) { + return; + } + boolean releasedSlot = cell.releaseScalarValueOwnerCount(referent, releaseSite); + boolean reachedZero; + if (releasedSlot) { + reachedZero = referent.refCount == 0; + } else { + referent.traceRefCount(-1, releaseSite); + reachedZero = --referent.refCount == 0; + } + if (reachedZero) { + referent.refCount = Integer.MIN_VALUE; + DestroyDispatch.callDestroy(referent); + } + } + + synchronized boolean acquireScalarValueOwnerCount(RuntimeBase referent, String acquireSite) { + if (scalarValueOwnerSlot == null) { + scalarValueOwnerSlot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_VALUE, null, null, this); + } + return scalarValueOwnerSlot.acquireScalarValueCount(referent, acquireSite); + } + + PerlOwnerSlot.PendingRelease deferScalarValueOwnerCount(RuntimeBase expectedReferent) { + PerlOwnerSlot slot = scalarValueOwnerSlot; + return slot != null + ? slot.deferScalarValueCountRelease(expectedReferent) + : null; + } + + public boolean hasScalarReferenceContentsOwner() { + return scalarReferenceContentsOwner != null + && scalarReferenceContentsOwner.isActive(); + } + + synchronized void registerScalarReferenceContentsOwner(PerlOwnerSlot owner) { + if (scalarReferenceContentsOwners == null) { + scalarReferenceContentsOwners = java.util.Collections.newSetFromMap( + new java.util.IdentityHashMap<>()); + } + scalarReferenceContentsOwners.add(owner); + } + + synchronized void unregisterScalarReferenceContentsOwner(PerlOwnerSlot owner) { + if (scalarReferenceContentsOwners != null) { + scalarReferenceContentsOwners.remove(owner); + if (scalarReferenceContentsOwners.isEmpty()) scalarReferenceContentsOwners = null; + } + } + + synchronized java.util.List scalarReferenceContentsOwnersSnapshot() { + return scalarReferenceContentsOwners == null || scalarReferenceContentsOwners.isEmpty() + ? java.util.List.of() : java.util.List.copyOf(scalarReferenceContentsOwners); + } + + /** Whether a strong refalias still owns this scalar cell's current contents. */ + boolean hasStrongScalarReferenceAlias() { + return !scalarReferenceContentsOwnersSnapshot().isEmpty(); + } + + /** Whether this cell's current value owner is protected by an aggregate or refalias. */ + boolean hasAggregateOrScalarReferenceOwner() { + return hasOwningAggregateSlot() || hasStrongScalarReferenceAlias(); + } /** * True once a scalar reference has been created to this scalar, e.g. @@ -2027,16 +2340,46 @@ public RuntimeScalar addToScalar(RuntimeScalar scalar) { RuntimeScalar result = scalar.set(this); if (postfixReferenceOwnershipTransfer) { postfixReferenceOwnershipTransfer = false; + boolean transferredOwnerSlot = postfixReferenceOwnerSlotTransferred; + postfixReferenceOwnerSlotTransferred = false; if ((type & REFERENCE_BIT) != 0 && value instanceof RuntimeBase base - && base.refCount > 0) { - // set() acquired the destination owner; cancel the extra - // increment because this temporary's owner was transferred. - base.refCount--; + && ownsCountedReferent(base)) { + // set() acquired the destination owner. Retire this temporary's + // transferred owner through its stable slot so the ledger and + // lifecycle bridge both lose exactly one owner. + if (!transferredOwnerSlot || !releaseScalarValueOwnerCount( + base, "RuntimeScalar.addToScalar postfix owner transfer")) { + if (base.refCount > 0) base.refCount--; + setRefCountOwned(false); + } } } return result; } + private void transferPostfixOwnerTo(RuntimeScalar result) { + RuntimeBase referent = value instanceof RuntimeBase base ? base : null; + PerlOwnerSlot slot = scalarValueOwnerSlot; + if (referent != null && slot != null && slot.referent() == referent + && slot.transferScalarValueCell(this, result)) { + scalarValueOwnerSlot = null; + result.scalarValueOwnerSlot = slot; + referent.transferScalarOwner(this, result, "RuntimeScalar postfix result"); + result.postfixReferenceOwnerSlotTransferred = true; + } else { + // Legacy and borrowed aliases may not yet have a transferable slot. + boolean transferredLegacyOwner = referent != null + && ownsCountedReferent(referent); + if (transferredLegacyOwner) { + referent.transferScalarOwner(this, result, "RuntimeScalar postfix result"); + } + result.setRefCountOwned(true); + setRefCountOwned(false); + result.postfixReferenceOwnerSlotTransferred = transferredLegacyOwner; + } + result.postfixReferenceOwnershipTransfer = true; + } + /** * Store a Perl expression value into an assignment target. * @@ -2109,14 +2452,26 @@ public void vivifyLogicalAssignmentLvalue() { * (to avoid over-counting for temporary copies). This method should be called * after storing a tracked reference in a container, if MortalList is active. *

- * Skips elements that already have {@code refCountOwned == true}, meaning they - * were created via {@code set()} / {@code setLarge()} rather than the copy - * constructor, and their refCount was already incremented at creation time. + * Skips elements that already have an explicit scalar owner slot or legacy + * owner token, meaning their refCount was already incremented at creation. */ public static void incrementRefCountForContainerStore(RuntimeScalar scalar) { - if (scalar == null || scalar.refCountOwned - || (scalar.type & REFERENCE_BIT) == 0 - || !(scalar.value instanceof RuntimeBase base)) { + if (scalar == null) { + return; + } + // A scalar-reference cell can own a counted referent through the + // RuntimeScalar cell it aliases. Container materialization copies the + // outer RV wrapper, so each copied aggregate cell must retain those + // contents independently instead of relying on the source wrapper's + // scalar-reference owner slot. + if (scalar.type == REFERENCE + && scalar.value instanceof RuntimeScalar + && !scalar.hasScalarReferenceContentsOwner()) { + scalar.retainScalarReferenceContents(scalar); + } + if ((scalar.type & REFERENCE_BIT) == 0 + || !(scalar.value instanceof RuntimeBase base) + || scalar.ownsCountedReferent(base)) { return; } // Destroyed / weaken sentinel states must not be reactivated here. @@ -2136,13 +2491,12 @@ public static void incrementRefCountForContainerStore(RuntimeScalar scalar) { return; } base.refCount = 0; + MortalList.promoteRootedTieWrapperOwners(base); + } + if (!scalar.acquireScalarValueOwnerCount( + base, "RuntimeScalar.incrementRefCountForContainerStore")) { + return; } - base.traceRefCount(+1, "RuntimeScalar.incrementRefCountForContainerStore"); - base.refCount++; - base.hadCountedReference = true; - base.recordOwner(scalar, "incrementRefCountForContainerStore"); - base.recordActiveOwner(scalar); - scalar.refCountOwned = true; // Phase B1 (refcount_alignment_52leaks_plan.md): track the // container element so ReachabilityWalker can see it via // ScalarRefRegistry. @@ -2186,33 +2540,22 @@ private static boolean scalarReferenceContentsNeedRetain(RuntimeScalar value) { && value.type == REFERENCE && value.value instanceof RuntimeScalar scalarReferent && !WeakRefRegistry.isweak(scalarReferent) - && !scalarReferent.refCountOwned && (scalarReferent.type & REFERENCE_BIT) != 0 && scalarReferent.value instanceof RuntimeBase inner + && (!scalarReferent.ownsCountedReferent(inner) + || scalarReferent.hasOwningAggregateSlot()) && inner.refCount >= 0; } private void retainScalarReferenceContents(RuntimeScalar value) { - this.ownsScalarReferenceContents = false; + this.scalarReferenceContentsOwner = null; if (!scalarReferenceContentsNeedRetain(value)) return; RuntimeScalar scalarReferent = (RuntimeScalar) value.value; RuntimeBase inner = (RuntimeBase) scalarReferent.value; - inner.traceRefCount(+1, "RuntimeScalar.retainScalarReferenceContents"); - inner.refCount++; - inner.hadCountedReference = true; - inner.acquireTransientTraceOwner("scalar-reference contents", - "RuntimeScalar.scalarReferenceContents"); - this.ownsScalarReferenceContents = true; - } - - private static RuntimeScalar scalarReferenceContentsReferent(RuntimeScalar owner) { - if (owner != null - && owner.ownsScalarReferenceContents - && owner.type == REFERENCE - && owner.value instanceof RuntimeScalar scalarReferent) { - return scalarReferent; - } - return null; + PerlOwnerSlot ownerSlot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS, null, null, this); + ownerSlot.acquireScalarReferenceContents(inner, scalarReferent); + this.scalarReferenceContentsOwner = ownerSlot; } public static void releaseScalarReferenceContents(RuntimeScalar scalarReferent) { @@ -2232,9 +2575,11 @@ public static void releaseScalarReferenceContents(RuntimeScalar scalarReferent) } public void releaseOwnedScalarReferenceContents() { - RuntimeScalar scalarReferent = scalarReferenceContentsReferent(this); - this.ownsScalarReferenceContents = false; - releaseScalarReferenceContents(scalarReferent); + PerlOwnerSlot ownerSlot = this.scalarReferenceContentsOwner; + this.scalarReferenceContentsOwner = null; + if (ownerSlot != null) { + ownerSlot.releaseScalarReferenceContents(); + } } /** @@ -2245,15 +2590,11 @@ public void releaseOwnedScalarReferenceContents() { * retain it first and keeps the retain/release accounting balanced. */ public void deferOwnedScalarReferenceContents() { - RuntimeScalar scalarReferent = scalarReferenceContentsReferent(this); - this.ownsScalarReferenceContents = false; - if (scalarReferent == null - || (scalarReferent.type & REFERENCE_BIT) == 0 - || !(scalarReferent.value instanceof RuntimeBase inner) - || inner.refCount <= 0) { - return; + PerlOwnerSlot ownerSlot = this.scalarReferenceContentsOwner; + this.scalarReferenceContentsOwner = null; + if (ownerSlot != null) { + ownerSlot.deferScalarReferenceContentsRelease(); } - MortalList.deferDecrement(inner); } // Keep this entry point small so HotSpot can inline it into generated @@ -2268,6 +2609,8 @@ && getClass() == RuntimeScalar.class && (value.getClass() == RuntimeScalar.class || value.getClass() == RuntimeScalarReadOnly.class) && type < TIED_SCALAR + && !hasScalarReferenceContentsOwner() + && scalarReferenceContentsOwnersSnapshot().isEmpty() && value.type == INTEGER && !value.transferableGrowingString) { return setPlainInteger(value); @@ -2599,6 +2942,9 @@ private RuntimeScalar setLargeRefCounted(RuntimeScalar value) { } if (this.value != value.value) { MortalList.invalidateFullRootSnapshotIfReachable(this); + if (this.value instanceof RuntimeBase displacedReferent) { + MortalList.invalidateFullRootSnapshotIfReachable(displacedReferent); + } if (containerOwner != null) { MortalList.invalidateFullRootSnapshotIfReachable(containerOwner); } @@ -2609,13 +2955,14 @@ private RuntimeScalar setLargeRefCounted(RuntimeScalar value) { // anonymous arrays/hashes that were never blessed). Skip all refCount // tracking, WeakRefRegistry checks, and MortalList flush. boolean scalarRefContentTrackingNeeded = - this.ownsScalarReferenceContents || scalarReferenceContentsNeedRetain(value); + this.hasScalarReferenceContentsOwner() || scalarReferenceContentsNeedRetain(value); boolean droppingAggregateWithWeakRefs = value.type == UNDEF && WeakRefRegistry.weakRefsExist() && this.value instanceof RuntimeBase oldReferent && (oldReferent instanceof RuntimeHash || oldReferent instanceof RuntimeArray); if (!scalarRefContentTrackingNeeded - && !this.refCountOwned && this.captureCount == 0 && this.captureRefCountOwned() == 0 + && !this.ownsCurrentCountedReferent() + && this.captureCount == 0 && this.captureRefCountOwned() == 0 && !WeakRefRegistry.isweak(this) && !droppingAggregateWithWeakRefs && this.type != GLOBREFERENCE && value.type != GLOBREFERENCE) { @@ -2756,11 +3103,16 @@ && isSocketIOHandle(oldIo.ioHandle)) { // scalarReferenceContents. Assigning through $$ref transfers that // ownership to the new contents, so the old referent must be released // even though this scalar did not itself record a direct store owner. + boolean oldOwnedByScalarValue = oldBase != null + && ownsCountedReferent(oldBase); boolean oldOwnedByScalarReference = oldBase != null && referencedByScalarReference + && scalarReferenceContentsOwnersSnapshot().isEmpty() && refCount > 0; - boolean oldOwnedScalarReferenceContents = this.ownsScalarReferenceContents; - RuntimeScalar oldScalarReferenceContents = scalarReferenceContentsReferent(this); + java.util.List scalarContentsOwners = + scalarReferenceContentsOwnersSnapshot(); + PerlOwnerSlot oldScalarReferenceContentsOwner = this.scalarReferenceContentsOwner; + boolean oldOwnedScalarReferenceContents = this.hasScalarReferenceContentsOwner(); boolean shouldReleaseUnrootedRescuedGraph = false; boolean requestedDiscardedAggregateSweep = false; @@ -2787,6 +3139,7 @@ && isSocketIOHandle(oldIo.ioHandle)) { // `%hash=()`/`deferDestroyForContainerClear` balance. Other referents at -1 // stay untracked (e.g. named \@x via local slot). boolean newOwned = false; + boolean scalarContentsOwnedByEscapedReference = !scalarContentsOwners.isEmpty(); if (((value.type & RuntimeScalarType.REFERENCE_BIT) != 0 || value.type == RuntimeScalarType.GLOB) && value.value != null) { @@ -2794,14 +3147,10 @@ && isSocketIOHandle(oldIo.ioHandle)) { if (nb.refCount < 0 && (nb instanceof RuntimeHash || nb instanceof RuntimeArray)) { nb.refCount = 0; + MortalList.promoteRootedTieWrapperOwners(nb); } - if (nb.refCount >= 0) { + if (nb.refCount >= 0 && !scalarContentsOwnedByEscapedReference) { if (nb.refCount == 0) nb.registerSharedFetchedView(); - nb.traceRefCount(+1, "RuntimeScalar.setLargeRefCounted (increment on store)"); - nb.recordOwner(this, "setLargeRefCounted store"); - nb.recordActiveOwner(this); - nb.refCount++; - nb.hadCountedReference = true; newOwned = true; } } @@ -2812,6 +3161,31 @@ && isSocketIOHandle(oldIo.ioHandle)) { // Do the assignment this.type = value.type; this.value = copyScalarPayload(value.type, value.value); + this.scalarReferenceContentsOwner = null; + // A self-assignment keeps its existing slot/count. Otherwise release + // the displaced scalar slot before reusing its stable identity for the + // assigned value. No lifecycle callback runs until the new edge is + // installed and the release policy below decides whether to dispatch. + boolean preservedExistingScalarValueOwner = newOwned + && this.value instanceof RuntimeBase assignedReferent + && hasScalarValueOwnerSlotFor(assignedReferent); + boolean releasedOldScalarValueSlot = false; + if (!preservedExistingScalarValueOwner) { + if (oldOwnedByScalarValue && oldBase != null && !thisWasWeak) { + releasedOldScalarValueSlot = releaseScalarValueOwnerCount( + oldBase, + "RuntimeScalar.setLargeRefCounted (decrement on overwrite)"); + } + syncScalarValueOwnerSlot(false); + } + if (newOwned && !preservedExistingScalarValueOwner) { + if (!(this.value instanceof RuntimeBase newReferent) + || !acquireScalarValueOwnerCount( + newReferent, "RuntimeScalar.setLargeRefCounted (increment on store)")) { + throw new IllegalStateException( + "tracked scalar assignment could not acquire its owner slot"); + } + } // An explicitly discarded closure can have an inflated CODE refcount, // so waiting for callDestroy() leaves its captures alive indefinitely. // Release them only after this slot is gone, the code is inactive, and @@ -2828,12 +3202,13 @@ && isSocketIOHandle(oldIo.ioHandle)) { // scalar wrapper is assigned a new referent (the normal class-field // ADJUST path). RuntimeHash's map hook covers slot replacement; this // covers mutation of a slot that is already in the map. - if (containerOwner instanceof RuntimeHash && oldBase != this.value) { - if (oldBase != null && oldBase.hashSlotOwnerCount > 0) { - oldBase.hashSlotOwnerCount--; + if (oldBase != this.value) { + RuntimeBase newBase = this.value instanceof RuntimeBase base ? base : null; + for (RuntimeHash owner : hashContainerOwnersSnapshot()) { + owner.updateHashEntryOwnersForScalar(this, oldBase, newBase); } - if (this.value instanceof RuntimeBase newBase) { - newBase.hashSlotOwnerCount++; + for (RuntimeArray owner : arrayContainerOwnersSnapshot()) { + owner.updateArrayElementOwnersForScalar(this); } } if (this.captureCount > 0 && (this.type & RuntimeScalarType.REFERENCE_BIT) != 0 @@ -2896,7 +3271,10 @@ && hasLiveIo(assignedGlob)) { && !(containerOwner instanceof RuntimeHash owner && owner.isPackageRootedHash()) && this.value instanceof RuntimeBase base - && base == DestroyDispatch.currentDestroyTarget()) { + && base == DestroyDispatch.currentDestroyTarget() + && (!DestroyDispatch.isInGlobalDestructionPhase() + || ReachabilityWalker.isReachableFromRoots( + containerOwner, true, base))) { DestroyDispatch.markDestroyTargetRescued(); // Transition from destroyed (MIN_VALUE) to tracked so that when the // rescuing reference is eventually released (e.g., source goes out of @@ -2947,18 +3325,41 @@ && hasLiveIo(assignedGlob)) { // Decrement old value's refCount AFTER assignment (skip for weak refs // and for scalars that didn't own a refCount increment). if (oldBase != null && !thisWasWeak - && (this.refCountOwned || oldOwnedByScalarReference)) { - if (oldBase.refCount > 0) { + && (oldOwnedByScalarValue || oldOwnedByScalarReference)) { + if (preservedExistingScalarValueOwner) { + // The same scalar slot still owns the same referent; assignment + // introduced no displaced count to release. + } else if (oldBase.refCount > 0 && !releasedOldScalarValueSlot) { oldBase.traceRefCount(-1, "RuntimeScalar.setLargeRefCounted (decrement on overwrite)"); - oldBase.releaseOwner(this, "setLargeRefCounted overwrite"); - oldBase.releaseActiveOwner(this); + if (!(oldOwnedByScalarValue && oldBase == this.value && newOwned)) { + oldBase.releaseOwner(this, "setLargeRefCounted overwrite"); + oldBase.releaseActiveOwner(this); + } } - if (oldBase.refCount > 0 && --oldBase.refCount == 0) { + boolean oldReferentReachedZero = !preservedExistingScalarValueOwner + && (releasedOldScalarValueSlot + ? oldBase.refCount == 0 + : oldBase.refCount > 0 && --oldBase.refCount == 0); + if (oldReferentReachedZero) { + int rootedScalarContentsOwners = oldBase.ownerSlotCount( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS) == 0 + ? 0 : oldBase.reachableOwnerCount(); if (oldBase.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. // Cleanup will happen at scope exit (scopeExitCleanupHash/Array). + } else if (MortalList.restoreReachableAggregateOwnerCount( + oldBase, "RuntimeScalar.setLargeRefCounted overwrite")) { + // Explicit aggregate slots can prove a rooted owner chain + // even when a nested expression temporary is absent from + // the conservative lexical walker roots. + } else if (rootedScalarContentsOwners > 0) { + // A scalar-reference contents slot is an explicit Perl + // owner even when the legacy decrement belongs to another + // scalar alias. Restore the reachable owner count instead + // of dispatching destruction at a transient zero. + oldBase.refCount = rootedScalarContentsOwners; } else if (oldBase.blessId == 0 && (oldBase instanceof RuntimeArray || oldBase instanceof RuntimeHash) && WeakRefRegistry.hasWeakRefsTo(oldBase) @@ -3059,7 +3460,9 @@ && hasLiveIo(assignedGlob)) { // nested DESTROY cleanup can release the just-returned value. boolean flushWasSuppressed = MortalList.suppressFlush(true); try { - releaseScalarReferenceContents(oldScalarReferenceContents); + if (oldScalarReferenceContentsOwner != null) { + oldScalarReferenceContentsOwner.releaseScalarReferenceContents(); + } } finally { MortalList.suppressFlush(flushWasSuppressed); } @@ -3098,9 +3501,18 @@ && hasLiveIo(assignedGlob)) { } // Update ownership: this scalar now owns a refCount iff we incremented. - this.refCountOwned = newOwned; + setRefCountOwned(newOwned); retainScalarReferenceContents(value); + if (!scalarContentsOwners.isEmpty()) { + RuntimeBase nextContents = (this.type & REFERENCE_BIT) != 0 + && this.value instanceof RuntimeBase base + && !WeakRefRegistry.isweak(this) ? base : null; + for (PerlOwnerSlot owner : scalarContentsOwners) { + owner.retargetScalarReferenceContents(nextContents); + } + } + // Flush deferred mortal decrements from the current function scope. // Entries below the current MortalList mark belong to the caller, and // must survive through nested calls such as chained AUTOLOAD dispatch. @@ -4937,20 +5349,21 @@ public RuntimeScalar undefine() { } if (type == RuntimeScalarType.CODE && value instanceof RuntimeCode code && !(code.isConstantCv && code.lexicalSubDisplayName)) { + if (globalCodeRefFqn != null) { + releaseAllClosureCaptureReferents(code); + boolean releasedCode = releaseGlobalCodeOwnerCount(code); + RuntimeCode.undefineCodeReference(this); + if (releasedCode && WeakRefRegistry.weakRefsExist() + && !ModuleInitGuard.inModuleInit()) { + ReachabilityWalker.sweepWeakRefs(true); + } + return this; + } return RuntimeCode.undefineCodeReference(this); } if (type == RuntimeScalarType.CODE && value instanceof RuntimeCode code && globalCodeRefFqn != null) { - boolean releasedCode = false; releaseAllClosureCaptureReferents(code); - if (this.refCountOwned && code.refCount > 0) { - this.refCountOwned = false; - code.releaseActiveOwner(this); - if (--code.refCount == 0) { - code.refCount = Integer.MIN_VALUE; - DestroyDispatch.callDestroy(code); - releasedCode = true; - } - } + boolean releasedCode = releaseGlobalCodeOwnerCount(code); // Clear the body but keep a declared CODE slot. Perl preserves the // glob's CODE entry after both `undef &existing` and // `undef &previously_unknown`: exists(&name) remains true while @@ -4988,6 +5401,7 @@ public RuntimeScalar undefine() { if (oldBase != null && this.captureRefCountOwned() > 0) { releaseAllClosureCaptureReferents(oldBase); } + boolean oldBaseOwnedByScalar = oldBase != null && ownsCountedReferent(oldBase); // Close IO handles when dropping a glob reference. // This mimics Perl's internal sv_clear behavior where IO handles are closed @@ -5014,10 +5428,17 @@ public RuntimeScalar undefine() { // but safe since unblessed objects have no DESTROY. oldBase.refCount = Integer.MIN_VALUE; DestroyDispatch.callDestroy(oldBase); - } else if (this.refCountOwned && oldBase.refCount > 0) { - this.refCountOwned = false; - oldBase.releaseActiveOwner(this); - if (--oldBase.refCount == 0) { + } else if (oldBaseOwnedByScalar && oldBase.refCount > 0) { + boolean releasedExplicitSlot = + releaseScalarValueOwnerCount(oldBase, "RuntimeScalar.undefine"); + if (!releasedExplicitSlot) { + // Compatibility fallback for scalar owners created before + // SCALAR_VALUE slots were installed. + this.setRefCountOwned(false); + oldBase.releaseActiveOwner(this); + oldBase.refCount--; + } + if (oldBase.refCount == 0) { if (oldBase.localBindingExists) { // Named container: local variable may still exist. Skip callDestroy. } else { @@ -5050,7 +5471,7 @@ public RuntimeScalar undefine() { } } } else if (oldBase.blessId != 0 && WeakRefRegistry.weakRefsExist()) { - // Phase D: no owned-count decrement (refCountOwned was false, or + // Phase D: no owned-count decrement (no scalar owner slot existed, or // refCount was already 0 from prior selective drift). The // object is blessed — if its class has DESTROY, let the walker // decide whether this undef just released the last live lexical @@ -5113,6 +5534,25 @@ public RuntimeScalar undefine() { return this; } + private boolean releaseGlobalCodeOwnerCount(RuntimeCode code) { + if (!this.ownsCountedReferent(code) || code.refCount <= 0) { + return false; + } + boolean releasedExplicitSlot = + releaseScalarValueOwnerCount(code, "RuntimeScalar.undefine global CODE"); + if (!releasedExplicitSlot) { + this.setRefCountOwned(false); + code.releaseActiveOwner(this); + code.refCount--; + } + if (code.refCount != 0) { + return false; + } + code.refCount = Integer.MIN_VALUE; + DestroyDispatch.callDestroy(code); + return true; + } + /** * Close any IO handle associated with a GLOBREFERENCE value being dropped. *

@@ -5201,11 +5641,13 @@ public static void scopeExitCleanup(RuntimeScalar scalar) { } // Fast path: skip if no special state (most common case for integer/string vars). - // When all three conditions are true, the entire method body is a no-op: - // - refCountOwned=false → deferDecrementIfTracked returns immediately + // When all conditions are true, the entire method body is a no-op: + // - no scalar-value owner exists → deferDecrementIfTracked returns immediately // - captureCount=0 → capture handling branch not taken - if (!scalar.refCountOwned && scalar.captureCount == 0 && !scalar.ioOwner - && !scalar.ownsScalarReferenceContents + boolean ownsCurrentReferent = scalar.refCountOwned + || scalar.ownsCurrentCountedReferent(); + if (!ownsCurrentReferent && scalar.captureCount == 0 && !scalar.ioOwner + && !scalar.hasScalarReferenceContentsOwner() && scalar.type != RuntimeScalarType.TIED_SCALAR) { // Special case: CODE refs with unreleased captures that were never // stored via set() (e.g., anonymous subs passed directly as arguments). @@ -5219,7 +5661,8 @@ public static void scopeExitCleanup(RuntimeScalar scalar) { && scalar.value instanceof RuntimeCode code && code.capturedScalars != null && code.refCount == 0 - && !GlobalVariable.installedGlobalCodeRefsView().contains(code)) { + && !GlobalVariable.installedGlobalCodeRefsView().contains(code) + && !ReachabilityWalker.isReachableFromRoots(code)) { code.releaseCaptures(); } return; @@ -5248,7 +5691,7 @@ public static void scopeExitCleanup(RuntimeScalar scalar) { } if (selfRef) { // Decrement our captureCount (the closure captured us) - scalar.releaseClosureCapture(); + scalar.releaseClosureCapture(code); // Remove self from capturedScalars to prevent double-decrement // when releaseCaptures runs later during CODE ref destruction RuntimeScalar[] old = code.capturedScalars; @@ -5290,7 +5733,7 @@ public static void scopeExitCleanup(RuntimeScalar scalar) { // At inner scope exit we can't decrement (closures in outer scopes // may legitimately keep the object alive), but after the main // script finishes ALL scopes have exited, so it's safe. - if (scalar.refCountOwned + if (scalar.ownsCurrentCountedReferent() && ((scalar.type & RuntimeScalarType.REFERENCE_BIT) != 0 || scalar.type == RuntimeScalarType.GLOB) && scalar.value instanceof RuntimeBase rb @@ -5945,9 +6388,7 @@ private RuntimeScalar postAutoIncrementLarge() { // returned reference becomes the first durable owner. The // assignment receiving this scalar will balance the temporary // copy, so retain one cleanup token for that returned value. - old.refCountOwned = true; - old.postfixReferenceOwnershipTransfer = true; - this.refCountOwned = false; + transferPostfixOwnerTo(old); // Check if object is eligible for overloading int blessId = blessedId(this); if (blessId != 0) { @@ -6256,9 +6697,7 @@ private RuntimeScalar postAutoDecrementWithoutWatcherNotification() { default -> { // All reference types // See postAutoIncrementLarge(): the returned pre-mutation // reference inherits this lvalue's ownership token. - old.refCountOwned = true; - old.postfixReferenceOwnershipTransfer = true; - this.refCountOwned = false; + transferPostfixOwnerTo(old); // Check if object is eligible for overloading int blessId = blessedId(this); if (blessId != 0) { @@ -6409,8 +6848,13 @@ public void dynamicSaveState() { // Copy the current type and value to the new state currentState.type = this.type; currentState.value = copyScalarPayload(this.type, this.value); + currentState.setRefCountOwned(ownsCurrentCountedReferent()); + setRefCountOwned(false); currentState.blessId = this.blessId; - currentState.ownsScalarReferenceContents = this.ownsScalarReferenceContents; + currentState.scalarReferenceContentsOwner = this.scalarReferenceContentsOwner; + if (currentState.scalarReferenceContentsOwner != null) { + currentState.scalarReferenceContentsOwner.transferScalarReferenceContentsCell(currentState); + } currentState.referencedByScalarReference = this.referencedByScalarReference; currentState.tainted = this.tainted; currentState.numericLiteralText = this.numericLiteralText; @@ -6425,7 +6869,7 @@ public void dynamicSaveState() { this.type = UNDEF; this.value = null; this.blessId = 0; - this.ownsScalarReferenceContents = false; + this.scalarReferenceContentsOwner = null; this.tainted = false; this.numericLiteralText = null; this.numericContextSeen = false; @@ -6469,13 +6913,23 @@ public void dynamicRestoreState() { && this.value instanceof RuntimeBase base) { displacedBase = base; } - RuntimeScalar scalarReferenceContents = scalarReferenceContentsReferent(this); + boolean releasedDisplacedScalarSlot = displacedBase != null + && releaseScalarValueOwnerCount( + displacedBase, "RuntimeScalar.dynamicRestoreState"); + PerlOwnerSlot displacedScalarReferenceContentsOwner = + this.scalarReferenceContentsOwner; // Restore the type, value from the saved state this.type = previousState.type; this.value = copyScalarPayload(previousState.type, previousState.value); + setRefCountOwned(previousState.ownsCurrentCountedReferent()); + previousState.setRefCountOwned(false); this.blessId = previousState.blessId; - this.ownsScalarReferenceContents = previousState.ownsScalarReferenceContents; + this.scalarReferenceContentsOwner = previousState.scalarReferenceContentsOwner; + previousState.scalarReferenceContentsOwner = null; + if (this.scalarReferenceContentsOwner != null) { + this.scalarReferenceContentsOwner.transferScalarReferenceContentsCell(this); + } this.referencedByScalarReference = previousState.referencedByScalarReference || referencedDuringLocal; this.tainted = previousState.tainted; @@ -6499,13 +6953,19 @@ public void dynamicRestoreState() { owner.endScalarLocalElement(); } - releaseScalarReferenceContents(scalarReferenceContents); + if (displacedScalarReferenceContentsOwner != null + && displacedScalarReferenceContentsOwner != this.scalarReferenceContentsOwner) { + displacedScalarReferenceContentsOwner.releaseScalarReferenceContents(); + } // Decrement refCount of the value displaced by local restoration // after restoring the old scalar state, so DESTROY sees the restored // variable rather than the dying localized value. - if (displacedBase != null - && displacedBase.refCount > 0 && --displacedBase.refCount == 0) { + boolean displacedReferentReachedZero = displacedBase != null + && (releasedDisplacedScalarSlot + ? displacedBase.refCount == 0 + : displacedBase.refCount > 0 && --displacedBase.refCount == 0); + if (displacedReferentReachedZero) { displacedBase.refCount = Integer.MIN_VALUE; DestroyDispatch.callDestroy(displacedBase); } @@ -6515,6 +6975,13 @@ public void dynamicRestoreState() { @Override public Object dynamicSuspendState() { RuntimeScalar activeState = new RuntimeScalar(this); + activeState.setRefCountOwned(ownsCurrentCountedReferent()); + setRefCountOwned(false); + activeState.scalarReferenceContentsOwner = this.scalarReferenceContentsOwner; + if (activeState.scalarReferenceContentsOwner != null) { + activeState.scalarReferenceContentsOwner.transferScalarReferenceContentsCell(activeState); + } + this.scalarReferenceContentsOwner = null; dynamicRestoreState(); return activeState; } @@ -6525,8 +6992,14 @@ public void dynamicResumeState(Object token) { if (token instanceof RuntimeScalar activeState) { this.type = activeState.type; this.value = copyScalarPayload(activeState.type, activeState.value); + setRefCountOwned(activeState.ownsCurrentCountedReferent()); + activeState.setRefCountOwned(false); this.blessId = activeState.blessId; - this.ownsScalarReferenceContents = activeState.ownsScalarReferenceContents; + this.scalarReferenceContentsOwner = activeState.scalarReferenceContentsOwner; + activeState.scalarReferenceContentsOwner = null; + if (this.scalarReferenceContentsOwner != null) { + this.scalarReferenceContentsOwner.transferScalarReferenceContentsCell(this); + } this.referencedByScalarReference = activeState.referencedByScalarReference; this.tainted = activeState.tainted; this.numericLiteralText = activeState.numericLiteralText; diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeStash.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeStash.java index 0c26546dc2..b781e646ed 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeStash.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeStash.java @@ -262,11 +262,19 @@ private RuntimeScalar deleteGlob(String k) { // Safe because unblessed objects have no DESTROY side effects. base.refCount = Integer.MIN_VALUE; DestroyDispatch.callDestroy(base); - } else if (base.refCount > 0 && savedScalar.refCountOwned) { - // Tracked object: decrement refCount (the stash was holding a strong ref). - savedScalar.refCountOwned = false; - base.releaseActiveOwner(savedScalar); - if (--base.refCount == 0) { + } else if (base.refCount > 0 && savedScalar.ownsCountedReferent(base)) { + // Tracked object: release the stash's direct scalar owner. + // Cells without a slot keep the legacy bridge path. + boolean releasedScalarSlot = savedScalar.releaseScalarValueOwnerCount( + base, "RuntimeStash.delete"); + if (!releasedScalarSlot) { + savedScalar.setRefCountOwned(false); + base.traceRefCount(-1, "RuntimeStash.delete"); + base.releaseOwner(savedScalar, "RuntimeStash.delete"); + base.releaseActiveOwner(savedScalar); + base.refCount--; + } + if (base.refCount == 0) { base.refCount = Integer.MIN_VALUE; DestroyDispatch.callDestroy(base); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/TieArray.java b/src/main/java/org/perlonjava/runtime/runtimetypes/TieArray.java index 90b878caf5..1b9abca6fa 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/TieArray.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/TieArray.java @@ -51,6 +51,7 @@ public class TieArray extends ArrayList { private final RuntimeArray parent; private boolean tiedObjectReleased = false; + private transient PerlOwnerSlot tiedObjectOwnerSlot; /** * Creates a new TieArray instance. @@ -64,12 +65,17 @@ public TieArray(String tiedPackage, RuntimeArray previousValue, RuntimeScalar se this.previousValue = previousValue; this.self = self; this.parent = parent; - // Increment refCount: the tie wrapper holds a strong reference to the tied object. + // Record the wrapper's strong edge, including before selective counting starts. if (self != null && (self.type & RuntimeScalarType.REFERENCE_BIT) != 0 - && self.value instanceof RuntimeBase base - && base.refCount >= 0) { - base.refCount++; - base.acquireTransientTraceOwner("tie wrapper", "TieArray"); + && self.value instanceof RuntimeBase base) { + PerlOwnerSlot ownerSlot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.TIE_WRAPPER, null, parent, self); + if (ownerSlot.acquireTieWrapperCount(base, "TieArray")) { + tiedObjectOwnerSlot = ownerSlot; + if (ownerSlot.tieWrapperCounted()) { + base.acquireTransientTraceOwner("tie wrapper", "TieArray"); + } + } } } @@ -301,11 +307,15 @@ public RuntimeScalar get(int i) { public void releaseTiedObject() { if (tiedObjectReleased) return; tiedObjectReleased = true; - 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) { + PerlOwnerSlot ownerSlot = tiedObjectOwnerSlot; + RuntimeBase base = ownerSlot == null ? null : ownerSlot.referent(); + if (base != null) { + boolean hadPositiveCount = base.refCount > 0; + if (ownerSlot.tieWrapperCounted()) { + base.releaseTransientTraceOwner("tie wrapper", "TieArray"); + } + ownerSlot.releaseTieWrapperCount(base, "TieArray.releaseTiedObject"); + if (hadPositiveCount && 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 cf68a57ad4..dd200ef435 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/TieHandle.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/TieHandle.java @@ -36,6 +36,8 @@ public class TieHandle extends RuntimeIO { */ private final RuntimeScalar self; private final boolean holdsSelfReference; + private transient PerlOwnerSlot tiedObjectOwnerSlot; + private boolean tiedObjectReleased; /** * The package name that this handle is tied to. @@ -55,21 +57,31 @@ public class TieHandle extends RuntimeIO { * @param self the blessed object returned by TIEHANDLE */ public TieHandle(String tiedPackage, RuntimeIO previousValue, RuntimeScalar self) { - this(tiedPackage, previousValue, self, true); + this(tiedPackage, previousValue, self, true, null); } public TieHandle(String tiedPackage, RuntimeIO previousValue, RuntimeScalar self, boolean holdSelfReference) { + this(tiedPackage, previousValue, self, holdSelfReference, null); + } + + public TieHandle(String tiedPackage, RuntimeIO previousValue, RuntimeScalar self, + boolean holdSelfReference, RuntimeBase ownerContainer) { this.tiedPackage = tiedPackage; this.previousValue = previousValue; this.self = self; this.holdsSelfReference = holdSelfReference; - // Increment refCount: the tie wrapper holds a strong reference to the tied object. + // Record the wrapper's strong edge, including before selective counting starts. if (holdSelfReference && self != null && (self.type & RuntimeScalarType.REFERENCE_BIT) != 0 - && self.value instanceof RuntimeBase base - && base.refCount >= 0) { - base.refCount++; - base.acquireTransientTraceOwner("tie wrapper", "TieHandle"); + && self.value instanceof RuntimeBase base) { + PerlOwnerSlot ownerSlot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.TIE_WRAPPER, null, ownerContainer, self); + if (ownerSlot.acquireTieWrapperCount(base, "TieHandle")) { + tiedObjectOwnerSlot = ownerSlot; + if (ownerSlot.tieWrapperCounted()) { + base.acquireTransientTraceOwner("tie wrapper", "TieHandle"); + } + } } } @@ -355,13 +367,19 @@ public String toString() { * Decrements refCount and triggers DESTROY if it reaches 0. */ public void releaseTiedObject() { - if (!holdsSelfReference) { + if (!holdsSelfReference || tiedObjectReleased) { return; } - if ((self.type & RuntimeScalarType.REFERENCE_BIT) != 0 - && self.value instanceof RuntimeBase base) { - base.releaseTransientTraceOwner("tie wrapper", "TieHandle"); - if (base.refCount > 0 && --base.refCount == 0) { + tiedObjectReleased = true; + PerlOwnerSlot ownerSlot = tiedObjectOwnerSlot; + RuntimeBase base = ownerSlot == null ? null : ownerSlot.referent(); + if (base != null) { + boolean hadPositiveCount = base.refCount > 0; + if (ownerSlot.tieWrapperCounted()) { + base.releaseTransientTraceOwner("tie wrapper", "TieHandle"); + } + ownerSlot.releaseTieWrapperCount(base, "TieHandle.releaseTiedObject"); + if (hadPositiveCount && 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 77cdbfb644..76c69a9f7c 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/TieHash.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/TieHash.java @@ -41,8 +41,10 @@ public class TieHash extends HashMap { * The original value of the hash before it was tied. */ private final RuntimeHash previousValue; + private transient RuntimeHash parent; private boolean tiedObjectReleased = false; + private transient PerlOwnerSlot tiedObjectOwnerSlot; /** * Creates a new TieHash instance. @@ -52,15 +54,26 @@ public class TieHash extends HashMap { * @param self the blessed object returned by TIEHASH */ public TieHash(String tiedPackage, RuntimeHash previousValue, RuntimeScalar self) { + this(tiedPackage, previousValue, self, null); + } + + public TieHash( + String tiedPackage, RuntimeHash previousValue, RuntimeScalar self, RuntimeHash parent) { this.tiedPackage = tiedPackage; this.previousValue = previousValue; this.self = self; - // Increment refCount: the tie wrapper holds a strong reference to the tied object. + this.parent = parent; + // Record the wrapper's strong edge, including before selective counting starts. if (self != null && (self.type & RuntimeScalarType.REFERENCE_BIT) != 0 - && self.value instanceof RuntimeBase base - && base.refCount >= 0) { - base.refCount++; - base.acquireTransientTraceOwner("tie wrapper", "TieHash"); + && self.value instanceof RuntimeBase base) { + PerlOwnerSlot ownerSlot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.TIE_WRAPPER, null, parent, self); + if (ownerSlot.acquireTieWrapperCount(base, "TieHash")) { + tiedObjectOwnerSlot = ownerSlot; + if (ownerSlot.tieWrapperCounted()) { + base.acquireTransientTraceOwner("tie wrapper", "TieHash"); + } + } } if (self != null && (self.type & RuntimeScalarType.REFERENCE_BIT) != 0 && self.value instanceof RuntimeBase base) { @@ -227,11 +240,15 @@ public String getTiedPackage() { public void releaseTiedObject() { if (tiedObjectReleased) return; tiedObjectReleased = true; - 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) { + PerlOwnerSlot ownerSlot = tiedObjectOwnerSlot; + RuntimeBase base = ownerSlot == null ? null : ownerSlot.referent(); + if (base != null) { + boolean hadPositiveCount = base.refCount > 0; + if (ownerSlot.tieWrapperCounted()) { + base.releaseTransientTraceOwner("tie wrapper", "TieHash"); + } + ownerSlot.releaseTieWrapperCount(base, "TieHash.releaseTiedObject"); + if (hadPositiveCount && base.refCount == 0) { base.refCount = Integer.MIN_VALUE; DestroyDispatch.callDestroy(base); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/TieScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/TieScalar.java index 4df5df2db3..4edb70616f 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/TieScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/TieScalar.java @@ -49,7 +49,7 @@ public TieScalar(String tiedPackage, RuntimeScalar previousValue, RuntimeScalar public TieScalar(String tiedPackage, RuntimeScalar previousValue, RuntimeScalar self, RuntimeScalar owner) { - super(self, tiedPackage); + super(self, tiedPackage, owner); this.previousValue = previousValue; this.owner = owner; } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/TiedVariableBase.java b/src/main/java/org/perlonjava/runtime/runtimetypes/TiedVariableBase.java index ef0b83f7bc..7cdda08bd1 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/TiedVariableBase.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/TiedVariableBase.java @@ -26,6 +26,7 @@ public abstract class TiedVariableBase extends RuntimeBaseProxy { protected final String tiedPackage; private boolean tiedObjectReleased = false; + private transient PerlOwnerSlot tiedObjectOwnerSlot; /** * Creates a new TiedVariableBase instance. @@ -34,20 +35,30 @@ public abstract class TiedVariableBase extends RuntimeBaseProxy { * @param tiedPackage the package name this variable is tied to */ public TiedVariableBase(RuntimeScalar tiedObject, String tiedPackage) { + this(tiedObject, tiedPackage, null); + } + + protected TiedVariableBase( + RuntimeScalar tiedObject, String tiedPackage, RuntimeBase ownerContainer) { super(); this.self = tiedObject; this.tiedPackage = tiedPackage; // The tie wrapper holds a strong reference to the tied object. - // Increment refCount so that untie's decrement can trigger DESTROY - // when no other references remain (matching Perl 5 behavior). + // Record the wrapper's strong edge even before selective counting is + // active; later owner recovery can then include it exactly once. // Note: tiedObject is null for proxy entries (RuntimeTiedHashProxyEntry, // RuntimeTiedArrayProxyEntry) which delegate to the parent container. if (tiedObject != null && (tiedObject.type & RuntimeScalarType.REFERENCE_BIT) != 0 - && tiedObject.value instanceof RuntimeBase base - && base.refCount >= 0) { - base.refCount++; - base.acquireTransientTraceOwner("tie wrapper", "TiedVariableBase"); + && tiedObject.value instanceof RuntimeBase base) { + PerlOwnerSlot ownerSlot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.TIE_WRAPPER, null, ownerContainer, tiedObject); + if (ownerSlot.acquireTieWrapperCount(base, "TiedVariableBase")) { + tiedObjectOwnerSlot = ownerSlot; + if (ownerSlot.tieWrapperCounted()) { + base.acquireTransientTraceOwner("tie wrapper", "TiedVariableBase"); + } + } } } @@ -231,10 +242,15 @@ public void releaseTiedObject() { // Array/hash element proxy entries delegate to their parent tied // container and intentionally have no handler of their own. 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) { + PerlOwnerSlot ownerSlot = tiedObjectOwnerSlot; + RuntimeBase base = ownerSlot == null ? null : ownerSlot.referent(); + if (base != null) { + boolean hadPositiveCount = base.refCount > 0; + if (ownerSlot.tieWrapperCounted()) { + base.releaseTransientTraceOwner("tie wrapper", "TiedVariableBase"); + } + ownerSlot.releaseTieWrapperCount(base, "TiedVariableBase.releaseTiedObject"); + if (hadPositiveCount && 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 5811970656..6a398b4719 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/WeakRefRegistry.java @@ -121,6 +121,11 @@ private static void weaken( .add(ref); base.activateOwnerTracking(); ref.releaseClosureCaptureReferentsForWeaken(base); + // A weak reference to a scalar cell must stop owning that cell's + // contents, just as weakening a direct scalar value stops owning its + // referent. This also lets a detached hash/array value die when its + // final strong refalias is weakened. + ref.releaseOwnedScalarReferenceContents(); if (System.getenv("PJ_WEAKCLEAR_TRACE") != null) { System.err.println("[WEAKEN] ref=" + System.identityHashCode(ref) + " referent=" + System.identityHashCode(base) @@ -151,23 +156,29 @@ private static void weaken( && !(base instanceof RuntimeCode) && weakenedLiveLexical; - if (base.refCount > 0 && ref.refCountOwned) { + if (base.refCount > 0 && ref.ownsCountedReferent(base)) { // Tracked object with a properly-counted reference: // decrement strong count (weak ref doesn't count). - // Only decrement if refCountOwned=true, meaning the hash element - // or variable's creation incremented the referent's refCount via + // Only decrement if the legacy ownership token or explicit scalar + // value slot proves that this cell acquired the referent's count via // setLargeRefCounted or incrementRefCountForContainerStore. // If refCountOwned=false (e.g., element in an untracked anonymous // hash like `{ weakref => $target }`), the store never incremented // refCount, so weaken must not decrement either — otherwise we // get a double-decrement that causes premature destruction. - // Clear refCountOwned because weaken's DEC consumes the ownership — - // the weak scalar should not trigger another DEC on scope exit or overwrite. - ref.refCountOwned = false; - base.traceRefCount(-1, "WeakRefRegistry.weaken (decrement on weakening)"); - base.releaseOwner(ref, "weaken"); - base.releaseActiveOwner(ref); - if (--base.refCount == 0) { + // The scalar-value slot owns this release when present. Older + // cells retain the explicit legacy bridge until their ownership + // path is migrated. + boolean releasedBySlot = ref.releaseScalarValueOwnerCount( + base, "WeakRefRegistry.weaken (decrement on weakening)"); + if (!releasedBySlot) { + ref.setRefCountOwned(false); + base.traceRefCount(-1, "WeakRefRegistry.weaken (decrement on weakening)"); + base.releaseOwner(ref, "weaken"); + base.releaseActiveOwner(ref); + base.refCount--; + } + if (base.refCount == 0) { if (base.hasSemanticCaptureOwner()) { // A captured pad is an authoritative Perl owner even when // the weak probe being weakened held the last ordinary @@ -236,7 +247,7 @@ && hasWeakRefsTo(base) // is still alive in the stash. This breaks Sub::Quote/Sub::Defer // (which use weaken() for back-references) and cascades to break // Moo's accessor inlining (51 test failures). See §15. - ref.refCountOwned = false; + ref.setRefCountOwned(false); if (base.blessId == 0 && (base instanceof RuntimeArray || base instanceof RuntimeHash) && ReachabilityWalker.isReachableFromRoots(base)) { @@ -290,7 +301,7 @@ && codeRefHasCountedOwners(base) } private static boolean codeRefHasCountedOwners(RuntimeBase base) { - return base.refCount > 0 || base.activeOwnerCount() > 0 + return base.refCount > 0 || base.countedScalarOwnerCount() > 0 || base.hasSemanticCaptureOwner(); } @@ -316,12 +327,8 @@ 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); + ref.acquireScalarValueOwnerCount( + base, "WeakRefRegistry.unweaken (restore strong count)"); } // Note: if MIN_VALUE, object already destroyed — unweaken is a no-op } diff --git a/src/main/perl/lib/B.pm b/src/main/perl/lib/B.pm index 879e87555f..09d93e288a 100644 --- a/src/main/perl/lib/B.pm +++ b/src/main/perl/lib/B.pm @@ -88,7 +88,7 @@ package B::SV { # B's C implementation inspects the referent without creating a Perl # owner. Ask the runtime to compensate for an independently live # lexical owner that selective refcounting may not have counted. - Internals::SvREFCNT($_[0]->{ref}, 1); + Internals::jperl_b_svrefcnt($_[0]->{ref}); } sub object_2svref { diff --git a/src/test/java/org/perlonjava/MojoliciousInterpreterControllerLifetimeTest.java b/src/test/java/org/perlonjava/MojoliciousInterpreterControllerLifetimeTest.java new file mode 100644 index 0000000000..1b69b6744d --- /dev/null +++ b/src/test/java/org/perlonjava/MojoliciousInterpreterControllerLifetimeTest.java @@ -0,0 +1,70 @@ +package org.perlonjava; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Assumptions; +import org.perlonjava.app.cli.CompilerOptions; +import org.perlonjava.app.scriptengine.PerlLanguageProvider; +import org.perlonjava.runtime.io.StandardIO; +import org.perlonjava.runtime.runtimetypes.GlobalVariable; +import org.perlonjava.runtime.runtimetypes.PerlExitException; +import org.perlonjava.runtime.runtimetypes.RuntimeArray; +import org.perlonjava.runtime.runtimetypes.RuntimeIO; +import org.perlonjava.runtime.runtimetypes.RuntimeScalar; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class MojoliciousInterpreterControllerLifetimeTest extends PerlRuntimeTestBase { + @Test + void controllerIsDestroyedAfterAsyncResponseFinishInInterpreter() throws Exception { + PerlLanguageProvider.resetAll(); + String script; + try (InputStream input = getClass().getClassLoader().getResourceAsStream( + "unit/mojolicious_controller_destroy_after_finish.t")) { + Assumptions.assumeTrue(input != null, + "Mojolicious controller regression resource is unavailable"); + script = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + + CompilerOptions options = new CompilerOptions(); + options.fileName = "unit/mojolicious_controller_destroy_after_finish.t"; + options.code = script; + options.useInterpreter = true; + RuntimeArray.push(options.inc, new RuntimeScalar("src/main/perl/lib")); + RuntimeArray.push(options.inc, new RuntimeScalar("src/test/resources/unit/lib")); + + PrintStream originalOut = System.out; + RuntimeIO originalRuntimeOut = RuntimeIO.getStdout(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + PrintStream capturedOut = new PrintStream(output, true, StandardCharsets.UTF_8); + try { + RuntimeIO.setStdout(new RuntimeIO(new StandardIO(output, true))); + GlobalVariable.getGlobalIO("main::STDOUT").setIO(RuntimeIO.getStdout()); + System.setOut(capturedOut); + try { + PerlLanguageProvider.executePerlCode(options, true); + } catch (PerlExitException exit) { + if (exit.getExitCode() != 0) throw exit; + } + } finally { + RuntimeIO.setStdout(originalRuntimeOut); + GlobalVariable.getGlobalIO("main::STDOUT").setIO(originalRuntimeOut); + System.setOut(originalOut); + capturedOut.close(); + } + + String tap = output.toString(StandardCharsets.UTF_8); + if (tap.contains("1..0 # SKIP")) { + Assumptions.abort("Mojolicious or loopback support is unavailable: " + tap.trim()); + } + assertTrue(tap.contains("1..5"), "interpreter did not run all five assertions:\n" + tap); + assertFalse(tap.lines().anyMatch(line -> line.trim().startsWith("not ok")), tap); + } +} diff --git a/src/test/java/org/perlonjava/MojoliciousInterpreterWebSocketLifetimeTest.java b/src/test/java/org/perlonjava/MojoliciousInterpreterWebSocketLifetimeTest.java new file mode 100644 index 0000000000..4678ebc41a --- /dev/null +++ b/src/test/java/org/perlonjava/MojoliciousInterpreterWebSocketLifetimeTest.java @@ -0,0 +1,70 @@ +package org.perlonjava; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.perlonjava.app.cli.CompilerOptions; +import org.perlonjava.app.scriptengine.PerlLanguageProvider; +import org.perlonjava.runtime.io.StandardIO; +import org.perlonjava.runtime.runtimetypes.GlobalVariable; +import org.perlonjava.runtime.runtimetypes.PerlExitException; +import org.perlonjava.runtime.runtimetypes.RuntimeArray; +import org.perlonjava.runtime.runtimetypes.RuntimeIO; +import org.perlonjava.runtime.runtimetypes.RuntimeScalar; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class MojoliciousInterpreterWebSocketLifetimeTest extends PerlRuntimeTestBase { + @Test + void websocketServerPushControllerIsDestroyedAfterFinishInInterpreter() throws Exception { + PerlLanguageProvider.resetAll(); + String script; + try (InputStream input = getClass().getClassLoader().getResourceAsStream( + "unit/mojolicious_websocket_server_push_lifetime.t")) { + Assumptions.assumeTrue(input != null, + "Mojolicious WebSocket regression resource is unavailable"); + script = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + + CompilerOptions options = new CompilerOptions(); + options.fileName = "unit/mojolicious_websocket_server_push_lifetime.t"; + options.code = script; + options.useInterpreter = true; + RuntimeArray.push(options.inc, new RuntimeScalar("src/main/perl/lib")); + RuntimeArray.push(options.inc, new RuntimeScalar("src/test/resources/unit/lib")); + + PrintStream originalOut = System.out; + RuntimeIO originalRuntimeOut = RuntimeIO.getStdout(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + PrintStream capturedOut = new PrintStream(output, true, StandardCharsets.UTF_8); + try { + RuntimeIO.setStdout(new RuntimeIO(new StandardIO(output, true))); + GlobalVariable.getGlobalIO("main::STDOUT").setIO(RuntimeIO.getStdout()); + System.setOut(capturedOut); + try { + PerlLanguageProvider.executePerlCode(options, true); + } catch (PerlExitException exit) { + if (exit.getExitCode() != 0) throw exit; + } + } finally { + RuntimeIO.setStdout(originalRuntimeOut); + GlobalVariable.getGlobalIO("main::STDOUT").setIO(originalRuntimeOut); + System.setOut(originalOut); + capturedOut.close(); + } + + String tap = output.toString(StandardCharsets.UTF_8); + if (tap.contains("1..0 # SKIP")) { + Assumptions.abort("Mojolicious or loopback support is unavailable: " + tap.trim()); + } + assertTrue(tap.contains("1..6"), "interpreter did not run all six assertions:\n" + tap); + assertFalse(tap.lines().anyMatch(line -> line.trim().startsWith("not ok")), tap); + } +} diff --git a/src/test/java/org/perlonjava/PerlScriptExecutionTest.java b/src/test/java/org/perlonjava/PerlScriptExecutionTest.java index f79c724386..62e5c7c53b 100644 --- a/src/test/java/org/perlonjava/PerlScriptExecutionTest.java +++ b/src/test/java/org/perlonjava/PerlScriptExecutionTest.java @@ -164,6 +164,15 @@ private static Stream getPerlScripts(boolean unitOnly) throws IOExceptio if (shardTotal > 1 && shardIndex >= 0 && shardIndex < shardTotal) { System.out.println("Running shard " + (shardIndex + 1) + " of " + shardTotal); + final List ISOLATED_TESTS = List.of( + "unit/mojolicious_websocket_callback_lifetime.t" + ); + java.util.function.Predicate isIsolated = s -> + ISOLATED_TESTS.contains(s.replace('\\', '/')); + if (shardTotal == 6 && shardIndex == 5) { + return sortedScripts.stream().filter(isIsolated); + } + // Tests known to be much slower than the rest. Use forward // slashes; we match against both '/' and '\' separators. final List HEAVY_TESTS = List.of( @@ -193,6 +202,7 @@ private static Stream getPerlScripts(boolean unitOnly) throws IOExceptio // Light shards: round-robin over the remaining (shardTotal - 1) shards. List lightScripts = sortedScripts.stream() .filter(isHeavy.negate()) + .filter(isIsolated.negate()) .collect(Collectors.toList()); final int lightShards = shardTotal - 1; final int finalShardIndex = shardIndex; diff --git a/src/test/java/org/perlonjava/runtime/perlmodule/StorableOwnerSlotTest.java b/src/test/java/org/perlonjava/runtime/perlmodule/StorableOwnerSlotTest.java new file mode 100644 index 0000000000..bcff742ffe --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/perlmodule/StorableOwnerSlotTest.java @@ -0,0 +1,34 @@ +package org.perlonjava.runtime.perlmodule; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.perlonjava.runtime.runtimetypes.PerlRuntime; +import org.perlonjava.runtime.runtimetypes.RuntimeArray; +import org.perlonjava.runtime.runtimetypes.RuntimeScalar; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +@Tag("unit") +class StorableOwnerSlotTest { + @Test + void releaseApplyArgsConsumesExplicitOwnerAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createAnonymousReference()); + owner.refCountOwned = false; + RuntimeArray args = new RuntimeArray(); + args.elements.add(owner); + args.elementsOwned = true; + + Storable.releaseApplyArgs(args); + + assertEquals(0, referent.refCount); + assertFalse(owner.scalarValueOwnerSlot().isActive()); + assertEquals(0, args.elements.size()); + assertFalse(args.elementsOwned); + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/CountedScalarOwnerSnapshotTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/CountedScalarOwnerSnapshotTest.java new file mode 100644 index 0000000000..1abeb2bb8f --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/CountedScalarOwnerSnapshotTest.java @@ -0,0 +1,77 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Tag("unit") +class CountedScalarOwnerSnapshotTest { + @Test + void includesDirectOwnerCreatedBeforeLegacyTracking() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createAnonymousReference()); + MyVarCleanupStack.register(owner); + + assertEquals(0, referent.activeOwnerCount(), + "the direct owner predates the legacy registry"); + assertEquals(1, referent.countedScalarOwnerCount(), + "the explicit scalar slot still identifies its owner"); + + MyVarCleanupStack.unregister(owner); + MortalList.deferDecrementIfTracked(owner); + MortalList.flush(); + } + } + + @Test + void deduplicatesOwnerPresentInLegacyRegistryAndExplicitSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + referent.activateOwnerTracking(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createAnonymousReference()); + MyVarCleanupStack.register(owner); + + assertEquals(1, referent.activeOwnerCount()); + assertEquals(1, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertEquals(1, referent.countedScalarOwnerCount(), + "the union counts one scalar cell, not both ledger entries"); + + MyVarCleanupStack.unregister(owner); + MortalList.deferDecrementIfTracked(owner); + MortalList.flush(); + } + } + + @Test + void retainsExplicitOwnerWhenLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + referent.activateOwnerTracking(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createAnonymousReference()); + MyVarCleanupStack.register(owner); + + assertEquals(1, referent.countedScalarOwnerCount()); + owner.refCountOwned = false; + + assertEquals(0, referent.activeOwnerCount(), + "the legacy registry retires the owner with its Boolean flag"); + assertEquals(1, referent.countedScalarOwnerCount(), + "the active scalar-value slot remains an explicit owner"); + + owner.setRefCountOwned(true); + MyVarCleanupStack.unregister(owner); + MortalList.deferDecrementIfTracked(owner); + MortalList.flush(); + assertEquals(0, referent.countedScalarOwnerCount(), + "releasing the explicit slot removes the counted owner"); + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchAggregateOwnerRootTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchAggregateOwnerRootTest.java new file mode 100644 index 0000000000..41735a0a77 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchAggregateOwnerRootTest.java @@ -0,0 +1,268 @@ +package org.perlonjava.runtime.runtimetypes; + +import java.util.Collections; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class DestroyDispatchAggregateOwnerRootTest { + @Test + void destructorLocalScalarOwnerDoesNotCountAsResurrection() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + referent.activateOwnerTracking(); + RuntimeScalar localOwner = new RuntimeScalar(); + localOwner.set(referent.createReference()); + MyVarCleanupStack.register(localOwner); + try { + assertFalse(hasNewRootedOwner(referent), + "a new scalar owner in an active local frame is not global resurrection"); + } finally { + MyVarCleanupStack.unregister(localOwner); + } + } + } + + @Test + void scalarOwnerInPackageGlobalHashCountsAsResurrection() { + PerlRuntime runtime = new PerlRuntime(); + String globalName = "DestroyDispatchAggregateOwnerRootTest::scalar_owner"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + referent.activateOwnerTracking(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createReference()); + RuntimeHash globalHash = new RuntimeHash(); + globalHash.isGlobalPackageHash = true; + GlobalVariable.globalHashes.put(globalName, globalHash); + globalHash.elements.put("owner", owner); + try { + assertTrue(hasNewRootedOwner(referent), + "a new scalar owner reachable through a package-global hash is resurrection"); + } finally { + globalHash.elements.clear(); + GlobalVariable.globalHashes.remove(globalName); + } + } + } + + @Test + void scalarOwnerInsideDyingReferentDoesNotCountAsResurrection() { + PerlRuntime runtime = new PerlRuntime(); + String rootName = "DestroyDispatchAggregateOwnerRootTest::self_root"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash referent = new RuntimeHash(); + referent.activateOwnerTracking(); + GlobalVariable.globalHashes.put(rootName, referent); + RuntimeScalar selfOwner = referent.createReference(); + referent.put("self", selfOwner); + try { + assertFalse(hasNewRootedOwner(referent), + "a new scalar owner reached only through the dying object is not resurrection"); + } finally { + referent.elements.clear(); + referent.releaseHashEntryOwnerSlots(); + GlobalVariable.globalHashes.remove(rootName); + } + } + } + + @Test + void aggregateOwnerContainerInsideDyingReferentDoesNotCountAsResurrection() { + PerlRuntime runtime = new PerlRuntime(); + String rootName = "DestroyDispatchAggregateOwnerRootTest::aggregate_self_root"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash referent = new RuntimeHash(); + RuntimeHash ownerContainer = new RuntimeHash(); + referent.activateOwnerTracking(); + GlobalVariable.globalHashes.put(rootName, referent); + ownerContainer.put("self", referent.createReference()); + referent.put("container", ownerContainer.createReference()); + try { + assertFalse(hasNewRootedOwner(referent), + "an owner container reached only through the dying object is not resurrection"); + } finally { + ownerContainer.elements.clear(); + ownerContainer.releaseHashEntryOwnerSlots(); + referent.elements.clear(); + referent.releaseHashEntryOwnerSlots(); + GlobalVariable.globalHashes.remove(rootName); + } + } + } + + @Test + void legacyClosurePadReachableOnlyThroughDyingReferentDoesNotCountAsResurrection() { + PerlRuntime runtime = new PerlRuntime(); + String rootName = "DestroyDispatchAggregateOwnerRootTest::closure_self_root"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash referent = new RuntimeHash(); + referent.activateOwnerTracking(); + GlobalVariable.globalHashes.put(rootName, referent); + RuntimeScalar padCell = referent.createReference(); + referent.put("pad", padCell); + PerlOwnerSlot slot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.CLOSURE_PAD, padCell); + slot.acquireCapture(referent); + try { + assertFalse(hasNewRootedOwner(referent), + "a legacy closure pad reachable only through the dying object is not resurrection"); + } finally { + slot.release(); + referent.elements.clear(); + referent.releaseHashEntryOwnerSlots(); + GlobalVariable.globalHashes.remove(rootName); + } + } + } + + @Test + void incompleteSmallRootBudgetDoesNotTreatNestedArrayOwnerAsResurrection() { + PerlRuntime runtime = new PerlRuntime(); + String referentRoot = "DestroyDispatchAggregateOwnerRootTest::large_self_root"; + String paddingRoot = "DestroyDispatchAggregateOwnerRootTest::large_padding"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash referent = new RuntimeHash(); + RuntimeArray ownerArray = new RuntimeArray(); + RuntimeArray padding = new RuntimeArray(60_000); + referent.activateOwnerTracking(); + GlobalVariable.globalHashes.put(referentRoot, referent); + GlobalVariable.globalArrays.put(paddingRoot, padding); + for (int i = 0; i < 60_000; i++) { + padding.elements.add(new RuntimeScalar(i)); + } + ownerArray.push(referent.createReference()); + referent.put("owner", ownerArray.createReference()); + try { + assertFalse(hasNewRootedOwner(referent), + "a capped walk across unrelated package roots must not invent resurrection through the dying referent"); + } finally { + ownerArray.elements.clear(); + ownerArray.releaseArrayElementOwnerSlots(); + referent.elements.clear(); + referent.releaseHashEntryOwnerSlots(); + GlobalVariable.globalHashes.remove(referentRoot); + GlobalVariable.globalArrays.remove(paddingRoot); + } + } + } + + @Test + void captureFromUnrootedClosureDoesNotCountAsResurrection() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeCode temporaryClosure = new RuntimeCode(null, java.util.List.of()); + PerlOwnerSlot slot = new PerlOwnerSlot(PerlOwnerSlot.Kind.CLOSURE_PAD); + slot.acquireCapture(referent, temporaryClosure); + try { + assertFalse(hasNewRootedOwner(referent), + "a capture owned only by an unrooted closure is not resurrection"); + } finally { + slot.release(); + } + } + } + + @Test + void captureFromPackageGlobalClosureCountsAsResurrection() { + PerlRuntime runtime = new PerlRuntime(); + String globalName = "DestroyDispatchAggregateOwnerRootTest::closure"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeCode rootedClosure = new RuntimeCode(null, java.util.List.of()); + GlobalVariable.globalCodeRefs.put(globalName, new RuntimeScalar(rootedClosure)); + PerlOwnerSlot slot = new PerlOwnerSlot(PerlOwnerSlot.Kind.CLOSURE_PAD); + slot.acquireCapture(referent, rootedClosure); + try { + assertTrue(hasNewRootedOwner(referent), + "a capture owned by a package-global closure is resurrection"); + } finally { + GlobalVariable.globalCodeRefs.remove(globalName); + slot.release(); + } + } + } + + @Test + void legacyCapturePadCountsOnlyWhenReachableThroughRootedCode() { + PerlRuntime runtime = new PerlRuntime(); + String globalName = "DestroyDispatchAggregateOwnerRootTest::capturing_closure"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray temporaryReferent = new RuntimeArray(); + RuntimeScalar temporaryPad = temporaryReferent.createReference(); + PerlOwnerSlot temporarySlot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.CLOSURE_PAD, temporaryPad); + temporarySlot.acquireCapture(temporaryReferent); + assertFalse(hasNewRootedOwner(temporaryReferent), + "a pad retained only by an unrooted code object is not resurrection"); + temporarySlot.release(); + + RuntimeArray rootedReferent = new RuntimeArray(); + RuntimeScalar rootedPad = rootedReferent.createReference(); + RuntimeCode rootedClosure = new RuntimeCode(null, java.util.List.of()); + rootedClosure.capturedScalars = new RuntimeScalar[] {rootedPad}; + GlobalVariable.globalCodeRefs.put(globalName, new RuntimeScalar(rootedClosure)); + PerlOwnerSlot rootedSlot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.CLOSURE_PAD, rootedPad); + rootedSlot.acquireCapture(rootedReferent); + try { + assertTrue(hasNewRootedOwner(rootedReferent), + "a pad captured by package-global code is resurrection"); + } finally { + GlobalVariable.globalCodeRefs.remove(globalName); + rootedSlot.release(); + } + } + } + + @Test + void destructorLocalHashEntryDoesNotCountAsResurrection() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash localHash = new RuntimeHash(); + RuntimeArray referent = new RuntimeArray(); + localHash.elements.put("self", referent.createReference()); + try { + assertFalse(hasNewRootedOwner(referent), + "a new hash entry in an unrooted temporary hash is not resurrection"); + } finally { + localHash.elements.clear(); + } + } + } + + @Test + void hashEntryInPackageGlobalHashCountsAsResurrection() { + PerlRuntime runtime = new PerlRuntime(); + String globalName = "DestroyDispatchAggregateOwnerRootTest::global"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash globalHash = new RuntimeHash(); + globalHash.isGlobalPackageHash = true; + GlobalVariable.globalHashes.put(globalName, globalHash); + RuntimeArray referent = new RuntimeArray(); + globalHash.elements.put("self", referent.createReference()); + try { + assertTrue(hasNewRootedOwner(referent), + "a new hash entry in a package global hash is resurrection"); + } finally { + globalHash.elements.clear(); + GlobalVariable.globalHashes.remove(globalName); + } + } + } + + private static boolean hasNewRootedOwner(RuntimeBase referent) { + Set noScalarsAtStart = Collections.newSetFromMap( + new java.util.IdentityHashMap<>()); + Set noSlotsAtStart = Collections.newSetFromMap( + new java.util.IdentityHashMap<>()); + return DestroyDispatch.hasEscapedPerlOwner( + referent, true, noScalarsAtStart, noSlotsAtStart); + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchOwnerSlotRootChainTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchOwnerSlotRootChainTest.java new file mode 100644 index 0000000000..a3e4484330 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchOwnerSlotRootChainTest.java @@ -0,0 +1,50 @@ +package org.perlonjava.runtime.runtimetypes; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class DestroyDispatchOwnerSlotRootChainTest { + @Test + void nestedHashOwnerSlotProvesResurrectionWithoutWholeRootWalk() { + PerlRuntime runtime = new PerlRuntime(); + String rootName = "DestroyDispatchOwnerSlotRootChainTest::root"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash referent = new RuntimeHash(); + RuntimeHash nested = new RuntimeHash(); + RuntimeHash root = new RuntimeHash(); + nested.put("target", referent.createReference()); + root.put("nested", nested.createReference()); + GlobalVariable.aliasGlobalHash(rootName, root); + referent.activateOwnerTracking(); + + Set noScalarOwnersAtStart = + Collections.newSetFromMap(new IdentityHashMap<>()); + Set noOwnerSlotsAtStart = + Collections.newSetFromMap(new IdentityHashMap<>()); + try { + try (MortalList.ReachabilityQueryMeasurement measurement = + MortalList.measureReachabilityQueries()) { + assertTrue(DestroyDispatch.hasEscapedPerlOwner( + referent, true, noScalarOwnersAtStart, noOwnerSlotsAtStart), + "a newly added nested hash slot rooted in a package global is resurrection"); + assertEquals(0, measurement.stats().rootQueries, + "typed owner slots prove the resurrection path"); + assertEquals(0, measurement.stats().snapshotsBuilt, + "the proof does not build a whole-root graph snapshot"); + } + } finally { + root.elements.clear(); + nested.elements.clear(); + GlobalVariable.globalHashes.remove(rootName); + MortalList.invalidateAllRootSnapshots(); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchScalarSlotRootTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchScalarSlotRootTest.java new file mode 100644 index 0000000000..02b66e3202 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchScalarSlotRootTest.java @@ -0,0 +1,69 @@ +package org.perlonjava.runtime.runtimetypes; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class DestroyDispatchScalarSlotRootTest { + @Test + void destroyArgumentBalanceReleasesDirectScalarSlotAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + referent.activateOwnerTracking(); + RuntimeScalar argument = new RuntimeScalar(); + argument.set(referent.createReference()); + PerlOwnerSlot slot = argument.scalarValueOwnerSlot(); + argument.refCountOwned = false; + + DestroyDispatch.balanceDestroyArgumentOwner(argument, referent); + + assertEquals(0, referent.refCount); + assertEquals(0, referent.activeOwnerCount()); + assertEquals(0, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertFalse(slot.isActive()); + } + } + + @Test + void packageRootedScalarSlotCountsWhenLegacyOwnerEntryIsMissing() { + PerlRuntime runtime = new PerlRuntime(); + String globalName = "DestroyDispatchScalarSlotRootTest::owner"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + referent.activateOwnerTracking(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createReference()); + RuntimeHash globalHash = new RuntimeHash(); + globalHash.isGlobalPackageHash = true; + GlobalVariable.globalHashes.put(globalName, globalHash); + globalHash.elements.put("owner", owner); + referent.releaseActiveOwner(owner); + + Set noScalarOwnersAtStart = + Collections.newSetFromMap(new IdentityHashMap<>()); + Set noOwnerSlotsAtStart = + Collections.newSetFromMap(new IdentityHashMap<>()); + try { + assertEquals(0, referent.activeOwnerCount(), + "the fixture removes the compatibility-ledger entry"); + assertEquals(1, referent.countedScalarOwnerCount(), + "the direct scalar slot remains authoritative"); + assertTrue(DestroyDispatch.hasEscapedPerlOwner( + referent, true, noScalarOwnersAtStart, noOwnerSlotsAtStart), + "the rooted scalar slot is visible to destructor owner-delta checks"); + } finally { + globalHash.elements.clear(); + GlobalVariable.globalHashes.remove(globalName); + owner.set(new RuntimeScalar()); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchWeakSlotRescueRootTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchWeakSlotRescueRootTest.java new file mode 100644 index 0000000000..f01f9ac95d --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/DestroyDispatchWeakSlotRescueRootTest.java @@ -0,0 +1,117 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class DestroyDispatchWeakSlotRescueRootTest { + @Test + void weakSelfReplacementInTemporaryHashIsNotGlobalRescue() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeScalar phase = GlobalVariable.getGlobalVariable(GlobalContext.GLOBAL_PHASE); + String oldPhase = phase.toString(); + RuntimeBase previousTarget = runtime.lifecycleState.currentDestroyTarget; + boolean previousRescued = runtime.lifecycleState.destroyTargetRescued; + RuntimeArray target = new RuntimeArray(); + RuntimeHash temporaryHash = new RuntimeHash(); + RuntimeScalar weakCell = weakSelfCell(target, temporaryHash); + try { + phase.set("DESTRUCT"); + runtime.lifecycleState.currentDestroyTarget = target; + runtime.lifecycleState.destroyTargetRescued = false; + + weakCell.set(target.createReference()); + + assertFalse(runtime.lifecycleState.destroyTargetRescued, + "a weak slot in an unrooted temporary hash cannot rescue during global destruction"); + } finally { + temporaryHash.elements.clear(); + runtime.lifecycleState.currentDestroyTarget = previousTarget; + runtime.lifecycleState.destroyTargetRescued = previousRescued; + phase.set(oldPhase); + } + } + } + + @Test + void weakSelfReplacementInGloballyReachableHashCanRescue() { + PerlRuntime runtime = new PerlRuntime(); + String rootName = "DestroyDispatchWeakSlotRescueRootTest::root"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeScalar phase = GlobalVariable.getGlobalVariable(GlobalContext.GLOBAL_PHASE); + String oldPhase = phase.toString(); + RuntimeBase previousTarget = runtime.lifecycleState.currentDestroyTarget; + boolean previousRescued = runtime.lifecycleState.destroyTargetRescued; + RuntimeArray target = new RuntimeArray(); + RuntimeHash nestedHash = new RuntimeHash(); + RuntimeArray globalArray = new RuntimeArray(); + globalArray.elements.add(nestedHash.createReference()); + GlobalVariable.globalArrays.put(rootName, globalArray); + RuntimeScalar weakCell = weakSelfCell(target, nestedHash); + try { + phase.set("DESTRUCT"); + runtime.lifecycleState.currentDestroyTarget = target; + runtime.lifecycleState.destroyTargetRescued = false; + + weakCell.set(target.createReference()); + + assertTrue(runtime.lifecycleState.destroyTargetRescued, + "a weak slot in a hash reachable from a package global can rescue the target"); + } finally { + nestedHash.elements.clear(); + GlobalVariable.globalArrays.remove(rootName); + runtime.lifecycleState.currentDestroyTarget = previousTarget; + runtime.lifecycleState.destroyTargetRescued = previousRescued; + phase.set(oldPhase); + } + } + } + + @Test + void weakSelfReplacementInHashReachableOnlyThroughDyingTargetCannotRescue() { + PerlRuntime runtime = new PerlRuntime(); + String rootName = "DestroyDispatchWeakSlotRescueRootTest::self_root"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeScalar phase = GlobalVariable.getGlobalVariable(GlobalContext.GLOBAL_PHASE); + String oldPhase = phase.toString(); + RuntimeBase previousTarget = runtime.lifecycleState.currentDestroyTarget; + boolean previousRescued = runtime.lifecycleState.destroyTargetRescued; + RuntimeArray target = new RuntimeArray(); + RuntimeHash nestedHash = new RuntimeHash(); + RuntimeArray globalArray = new RuntimeArray(); + globalArray.add(target.createReference()); + target.add(nestedHash.createReference()); + GlobalVariable.globalArrays.put(rootName, globalArray); + RuntimeScalar weakCell = weakSelfCell(target, nestedHash); + try { + phase.set("DESTRUCT"); + runtime.lifecycleState.currentDestroyTarget = target; + runtime.lifecycleState.destroyTargetRescued = false; + + weakCell.set(target.createReference()); + + assertFalse(runtime.lifecycleState.destroyTargetRescued, + "a weak slot reached only through the dying referent cannot rescue it"); + } finally { + nestedHash.elements.clear(); + target.elements.clear(); + globalArray.elements.clear(); + GlobalVariable.globalArrays.remove(rootName); + runtime.lifecycleState.currentDestroyTarget = previousTarget; + runtime.lifecycleState.destroyTargetRescued = previousRescued; + phase.set(oldPhase); + } + } + } + + private static RuntimeScalar weakSelfCell(RuntimeBase target, RuntimeHash container) { + RuntimeScalar weakCell = target.createReference(); + container.elements.put("self", weakCell); + WeakRefRegistry.weaken(weakCell); + return weakCell; + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/ExpiredContainerRootTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/ExpiredContainerRootTest.java new file mode 100644 index 0000000000..e86ddd27f3 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/ExpiredContainerRootTest.java @@ -0,0 +1,76 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class ExpiredContainerRootTest { + @Test + void capturedScopeExitedContainerRemainsAnOwner() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray container = new RuntimeArray(); + RuntimeScalar value = new RuntimeScalar(); + container.elements.add(value); + value.markArrayContainerOwner(container); + container.scopeExited = true; + MyVarCleanupStack.register(container); + + PerlOwnerSlot capture = new PerlOwnerSlot(PerlOwnerSlot.Kind.CLOSURE_PAD); + try { + capture.acquireCapture(container); + + assertTrue(value.isStoredInRegisteredContainerOwner(), + "an active captured aggregate keeps its stored scalar alive after lexical exit"); + } finally { + capture.release(); + MyVarCleanupStack.unregister(container); + } + } + } + + @Test + void unregisteredContainerLeftInLiveVarSnapshotIsNotAnOwner() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray weakReferent = new RuntimeArray(); + RuntimeScalar weakReference = weakReferent.createReference(); + WeakRefRegistry.weaken(weakReference); + + RuntimeArray expired = new RuntimeArray(); + RuntimeScalar cell = new RuntimeScalar(); + expired.elements.add(cell); + MyVarCleanupStack.register(expired); + MyVarCleanupStack.snapshotStackToLiveCounts(); + MyVarCleanupStack.unregister(expired); + + assertFalse(MyVarCleanupStack.isRegistered(expired)); + assertTrue(MyVarCleanupStack.snapshotLiveVars().contains(expired), + "setup must retain the test container in the stale live-var snapshot"); + assertFalse(ReachabilityWalker.isScalarReachable(cell), + "an unregistered container in a stale snapshot must not retain its cell"); + } + } + + @Test + void scopeExitedContainerInLiveVarSnapshotIsNotAnOwner() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray expired = new RuntimeArray(); + RuntimeScalar cell = new RuntimeScalar(); + expired.elements.add(cell); + expired.scopeExited = true; + MyVarCleanupStack.register(expired); + MyVarCleanupStack.snapshotStackToLiveCounts(); + try { + assertFalse(ReachabilityWalker.isScalarReachable(cell), + "a stale container snapshot must not revive an expired element"); + } finally { + MyVarCleanupStack.unregister(expired); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/ExternalRootSnapshotCodeStateTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/ExternalRootSnapshotCodeStateTest.java new file mode 100644 index 0000000000..d41fa04f89 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/ExternalRootSnapshotCodeStateTest.java @@ -0,0 +1,69 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class ExternalRootSnapshotCodeStateTest { + @Test + void installedCodeStateKeepsItsCallbackGraphAsAnExternalRoot() { + PerlRuntime runtime = new PerlRuntime(); + String globalName = "ExternalRootSnapshotCodeStateTest::singleton"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray controller = new RuntimeArray(); + RuntimeScalar controllerCell = controller.createReference(); + + RuntimeCode userCallback = new RuntimeCode(null, List.of()); + userCallback.capturedScalars = new RuntimeScalar[] { controllerCell }; + RuntimeScalar callbackCell = new RuntimeScalar(userCallback); + RuntimeCode timerWrapper = new RuntimeCode(null, List.of()); + timerWrapper.capturedScalars = new RuntimeScalar[] { callbackCell }; + timerWrapper.closedOverVariables = new java.util.LinkedHashMap<>(); + timerWrapper.closedOverVariables.put("$callback", callbackCell); + RuntimeHash timerRecord = new RuntimeHash(); + timerRecord.elements.put("cb", new RuntimeScalar(timerWrapper)); + RuntimeHash reactorTimers = new RuntimeHash(); + reactorTimers.elements.put("timer", timerRecord.createReference()); + + RuntimeCode arrayStateCallback = new RuntimeCode(null, List.of()); + RuntimeArray stateArray = new RuntimeArray(); + stateArray.elements.add(new RuntimeScalar(arrayStateCallback)); + + RuntimeCode hashStateCallback = new RuntimeCode(null, List.of()); + RuntimeHash stateHash = new RuntimeHash(); + stateHash.elements.put("callback", new RuntimeScalar(hashStateCallback)); + + RuntimeCode singletonMethod = new RuntimeCode(null, List.of()); + singletonMethod.stateVariable.put("loop", reactorTimers.createReference()); + singletonMethod.stateArray.put("callbacks", stateArray); + singletonMethod.stateHash.put("callbacks", stateHash); + GlobalVariable.globalCodeRefs.put(globalName, new RuntimeScalar(singletonMethod)); + GlobalVariable.invalidateGlobalCodeRefGraphRoots(); + + try { + ReachabilityWalker.ExternalRootSnapshot roots = + new ReachabilityWalker.ExternalRootSnapshot(false); + + assertTrue(roots.isReachableFromNonLexicalRoot(reactorTimers), + "the installed CV's scalar state must root the singleton reactor"); + assertTrue(roots.isReachableFromNonLexicalRoot(timerWrapper), + "timer callbacks must remain reachable through the singleton reactor"); + assertTrue(roots.isReachableFromNonLexicalRoot(userCallback), + "nested user callbacks must remain reachable through their wrappers"); + assertTrue(roots.isReachableFromNonLexicalRoot(controller), + "a callback's captured controller must remain reachable"); + assertTrue(roots.isReachableFromNonLexicalRoot(arrayStateCallback), + "persistent state arrays must remain reachable"); + assertTrue(roots.isReachableFromNonLexicalRoot(hashStateCallback), + "persistent state hashes must remain reachable"); + } finally { + GlobalVariable.globalCodeRefs.remove(globalName); + GlobalVariable.invalidateGlobalCodeRefGraphRoots(); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/GlobalCodeRuntimeIsolationTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/GlobalCodeRuntimeIsolationTest.java index aa1d0e359f..7785607a50 100644 --- a/src/test/java/org/perlonjava/runtime/runtimetypes/GlobalCodeRuntimeIsolationTest.java +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/GlobalCodeRuntimeIsolationTest.java @@ -69,6 +69,36 @@ void codeSlotsPinsCompiledIdsAndImportedSubFlagsBelongToTheBoundRuntime() { } } + @Test + void undefiningTrackedGlobalCodeReleasesItsScalarOwnerSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + String name = "Phase8bLowLevel::tracked_code"; + RuntimeScalar codeValue = codeScalar("tracked", null, name); + RuntimeCode code = (RuntimeCode) codeValue.value; + code.refCount = 0; + + RuntimeScalar globalCode = GlobalVariable.getGlobalCodeRef(name); + globalCode.set(codeValue); + PerlOwnerSlot ownerSlot = globalCode.scalarValueOwnerSlot(); + + assertNotNull(ownerSlot, "tracked global CODE has a scalar owner slot"); + assertTrue(ownerSlot.isActive()); + assertEquals(1, code.refCount); + assertEquals(1, code.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + + globalCode.undefine(); + + assertFalse(ownerSlot.isActive(), "undef retires the CODE owner slot"); + assertEquals(0, code.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertEquals(Integer.MIN_VALUE, code.refCount, + "the final tracked CODE owner still dispatches lifecycle cleanup"); + assertSame(code, globalCode.value, + "the global CODE entry keeps the undefined CV for existing aliases"); + assertTrue(code.codeReferenceUndefined); + } + } + @Test void conflictingNamedSubsAndRedefinitionStayIsolatedOnJvmAndInterpreter() throws Exception { for (boolean interpreter : new boolean[]{false, true}) { diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalarDynamicOwnerSlotTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalarDynamicOwnerSlotTest.java new file mode 100644 index 0000000000..77b1868b00 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalarDynamicOwnerSlotTest.java @@ -0,0 +1,45 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class GlobalRuntimeScalarDynamicOwnerSlotTest { + @Test + void globalRestoreReleasesDisplacedScalarSlotAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + String name = "DynamicOwnerSlot::global"; + RuntimeArray originalReferent = new RuntimeArray(); + RuntimeArray localizedReferent = new RuntimeArray(); + originalReferent.activateOwnerTracking(); + localizedReferent.activateOwnerTracking(); + RuntimeScalar original = GlobalVariable.getGlobalVariable(name); + original.set(originalReferent.createAnonymousReference()); + + GlobalRuntimeScalar localizer = new GlobalRuntimeScalar(name); + localizer.dynamicSaveState(); + RuntimeScalar localized = GlobalVariable.getGlobalVariable(name); + localized.set(localizedReferent.createAnonymousReference()); + localized.refCountOwned = false; + assertTrue(localized.scalarValueOwnerSlot().isActive()); + + localizer.dynamicRestoreState(); + + assertSame(original, GlobalVariable.getGlobalVariable(name)); + assertEquals(Integer.MIN_VALUE, localizedReferent.refCount, + "restoring the global destroys its final localized referent"); + assertEquals(0, localizedReferent.activeOwnerCount()); + assertEquals(0, localizedReferent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertEquals(1, originalReferent.refCount); + assertEquals(1, originalReferent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertTrue(original.ownsCountedReferent(originalReferent)); + + original.undefine(); + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListAggregateArrayOwnerSlotTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListAggregateArrayOwnerSlotTest.java new file mode 100644 index 0000000000..f5cf4dc007 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListAggregateArrayOwnerSlotTest.java @@ -0,0 +1,164 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Tag("unit") +class MortalListAggregateArrayOwnerSlotTest { + @Test + void arrayElementOwnerSurvivesWhenAnonymousArrayIsNestedInPackageRootedHash() { + PerlRuntime runtime = new PerlRuntime(); + String rootName = "MortalListAggregateArrayOwnerSlotTest::nested_root"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + boolean previousActive = MortalList.isActive(); + RuntimeHash referent = new RuntimeHash(); + RuntimeArray nestedArray = new RuntimeArray(); + RuntimeScalar arrayElement = referenceTo(referent); + arrayElement.refCountOwned = true; + nestedArray.elements.add(arrayElement); + nestedArray.elementsOwned = true; + nestedArray.reconcileArrayElementOwnerSlots(); + + RuntimeHash packageRoot = new RuntimeHash(); + packageRoot.elements.put("nested", nestedArray.createAnonymousReference()); + GlobalVariable.aliasGlobalHash(rootName, packageRoot); + + RuntimeScalar unrelatedLegacyOwner = referenceTo(referent); + unrelatedLegacyOwner.refCountOwned = true; + referent.refCount = 1; + + try { + MortalList.setActive(true); + MortalList.deferDecrementIfTracked(unrelatedLegacyOwner); + MortalList.flush(); + + assertEquals(RuntimeBase.PerlLifecycleState.LIVE, + referent.perlLifecycleState(), + "the nested array's strong element remains owned through the rooted hash"); + assertEquals(1, referent.refCount, + "the nested array element restores one referent owner at legacy zero"); + } finally { + packageRoot.elements.clear(); + GlobalVariable.globalHashes.remove(rootName); + nestedArray.elements.clear(); + if (arrayElement.refCountOwned) { + MortalList.deferDecrementIfTracked(arrayElement); + MortalList.flush(); + } + MortalList.setActive(previousActive); + } + } + } + + @Test + void directlyRootedArrayElementRestoresItsStrongOwnerAtLegacyZero() { + PerlRuntime runtime = new PerlRuntime(); + String className = "MortalListAggregateArrayOwnerSlotTest::Tracked"; + String rootName = "MortalListAggregateArrayOwnerSlotTest::root"; + String weakRootName = "MortalListAggregateArrayOwnerSlotTest::weak_root"; + String weakHashName = "MortalListAggregateArrayOwnerSlotTest::weak_hash"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + boolean previousActive = MortalList.isActive(); + RuntimeHash referent = new RuntimeHash(); + referent.blessId = NameNormalizer.getBlessId(className); + referent.refCount = 1; + installDestroy(className); + + RuntimeArray root = new RuntimeArray(); + root.push(referenceTo(referent)); + GlobalVariable.aliasGlobalArray(rootName, root); + RuntimeArray lexicalRoot = new RuntimeArray(); + lexicalRoot.push(referenceTo(referent)); + PerlOwnerSlot lexicalArrayCapture = lexicalRoot.retainClosureCaptureOwner(); + lexicalRoot.scopeExited = true; + RuntimeArray weakRoot = new RuntimeArray(); + weakRoot.push(referenceTo(referent)); + RuntimeScalar weakEntry = weakRoot.elements.get(0); + WeakRefRegistry.weaken(weakEntry); + GlobalVariable.aliasGlobalArray(weakRootName, weakRoot); + RuntimeHash weakHash = new RuntimeHash(); + RuntimeScalar weakHashEntry = referenceTo(referent); + weakHash.elements.put("value", weakHashEntry); + WeakRefRegistry.weaken(weakHashEntry); + GlobalVariable.aliasGlobalHash(weakHashName, weakHash); + + RuntimeScalar lexicalOwner = referenceTo(referent); + lexicalOwner.refCountOwned = true; + referent.activateOwnerTracking(); + referent.recordActiveOwner(lexicalOwner); + MyVarCleanupStack.register(lexicalOwner); + + RuntimeScalar unrelatedLegacyOwner = referenceTo(referent); + unrelatedLegacyOwner.refCountOwned = true; + referent.refCount = 1; + + try { + MortalList.setActive(true); + MortalList.deferDecrementIfTracked(unrelatedLegacyOwner); + MortalList.flush(); + + assertEquals(RuntimeBase.PerlLifecycleState.LIVE, + referent.perlLifecycleState(), + "a package-rooted strong array element retains its referent"); + assertEquals(3, referent.refCount, + "package and closure-owned array elements plus the scalar owner are restored"); + + // Keep the weak edge in place for the count assertion, then + // remove its diagnostic root before proving final destruction. + weakRoot.elements.clear(); + GlobalVariable.globalArrays.remove(weakRootName); + weakHash.elements.clear(); + GlobalVariable.globalHashes.remove(weakHashName); + WeakRefRegistry.clearWeakRefsTo(referent); + + RuntimeScalar removed = root.elements.remove(0); + MortalList.deferDecrementIfTracked(removed); + MortalList.flush(); + + assertEquals(RuntimeBase.PerlLifecycleState.LIVE, + referent.perlLifecycleState()); + assertEquals(2, referent.refCount, + "the registered lexical array and scalar owner remain"); + + lexicalRoot.releaseClosureCaptureOwner(lexicalArrayCapture); + MortalList.flush(); + assertEquals(1, referent.refCount, + "releasing the final array capture preserves the scalar owner"); + + MortalList.deferDecrementIfTracked(lexicalOwner); + MortalList.flush(); + + assertEquals(RuntimeBase.PerlLifecycleState.DESTROYED, + referent.perlLifecycleState(), + "releasing the final owner permits destruction"); + } finally { + if (lexicalArrayCapture.isActive()) { + lexicalRoot.releaseClosureCaptureOwner(lexicalArrayCapture); + } + MortalList.setActive(previousActive); + MyVarCleanupStack.unregister(lexicalOwner); + GlobalVariable.globalArrays.remove(rootName); + GlobalVariable.globalArrays.remove(weakRootName); + GlobalVariable.globalHashes.remove(weakHashName); + GlobalVariable.removeGlobalCodeRefForStashDelete(className + "::DESTROY"); + } + } + } + + private static RuntimeScalar referenceTo(RuntimeBase referent) { + RuntimeScalar scalar = new RuntimeScalar(); + scalar.type = RuntimeScalarType.HASHREFERENCE; + scalar.value = referent; + return scalar; + } + + private static void installDestroy(String className) { + RuntimeCode code = new RuntimeCode((args, context) -> new RuntimeScalar().getList(), null); + int separator = className.lastIndexOf("::"); + code.packageName = className.substring(0, separator); + code.subName = "DESTROY"; + GlobalVariable.getGlobalCodeRef(className + "::DESTROY").set(new RuntimeScalar(code)); + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListAggregateOwnerSlotRootChainTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListAggregateOwnerSlotRootChainTest.java new file mode 100644 index 0000000000..fd71f62b01 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListAggregateOwnerSlotRootChainTest.java @@ -0,0 +1,163 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Tag("unit") +class MortalListAggregateOwnerSlotRootChainTest { + @Test + void nestedHashOwnerSlotsProveRootWithoutWholeRootWalk() { + PerlRuntime runtime = new PerlRuntime(); + String rootName = "MortalListAggregateOwnerSlotRootChainTest::root"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + boolean previousActive = MortalList.isActive(); + RuntimeHash target = new RuntimeHash(); + RuntimeHash nested = new RuntimeHash(); + RuntimeHash root = new RuntimeHash(); + root.isPackageGlobalRoot = true; + root.elements.put("nested", nested.createReference()); + RuntimeScalar nestedValue = referenceTo(target); + nestedValue.refCountOwned = true; + nested.elements.put("target", nestedValue); + GlobalVariable.globalHashes.put(rootName, root); + + RuntimeScalar unrelatedLegacyOwner = referenceTo(target); + unrelatedLegacyOwner.refCountOwned = true; + target.refCount = 1; + + try { + MortalList.setActive(true); + try (MortalList.ReachabilityQueryMeasurement measurement = + MortalList.measureReachabilityQueries()) { + MortalList.deferDecrementIfTracked(unrelatedLegacyOwner); + MortalList.flush(); + + assertEquals(1, target.refCount, + "the live nested hash slot restores the displaced legacy count"); + assertEquals(0, measurement.stats().rootQueries, + "typed owner slots prove the path to the package root"); + assertEquals(0, measurement.stats().snapshotsBuilt, + "the nested owner chain avoids building a graph snapshot"); + } + } finally { + MortalList.setActive(previousActive); + root.elements.clear(); + nested.elements.clear(); + GlobalVariable.globalHashes.remove(rootName); + MortalList.invalidateAllRootSnapshots(); + } + } + } + + @Test + void scalarValueOwnerSlotProvesRootWithoutWholeRootWalk() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + boolean previousActive = MortalList.isActive(); + RuntimeArray target = new RuntimeArray(); + RuntimeHash nested = new RuntimeHash(); + RuntimeArray root = new RuntimeArray(); + RuntimeScalar rootCell = new RuntimeScalar(); + root.elementsOwned = true; + root.elements.add(nested.createReference()); + root.reconcileArrayElementOwnerSlots(); + RuntimeScalar nestedValue = referenceTo(target); + nestedValue.refCountOwned = true; + nested.elements.put("target", nestedValue); + rootCell.set(root.createReference()); + MyVarCleanupStack.register(rootCell); + + RuntimeScalar unrelatedLegacyOwner = referenceTo(target); + unrelatedLegacyOwner.refCountOwned = true; + target.refCount = 1; + + try { + MortalList.setActive(true); + try (MortalList.ReachabilityQueryMeasurement measurement = + MortalList.measureReachabilityQueries()) { + MortalList.deferDecrementIfTracked(unrelatedLegacyOwner); + MortalList.flush(); + + assertEquals(1, target.refCount, + "the scalar-owned nested array keeps the hash slot live"); + assertEquals(0, measurement.stats().rootQueries, + "the scalar owner slot proves the path to its lexical root"); + assertEquals(0, measurement.stats().snapshotsBuilt, + "the scalar owner chain avoids a graph snapshot"); + } + } finally { + MortalList.setActive(previousActive); + MyVarCleanupStack.unregister(rootCell); + rootCell.scalarValueOwnerSlot().release(); + root.elements.clear(); + nested.elements.clear(); + MortalList.invalidateAllRootSnapshots(); + } + } + } + + @Test + void scalarReferenceContentsOwnerSlotProvesRootWithoutWholeRootWalk() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + boolean previousActive = MortalList.isActive(); + RuntimeArray target = new RuntimeArray(); + RuntimeHash nested = new RuntimeHash(); + RuntimeArray root = new RuntimeArray(); + root.elementsOwned = true; + root.elements.add(nested.createReference()); + root.reconcileArrayElementOwnerSlots(); + RuntimeScalar nestedValue = referenceTo(target); + nestedValue.refCountOwned = true; + nested.elements.put("target", nestedValue); + + root.refCount = 1; + RuntimeScalar contentsCell = referenceTo(root); + RuntimeScalar outerReference = new RuntimeScalar(); + outerReference.type = RuntimeScalarType.REFERENCE; + outerReference.value = contentsCell; + PerlOwnerSlot contentsOwner = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS, + null, null, outerReference); + contentsOwner.acquireScalarReferenceContents(root, contentsCell); + outerReference.scalarReferenceContentsOwner = contentsOwner; + MyVarCleanupStack.register(outerReference); + + RuntimeScalar unrelatedLegacyOwner = referenceTo(target); + unrelatedLegacyOwner.refCountOwned = true; + target.refCount = 1; + + try { + MortalList.setActive(true); + try (MortalList.ReachabilityQueryMeasurement measurement = + MortalList.measureReachabilityQueries()) { + MortalList.deferDecrementIfTracked(unrelatedLegacyOwner); + MortalList.flush(); + + assertEquals(1, target.refCount, + "the scalar-reference contents owner keeps the hash slot live"); + assertEquals(0, measurement.stats().rootQueries, + "the contents owner slot proves the path to its lexical root"); + assertEquals(0, measurement.stats().snapshotsBuilt, + "the contents owner chain avoids a graph snapshot"); + } + } finally { + MortalList.setActive(previousActive); + MyVarCleanupStack.unregister(outerReference); + outerReference.releaseOwnedScalarReferenceContents(); + root.elements.clear(); + nested.elements.clear(); + MortalList.invalidateAllRootSnapshots(); + } + } + } + + private static RuntimeScalar referenceTo(RuntimeBase referent) { + RuntimeScalar scalar = new RuntimeScalar(); + scalar.type = RuntimeScalarType.HASHREFERENCE; + scalar.value = referent; + return scalar; + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListAggregateOwnerSlotTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListAggregateOwnerSlotTest.java new file mode 100644 index 0000000000..8b527e28d3 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListAggregateOwnerSlotTest.java @@ -0,0 +1,91 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Tag("unit") +class MortalListAggregateOwnerSlotTest { + @Test + void rootedHashEntrySlotRestoresAnUnderflowedLegacyCountUntilEntryRemoval() { + PerlRuntime runtime = new PerlRuntime(); + String className = "MortalListAggregateOwnerSlotTest::Tracked"; + String rootName = "MortalListAggregateOwnerSlotTest::root"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + boolean previousActive = MortalList.isActive(); + RuntimeHash referent = new RuntimeHash(); + referent.blessId = NameNormalizer.getBlessId(className); + referent.refCount = 1; + installDestroy(className); + + RuntimeScalar hashEntry = referenceTo(referent); + hashEntry.refCountOwned = true; + RuntimeHash root = new RuntimeHash(); + root.elements.put("value", hashEntry); + GlobalVariable.globalHashes.put(rootName, root); + + RuntimeScalar lexicalOwner = referenceTo(referent); + lexicalOwner.refCountOwned = true; + referent.activateOwnerTracking(); + referent.recordActiveOwner(hashEntry); + referent.recordActiveOwner(lexicalOwner); + MyVarCleanupStack.register(lexicalOwner); + + RuntimeScalar unrelatedLegacyOwner = referenceTo(referent); + unrelatedLegacyOwner.refCountOwned = true; + // Model the legacy selective count having missed this transient + // owner while the independently tracked package-rooted hash slot + // remains active. + referent.refCount = 1; + + try { + MortalList.setActive(true); + MortalList.deferDecrementIfTracked(unrelatedLegacyOwner); + MortalList.flush(); + + assertEquals(RuntimeBase.PerlLifecycleState.LIVE, + referent.perlLifecycleState(), + "the rooted hash entry keeps the referent alive at legacy count zero"); + assertEquals(2, referent.refCount, + "the active hash slot and separate lexical owner are both restored"); + + RuntimeScalar removed = root.elements.remove("value"); + MortalList.deferDecrementIfTracked(removed); + MortalList.flush(); + + assertEquals(RuntimeBase.PerlLifecycleState.LIVE, + referent.perlLifecycleState(), + "removing the hash entry preserves the separate lexical owner"); + assertEquals(1, referent.refCount); + + MortalList.deferDecrementIfTracked(lexicalOwner); + MortalList.flush(); + + assertEquals(RuntimeBase.PerlLifecycleState.DESTROYED, + referent.perlLifecycleState(), + "releasing the final lexical owner permits destruction"); + } finally { + MortalList.setActive(previousActive); + MyVarCleanupStack.unregister(lexicalOwner); + GlobalVariable.globalHashes.remove(rootName); + GlobalVariable.removeGlobalCodeRefForStashDelete(className + "::DESTROY"); + } + } + } + + private static RuntimeScalar referenceTo(RuntimeBase referent) { + RuntimeScalar scalar = new RuntimeScalar(); + scalar.type = RuntimeScalarType.HASHREFERENCE; + scalar.value = referent; + return scalar; + } + + private static void installDestroy(String className) { + RuntimeCode code = new RuntimeCode((args, context) -> new RuntimeScalar().getList(), null); + int separator = className.lastIndexOf("::"); + code.packageName = className.substring(0, separator); + code.subName = "DESTROY"; + GlobalVariable.getGlobalCodeRef(className + "::DESTROY").set(new RuntimeScalar(code)); + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListDeferredCaptureTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListDeferredCaptureTest.java index 1302aa1b08..c8acc51207 100644 --- a/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListDeferredCaptureTest.java +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListDeferredCaptureTest.java @@ -4,10 +4,28 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("unit") class MortalListDeferredCaptureTest { + @Test + void forwardedRuntimeListUsesOneTemporaryRootUntilItsSingleRelease() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeList result = new RuntimeList(); + + MortalList.pushTemporaryRoot(result); + MortalList.pushTemporaryRoot(result); + assertEquals(1, MortalList.snapshotTemporaryRoots().size(), + "forwarding the same result must not duplicate its temporary root"); + + MortalList.popTemporaryRoot(result); + assertFalse(MortalList.hasTemporaryRoots(), + "one release clears the single forwarded result root"); + } + } + @Test void unreachableHttpBodyCaptureReleasesAtItsScopeBoundaryWithoutWeakRefs() { PerlRuntime runtime = new PerlRuntime(); @@ -34,6 +52,33 @@ void unreachableHttpBodyCaptureReleasesAtItsScopeBoundaryWithoutWeakRefs() { } } + @Test + void unreachableBlessedCaptureUsesExplicitScalarSlotAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + String className = "DeferredCaptureBoundary::ExplicitSlot"; + RuntimeHash referent = new RuntimeHash(); + referent.blessId = NameNormalizer.getBlessId(className); + referent.refCount = 1; + installDestroy(className); + + RuntimeScalar pad = referenceTo(referent); + pad.setRefCountOwned(true); + pad.refCountOwned = false; + pad.retainClosureCapture(); + + MortalList.flush(); + + MortalList.pushMark(); + RuntimeScalar.scopeExitCleanup(pad); + MortalList.popAndFlush(); + + assertEquals(RuntimeBase.PerlLifecycleState.DESTROYED, + referent.perlLifecycleState()); + GlobalVariable.removeGlobalCodeRefForStashDelete(className + "::DESTROY"); + } + } + @Test void unreachableBlessedCaptureWithWeakObserverReleasesAtItsScopeBoundary() { PerlRuntime runtime = new PerlRuntime(); @@ -65,6 +110,26 @@ void unreachableBlessedCaptureWithWeakObserverReleasesAtItsScopeBoundary() { } } + @Test + void closureCaptureDoesNotDoubleCountExplicitScalarOwnerAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash referent = new RuntimeHash(); + referent.refCount = 1; + RuntimeScalar pad = referenceTo(referent); + pad.setRefCountOwned(true); + pad.refCountOwned = false; + + pad.retainClosureCapture(); + + assertEquals(1, referent.refCount, + "the explicit scalar slot already accounts for the referent"); + assertEquals(0, pad.captureRefCountOwned(), + "capture metadata must not add a second legacy bridge owner"); + pad.releaseClosureCapture(); + } + } + private static RuntimeScalar referenceTo(RuntimeBase referent) { RuntimeScalar scalar = new RuntimeScalar(); scalar.type = RuntimeScalarType.HASHREFERENCE; diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListOwnerSlotLedgerAuthorityTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListOwnerSlotLedgerAuthorityTest.java new file mode 100644 index 0000000000..70e77ef271 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListOwnerSlotLedgerAuthorityTest.java @@ -0,0 +1,66 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class MortalListOwnerSlotLedgerAuthorityTest { + @Test + void rootedAggregateSlotsUseScalarOwnerIdentityAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeArray arrayOwner = new RuntimeArray(); + arrayOwner.elementsOwned = true; + RuntimeScalar arrayCell = referenceTo(referent); + arrayCell.setRefCountOwned(true); + arrayCell.refCountOwned = false; + arrayOwner.elements.add(arrayCell); + arrayOwner.reconcileArrayElementOwnerSlots(); + + RuntimeHash hashOwner = new RuntimeHash(); + RuntimeScalar hashCell = referenceTo(referent); + hashCell.setRefCountOwned(true); + hashCell.refCountOwned = false; + hashOwner.elements.put("target", hashCell); + + MyVarCleanupStack.register(arrayOwner); + MyVarCleanupStack.register(hashOwner); + try { + assertTrue(arrayCell.hasScalarValueOwnerSlotFor(referent)); + assertTrue(hashCell.hasScalarValueOwnerSlotFor(referent)); + + assertTrue(MortalList.restoreReachableAggregateOwnerCount(referent), + "rooted array and hash slots restore owners from ledger identity"); + assertEquals(2, referent.refCount); + assertEquals(2, referent.reachableOwnerCount()); + + arrayOwner.elements.clear(); + hashOwner.elements.clear(); + arrayCell.scalarValueOwnerSlot().release(); + hashCell.scalarValueOwnerSlot().release(); + referent.refCount = 0; + assertFalse(MortalList.restoreReachableAggregateOwnerCount(referent), + "removing the semantic slots removes the recoverable owners"); + } finally { + MyVarCleanupStack.unregister(hashOwner); + MyVarCleanupStack.unregister(arrayOwner); + hashOwner.elements.clear(); + arrayOwner.elements.clear(); + arrayCell.scalarValueOwnerSlot().release(); + hashCell.scalarValueOwnerSlot().release(); + } + } + } + + private static RuntimeScalar referenceTo(RuntimeArray referent) { + RuntimeScalar scalar = new RuntimeScalar(); + scalar.type = RuntimeScalarType.ARRAYREFERENCE; + scalar.value = referent; + return scalar; + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListScalarReferenceContentsRecoveryTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListScalarReferenceContentsRecoveryTest.java new file mode 100644 index 0000000000..8ff6cebd05 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListScalarReferenceContentsRecoveryTest.java @@ -0,0 +1,64 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class MortalListScalarReferenceContentsRecoveryTest { + @Test + void aggregateRecoveryCountsDistinctRootedScalarContentsOwner() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash referent = new RuntimeHash(); + referent.refCount = 1; + + RuntimeHash packageRoot = new RuntimeHash(); + packageRoot.isPackageGlobalRoot = true; + RuntimeScalar hashCell = referenceTo(referent); + hashCell.setRefCountOwned(true); + packageRoot.elements.put("target", hashCell); + + RuntimeScalar contentsCell = referenceTo(referent); + RuntimeScalar outerReference = new RuntimeScalar(); + outerReference.type = RuntimeScalarType.REFERENCE; + outerReference.value = contentsCell; + PerlOwnerSlot contentsOwner = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS, + null, null, outerReference); + contentsOwner.acquireScalarReferenceContents(referent, contentsCell); + outerReference.scalarReferenceContentsOwner = contentsOwner; + MyVarCleanupStack.register(outerReference); + + try { + assertEquals(2, referent.refCount, + "the hash value and scalar-reference contents own distinct edges"); + + // Model the selective counter having lost both live owners + // before recovery. The recovery contract must rebuild the + // count from each independently rooted owner edge. + referent.refCount = 0; + + assertTrue(MortalList.restoreReachableAggregateOwnerCount(referent)); + assertEquals(2, referent.refCount, + "recovery must include the counted scalar-reference contents edge"); + } finally { + MyVarCleanupStack.unregister(outerReference); + referent.refCount = 2; + outerReference.releaseOwnedScalarReferenceContents(); + packageRoot.elements.remove("target"); + hashCell.releaseScalarValueOwnerCount( + referent, "MortalListScalarReferenceContentsRecoveryTest cleanup"); + } + } + } + + private static RuntimeScalar referenceTo(RuntimeBase referent) { + RuntimeScalar scalar = new RuntimeScalar(); + scalar.type = RuntimeScalarType.REFERENCE; + scalar.value = referent; + return scalar; + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListScalarValueOwnerRestoreTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListScalarValueOwnerRestoreTest.java new file mode 100644 index 0000000000..244ea49e12 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/MortalListScalarValueOwnerRestoreTest.java @@ -0,0 +1,269 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class MortalListScalarValueOwnerRestoreTest { + @Test + void standaloneScalarSlotRestoresCountWithoutActiveOwnerRegistry() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createAnonymousReference()); + MyVarCleanupStack.register(owner); + + assertEquals(0, referent.activeOwnerCount(), + "the scalar was stored before active-owner tracking existed"); + assertEquals(1, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + + referent.refCount = 0; + try { + assertTrue(MortalList.restoreReachableAggregateOwnerCount(referent), + "the explicit scalar slot supplies the lost direct owner"); + assertEquals(1, referent.refCount); + } finally { + MyVarCleanupStack.unregister(owner); + MortalList.deferDecrementIfTracked(owner); + MortalList.flush(); + } + } + } + + @Test + void deferredReleaseUsesExplicitScalarOwnerAfterLegacyFlagRetires() { + assertExplicitScalarOwnerRelease(false); + } + + @Test + void scopeExitReleasesExplicitScalarOwnerAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createAnonymousReference()); + MyVarCleanupStack.register(owner); + int originalRefCount = referent.refCount; + owner.refCountOwned = false; + + try { + RuntimeScalar.scopeExitCleanup(owner); + assertFalse(owner.hasScalarValueOwnerSlotFor(referent), + "scope exit retires the explicit scalar slot"); + MyVarCleanupStack.unregister(owner); + MortalList.flush(); + assertTrue(referent.refCount < originalRefCount, + "scope exit queues the explicit owner's decrement"); + } finally { + MyVarCleanupStack.unregister(owner); + MortalList.flush(); + } + } + } + + @Test + void scopeExitFastPathRecognizesExplicitGlobOwnerAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeGlob referent = new RuntimeGlob("OwnerSlot::HANDLE"); + referent.refCount = 1; + RuntimeScalar owner = new RuntimeScalar(); + owner.type = RuntimeScalarType.GLOB; + owner.value = referent; + owner.setRefCountOwned(true); + owner.refCountOwned = false; + MyVarCleanupStack.register(owner); + + try { + RuntimeScalar.scopeExitCleanup(owner); + MortalList.flush(); + + assertFalse(owner.scalarValueOwnerSlot().isActive()); + assertTrue(referent.refCount < 1, + "scope exit reaches GLOB cleanup through the explicit owner slot"); + } finally { + MyVarCleanupStack.unregister(owner); + MortalList.flush(); + } + } + } + + @Test + void explicitUndefReleasesScalarOwnerAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createAnonymousReference()); + int originalRefCount = referent.refCount; + owner.refCountOwned = false; + + owner.undefine(); + + assertFalse(owner.hasScalarValueOwnerSlotFor(referent), + "explicit undef retires the scalar owner slot"); + assertTrue(referent.refCount < originalRefCount, + "explicit undef releases the slot's counted owner"); + } + } + + @Test + void scalarOverwriteReleasesExplicitOwnerAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray previous = new RuntimeArray(); + RuntimeArray replacement = new RuntimeArray(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(previous.createAnonymousReference()); + int originalRefCount = previous.refCount; + owner.refCountOwned = false; + + owner.set(replacement.createAnonymousReference()); + + assertFalse(owner.hasScalarValueOwnerSlotFor(previous), + "overwrite retires the previous scalar owner slot"); + assertTrue(previous.refCount < originalRefCount, + "overwrite releases the previous slot's counted owner"); + assertTrue(owner.hasScalarValueOwnerSlotFor(replacement), + "overwrite installs the replacement scalar owner slot"); + } + } + + @Test + void capturedReleaseUsesExplicitScalarOwnerAfterLegacyFlagRetires() { + assertExplicitScalarOwnerRelease(true); + } + + @Test + void recursiveContainerCleanupUsesExplicitScalarOwnerAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createAnonymousReference()); + MyVarCleanupStack.register(owner); + int originalRefCount = referent.refCount; + owner.refCountOwned = false; + + try { + MortalList.deferDestroyForContainerClear(java.util.List.of(owner)); + assertFalse(owner.hasScalarValueOwnerSlotFor(referent)); + MyVarCleanupStack.unregister(owner); + MortalList.flush(); + assertTrue(referent.refCount < originalRefCount, + "recursive cleanup queues the explicit owner's decrement"); + } finally { + MyVarCleanupStack.unregister(owner); + MortalList.flush(); + } + } + } + + @Test + void tailCallCleanupUsesExplicitScalarOwnerAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createAnonymousReference()); + RuntimeArray args = new RuntimeArray(); + args.elements.add(owner); + args.elementsOwned = true; + args.reconcileArrayElementOwnerSlots(); + int originalRefCount = referent.refCount; + owner.refCountOwned = false; + + try { + MortalList.releaseLastOwnedTailCallArgs(args); + assertFalse(owner.hasScalarValueOwnerSlotFor(referent)); + assertTrue(referent.refCount < originalRefCount, + "tail-call cleanup releases the explicit owner's count"); + } finally { + args.releaseArrayElementOwnerSlots(); + args.elements.clear(); + MortalList.flush(); + } + } + } + + @Test + void tailCallCodeReleaseUsesExplicitScalarOwnerSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeCode code = new RuntimeCode((String) null, null); + code.refCount = 1; + RuntimeScalar codeRef = new RuntimeScalar(code); + codeRef.setRefCountOwned(true); + + try { + MortalList.releaseTailCallCodeRef(codeRef); + + assertFalse(codeRef.hasScalarValueOwnerSlotFor(code), + "tail-call release retires the direct CODE owner slot"); + assertEquals(Integer.MIN_VALUE, code.refCount, + "the last tail-call CODE owner reaches the lifecycle dispatcher"); + } finally { + MortalList.flush(); + } + } + } + + @Test + void voidResultCleanupUsesExplicitScalarOwnerAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createAnonymousReference()); + RuntimeList result = new RuntimeList(owner); + int originalRefCount = referent.refCount; + owner.refCountOwned = false; + + try { + MortalList.mortalizeForVoidDiscard(result); + assertFalse(owner.hasScalarValueOwnerSlotFor(referent)); + MortalList.flush(); + assertTrue(referent.refCount < originalRefCount, + "void-result cleanup releases the explicit owner's count"); + } finally { + MortalList.flush(); + } + } + } + + private static void assertExplicitScalarOwnerRelease(boolean captured) { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createAnonymousReference()); + MyVarCleanupStack.register(owner); + int originalRefCount = referent.refCount; + + owner.refCountOwned = false; + assertTrue(owner.hasScalarValueOwnerSlotFor(referent)); + + try { + if (captured) { + MortalList.releaseCapturedDecrement(owner); + } else { + MortalList.deferDecrementIfTracked(owner); + } + assertFalse(owner.hasScalarValueOwnerSlotFor(referent), + "deferred cleanup retires the explicit scalar slot"); + MyVarCleanupStack.unregister(owner); + MortalList.flush(); + assertTrue(referent.refCount < originalRefCount, + "the deferred bridge decrement is released"); + } finally { + MyVarCleanupStack.unregister(owner); + MortalList.flush(); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/MyVarCleanupStackOwnerSlotTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/MyVarCleanupStackOwnerSlotTest.java new file mode 100644 index 0000000000..6832f43654 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/MyVarCleanupStackOwnerSlotTest.java @@ -0,0 +1,59 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class MyVarCleanupStackOwnerSlotTest { + @Test + void retainingTrackedBlessedGlobAliasAcquiresCountThroughScalarSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeGlob glob = new RuntimeGlob("OwnerSlotGlob::TRACKED_HANDLE"); + glob.refCount = 0; + RuntimeScalar lexicalAlias = new RuntimeScalar(); + lexicalAlias.type = RuntimeScalarType.GLOB; + lexicalAlias.value = glob; + MyVarCleanupStack.register(lexicalAlias); + try { + MyVarCleanupStack.retainBlessedGlobAliases(glob, glob); + + assertEquals(1, glob.refCount); + assertEquals(1, glob.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertTrue(lexicalAlias.releaseScalarValueOwnerCount( + glob, "MyVarCleanupStackOwnerSlotTest release")); + assertEquals(0, glob.refCount); + assertEquals(0, glob.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + } finally { + MyVarCleanupStack.unregister(lexicalAlias); + } + } + } + + @Test + void retainingBlessedGlobAliasDoesNotDuplicateRetiredLegacyOwner() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeGlob glob = new RuntimeGlob("OwnerSlotGlob::HANDLE"); + RuntimeScalar lexicalAlias = new RuntimeScalar(); + lexicalAlias.type = RuntimeScalarType.GLOB; + lexicalAlias.value = glob; + lexicalAlias.setRefCountOwned(true); + lexicalAlias.refCountOwned = false; + MyVarCleanupStack.register(lexicalAlias); + try { + glob.refCount = 4; + + MyVarCleanupStack.retainBlessedGlobAliases(glob, glob); + + assertEquals(4, glob.refCount); + assertTrue(lexicalAlias.scalarValueOwnerSlot().isActive()); + } finally { + MyVarCleanupStack.unregister(lexicalAlias); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/PerlOwnerSlotLegacyBridgeTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/PerlOwnerSlotLegacyBridgeTest.java index 1d1751eaf5..b1e3fee453 100644 --- a/src/test/java/org/perlonjava/runtime/runtimetypes/PerlOwnerSlotLegacyBridgeTest.java +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/PerlOwnerSlotLegacyBridgeTest.java @@ -67,4 +67,34 @@ void queuedCaptureReleaseRetainsSlotProvenanceUntilDrain() { MortalList.popMark(); } } + + @Test + void queuedScalarValueReleaseRetainsSlotProvenanceUntilDrain() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar scalar = new RuntimeScalar(); + scalar.set(referent.createReference()); + PerlOwnerSlot slot = scalar.scalarValueOwnerSlot(); + long identity = slot.identity(); + + MortalList.pushMark(); + PerlOwnerSlot.PendingRelease release = + scalar.deferScalarValueOwnerCount(referent); + assertNotNull(release); + assertEquals(PerlOwnerSlot.Kind.SCALAR_VALUE, release.kind()); + assertEquals(identity, release.ownerSlotIdentity()); + assertEquals(1, referent.refCount, + "deferral preserves the bridged count until the mortal drain"); + assertFalse(slot.isActive(), + "the semantic scalar owner retires when its release is queued"); + + MortalList.deferOwnerSlotDecrement(referent, null, release); + MortalList.flushAboveMark(); + + assertEquals(0, referent.refCount); + assertTrue(release.isCompleted()); + MortalList.popMark(); + } + } } diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/PerlOwnerSlotScalarReferenceContentsTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/PerlOwnerSlotScalarReferenceContentsTest.java new file mode 100644 index 0000000000..65468d900a --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/PerlOwnerSlotScalarReferenceContentsTest.java @@ -0,0 +1,302 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class PerlOwnerSlotScalarReferenceContentsTest { + @Test + void referencedCellOverwriteTransfersItsContentsOwner() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash previous = new RuntimeHash(); + RuntimeHash replacement = new RuntimeHash(); + previous.refCount = 3; + replacement.refCount = 3; + + RuntimeScalar referenceCell = new RuntimeScalar(); + referenceCell.type = RuntimeScalarType.REFERENCE; + referenceCell.value = previous; + referenceCell.referencedByScalarReference = true; + RuntimeScalar outerReference = new RuntimeScalar(); + outerReference.type = RuntimeScalarType.REFERENCE; + outerReference.value = referenceCell; + PerlOwnerSlot slot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS, null, null, outerReference); + slot.acquireScalarReferenceContents(previous, referenceCell); + outerReference.scalarReferenceContentsOwner = slot; + + RuntimeScalar replacementValue = new RuntimeScalar(); + replacementValue.type = RuntimeScalarType.REFERENCE; + replacementValue.value = replacement; + referenceCell.set(replacementValue); + + assertEquals(3, previous.refCount); + assertEquals(4, replacement.refCount); + assertSame(replacement, slot.referent()); + assertEquals(0, previous.ownerSlotCount( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS)); + assertEquals(1, replacement.ownerSlotCount( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS)); + + outerReference.releaseOwnedScalarReferenceContents(); + assertEquals(3, replacement.refCount); + } + } + + @Test + void acquireAndReleaseUseTheScalarContentsSlotIdentity() { + RuntimeHash referent = new RuntimeHash(); + referent.refCount = 3; + PerlOwnerSlot slot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS); + + slot.acquireScalarReferenceContents(referent); + + assertEquals(4, referent.refCount); + assertEquals(1, referent.ownerSlotCount( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS)); + assertSame(slot, referent.ownerSlotSnapshot().iterator().next()); + assertTrue(slot.isActive()); + + slot.releaseScalarReferenceContents(); + + assertEquals(3, referent.refCount); + assertEquals(0, referent.ownerSlotCount( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS)); + assertFalse(slot.isActive()); + slot.releaseScalarReferenceContents(); + assertEquals(3, referent.refCount); + } + + @Test + void scalarReferenceCopyDoesNotDoubleCountExplicitlyOwnedCellContents() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash referent = new RuntimeHash(); + referent.refCount = 3; + RuntimeScalar scalarReferent = new RuntimeScalar(); + scalarReferent.type = RuntimeScalarType.REFERENCE; + scalarReferent.value = referent; + scalarReferent.setRefCountOwned(true); + scalarReferent.refCountOwned = false; + + RuntimeScalar outerReference = new RuntimeScalar(); + outerReference.type = RuntimeScalarType.REFERENCE; + outerReference.value = scalarReferent; + RuntimeScalar.incrementRefCountForContainerStore(outerReference); + + assertEquals(3, referent.refCount, + "the copied scalar reference does not duplicate its cell's owner"); + assertEquals(0, referent.ownerSlotCount( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS)); + } + } + + @Test + void aliasesToOneCellShareOneReferentCountAcrossRetargetAndRelease() { + RuntimeHash original = new RuntimeHash(); + RuntimeHash replacement = new RuntimeHash(); + original.refCount = 3; + replacement.refCount = 3; + RuntimeScalar referenceCell = new RuntimeScalar(); + referenceCell.type = RuntimeScalarType.REFERENCE; + referenceCell.value = original; + PerlOwnerSlot firstAlias = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS); + PerlOwnerSlot secondAlias = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS); + + firstAlias.acquireScalarReferenceContents(original, referenceCell); + firstAlias.acquireScalarReferenceContents(original, referenceCell); + assertEquals(4, original.refCount, + "reacquiring the same slot identity does not duplicate its count"); + secondAlias.acquireScalarReferenceContents(original, referenceCell); + + assertEquals(4, original.refCount, + "one scalar cell contributes one referent count regardless of aliases"); + assertEquals(2, original.ownerSlotCount( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS)); + + firstAlias.retargetScalarReferenceContents(replacement); + assertEquals(4, original.refCount, + "retargeting one alias preserves the cell's original contents owner"); + assertEquals(4, replacement.refCount, + "the new cell contents acquire one owner"); + + secondAlias.retargetScalarReferenceContents(replacement); + assertEquals(3, original.refCount, + "the final old-target alias releases the old contents exactly once"); + assertEquals(4, replacement.refCount, + "retargeting another alias does not duplicate the new contents owner"); + + firstAlias.releaseScalarReferenceContents(); + assertEquals(4, replacement.refCount, + "releasing one alias leaves the shared contents owner active"); + secondAlias.releaseScalarReferenceContents(); + assertEquals(3, replacement.refCount, + "the final alias releases the shared contents owner exactly once"); + } + + @Test + void deferringOneAliasReleaseLeavesTheSharedContentsOwned() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash referent = new RuntimeHash(); + referent.refCount = 3; + RuntimeScalar referenceCell = new RuntimeScalar(); + referenceCell.type = RuntimeScalarType.REFERENCE; + referenceCell.value = referent; + PerlOwnerSlot firstAlias = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS); + PerlOwnerSlot secondAlias = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS); + firstAlias.acquireScalarReferenceContents(referent, referenceCell); + secondAlias.acquireScalarReferenceContents(referent, referenceCell); + + MortalList.pushMark(); + PerlOwnerSlot.PendingRelease release = firstAlias.deferScalarReferenceContentsRelease(); + + assertNull(release, + "dropping one alias does not enqueue the cell's shared contents release"); + assertEquals(4, referent.refCount); + assertEquals(1, referent.ownerSlotCount( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS)); + + secondAlias.releaseScalarReferenceContents(); + assertEquals(3, referent.refCount, + "the remaining alias releases the contents owner exactly once"); + MortalList.popMark(); + } + } + + @Test + void reachableOwnerCountDeduplicatesAliasesToOneScalarCell() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash referent = new RuntimeHash(); + referent.refCount = 3; + RuntimeScalar referenceCell = new RuntimeScalar(); + referenceCell.type = RuntimeScalarType.REFERENCE; + referenceCell.value = referent; + RuntimeScalar firstAlias = new RuntimeScalar(); + RuntimeScalar secondAlias = new RuntimeScalar(); + firstAlias.type = RuntimeScalarType.REFERENCE; + firstAlias.value = referenceCell; + secondAlias.type = RuntimeScalarType.REFERENCE; + secondAlias.value = referenceCell; + MyVarCleanupStack.register(firstAlias); + MyVarCleanupStack.register(secondAlias); + try { + PerlOwnerSlot firstOwner = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS, + null, null, firstAlias); + PerlOwnerSlot secondOwner = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS, + null, null, secondAlias); + firstOwner.acquireScalarReferenceContents(referent, referenceCell); + secondOwner.acquireScalarReferenceContents(referent, referenceCell); + + assertEquals(1, referent.reachableOwnerCount()); + firstOwner.releaseScalarReferenceContents(); + assertEquals(1, referent.reachableOwnerCount(), + "the remaining reachable alias still represents one owner"); + secondOwner.releaseScalarReferenceContents(); + assertEquals(0, referent.reachableOwnerCount()); + } finally { + MyVarCleanupStack.unregister(firstAlias); + MyVarCleanupStack.unregister(secondAlias); + } + } + } + + @Test + void deferredReleaseCarriesTheOwnerSlotIdentityUntilDrain() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash referent = new RuntimeHash(); + referent.refCount = 3; + PerlOwnerSlot slot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS); + slot.acquireScalarReferenceContents(referent); + + MortalList.pushMark(); + PerlOwnerSlot.PendingRelease release = slot.deferScalarReferenceContentsRelease(); + + assertNotNull(release); + assertEquals(slot.identity(), release.ownerSlotIdentity()); + assertEquals(PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS, release.kind()); + assertFalse(release.isCompleted()); + assertEquals(4, referent.refCount); + assertTrue(slot.isActive()); + assertEquals(1, referent.ownerSlotCount( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS)); + + MortalList.flushAboveMark(); + + assertEquals(3, referent.refCount); + assertTrue(release.isCompleted()); + assertFalse(slot.isActive()); + assertEquals(0, referent.ownerSlotCount( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS)); + MortalList.popMark(); + } + } + + @Test + void scalarReturnDefersItsContentsOwnerThroughTheOwnerSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash referent = new RuntimeHash(); + referent.refCount = 3; + RuntimeScalar referenceCell = new RuntimeScalar(); + referenceCell.type = RuntimeScalarType.REFERENCE; + referenceCell.value = referent; + RuntimeScalar returnedReference = new RuntimeScalar(); + returnedReference.type = RuntimeScalarType.REFERENCE; + returnedReference.value = referenceCell; + PerlOwnerSlot owner = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS, + null, null, returnedReference); + owner.acquireScalarReferenceContents(referent, referenceCell); + returnedReference.scalarReferenceContentsOwner = owner; + + MortalList.pushMark(); + returnedReference.deferOwnedScalarReferenceContents(); + + assertNull(returnedReference.scalarReferenceContentsOwner); + assertTrue(owner.isActive(), "the pending release retains slot identity until drain"); + assertEquals(4, referent.refCount, + "the referent remains alive until the caller can install the returned value"); + MortalList.flushAboveMark(); + + assertFalse(owner.isActive()); + assertEquals(3, referent.refCount, + "drain releases the deferred contents owner exactly once"); + MortalList.popMark(); + } + } + + @Test + void rejectsOverflowBeforeRegisteringTheOwnerSlot() { + RuntimeHash referent = new RuntimeHash(); + referent.refCount = Integer.MAX_VALUE; + PerlOwnerSlot slot = new PerlOwnerSlot( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS); + + assertThrows(IllegalStateException.class, + () -> slot.acquireScalarReferenceContents(referent)); + assertEquals(Integer.MAX_VALUE, referent.refCount); + assertEquals(0, referent.ownerSlotCount( + PerlOwnerSlot.Kind.SCALAR_REFERENCE_CONTENTS)); + assertNull(slot.referent()); + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityQueryCostTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityQueryCostTest.java index d24797810d..17bf4ba531 100644 --- a/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityQueryCostTest.java +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityQueryCostTest.java @@ -52,6 +52,51 @@ void repeatedContainerScopeCleanupDoesNotEnumerateNestedStashRoots() { } } + @Test + void aggregateOwnerRecoveryChecksEachSharedContainerOnce() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeHash owner = new RuntimeHash(); + ArrayList cells = new ArrayList<>(); + for (int i = 0; i < 64; i++) { + RuntimeScalar cell = new RuntimeScalar(); + cell.type = RuntimeScalarType.ARRAYREFERENCE; + cell.value = referent; + cell.setRefCountOwned(true); + cell.refCountOwned = false; + owner.elements.put("slot" + i, cell); + cells.add(cell); + } + referent.refCount = 0; + MyVarCleanupStack.register(owner); + try { + try (MortalList.ReachabilityQueryMeasurement measurement = + MortalList.measureReachabilityQueries()) { + assertTrue(MortalList.restoreReachableAggregateOwnerCount(referent)); + + assertEquals(64, referent.refCount, + "every rooted hash-entry cell remains an owner"); + assertEquals(1, measurement.stats().aggregateOwnerRootProofs, + "one owner-container proof covers all entries in the same hash"); + assertEquals(63, measurement.stats().aggregateOwnerRootProofCacheHits, + "the remaining entry slots reuse that proof"); + assertEquals(128, measurement.stats().aggregateOwnerSlotsInspected, + "each referent owner slot is inspected once during recovery"); + } + } finally { + MyVarCleanupStack.unregister(owner); + owner.elements.clear(); + for (RuntimeScalar cell : cells) { + if (cell.scalarValueOwnerSlot() != null) { + cell.scalarValueOwnerSlot().release(); + } + } + referent.refCount = 0; + } + } + } + @Test void scopeExitedCaptureOwnersShareOneScalarRootSnapshot() { PerlRuntime runtime = new PerlRuntime(); diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilitySnapshotCompletenessTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilitySnapshotCompletenessTest.java index 5319ea6459..d116b8a495 100644 --- a/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilitySnapshotCompletenessTest.java +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilitySnapshotCompletenessTest.java @@ -52,6 +52,12 @@ void cappedRootSnapshotsCannotProveAnUnseenTargetUnreachable() throws Exception "an incomplete lexical snapshot cannot prove a miss"); assertTrue(scalars.isReachable(chain.tailOwner()), "an incomplete scalar snapshot cannot prove an owner miss"); + ReachabilityWalker.ScalarRootReachabilitySnapshot completeScalars = + ReachabilityWalker.completeScalarReachabilitySnapshot(); + assertTrue(completeScalars.complete(), + "capture cleanup can request a complete scalar-root walk"); + assertTrue(completeScalars.isReachable(chain.tailOwner()), + "the complete capture cleanup walk reaches a lexical beyond the bounded prefix"); state.scalarRootSnapshot = scalars; state.reachabilityDrainDepth++; try { diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalkerCapturedAggregateReleaseTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalkerCapturedAggregateReleaseTest.java index cf2a514b87..ff5d3e5fd2 100644 --- a/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalkerCapturedAggregateReleaseTest.java +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalkerCapturedAggregateReleaseTest.java @@ -6,6 +6,7 @@ import java.util.Collections; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("unit") @@ -56,4 +57,42 @@ void laterCaptureReconcilesAStaleDestroyedSentinelWhileOwnersRemain() { captured.releaseClosureCaptureOwner(firstOwner); } } + + @Test + void capturedAggregateIsReachedThroughItsLiveClosureNotAsAnIndependentRoot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash capturedValue = new RuntimeHash(); + RuntimeScalar weakProbe = capturedValue.createReference(); + WeakRefRegistry.weaken(weakProbe); + RuntimeArray capturedPad = new RuntimeArray(); + capturedPad.elements.add(capturedValue.createReference()); + capturedPad.elementsOwned = true; + capturedPad.reconcileArrayElementOwnerSlots(); + + RuntimeCode closure = new RuntimeCode((String) null, java.util.List.of()); + closure.capturedAggregates = new RuntimeBase[] {capturedPad}; + PerlOwnerSlot captureOwner = capturedPad.retainClosureCaptureOwner(closure); + closure.capturedAggregateOwnerSlots = new PerlOwnerSlot[] {captureOwner}; + + assertTrue(runtime.retainsPositiveOwnerSlotReferent(capturedPad)); + assertTrue(ReachabilityWalker.isReachableFromRoots(capturedValue), + "ordinary cleanup still treats the captured pad as a semantic owner"); + assertFalse(ReachabilityWalker.isCodeReachableFromRoots(closure), + "the closure must not be rescued through its own captured aggregate pad"); + + RuntimeScalar closureOwner = new RuntimeScalar(closure); + closureOwner.refCountOwned = true; + ScalarRefRegistry.registerRef(closureOwner); + MyVarCleanupStack.register(closureOwner); + try { + assertTrue(ReachabilityWalker.isCodeReachableFromRoots(closure), + "a live Perl closure remains rooted through its lexical owner"); + } finally { + MyVarCleanupStack.unregister(closureOwner); + } + + closure.releaseCaptures(); + } + } } diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalkerFullRootSnapshotTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalkerFullRootSnapshotTest.java index b837a96af9..3ac9a98d98 100644 --- a/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalkerFullRootSnapshotTest.java +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalkerFullRootSnapshotTest.java @@ -13,6 +13,23 @@ @Tag("unit") class ReachabilityWalkerFullRootSnapshotTest { + @Test + void scopeExitedScalarInLiveVarSnapshotIsNotAnOwner() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeScalar expired = new RuntimeScalar(); + expired.scopeExited = true; + MyVarCleanupStack.register(expired); + MyVarCleanupStack.snapshotStackToLiveCounts(); + try { + assertFalse(ReachabilityWalker.isScalarReachable(expired), + "a stale live-var snapshot must not revive an expired scalar"); + } finally { + MyVarCleanupStack.unregister(expired); + } + } + } + @Test void oneSnapshotAnswersAllTargetsLikeThePerTargetRootWalk() { PerlRuntime runtime = new PerlRuntime(); diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalkerScalarValueOwnerSlotTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalkerScalarValueOwnerSlotTest.java new file mode 100644 index 0000000000..c536f604c2 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalkerScalarValueOwnerSlotTest.java @@ -0,0 +1,113 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class ReachabilityWalkerScalarValueOwnerSlotTest { + @Test + void directOwnerSlotPreservesTargetCreatedBeforeWeakTracking() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray target = new RuntimeArray(); + RuntimeScalar strongOwner = new RuntimeScalar(); + strongOwner.set(target.createAnonymousReference()); + MyVarCleanupStack.register(strongOwner); + + RuntimeScalar weakObserver = target.createReference(); + WeakRefRegistry.weaken(weakObserver); + + assertFalse(ScalarRefRegistry.snapshot().contains(strongOwner), + "the strong owner predates lazy weak-scalar registration"); + assertTrue(strongOwner.scalarValueOwnerSlot().isActive()); + assertEquals(1, target.refCount); + // Simulate retirement of the legacy bridge while the identity-based + // scalar owner slot remains the authoritative ownership record. + strongOwner.refCountOwned = false; + + try (MortalList.ReachabilityQueryMeasurement measurement = + MortalList.measureReachabilityQueries()) { + ReachabilityWalker.sweepReleasedWeakReferents( + Collections.singleton(target)); + + assertEquals(1, target.refCount, + "the direct scalar owner keeps the target alive"); + assertEquals(0, measurement.stats().snapshotsBuilt, + "the explicit owner slot proves the target without a root walk"); + assertEquals(0, measurement.stats().scalarReachabilityQueries); + } finally { + WeakRefRegistry.unweaken(weakObserver); + MyVarCleanupStack.unregister(strongOwner); + } + } + } + + @Test + void weakeningUsesAnActiveScalarOwnerSlotAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray target = new RuntimeArray(); + RuntimeScalar strongOwner = new RuntimeScalar(); + strongOwner.set(target.createAnonymousReference()); + MyVarCleanupStack.register(strongOwner); + + RuntimeScalar weakObserver = new RuntimeScalar(); + weakObserver.set(target.createAnonymousReference()); + assertEquals(2, target.refCount); + weakObserver.refCountOwned = false; + assertTrue(weakObserver.scalarValueOwnerSlot().isActive()); + + try { + WeakRefRegistry.weaken(weakObserver); + + assertEquals(1, target.refCount, + "weakening consumes the count proven by the explicit slot"); + assertFalse(weakObserver.scalarValueOwnerSlot().isActive()); + assertTrue(WeakRefRegistry.isweak(weakObserver)); + } finally { + WeakRefRegistry.unweaken(weakObserver); + MyVarCleanupStack.unregister(strongOwner); + } + } + } + + @Test + void unweakeningRestoresDirectOwnerThroughScalarValueSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray target = new RuntimeArray(); + RuntimeScalar strongOwner = new RuntimeScalar(); + strongOwner.set(target.createAnonymousReference()); + MyVarCleanupStack.register(strongOwner); + + RuntimeScalar weakObserver = new RuntimeScalar(); + weakObserver.set(target.createAnonymousReference()); + WeakRefRegistry.weaken(weakObserver); + assertEquals(1, target.refCount); + assertFalse(weakObserver.scalarValueOwnerSlot().isActive()); + + try { + WeakRefRegistry.unweaken(weakObserver); + + assertEquals(2, target.refCount, + "unweakening restores the direct scalar owner count"); + assertTrue(weakObserver.ownsCountedReferent(target)); + assertTrue(weakObserver.scalarValueOwnerSlot().isActive(), + "unweakening restores ownership through the explicit slot"); + } finally { + if (WeakRefRegistry.isweak(weakObserver)) { + WeakRefRegistry.unweaken(weakObserver); + } + weakObserver.undefine(); + strongOwner.undefine(); + MyVarCleanupStack.unregister(strongOwner); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeArrayElementOwnershipTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeArrayElementOwnershipTest.java new file mode 100644 index 0000000000..b447423813 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeArrayElementOwnershipTest.java @@ -0,0 +1,348 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class RuntimeArrayElementOwnershipTest { + @Test + void elementSlotsFollowStructuralAndScalarCellMutations() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray first = new RuntimeArray(); + RuntimeArray second = new RuntimeArray(); + RuntimeArray third = new RuntimeArray(); + RuntimeArray array = new RuntimeArray(); + RuntimeScalar cell = first.createReference(); + array.elements.add(cell); + array.elementsOwned = true; + array.reconcileArrayElementOwnerSlots(); + + PerlOwnerSlot slot = array.arrayElementOwnerSlot(0); + assertEquals(1, first.arraySlotOwnerCount()); + assertSame(first, slot.referent()); + + cell.set(second.createReference()); + assertEquals(0, first.arraySlotOwnerCount()); + assertEquals(1, second.arraySlotOwnerCount()); + assertSame(slot, array.arrayElementOwnerSlot(0)); + assertSame(second, slot.referent()); + + array.elements.add(0, third.createReference()); + assertEquals(1, third.arraySlotOwnerCount()); + assertSame(slot, array.arrayElementOwnerSlot(1), + "insertion moves the existing element slot without replacing its identity"); + + array.elements.remove(0); + assertEquals(0, third.arraySlotOwnerCount()); + assertSame(slot, array.arrayElementOwnerSlot(0)); + + array.elements.set(0, third.createReference()); + assertEquals(0, second.arraySlotOwnerCount()); + assertEquals(1, third.arraySlotOwnerCount()); + assertSame(slot, array.arrayElementOwnerSlot(0)); + assertSame(third, slot.referent()); + + array.elements.clear(); + assertEquals(0, third.arraySlotOwnerCount()); + assertNull(array.arrayElementOwnerSlot(0)); + } + } + + @Test + void aliasedElementsDoNotOwnReferentsUnlessExplicitlyTransferred() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeArray array = new RuntimeArray(); + RuntimeScalar cell = referent.createReference(); + array.elements.add(cell); + array.elementsAliased = true; + array.elementsOwned = false; + + assertEquals(0, referent.arraySlotOwnerCount()); + + array.markOwnedAliasElement(cell); + assertEquals(1, referent.arraySlotOwnerCount()); + assertSame(referent, array.arrayElementOwnerSlot(0).referent()); + + array.forgetOwnedAliasElement(cell); + assertEquals(0, referent.arraySlotOwnerCount()); + assertNull(array.arrayElementOwnerSlot(0)); + } + } + + @Test + void replacingAnAliasedSlotTransfersOwnershipToTheArray() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeArray array = new RuntimeArray(); + RuntimeScalar borrowed = new RuntimeScalar(); + array.elements.add(borrowed); + array.elementsAliased = true; + array.elementsOwned = false; + RuntimeScalar stored = referent.createReference(); + + array.elements.set(0, stored); + + assertTrue(array.ownsElement(stored)); + assertEquals(1, referent.arraySlotOwnerCount()); + assertSame(referent, array.arrayElementOwnerSlot(0).referent()); + } + } + + @Test + void proxyStoreCreatesAndTransfersItsArrayElementOwnerSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray first = new RuntimeArray(); + RuntimeArray second = new RuntimeArray(); + RuntimeArray array = new RuntimeArray(); + RuntimeArrayProxyEntry proxy = new RuntimeArrayProxyEntry(array, 0); + + proxy.set(first.createReference()); + + PerlOwnerSlot slot = array.arrayElementOwnerSlot(0); + assertTrue(array.ownsElement(array.elements.get(0))); + assertEquals(1, first.arraySlotOwnerCount(), + "a proxy store into a new array must register its referent owner"); + assertSame(first, slot.referent()); + + proxy.set(second.createReference()); + + assertSame(slot, array.arrayElementOwnerSlot(0), + "replacing through the proxy preserves the structural slot identity"); + assertEquals(0, first.arraySlotOwnerCount()); + assertEquals(1, second.arraySlotOwnerCount()); + assertSame(second, slot.referent()); + + array.elements.clear(); + assertEquals(0, second.arraySlotOwnerCount(), + "removing the proxy-written element releases its owner slot"); + } + } + + @Test + void scalarRefaliasCreatesAnArrayElementOwnerSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar scalarCell = referent.createReference(); + RuntimeScalar scalarReference = scalarCell.createReference(); + RuntimeArray array = new RuntimeArray(); + RuntimeArrayProxyEntry proxy = new RuntimeArrayProxyEntry(array, 0); + + proxy.aliasToReference(scalarReference); + + assertSame(scalarCell, array.elements.get(0)); + assertTrue(array.ownsElement(scalarCell)); + assertEquals(1, referent.arraySlotOwnerCount(), + "refaliasing promotes the new array and registers the referenced aggregate"); + assertSame(referent, array.arrayElementOwnerSlot(0).referent()); + + array.elements.clear(); + assertEquals(0, referent.arraySlotOwnerCount()); + } + } + + @Test + void aggregateRefaliasCreatesAnArrayElementOwnerSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeArray array = new RuntimeArray(); + RuntimeScalar aggregateReference = referent.createReference(); + + new RuntimeArrayProxyEntry(array, 0).aliasToAggregateReference(aggregateReference); + + assertTrue(array.elementsOwned, + "aggregate refaliasing promotes an ordinary array to an owning aggregate"); + assertEquals(1, referent.arraySlotOwnerCount()); + assertSame(referent, array.arrayElementOwnerSlot(0).referent()); + + array.elements.clear(); + assertEquals(0, referent.arraySlotOwnerCount(), + "removing the aliased aggregate reference releases the array owner slot"); + } + } + + @Test + void scalarReferenceToNewArrayElementTracksLaterReferentAssignment() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeArray array = new RuntimeArray(); + RuntimeScalar elementReference = new RuntimeArrayProxyEntry(array, 0).createReference(); + + elementReference.scalarDeref().set(referent.createReference()); + + RuntimeScalar element = array.elements.get(0); + assertTrue(array.ownsElement(element)); + assertEquals(1, referent.arraySlotOwnerCount(), + "later writes through the scalar reference need an array owner slot"); + assertSame(referent, array.arrayElementOwnerSlot(0).referent()); + + array.elements.clear(); + assertEquals(0, referent.arraySlotOwnerCount()); + } + } + + @Test + void integerAssignmentTransfersArrayElementOwnerSlotToNoReferent() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeArray array = new RuntimeArray(); + RuntimeScalar cell = referent.createReference(); + array.elements.add(cell); + array.elementsOwned = true; + array.reconcileArrayElementOwnerSlots(); + PerlOwnerSlot slot = array.arrayElementOwnerSlot(0); + assertEquals(1, referent.arraySlotOwnerCount()); + + cell.set(new RuntimeScalar(7)); + + assertNull(array.arrayElementOwnerSlot(0), + "a scalar element with no referent has no active owner slot"); + assertEquals(0, referent.arraySlotOwnerCount(), + "replacing the referent with an integer releases its array owner slot"); + assertNull(slot.referent(), "the released slot no longer names a referent"); + } + } + + @Test + void nonOwningAliasedElementDoesNotProveScalarReachability() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeArray packageRoot = new RuntimeArray(); + RuntimeScalar cell = referent.createReference(); + packageRoot.elements.add(cell); + packageRoot.elementsAliased = true; + packageRoot.elementsOwned = false; + packageRoot.isPackageGlobalRoot = true; + + assertFalse(packageRoot.ownsElement(cell)); + assertFalse(ReachabilityWalker.isScalarReachable(cell), + "an @_ style alias is not an independent Perl owner"); + cell.scopeExited = true; + assertTrue(cell.isRetiredBorrowedArrayAlias()); + + packageRoot.markOwnedAliasElement(cell); + assertTrue(packageRoot.ownsElement(cell)); + assertFalse(cell.isRetiredBorrowedArrayAlias()); + assertTrue(ReachabilityWalker.isScalarReachable(cell), + "an explicitly transferred alias element is an owner"); + } + } + + @Test + void rangeRemovalReleasesOnlyRemovedElementSlots() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray first = new RuntimeArray(); + RuntimeArray second = new RuntimeArray(); + RuntimeArray array = new RuntimeArray(); + array.elements.add(first.createReference()); + array.elements.add(second.createReference()); + array.elementsOwned = true; + array.reconcileArrayElementOwnerSlots(); + + PerlOwnerSlot retained = array.arrayElementOwnerSlot(1); + array.elements.subList(0, 1).clear(); + + assertEquals(0, first.arraySlotOwnerCount()); + assertEquals(1, second.arraySlotOwnerCount()); + assertSame(retained, array.arrayElementOwnerSlot(0)); + } + } + + @Test + void popReleasesAnExplicitScalarOwnerAfterLegacyFlagRetires() { + assertRemovedElementOwnerIsReleased(true); + } + + @Test + void shiftReleasesAnExplicitScalarOwnerAfterLegacyFlagRetires() { + assertRemovedElementOwnerIsReleased(false); + } + + @Test + void containerStoreDoesNotDoubleCountExplicitScalarOwnerAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + referent.refCount = 1; + RuntimeScalar cell = new RuntimeScalar(); + cell.type = RuntimeScalarType.ARRAYREFERENCE; + cell.value = referent; + cell.setRefCountOwned(true); + cell.refCountOwned = false; + + RuntimeScalar.incrementRefCountForContainerStore(cell); + + assertEquals(1, referent.refCount, + "container materialization recognizes the active scalar slot"); + assertEquals(1, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + } + } + + @Test + void containerStoreStillCountsASeparateCellWhenSourceOwnerIsExplicit() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + referent.refCount = 1; + RuntimeScalar source = new RuntimeScalar(); + source.type = RuntimeScalarType.ARRAYREFERENCE; + source.value = referent; + source.setRefCountOwned(true); + source.refCountOwned = false; + RuntimeScalar storedCopy = new RuntimeScalar(source); + + RuntimeScalar.incrementRefCountForContainerStore(storedCopy); + + assertEquals(2, referent.refCount, + "a separate aggregate cell acquires its own owner"); + assertEquals(2, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE), + "the source and copied cell have distinct scalar owner slots"); + } + } + + private static void assertRemovedElementOwnerIsReleased(boolean pop) { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar cell = new RuntimeScalar(); + cell.set(referent.createAnonymousReference()); + RuntimeArray array = new RuntimeArray(); + array.elements.add(cell); + array.elementsOwned = true; + array.reconcileArrayElementOwnerSlots(); + MyVarCleanupStack.register(array); + int originalRefCount = referent.refCount; + cell.refCountOwned = false; + + try { + RuntimeScalar removed = pop ? RuntimeArray.pop(array) : RuntimeArray.shift(array); + assertSame(cell, removed); + assertFalse(cell.hasScalarValueOwnerSlotFor(referent), + "removing the element retires its explicit scalar slot"); + MyVarCleanupStack.unregister(array); + MortalList.flush(); + assertTrue(referent.refCount < originalRefCount, + "the removed element's deferred count is released"); + } finally { + MyVarCleanupStack.unregister(array); + MortalList.flush(); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeBaseActiveOwnersConcurrencyTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeBaseActiveOwnersConcurrencyTest.java new file mode 100644 index 0000000000..ea12f4b4c0 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeBaseActiveOwnersConcurrencyTest.java @@ -0,0 +1,72 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class RuntimeBaseActiveOwnersConcurrencyTest { + @Test + void concurrentOwnerRegistrationAndSnapshotsRemainSafe() throws Exception { + RuntimeArray referent = new RuntimeArray(); + referent.activateOwnerTracking(); + + int workerCount = 8; + ExecutorService workers = Executors.newFixedThreadPool(workerCount + 2); + CountDownLatch ready = new CountDownLatch(workerCount + 2); + CountDownLatch start = new CountDownLatch(1); + try { + List> tasks = new ArrayList<>(); + for (int worker = 0; worker < workerCount; worker++) { + RuntimeScalar owner = new RuntimeScalar(); + owner.value = referent; + owner.refCountOwned = true; + tasks.add(workers.submit(() -> { + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("owner workers did not start"); + } + for (int i = 0; i < 10_000; i++) { + referent.recordActiveOwner(owner); + if ((i & 1) == 0) { + referent.activeOwnerCount(); + referent.activeOwnerSnapshot(); + } + referent.releaseActiveOwner(owner); + } + return null; + })); + } + for (int reader = 0; reader < 2; reader++) { + tasks.add(workers.submit(() -> { + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("owner readers did not start"); + } + for (int i = 0; i < 10_000; i++) { + referent.activeOwnerCount(); + referent.activeOwnerSnapshot(); + referent.countedScalarOwnerSnapshot(); + } + return null; + })); + } + + assertTrue(ready.await(5, TimeUnit.SECONDS), "all owner workers should be ready"); + start.countDown(); + for (Future task : tasks) task.get(20, TimeUnit.SECONDS); + assertTrue(referent.activeOwnerSnapshot().isEmpty()); + } finally { + workers.shutdownNow(); + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeCodeScopeExitReachabilityTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeCodeScopeExitReachabilityTest.java new file mode 100644 index 0000000000..4609476deb --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeCodeScopeExitReachabilityTest.java @@ -0,0 +1,154 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +@Tag("unit") +class RuntimeCodeScopeExitReachabilityTest { + @Test + void scopeExitRetainsCapturesWhileCodeIsStoredInReachableHash() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeScalar captured = new RuntimeScalar("controller"); + captured.retainClosureCapture(); + RuntimeCode callback = new RuntimeCode(null, List.of()); + callback.refCount = 0; + callback.capturedScalars = new RuntimeScalar[] { captured }; + + RuntimeScalar codeRef = new RuntimeScalar(callback); + RuntimeHash callbacks = new RuntimeHash(); + callbacks.elements.put("callback", codeRef); + String globalName = "RuntimeCodeScopeExitReachabilityTest::callbacks"; + GlobalVariable.globalHashes.put(globalName, callbacks); + + try { + RuntimeScalar.scopeExitCleanup(codeRef); + + assertNotNull(callback.capturedScalars, + "scope cleanup must preserve captures while a hash owns the CODE ref"); + assertEquals(1, captured.captureCount); + + GlobalVariable.globalHashes.remove(globalName); + RuntimeScalar.scopeExitCleanup(codeRef); + + assertNull(callback.capturedScalars, + "discarding the final reachable CODE owner releases its captures"); + assertEquals(0, captured.captureCount); + } finally { + GlobalVariable.globalHashes.remove(globalName); + } + } + } + + @Test + void deferredReleaseRetainsCapturesForCodeStoredInReachableHashWithoutWeakRefs() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeScalar captured = new RuntimeScalar("controller"); + captured.retainClosureCapture(); + RuntimeCode callback = new RuntimeCode(null, List.of()); + callback.refCount = 1; + callback.capturedScalars = new RuntimeScalar[] { captured }; + + RuntimeHash callbacks = new RuntimeHash(); + callbacks.elements.put("callback", new RuntimeScalar(callback)); + String globalName = "RuntimeCodeScopeExitReachabilityTest::deferredCallbacks"; + GlobalVariable.globalHashes.put(globalName, callbacks); + boolean previousActive = MortalList.isActive(); + MortalList.setActive(true); + + try { + MortalList.deferDecrement(callback); + MortalList.flush(); + + assertNotNull(callback.capturedScalars, + "a rooted callback must keep its lexical captures when a temporary owner is released"); + assertEquals(1, captured.captureCount); + + GlobalVariable.globalHashes.remove(globalName); + MortalList.deferDecrement(callback); + MortalList.flush(); + + assertNull(callback.capturedScalars, + "removing the last Perl root must release callback captures"); + assertEquals(0, captured.captureCount); + } finally { + GlobalVariable.globalHashes.remove(globalName); + MortalList.setActive(previousActive); + } + } + } + + @Test + void deferredReleaseFollowsCaptureFromRootedCallbackToNestedCallback() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeScalar captured = new RuntimeScalar("controller"); + captured.retainClosureCapture(); + RuntimeCode callback = new RuntimeCode(null, List.of()); + callback.refCount = 1; + callback.capturedScalars = new RuntimeScalar[] { captured }; + + RuntimeScalar callbackRef = new RuntimeScalar(callback); + callbackRef.captureCount = 1; + callbackRef.scopeExited = true; + RuntimeCode timerWrapper = new RuntimeCode(null, List.of()); + timerWrapper.refCount = 1; + timerWrapper.closedOverVariables = new java.util.LinkedHashMap<>(); + timerWrapper.closedOverVariables.put("$callback", callbackRef); + + RuntimeHash reactorTimers = new RuntimeHash(); + RuntimeHash timerRecord = new RuntimeHash(); + timerRecord.elements.put("cb", new RuntimeScalar(timerWrapper)); + reactorTimers.elements.put("timer", timerRecord.createReference()); + RuntimeCode singletonMethod = new RuntimeCode(null, List.of()); + singletonMethod.stateVariable.put("loop", reactorTimers.createReference()); + String globalName = "RuntimeCodeScopeExitReachabilityTest::singleton"; + GlobalVariable.globalCodeRefs.put(globalName, new RuntimeScalar(singletonMethod)); + GlobalVariable.invalidateGlobalCodeRefGraphRoots(); + boolean previousActive = MortalList.isActive(); + MortalList.setActive(true); + + try { + MortalList.deferDecrement(callback); + MortalList.flush(); + + assertNotNull(callback.capturedScalars, + "a rooted timer wrapper must retain the callback stored in its capture pad"); + assertEquals(1, captured.captureCount); + + GlobalVariable.globalCodeRefs.remove(globalName); + GlobalVariable.invalidateGlobalCodeRefGraphRoots(); + RuntimeCode.pushActiveCode(timerWrapper); + try { + MortalList.deferDecrement(callback); + MortalList.flush(); + + assertNotNull(callback.capturedScalars, + "an active callback must retain a nested callback in its capture pad"); + assertEquals(1, captured.captureCount); + } finally { + RuntimeCode.popActiveCode(timerWrapper); + } + + MortalList.deferDecrement(callback); + MortalList.flush(); + + assertNull(callback.capturedScalars, + "removing the rooted wrapper must release the nested callback captures"); + assertEquals(0, captured.captureCount); + } finally { + GlobalVariable.globalCodeRefs.remove(globalName); + GlobalVariable.invalidateGlobalCodeRefGraphRoots(); + MortalList.setActive(previousActive); + } + } + } + +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeGlobCodeOwnerSlotTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeGlobCodeOwnerSlotTest.java new file mode 100644 index 0000000000..c016fa1205 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeGlobCodeOwnerSlotTest.java @@ -0,0 +1,43 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class RuntimeGlobCodeOwnerSlotTest { + @Test + void restoringLocalizedGlobRetiresTrackedTemporaryCodeOwnerSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + String name = "LocalizedCodeOwner::callback"; + RuntimeCode localCodeValue = new RuntimeCode( + (args, context) -> new RuntimeList(), null); + localCodeValue.refCount = 0; + RuntimeScalar localCodeSource = new RuntimeScalar(localCodeValue); + RuntimeGlob glob = new RuntimeGlob(name); + + glob.dynamicSaveState(); + RuntimeScalar localCode = GlobalVariable.globalCodeRefs.get(name); + localCode.set(localCodeSource); + PerlOwnerSlot ownerSlot = localCode.scalarValueOwnerSlot(); + + assertNotNull(ownerSlot); + assertTrue(ownerSlot.isActive()); + assertEquals(1, localCodeValue.refCount); + assertEquals(1, localCodeValue.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + + glob.dynamicRestoreState(); + + assertFalse(ownerSlot.isActive(), + "restoring the glob retires the temporary CODE scalar owner slot"); + assertEquals(0, localCodeValue.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertEquals(Integer.MIN_VALUE, localCodeValue.refCount, + "the final temporary CODE owner still dispatches cleanup"); + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphClonerTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphClonerTest.java index 117f041957..68aed3d138 100644 --- a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphClonerTest.java +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphClonerTest.java @@ -90,6 +90,32 @@ void preservesAliasingAcrossSeparateRoots() { .elements.get("value").getInt()); } + @Test + void restoresClonedOwnershipFromExplicitSourceSlotAfterLegacyFlagRetires() { + PerlRuntime sourceRuntime = new PerlRuntime(); + PerlRuntime targetRuntime = new PerlRuntime(); + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar source = new RuntimeScalar(); + try (PerlRuntime.Binding ignored = sourceRuntime.bind()) { + source.set(referent.createAnonymousReference()); + source.refCountOwned = false; + } + assertTrue(source.scalarValueOwnerSlot().isActive()); + + RuntimeScalar cloned = (RuntimeScalar) new RuntimeGraphCloner( + sourceRuntime, targetRuntime).cloneGraph(source); + + RuntimeArray clonedReferent = (RuntimeArray) cloned.value; + assertEquals(1, clonedReferent.refCount); + assertTrue(cloned.refCountOwned); + assertTrue(cloned.scalarValueOwnerSlot().isActive()); + assertEquals(1, clonedReferent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertTrue(cloned.releaseScalarValueOwnerCount( + clonedReferent, "RuntimeGraphClonerTest cloned owner release")); + assertEquals(0, clonedReferent.refCount); + assertEquals(0, clonedReferent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + } + @Test void normalizesClearedReferencePayloadWithoutMutatingSource() { PerlRuntime sourceRuntime = new PerlRuntime(); diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeHashElementOwnershipTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeHashElementOwnershipTest.java new file mode 100644 index 0000000000..5f739b6a65 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeHashElementOwnershipTest.java @@ -0,0 +1,151 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +@Tag("unit") +class RuntimeHashElementOwnershipTest { + @Test + void hashMapMutationsTrackEveryDirectReferentSlot() { + RuntimeArray first = new RuntimeArray(); + RuntimeArray second = new RuntimeArray(); + RuntimeHash hash = new RuntimeHash(); + + hash.elements.put("first", first.createReference()); + PerlOwnerSlot firstSlot = hash.hashEntryOwnerSlot("first"); + assertEquals(1, first.hashSlotOwnerCount()); + assertSame(first, firstSlot.referent()); + + hash.elements.put("first", second.createReference()); + assertEquals(0, first.hashSlotOwnerCount()); + assertEquals(1, second.hashSlotOwnerCount()); + assertSame(firstSlot, hash.hashEntryOwnerSlot("first"), + "replacing a value transfers the stable key slot"); + assertSame(second, firstSlot.referent()); + + hash.elements.putAll(Map.of("second", first.createReference())); + PerlOwnerSlot secondSlot = hash.hashEntryOwnerSlot("second"); + assertEquals(1, first.hashSlotOwnerCount()); + assertEquals(1, second.hashSlotOwnerCount()); + + hash.elements.putAll(Map.of("second", second.createReference())); + assertEquals(0, first.hashSlotOwnerCount()); + assertEquals(2, second.hashSlotOwnerCount()); + assertSame(secondSlot, hash.hashEntryOwnerSlot("second")); + + hash.elements.remove("first"); + assertEquals(1, second.hashSlotOwnerCount()); + assertNull(hash.hashEntryOwnerSlot("first")); + + hash.elements.clear(); + assertEquals(0, second.hashSlotOwnerCount()); + assertNull(hash.hashEntryOwnerSlot("second")); + } + + @Test + void aliasedHashScalarTransfersEachEntrySlotWhenItsReferentChanges() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray first = new RuntimeArray(); + RuntimeArray second = new RuntimeArray(); + RuntimeHash hash = new RuntimeHash(); + RuntimeHash otherHash = new RuntimeHash(); + var cell = first.createReference(); + + hash.elements.put("left", cell); + hash.elements.put("right", cell); + otherHash.elements.put("third", cell); + PerlOwnerSlot leftSlot = hash.hashEntryOwnerSlot("left"); + PerlOwnerSlot rightSlot = hash.hashEntryOwnerSlot("right"); + PerlOwnerSlot thirdSlot = otherHash.hashEntryOwnerSlot("third"); + + cell.set(second.createReference()); + + assertEquals(0, first.hashSlotOwnerCount()); + assertEquals(3, second.hashSlotOwnerCount()); + assertSame(second, leftSlot.referent()); + assertSame(second, rightSlot.referent()); + assertSame(second, thirdSlot.referent()); + } + } + + @Test + void proxyStoreCreatesAndTransfersItsHashEntryOwnerSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray first = new RuntimeArray(); + RuntimeArray second = new RuntimeArray(); + RuntimeHash hash = new RuntimeHash(); + RuntimeHashProxyEntry proxy = new RuntimeHashProxyEntry(hash, "value"); + + proxy.set(first.createReference()); + + PerlOwnerSlot slot = hash.hashEntryOwnerSlot("value"); + assertEquals(1, first.hashSlotOwnerCount(), + "a proxy store into a new hash must register its referent owner"); + assertSame(first, slot.referent()); + + proxy.set(second.createReference()); + + assertSame(slot, hash.hashEntryOwnerSlot("value"), + "replacing through the proxy preserves the key's slot identity"); + assertEquals(0, first.hashSlotOwnerCount()); + assertEquals(1, second.hashSlotOwnerCount()); + assertSame(second, slot.referent()); + + hash.elements.remove("value"); + assertEquals(0, second.hashSlotOwnerCount(), + "deleting the proxy-written entry releases its owner slot"); + assertNull(hash.hashEntryOwnerSlot("value")); + } + } + + @Test + void integerAssignmentReleasesHashEntryReferentOwner() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeHash hash = new RuntimeHash(); + RuntimeScalar cell = referent.createReference(); + hash.elements.put("value", cell); + PerlOwnerSlot slot = hash.hashEntryOwnerSlot("value"); + assertEquals(1, referent.hashSlotOwnerCount()); + + cell.set(new RuntimeScalar(7)); + + assertSame(slot, hash.hashEntryOwnerSlot("value"), + "the hash key retains its stable slot identity after scalar replacement"); + assertEquals(0, referent.hashSlotOwnerCount(), + "replacing the reference with an integer releases the hash owner slot"); + assertNull(slot.referent(), "the active key slot no longer names the old referent"); + } + } + + @Test + void hashScopeExitReleasesEntrySlotsWhenNoContainerOwnerRemains() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + boolean previousActive = MortalList.isActive(); + MortalList.setActive(true); + try { + RuntimeArray referent = new RuntimeArray(); + RuntimeHash hash = new RuntimeHash(); + hash.elements.put("value", referent.createReference()); + assertEquals(1, referent.hashSlotOwnerCount()); + + MortalList.scopeExitCleanupHash(hash); + + assertEquals(0, referent.hashSlotOwnerCount(), + "a dead lexical hash must release its semantic entry slots"); + } finally { + MortalList.setActive(previousActive); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeHashRegexRestoreOwnershipTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeHashRegexRestoreOwnershipTest.java new file mode 100644 index 0000000000..a7f36e9365 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeHashRegexRestoreOwnershipTest.java @@ -0,0 +1,30 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Tag("unit") +class RuntimeHashRegexRestoreOwnershipTest { + @Test + void restoringMutationSnapshotReplacesElementSlotOwnership() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray stableReferent = new RuntimeArray(); + RuntimeArray temporaryReferent = new RuntimeArray(); + RuntimeHash hash = new RuntimeHash(); + hash.elements.put("stable", stableReferent.createReference()); + + Object snapshot = hash.snapshotRegexMutationState(); + hash.elements.put("temporary", temporaryReferent.createReference()); + + hash.restoreRegexMutationState(snapshot); + + assertEquals(1, stableReferent.hashSlotOwnerCount(), + "the restored entry has exactly one hash slot owner"); + assertEquals(0, temporaryReferent.hashSlotOwnerCount(), + "the discarded entry no longer has a hash slot owner"); + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeListArrayOwnerTransferTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeListArrayOwnerTransferTest.java new file mode 100644 index 0000000000..191cbe7870 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeListArrayOwnerTransferTest.java @@ -0,0 +1,91 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class RuntimeListArrayOwnerTransferTest { + @Test + void materializedCopyReleaseUsesScalarSlotAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + referent.activateOwnerTracking(); + RuntimeScalar materialized = new RuntimeScalar(); + materialized.set(referent.createAnonymousReference()); + PerlOwnerSlot slot = materialized.scalarValueOwnerSlot(); + materialized.refCountOwned = false; + + RuntimeList.releaseMaterializedScalarOwner( + materialized, "RuntimeListArrayOwnerTransferTest"); + + assertEquals(0, referent.refCount); + assertEquals(0, referent.activeOwnerCount()); + assertEquals(0, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertFalse(slot.isActive()); + } + } + + @Test + void listAssignmentTransfersOwnedCellsAndReleasesDisplacedArrayValues() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + boolean previousMortalState = MortalList.isActive(); + RuntimeHash displacedReferent = new RuntimeHash(); + RuntimeScalar displacedCell = new RuntimeScalar(); + displacedCell.set(displacedReferent.createAnonymousReference()); + + RuntimeArray destination = new RuntimeArray(); + destination.elements.add(displacedCell); + destination.elementsOwned = true; + destination.reconcileArrayElementOwnerSlots(); + + RuntimeHash incomingReferent = new RuntimeHash(); + RuntimeArray sourceValues = new RuntimeArray(); + sourceValues.elements.add(incomingReferent.createAnonymousReference()); + + RuntimeList rightHandSide = new RuntimeList(); + rightHandSide.elements.add(new RuntimeScalar(7)); + rightHandSide.elements.add(sourceValues); + + RuntimeList leftHandSide = new RuntimeList(); + leftHandSide.elements.add(new RuntimeScalar()); + leftHandSide.elements.add(destination); + + try { + MortalList.setActive(true); + leftHandSide.setFromList(rightHandSide); + + assertTrue(destination.elementsOwned, + "the assigned tail becomes an owning array"); + assertEquals(1, incomingReferent.refCount, + "the transferred cell acquires one durable scalar owner"); + assertEquals(1, incomingReferent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertEquals(1, incomingReferent.arraySlotOwnerCount(), + "the destination registers its array element slot"); + assertEquals(0, displacedReferent.arraySlotOwnerCount(), + "replacing the old tail retires its array element slot"); + + MortalList.flush(); + assertEquals(0, displacedReferent.countedScalarOwnerCount(), + "clearing the old tail releases its scalar owner"); + + RuntimeArray.pop(destination); + MortalList.flush(); + assertEquals(0, incomingReferent.arraySlotOwnerCount()); + assertEquals(0, incomingReferent.countedScalarOwnerCount(), + "removing the transferred cell releases its owner"); + assertTrue(incomingReferent.refCount <= 0, + "the final array removal drains the referent count"); + } finally { + MortalList.setActive(previousMortalState); + MortalList.flush(); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeListHashOwnerTransferTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeListHashOwnerTransferTest.java new file mode 100644 index 0000000000..12af9172ae --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeListHashOwnerTransferTest.java @@ -0,0 +1,60 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class RuntimeListHashOwnerTransferTest { + @Test + void listAssignmentTransfersHashSlotsAndReleasesDisplacedValues() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + boolean previousMortalState = MortalList.isActive(); + RuntimeHash displacedReferent = new RuntimeHash(); + RuntimeScalar displacedCell = new RuntimeScalar(); + displacedCell.set(displacedReferent.createAnonymousReference()); + RuntimeHash destination = new RuntimeHash(); + destination.elements.put("old", displacedCell); + + RuntimeHash incomingReferent = new RuntimeHash(); + RuntimeArray sourceValues = new RuntimeArray(); + sourceValues.elements.add(new RuntimeScalar("new")); + sourceValues.elements.add(incomingReferent.createAnonymousReference()); + + RuntimeList rightHandSide = new RuntimeList(); + rightHandSide.elements.add(new RuntimeScalar(7)); + rightHandSide.elements.add(sourceValues); + RuntimeList leftHandSide = new RuntimeList(); + leftHandSide.elements.add(new RuntimeScalar()); + leftHandSide.elements.add(destination); + + try { + MortalList.setActive(true); + leftHandSide.setFromList(rightHandSide); + + assertNull(destination.elements.get("old")); + assertNotNull(destination.elements.get("new")); + assertEquals(0, displacedReferent.hashSlotOwnerCount(), + "replacing the hash releases its old entry slot"); + assertEquals(1, incomingReferent.hashSlotOwnerCount(), + "the destination's owner-aware map registers the transferred entry"); + assertEquals(1, incomingReferent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertEquals(1, incomingReferent.refCount, + "the destination hash value owns exactly one referent count"); + + MortalList.flush(); + assertEquals(0, displacedReferent.countedScalarOwnerCount(), + "replacement releases the displaced scalar owner"); + assertTrue(displacedReferent.refCount <= 0); + } finally { + MortalList.setActive(previousMortalState); + MortalList.flush(); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeProxyDynamicOwnerSlotTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeProxyDynamicOwnerSlotTest.java new file mode 100644 index 0000000000..48e68c0d48 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeProxyDynamicOwnerSlotTest.java @@ -0,0 +1,61 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class RuntimeProxyDynamicOwnerSlotTest { + @Test + void hashRestoreReleasesNewEntryScalarOwnerSlotAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeHash hash = new RuntimeHash(); + RuntimeArray referent = new RuntimeArray(); + RuntimeHashProxyEntry proxy = new RuntimeHashProxyEntry(hash, "temporary"); + referent.activateOwnerTracking(); + + proxy.dynamicSaveState(); + RuntimeScalar cell = hash.elements.get("temporary"); + proxy.set(referent.createAnonymousReference()); + cell.refCountOwned = false; + assertTrue(cell.scalarValueOwnerSlot().isActive()); + + proxy.dynamicRestoreState(); + + assertFalse(hash.elements.containsKey("temporary")); + assertEquals(Integer.MIN_VALUE, referent.refCount); + assertEquals(0, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertEquals(0, referent.ownerSlotCount(PerlOwnerSlot.Kind.HASH_ENTRY)); + assertEquals(0, referent.activeOwnerCount()); + } + } + + @Test + void arrayRestoreReleasesNewElementScalarOwnerSlotAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray array = new RuntimeArray(); + RuntimeArray referent = new RuntimeArray(); + RuntimeArrayProxyEntry proxy = new RuntimeArrayProxyEntry(array, 0); + referent.activateOwnerTracking(); + + proxy.dynamicSaveState(); + RuntimeScalar cell = array.elements.get(0); + proxy.set(referent.createAnonymousReference()); + cell.refCountOwned = false; + assertTrue(cell.scalarValueOwnerSlot().isActive()); + + proxy.dynamicRestoreState(); + + assertTrue(array.elements.isEmpty()); + assertEquals(Integer.MIN_VALUE, referent.refCount); + assertEquals(0, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertEquals(0, referent.ownerSlotCount(PerlOwnerSlot.Kind.ARRAY_ELEMENT)); + assertEquals(0, referent.activeOwnerCount()); + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarContainerOwnersConcurrencyTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarContainerOwnersConcurrencyTest.java new file mode 100644 index 0000000000..3ff73247de --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarContainerOwnersConcurrencyTest.java @@ -0,0 +1,55 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class RuntimeScalarContainerOwnersConcurrencyTest { + @Test + void concurrentArrayOwnerRegistrationAndSnapshotsRemainSafe() throws Exception { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeScalar cell = new RuntimeScalar(); + List owners = new ArrayList<>(); + for (int i = 0; i < 8; i++) owners.add(new RuntimeArray()); + + ExecutorService workers = Executors.newFixedThreadPool(owners.size()); + CountDownLatch ready = new CountDownLatch(owners.size()); + CountDownLatch start = new CountDownLatch(1); + try { + List> tasks = new ArrayList<>(); + for (RuntimeArray owner : owners) { + tasks.add(workers.submit((Callable) () -> { + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("concurrent owner workers did not start"); + } + for (int i = 0; i < 10_000; i++) { + cell.markArrayContainerOwner(owner); + cell.arrayContainerOwnersSnapshot(); + cell.unmarkArrayContainerOwner(owner); + } + return null; + })); + } + assertTrue(ready.await(5, TimeUnit.SECONDS), "all owner workers should be ready"); + start.countDown(); + for (Future task : tasks) task.get(10, TimeUnit.SECONDS); + assertTrue(cell.arrayContainerOwnersSnapshot().isEmpty()); + } finally { + workers.shutdownNow(); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarDestroyableClosureOwnersConcurrencyTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarDestroyableClosureOwnersConcurrencyTest.java new file mode 100644 index 0000000000..5118bc5289 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarDestroyableClosureOwnersConcurrencyTest.java @@ -0,0 +1,69 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.lang.ref.WeakReference; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertNull; + +@Tag("unit") +class RuntimeScalarDestroyableClosureOwnersConcurrencyTest { + @Test + void concurrentCaptureReleaseDoesNotMutateTheReverseIndexDuringIteration() throws Exception { + RuntimeScalar scalar = new RuntimeScalar(); + int workerCount = 12; + List owners = new ArrayList<>(workerCount); + List> references = new ArrayList<>(workerCount * 2_000); + for (int i = 0; i < workerCount; i++) owners.add(new RuntimeCode(null, List.of())); + for (int repeat = 0; repeat < 2_000; repeat++) { + for (RuntimeCode owner : owners) references.add(new WeakReference<>(owner)); + } + + Field index = RuntimeScalar.class.getDeclaredField("destroyableClosureOwners"); + index.setAccessible(true); + index.set(scalar, references); + Method forgetOwner = RuntimeScalar.class.getDeclaredMethod( + "forgetDestroyableClosureOwner", RuntimeCode.class); + forgetOwner.setAccessible(true); + + ExecutorService workers = Executors.newFixedThreadPool(workerCount); + CountDownLatch ready = new CountDownLatch(workerCount); + CountDownLatch start = new CountDownLatch(1); + try { + List> tasks = new ArrayList<>(); + for (RuntimeCode owner : owners) { + tasks.add(workers.submit(() -> { + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("capture-release workers did not start"); + } + try { + forgetOwner.invoke(scalar, owner); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure.getCause() == null + ? failure : failure.getCause()); + } + return null; + })); + } + if (!ready.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("capture-release workers were not ready"); + } + start.countDown(); + for (Future task : tasks) task.get(10, TimeUnit.SECONDS); + assertNull(index.get(scalar), "the final released owner clears the reverse index"); + } finally { + workers.shutdownNow(); + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarDynamicOwnerSlotTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarDynamicOwnerSlotTest.java new file mode 100644 index 0000000000..dff9df73bc --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarDynamicOwnerSlotTest.java @@ -0,0 +1,61 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class RuntimeScalarDynamicOwnerSlotTest { + @Test + void dynamicRestoreReleasesDisplacedScalarSlotAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray original = new RuntimeArray(); + RuntimeArray localized = new RuntimeArray(); + original.activateOwnerTracking(); + localized.activateOwnerTracking(); + RuntimeScalar scalar = new RuntimeScalar(); + scalar.set(original.createAnonymousReference()); + + scalar.dynamicSaveState(); + scalar.set(localized.createAnonymousReference()); + scalar.refCountOwned = false; + assertTrue(scalar.scalarValueOwnerSlot().isActive()); + + scalar.dynamicRestoreState(); + + assertEquals(Integer.MIN_VALUE, localized.refCount, + "restoring the saved value destroys its final localized referent"); + assertEquals(0, localized.activeOwnerCount()); + assertEquals(0, localized.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertEquals(1, original.refCount, + "the saved scalar owner transfers back without changing its count"); + assertEquals(1, original.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertTrue(scalar.ownsCountedReferent(original)); + + scalar.undefine(); + } + } + + @Test + void dynamicSaveAndRestorePreservesOwnerSlotAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar scalar = new RuntimeScalar(); + scalar.set(referent.createAnonymousReference()); + scalar.refCountOwned = false; + assertTrue(scalar.scalarValueOwnerSlot().isActive()); + int originalRefCount = referent.refCount; + + scalar.dynamicSaveState(); + scalar.dynamicRestoreState(); + + assertEquals(originalRefCount, referent.refCount); + assertTrue(scalar.ownsCountedReferent(referent)); + assertTrue(scalar.scalarValueOwnerSlot().isActive()); + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarPostfixOwnerTransferTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarPostfixOwnerTransferTest.java new file mode 100644 index 0000000000..f00fd261f2 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarPostfixOwnerTransferTest.java @@ -0,0 +1,58 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class RuntimeScalarPostfixOwnerTransferTest { + @Test + void postfixIncrementMovesAndThenReleasesTheScalarValueSlot() { + assertPostfixOwnerTransfer(true); + } + + @Test + void postfixDecrementMovesAndThenReleasesTheScalarValueSlot() { + assertPostfixOwnerTransfer(false); + } + + private static void assertPostfixOwnerTransfer(boolean increment) { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar source = new RuntimeScalar(); + source.set(referent.createAnonymousReference()); + PerlOwnerSlot originalSlot = source.scalarValueOwnerSlot(); + assertNotNull(originalSlot); + assertEquals(1, referent.refCount); + + RuntimeScalar postfix = increment + ? source.postAutoIncrement() + : source.postAutoDecrement(); + + assertNull(source.scalarValueOwnerSlot(), + "the lvalue no longer owns the old reference after postfix mutation"); + assertTrue(originalSlot.isActive()); + assertEquals(originalSlot.identity(), postfix.scalarValueOwnerSlot().identity(), + "the returned scalar inherits the same owner-slot identity"); + assertEquals(1, referent.refCount); + + RuntimeScalar destination = new RuntimeScalar(); + postfix.addToScalar(destination); + + assertFalse(originalSlot.isActive(), + "assignment consumes the postfix temporary owner"); + assertTrue(destination.scalarValueOwnerSlot().isActive()); + assertFalse(postfix.scalarValueOwnerSlot().isActive(), + "the consumed slot remains attached only as an inactive reusable token"); + assertEquals(1, referent.refCount); + assertEquals(1, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE), + "one counted owner has exactly one live scalar-value slot"); + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarValueOwnerSlotTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarValueOwnerSlotTest.java new file mode 100644 index 0000000000..fb0f55bbe0 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeScalarValueOwnerSlotTest.java @@ -0,0 +1,140 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; + +@Tag("unit") +class RuntimeScalarValueOwnerSlotTest { + @Test + void scalarSelfAssignmentKeepsOneCountedOwnerAndSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + referent.activateOwnerTracking(); + RuntimeScalar scalar = new RuntimeScalar(); + scalar.set(referent.createAnonymousReference()); + PerlOwnerSlot slot = scalar.scalarValueOwnerSlot(); + + assertEquals(1, referent.refCount); + assertEquals(1, referent.activeOwnerCount()); + + scalar.set(scalar); + + assertEquals(1, referent.refCount, + "self-assignment balances its temporary retain and release"); + assertEquals(1, referent.activeOwnerCount(), + "the scalar remains in the legacy owner registry"); + assertEquals(1, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertSame(slot, scalar.scalarValueOwnerSlot()); + scalar.undefine(); + } + } + + @Test + void scalarAssignmentTransfersOneStableOwnerSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray first = new RuntimeArray(); + RuntimeArray second = new RuntimeArray(); + RuntimeScalar scalar = new RuntimeScalar(); + + scalar.set(first.createReference()); + + PerlOwnerSlot slot = scalar.scalarValueOwnerSlot(); + assertEquals(1, first.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertSame(first, slot.referent()); + + scalar.set(second.createReference()); + + assertSame(slot, scalar.scalarValueOwnerSlot(), + "the scalar cell keeps its owner-slot identity across assignment"); + assertEquals(0, first.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertEquals(1, second.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertSame(second, slot.referent()); + + scalar.set(new RuntimeScalar(7)); + + assertEquals(0, second.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertFalse(slot.isActive()); + } + } + + @Test + void scalarOverwriteReleasesDisplacedSlotAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray first = new RuntimeArray(); + RuntimeArray second = new RuntimeArray(); + first.activateOwnerTracking(); + second.activateOwnerTracking(); + RuntimeScalar scalar = new RuntimeScalar(); + scalar.set(first.createReference()); + PerlOwnerSlot slot = scalar.scalarValueOwnerSlot(); + scalar.refCountOwned = false; + + scalar.set(second.createReference()); + + assertEquals(0, first.refCount, + "overwriting the cell releases the referent recorded by its slot"); + assertEquals(0, first.activeOwnerCount(), + "the displaced cell is removed from the compatibility owner registry"); + assertEquals(1, second.refCount); + assertEquals(1, second.activeOwnerCount()); + assertEquals(0, first.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertEquals(1, second.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertSame(slot, scalar.scalarValueOwnerSlot()); + assertSame(second, slot.referent()); + + scalar.undefine(); + } + } + + @Test + void localizedScalarValueKeepsItsOwnerUntilRestore() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray original = new RuntimeArray(); + RuntimeArray localized = new RuntimeArray(); + RuntimeScalar scalar = new RuntimeScalar(); + scalar.set(original.createReference()); + + scalar.dynamicSaveState(); + scalar.set(localized.createReference()); + + assertEquals(1, original.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE), + "localization preserves the saved scalar value owner"); + assertEquals(1, localized.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE), + "the localized assignment owns its new referent"); + + scalar.dynamicRestoreState(); + + assertEquals(1, original.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertEquals(0, localized.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + assertSame(original, scalar.scalarValueOwnerSlot().referent()); + } + } + + @Test + void explicitUndefReleasesTheScalarValueSlotAndItsCountTogether() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar scalar = new RuntimeScalar(); + scalar.set(referent.createReference()); + + assertEquals(1, referent.refCount); + assertEquals(1, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + + scalar.undefine(); + + assertEquals(0, referent.refCount, + "explicit undef decrements the count represented by the scalar slot"); + assertEquals(0, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE), + "the scalar slot is retired with its count"); + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeStashOwnerSlotTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeStashOwnerSlotTest.java new file mode 100644 index 0000000000..550bcbd4dd --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeStashOwnerSlotTest.java @@ -0,0 +1,37 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class RuntimeStashOwnerSlotTest { + @Test + void deletingGlobalScalarReleasesExplicitSlotAfterLegacyFlagRetires() { + PerlRuntime runtime = new PerlRuntime(); + String name = "RuntimeStashOwnerSlotTest::owner"; + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeScalar owner = new RuntimeScalar(); + owner.set(referent.createAnonymousReference()); + owner.refCountOwned = false; + assertTrue(owner.scalarValueOwnerSlot().isActive()); + GlobalVariable.scalarSlots().put(name, owner); + + try { + assertEquals(1, referent.refCount); + new RuntimeStash("RuntimeStashOwnerSlotTest::").delete("owner"); + + assertEquals(Integer.MIN_VALUE, referent.refCount); + assertFalse(owner.scalarValueOwnerSlot().isActive()); + assertEquals(0, referent.activeOwnerCount()); + assertEquals(0, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + } finally { + GlobalVariable.scalarSlots().remove(name); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/ScalarSnapshotCodeStateRootTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/ScalarSnapshotCodeStateRootTest.java new file mode 100644 index 0000000000..f3057ea7f2 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/ScalarSnapshotCodeStateRootTest.java @@ -0,0 +1,40 @@ +package org.perlonjava.runtime.runtimetypes; + +import java.util.List; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class ScalarSnapshotCodeStateRootTest { + @Test + void completeScalarSnapshotFollowsPersistentCodeState() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeScalar persistentState = new RuntimeScalar("state"); + RuntimeScalar stateSlot = new RuntimeScalar(); + stateSlot.type = RuntimeScalarType.REFERENCE; + stateSlot.value = persistentState; + + RuntimeCode code = new RuntimeCode(null, List.of()); + code.stateVariable.put("persistent", stateSlot); + String globalName = "ScalarSnapshotCodeStateRootTest::state_holder"; + GlobalVariable.globalCodeRefs.put(globalName, new RuntimeScalar(code)); + try { + ReachabilityWalker.ScalarRootReachabilitySnapshot snapshot = + ReachabilityWalker.completeScalarReachabilitySnapshot(); + + assertTrue(snapshot.complete(), "the cleanup snapshot is complete"); + assertTrue(snapshot.isReachable(stateSlot), + "a globally installed sub keeps its persistent state slot live"); + assertTrue(snapshot.isReachable(persistentState), + "persistent state values remain roots while their sub is installed"); + } finally { + GlobalVariable.globalCodeRefs.remove(globalName); + MortalList.invalidateAllRootSnapshots(); + } + } + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/TieWrapperOwnerSlotTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/TieWrapperOwnerSlotTest.java new file mode 100644 index 0000000000..5e44002d59 --- /dev/null +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/TieWrapperOwnerSlotTest.java @@ -0,0 +1,129 @@ +package org.perlonjava.runtime.runtimetypes; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class TieWrapperOwnerSlotTest { + @Test + void tiedScalarWrapperOwnsAndReleasesDistinctHandlerSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeScalar handler = trackedHandler(); + RuntimeBase referent = (RuntimeBase) handler.value; + TieScalar wrapper = new TieScalar( + "TieWrapperOwnerSlotTest", new RuntimeScalar(), handler); + assertWrapperRelease(referent, handler, wrapper::releaseTiedObject); + } + } + + @Test + void tiedArrayWrapperOwnsAndReleasesDistinctHandlerSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeScalar handler = trackedHandler(); + RuntimeBase referent = (RuntimeBase) handler.value; + TieArray wrapper = new TieArray( + "TieWrapperOwnerSlotTest", new RuntimeArray(), handler, new RuntimeArray()); + assertWrapperRelease(referent, handler, wrapper::releaseTiedObject); + } + } + + @Test + void tiedHashWrapperOwnsAndReleasesDistinctHandlerSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeScalar handler = trackedHandler(); + RuntimeBase referent = (RuntimeBase) handler.value; + TieHash wrapper = new TieHash( + "TieWrapperOwnerSlotTest", new RuntimeHash(), handler); + assertWrapperRelease(referent, handler, wrapper::releaseTiedObject); + } + } + + @Test + void tiedHandleWrapperOwnsAndReleasesDistinctHandlerSlot() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeScalar handler = trackedHandler(); + RuntimeBase referent = (RuntimeBase) handler.value; + TieHandle wrapper = new TieHandle( + "TieWrapperOwnerSlotTest", new RuntimeIO(), handler); + assertWrapperRelease(referent, handler, wrapper::releaseTiedObject); + } + } + + @Test + void wrapperCreatedBeforeHandlerTrackingCountsWhenRootedHandlerIsPromoted() { + PerlRuntime runtime = new PerlRuntime(); + try (PerlRuntime.Binding ignored = runtime.bind()) { + RuntimeArray referent = new RuntimeArray(); + RuntimeArray parent = new RuntimeArray(); + parent.elementsOwned = true; + RuntimeScalar handler = new RuntimeScalar(); + handler.type = RuntimeScalarType.ARRAYREFERENCE; + handler.value = referent; + TieArray wrapper = new TieArray( + "TieWrapperOwnerSlotTest", new RuntimeArray(), handler, parent); + parent.elements = wrapper; + MyVarCleanupStack.register(parent); + RuntimeScalar countedOwner = new RuntimeScalar(); + try { + assertEquals(-1, referent.refCount); + assertEquals(1, referent.ownerSlotCount(PerlOwnerSlot.Kind.TIE_WRAPPER)); + + referent.activateOwnerTracking(); + RuntimeScalar handlerReference = new RuntimeScalar(); + handlerReference.type = RuntimeScalarType.ARRAYREFERENCE; + handlerReference.value = referent; + countedOwner.set(handlerReference); + MyVarCleanupStack.register(countedOwner); + assertEquals(2, referent.refCount); + assertTrue(referent.ownerSlotSnapshot().stream() + .filter(slot -> slot.kind() == PerlOwnerSlot.Kind.TIE_WRAPPER) + .findFirst().orElseThrow().tieWrapperCounted(), + "the rooted wrapper edge is counted during handler promotion"); + + wrapper.releaseTiedObject(); + + assertEquals(1, referent.refCount, + "releasing the recovered wrapper owner preserves the scalar owner's count"); + assertEquals(0, referent.ownerSlotCount(PerlOwnerSlot.Kind.TIE_WRAPPER)); + } finally { + wrapper.releaseTiedObject(); + MyVarCleanupStack.unregister(countedOwner); + countedOwner.undefine(); + MyVarCleanupStack.unregister(parent); + } + } + } + + private static RuntimeScalar trackedHandler() { + RuntimeArray referent = new RuntimeArray(); + referent.activateOwnerTracking(); + RuntimeScalar handler = new RuntimeScalar(); + handler.set(referent.createAnonymousReference()); + return handler; + } + + private static void assertWrapperRelease( + RuntimeBase referent, RuntimeScalar handler, Runnable release) { + // Constructor increments a second count for the wrapper-held edge. + assertEquals(2, referent.refCount); + assertEquals(1, referent.ownerSlotCount(PerlOwnerSlot.Kind.TIE_WRAPPER)); + + release.run(); + assertEquals(1, referent.refCount); + assertEquals(0, referent.ownerSlotCount(PerlOwnerSlot.Kind.TIE_WRAPPER)); + assertEquals(1, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + + release.run(); + assertEquals(1, referent.refCount, + "repeated wrapper release does not consume the handler cell's count"); + handler.undefine(); + assertEquals(0, referent.ownerSlotCount(PerlOwnerSlot.Kind.SCALAR_VALUE)); + } +} diff --git a/src/test/resources/unit/catalyst_request_body_tempfile_global_destroy.t b/src/test/resources/unit/catalyst_request_body_tempfile_global_destroy.t new file mode 100644 index 0000000000..4c1ebf3c03 --- /dev/null +++ b/src/test/resources/unit/catalyst_request_body_tempfile_global_destroy.t @@ -0,0 +1,43 @@ +use strict; +use warnings; +use File::Basename qw(dirname); +use File::Temp qw(tempfile); +use Test::More; + +if ($^O eq 'MSWin32') { + plan skip_all => 'timeout command is unavailable on Windows'; +} + +my $lifetime_test = dirname($0) . '/catalyst_request_body_tempfile_lifetime.t'; +my ($log_fh, $log_path) = tempfile(); +close($log_fh) or die "close child output file: $!"; + +open(my $saved_stdout, '>&', \*STDOUT) or die "save stdout: $!"; +open(my $saved_stderr, '>&', \*STDERR) or die "save stderr: $!"; +open(STDOUT, '>', $log_path) or die "redirect stdout: $!"; +open(STDERR, '>&', \*STDOUT) or die "redirect stderr: $!"; + +my $runner = $^X eq 'jperl' ? './jperl' : $^X; +my $status = system('timeout', '300', $runner, $lifetime_test); + +open(STDOUT, '>&', $saved_stdout) or die "restore stdout: $!"; +open(STDERR, '>&', $saved_stderr) or die "restore stderr: $!"; +close($saved_stdout); +close($saved_stderr); + +open(my $log_read, '<', $log_path) or die "read child output: $!"; +my $output = do { local $/; <$log_read> }; +close($log_read); +unlink($log_path); + +if ($output =~ /^1\.\.0 # SKIP (.+)$/m) { + plan skip_all => $1; +} + +is($status, 0, 'Catalyst tempfile lifetime regression passes in a child process'); +like($output, qr/^1\.\.24$/m, 'child ran all 24 Catalyst lifetime assertions'); +unlike($output, + qr/DESTROY created new reference to dead object 'File::Temp'/, + 'normal tempfile cleanup does not report global-destruction resurrection'); + +done_testing; diff --git a/src/test/resources/unit/catalyst_request_body_tempfile_lifetime.t b/src/test/resources/unit/catalyst_request_body_tempfile_lifetime.t index f9a9340ee5..6b04cbd656 100644 --- a/src/test/resources/unit/catalyst_request_body_tempfile_lifetime.t +++ b/src/test/resources/unit/catalyst_request_body_tempfile_lifetime.t @@ -1,68 +1,250 @@ use strict; use warnings; use Test::More; +use Data::Dumper (); +use Scalar::Util qw(weaken); BEGIN { - # The regular unit corpus has a small HTTP::Request fixture that omits - # the API Catalyst::Test needs. Keep this integration check on the real - # cached CPAN module instead. - @INC = grep { - ref($_) || $_ !~ m{(?:^|[/\\])src/test/resources/unit/lib/?\z} - } @INC; - - my $available = eval { - require Catalyst; - require Catalyst::Controller; - require Catalyst::Test; - require HTTP::Request; - die 'HTTP::Request fixture is incomplete' unless HTTP::Request->can('push_header'); - require HTTP::Request::Common; - 1; - }; - plan skip_all => 'Catalyst test dependencies are not installed' unless $available; + eval { + require Catalyst; + require Catalyst::Controller; + require Catalyst::Test; + require HTTP::Request; + require HTTP::Body::OctetStream; + require File::Temp; + 1; + } or plan skip_all => 'Catalyst request test dependencies are not installed'; } -package CatalystUploadLifetimeApp; - +package Issue1795OwnershipTest; use Catalyst; -__PACKAGE__->config(name => 'CatalystUploadLifetimeApp'); - -package CatalystUploadLifetimeApp::Controller::Root; +our ($original_body, $original_context, $original_request, $original_temp, + $tempfile_path, $tempfile_exists_during_request); +our ($multipart_context, $multipart_request, $multipart_body, + $multipart_tempfile_paths, $multipart_tempfiles_exist_during_request); +__PACKAGE__->config(name => 'Issue1795OwnershipTest'); +package Issue1795OwnershipTest::Controller::Root; use base 'Catalyst::Controller'; - -our $temp_path; - -sub body :Path('/body') { - my ($self, $c) = @_; - $c->prepare_body; - $temp_path = $c->req->{_body}->body->filename; - $c->res->body('ok'); +__PACKAGE__->config(namespace => ''); + +sub dump_body : Local { + my ($self, $context) = @_; + my $request = $context->request; + $request->prepare_body; + $Issue1795OwnershipTest::original_context = $context; + Scalar::Util::weaken($Issue1795OwnershipTest::original_context); + $Issue1795OwnershipTest::original_request = $request; + Scalar::Util::weaken($Issue1795OwnershipTest::original_request); + $Issue1795OwnershipTest::original_body = $request->{_body}; + Scalar::Util::weaken($Issue1795OwnershipTest::original_body); + my $tempfile = $request->{_body}->body; + $Issue1795OwnershipTest::original_temp = $tempfile; + Scalar::Util::weaken($Issue1795OwnershipTest::original_temp); + $Issue1795OwnershipTest::tempfile_path = $tempfile->filename; + $Issue1795OwnershipTest::tempfile_exists_during_request = + -e $Issue1795OwnershipTest::tempfile_path; + + my $dumper = Data::Dumper->new([$request->{_body}]); + $dumper->Indent(1); + $dumper->Purity(1); + $dumper->Useqq(0); + $dumper->Quotekeys(1); + $dumper->Terse(1); + $dumper->Deepcopy(1); + + $context->res->content_type('text/plain'); + $context->res->body($dumper->Dump); } -sub index :Path('/') { - $_[1]->res->body('ok'); +sub index : Local { $_[1]->res->body('ok') } + +sub multipart_upload : Local { + my ($self, $context) = @_; + my $request = $context->request; + $request->prepare_body; + $request->prepare_body_parameters($context); + my @uploads = $request->upload('attachment'); + + $Issue1795OwnershipTest::multipart_context = $context; + Scalar::Util::weaken($Issue1795OwnershipTest::multipart_context); + $Issue1795OwnershipTest::multipart_request = $request; + Scalar::Util::weaken($Issue1795OwnershipTest::multipart_request); + $Issue1795OwnershipTest::multipart_body = $request->{_body}; + Scalar::Util::weaken($Issue1795OwnershipTest::multipart_body); + $Issue1795OwnershipTest::multipart_tempfile_paths = + [map { $_->tempname } @uploads]; + $Issue1795OwnershipTest::multipart_tempfiles_exist_during_request = + [map { -e $_->tempname } @uploads]; + + my $body = delete $request->{_body}; + my $request_context = delete $request->{_context}; + my $request_env = delete $request->{env}; + my $dumper = Data::Dumper->new([$request]); + $dumper->Indent(1); + $dumper->Purity(1); + $dumper->Useqq(0); + $dumper->Quotekeys(1); + $dumper->Terse(1); + $dumper->Deepcopy(1); + my $serialized = $dumper->Dump; + $request->{_context} = $request_context; + Scalar::Util::weaken($request->{_context}); + $request->{_body} = $body; + $request->{env} = $request_env; + $context->res->body($serialized); } -package CatalystUploadLifetimeApp; - +package Issue1795OwnershipTest; __PACKAGE__->setup; package main; +Catalyst::Test->import('Issue1795OwnershipTest'); + +# The unit-test HTTP::Request shim only implements the request fields these +# tests need. Add the small HTTP::Message surface used by Catalyst::Test when +# that shim is active; installed HTTP::Request distributions already provide +# these methods. +{ + no strict 'refs'; + no warnings qw(once redefine); + *HTTP::Request::method = sub { $_[0]{method} } + unless HTTP::Request->can('method'); + *HTTP::Request::headers = sub { + bless {request => $_[0]}, 'HTTP::Request::_Headers'; + } unless HTTP::Request->can('headers'); + *HTTP::Request::protocol = sub { 'HTTP/1.1' } + unless HTTP::Request->can('protocol'); + *HTTP::Request::content = sub { + my ($self, $content) = @_; + $self->{content} = $content if @_ > 1; + return $self->{content} // ''; + } unless HTTP::Request->can('content'); + *HTTP::Request::content_length = sub { + $_[0]->header('Content-Length'); + } unless HTTP::Request->can('content_length'); + *HTTP::Request::_URI::clone = sub { + my %copy = %{$_[0]}; + return bless \%copy, ref($_[0]); + } unless HTTP::Request::_URI->can('clone'); + *HTTP::Request::_URI::query = sub { $_[0]{query} } + unless HTTP::Request::_URI->can('query'); + *HTTP::Request::_URI::path_query = sub { $_[0]{path} } + unless HTTP::Request::_URI->can('path_query'); + *HTTP::Request::_URI::host_port = sub { + $_[0]{host} . ':' . $_[0]{port}; + } unless HTTP::Request::_URI->can('host_port'); + *HTTP::Request::_URI::default_port = sub { + $_[0]{scheme} eq 'https' ? 443 : 80; + } unless HTTP::Request::_URI->can('default_port'); +} -Catalyst::Test->import('CatalystUploadLifetimeApp'); - -plan tests => 2; +{ + no strict 'refs'; + no warnings qw(once redefine); + *HTTP::Request::_Headers::header_field_names = sub { + my ($self) = @_; + return map { + my $name = $_; + $name =~ s/(^|-)([a-z])/$1 . uc($2)/ge; + $name; + } keys %{$self->{request}{headers}}; + } unless HTTP::Request::_Headers->can('header_field_names'); + *HTTP::Request::_Headers::header = sub { + my ($self, @args) = @_; + return $self->{request}->header(@args); + } unless HTTP::Request::_Headers->can('header'); +} -my $request = HTTP::Request::Common::PUT( - 'http://localhost/body', - 'Content-Type' => 'application/octet-stream', - 'Content' => 'payload', - 'Content-Length' => 7, +my $request = HTTP::Request->new( + 'PUT', 'http://localhost/dump_body', ); +$request->header('Content-Type' => 'application/octet-stream'); +$request->header('Content-Length' => 9); +$request->content('foobarbaz'); my $response = request($request); -is($response->code, 200, 'octet-stream request reaches the Catalyst app'); - -my $upload_path = $CatalystUploadLifetimeApp::Controller::Root::temp_path; -request(HTTP::Request::Common::GET('/')); -ok(!-e $upload_path, 'request temp file is removed when the next request releases the old context'); +ok($response && $response->is_success, 'octet-stream request succeeds'); +ok($Issue1795OwnershipTest::tempfile_exists_during_request, + 'request body tempfile remains available while handling the upload'); +unlike($response->content, qr/\*\{'null'\}/, + 'serialized tempfile handle retains its Perl glob name'); +unlike($response->content, + qr/^\# \*\{'File::Temp::\$fh'\} = \[\];$/m, + 'serialization does not invent an ARRAY slot on the lexical tempfile glob'); + +my $body; +{ + no strict 'refs'; + eval '$body = ' . substr($response->content, 8); +} +die $@ if $@; +isa_ok($body, 'HTTP::Body::OctetStream'); +isa_ok($body->body, 'File::Temp'); +my $path = $body->body->filename; +is($path, $Issue1795OwnershipTest::tempfile_path, + 'serialized body refers to the upload tempfile'); + +my $next_response = request(HTTP::Request->new('GET', 'http://localhost/index')); +ok($next_response && $next_response->is_success, 'subsequent request succeeds'); +ok(!defined($Issue1795OwnershipTest::original_context), + 'completed request context has no remaining strong owner'); +ok(!defined($Issue1795OwnershipTest::original_request), + 'completed Catalyst request has no remaining strong owner'); +ok(!defined($Issue1795OwnershipTest::original_body), + 'completed request body has no remaining strong owner'); +ok(!defined($Issue1795OwnershipTest::original_temp), + 'completed request tempfile object has no remaining strong owner'); +ok(!-e $path, 'request body tempfile is released with the previous request'); + +my $boundary = '----perlonjava-upload-boundary'; +my $multipart_content = ''; +for my $index (1 .. 3) { + $multipart_content .= join "\r\n", + "--$boundary", + "Content-Disposition: form-data; name=\"attachment\"; filename=\"note$index.txt\"", + 'Content-Type: text/plain', + '', + "upload payload $index", + ''; +} +$multipart_content .= "--$boundary--\r\n"; +my $upload_request = HTTP::Request->new( + 'POST', 'http://localhost/multipart_upload', +); +$upload_request->header( + 'Content-Type' => "multipart/form-data; boundary=$boundary"); +$upload_request->header('Content-Length' => length($multipart_content)); +$upload_request->content($multipart_content); +my $multipart_response = request($upload_request); +ok($multipart_response && $multipart_response->is_success, + 'multipart upload request succeeds'); +my $decoded_request; +{ + no strict 'refs'; + eval '$decoded_request = ' . $multipart_response->content; +} +die $@ if $@; +isa_ok($decoded_request, 'Catalyst::Request'); +my @decoded_uploads = $decoded_request->upload('attachment'); +is(scalar @decoded_uploads, 3, 'all multipart uploads are present'); +isa_ok($decoded_uploads[0], 'Catalyst::Request::Upload'); +my @multipart_paths = map { $_->tempname } @decoded_uploads; +is_deeply(\@multipart_paths, + $Issue1795OwnershipTest::multipart_tempfile_paths, + 'serialized uploads retain their tempfile paths'); +ok(!(grep { !$_ } @{$Issue1795OwnershipTest::multipart_tempfiles_exist_during_request}), + 'all multipart tempfiles remain available while handling the upload'); +my $after_upload_response = request( + HTTP::Request->new('GET', 'http://localhost/index')); +ok($after_upload_response && $after_upload_response->is_success, + 'request after multipart upload succeeds'); +ok(!defined($Issue1795OwnershipTest::multipart_context), + 'completed multipart request context has no remaining strong owner'); +ok(!defined($Issue1795OwnershipTest::multipart_request), + 'completed multipart Catalyst request has no remaining strong owner'); +ok(!defined($Issue1795OwnershipTest::multipart_body), + 'completed multipart request body has no remaining strong owner'); +ok(!(grep { -e $_ } @multipart_paths), + 'all multipart upload tempfiles are removed after the request completes'); + +done_testing; diff --git a/src/test/resources/unit/io_select_nested_process_handle.t b/src/test/resources/unit/io_select_nested_process_handle.t index 5e33619af8..a4b7d25078 100644 --- a/src/test/resources/unit/io_select_nested_process_handle.t +++ b/src/test/resources/unit/io_select_nested_process_handle.t @@ -20,7 +20,10 @@ sub add_delayed_child { } my $pid = add_delayed_child($selector, $launcher); -my @ready = $selector->can_read(5); +# On Windows CI the nested PerlOnJava child can take several seconds to start +# under parallel load. Keep the timeout long enough to test handle lifetime +# instead of measuring JVM startup latency. +my @ready = $selector->can_read(30); is(scalar @ready, 1, 'nested process handle remains selectable after scope exit'); my $line = @ready ? readline($ready[0][0]) : undef; diff --git a/src/test/resources/unit/list_assignment_array_tail_lifetime.t b/src/test/resources/unit/list_assignment_array_tail_lifetime.t new file mode 100644 index 0000000000..ceffd2cc5d --- /dev/null +++ b/src/test/resources/unit/list_assignment_array_tail_lifetime.t @@ -0,0 +1,31 @@ +use strict; +use warnings; +use Test::More tests => 4; + +{ + package Local::ListAssignmentTailLifetime; + our $destroyed = 0; + sub DESTROY { $destroyed++ } +} + +my $old = bless {}, 'Local::ListAssignmentTailLifetime'; +my $new = bless {}, 'Local::ListAssignmentTailLifetime'; +my @tail = ($old); +my $head; + +($head, @tail) = (7, $new); +undef $old; +is($Local::ListAssignmentTailLifetime::destroyed, 1, + 'list assignment releases the displaced array-tail value'); + +undef $new; +is($Local::ListAssignmentTailLifetime::destroyed, 1, + 'the assigned array tail keeps its value alive'); + +my $removed = pop @tail; +is($Local::ListAssignmentTailLifetime::destroyed, 1, + 'pop transfers the final array owner to its result'); + +undef $removed; +is($Local::ListAssignmentTailLifetime::destroyed, 2, + 'releasing the popped result destroys the final value'); diff --git a/src/test/resources/unit/list_assignment_hash_tail_lifetime.t b/src/test/resources/unit/list_assignment_hash_tail_lifetime.t new file mode 100644 index 0000000000..35a0869411 --- /dev/null +++ b/src/test/resources/unit/list_assignment_hash_tail_lifetime.t @@ -0,0 +1,31 @@ +use strict; +use warnings; +use Test::More tests => 4; + +{ + package Local::ListAssignmentHashTailLifetime; + our $destroyed = 0; + sub DESTROY { $destroyed++ } +} + +my $old = bless {}, 'Local::ListAssignmentHashTailLifetime'; +my $new = bless {}, 'Local::ListAssignmentHashTailLifetime'; +my %tail = (old => $old); +my $head; + +($head, %tail) = (7, new => $new); +undef $old; +is($Local::ListAssignmentHashTailLifetime::destroyed, 1, + 'list assignment releases the displaced hash-tail value'); + +undef $new; +is($Local::ListAssignmentHashTailLifetime::destroyed, 1, + 'the assigned hash tail keeps its value alive'); + +my $removed = delete $tail{new}; +is($Local::ListAssignmentHashTailLifetime::destroyed, 1, + 'delete transfers the final hash owner to its result'); + +undef $removed; +is($Local::ListAssignmentHashTailLifetime::destroyed, 2, + 'releasing the deleted result destroys the final value'); diff --git a/src/test/resources/unit/mojolicious_controller_destroy_after_finish.t b/src/test/resources/unit/mojolicious_controller_destroy_after_finish.t new file mode 100644 index 0000000000..771ff9ce3b --- /dev/null +++ b/src/test/resources/unit/mojolicious_controller_destroy_after_finish.t @@ -0,0 +1,54 @@ +use strict; +use warnings; +use Test::More; + +BEGIN { + $ENV{MOJO_REACTOR} = 'Mojo::Reactor::Poll'; + eval { + require IO::Socket::INET; + require Mojolicious::Lite; + Mojolicious::Lite->import; + require Test::Mojo; + require Mojo::IOLoop; + 1; + } or plan skip_all => 'Mojolicious and Test::Mojo are required'; + + my $probe = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => 0, + Listen => 1, + Proto => 'tcp', + ) or plan skip_all => 'loopback sockets are unavailable'; + close $probe; +} + +package MojoliciousControllerDestroyAfterFinish; +use Mojo::Base 'Mojolicious::Controller'; +sub DESTROY { shift->stash->{destroyed} = 1 } + +package main; +app->controller_class('MojoliciousControllerDestroyAfterFinish'); +get '/longpoll' => sub { + my $controller = shift; + $controller->on(finish => sub { shift->stash->{finished} = 1 }); + $controller->write_chunk('P' => sub { + shift->write_chunk('oll!' => sub { shift->write_chunk('') }); + }); +}; + +my $stash; +my $tick_count = 0; +my $timer = Mojo::IOLoop->recurring(0.1 => sub { ++$tick_count }); +my $test = Test::Mojo->new; +$test->app->plugins->once(before_dispatch => sub { $stash = shift->stash }); +$test->get_ok('/longpoll')->status_is(200)->content_is('Poll!'); + +Mojo::IOLoop->one_tick while !$stash->{finished} && $tick_count < 50; +is($stash->{finished}, 1, 'long-poll response finish event runs'); + +$tick_count = 0; +Mojo::IOLoop->one_tick while !$stash->{destroyed} && $tick_count < 50; +Mojo::IOLoop->remove($timer); +ok($stash->{destroyed}, 'controller is destroyed after response finish'); + +done_testing; diff --git a/src/test/resources/unit/mojolicious_websocket_callback_lifetime.t b/src/test/resources/unit/mojolicious_websocket_callback_lifetime.t new file mode 100644 index 0000000000..14d78b2195 --- /dev/null +++ b/src/test/resources/unit/mojolicious_websocket_callback_lifetime.t @@ -0,0 +1,93 @@ +use strict; +use warnings; + +BEGIN { $ENV{MOJO_REACTOR} = 'Mojo::Reactor::Poll' } + +use Test::More; + +BEGIN { + $ENV{MOJO_REACTOR} = 'Mojo::Reactor::Poll'; + eval { + require IO::Socket::INET; + require Mojolicious::Lite; + Mojolicious::Lite->import; + require Test::Mojo; + require Mojo::IOLoop; + 1; + } or plan skip_all => 'Mojolicious and Test::Mojo are required'; + + my $probe = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => 0, + Listen => 1, + Proto => 'tcp', + ) or plan skip_all => 'loopback sockets are unavailable'; + close $probe; +} + +package LifetimeController; +use Mojo::Base 'Mojolicious::Controller'; +sub DESTROY { + my $self = shift; + $main::websocket_controller_destroyed = 1 if $self->stash->{websocket_lifetime}; + $self->stash->{destroyed} = 1; +} +package main; +our $push_timer_id; +our $websocket_controller_destroyed = 0; +app->controller_class('LifetimeController'); +websocket '/push' => sub { + my $c = shift; + $c->stash->{websocket_lifetime} = 1; + $push_timer_id = Mojo::IOLoop->recurring(0.1 => sub { $c->send('push') }); +}; +get '/longpoll' => sub { + my $c = shift; + $c->on(finish => sub { shift->stash->{finished} = 1 }); + $c->write_chunk('P' => sub { + shift->write_chunk('oll!' => sub { shift->write_chunk('') }); + }); +}; +get '/plugin/upper_case' => {text => 'WELCOME aboard!'}; +get '/plugin/camel_case' => {text => 'Welcome aboard!'}; +get '/staged' => sub { + my $c = shift; + return $c->render(text => 'Go away!') unless $c->req->headers->header('X-Pass'); + $c->render(text => 'Welcome aboard!'); +}; +get '/suspended' => sub { + my $c = shift; + $c->res->headers->header('X-Suspended' => '0'); + Mojo::IOLoop->next_tick(sub { + $c->res->headers->append('X-Suspended' => '1, 1, 2'); + $c->render(text => '

Have fun!

'); + }); +}; + +my $t = Test::Mojo->new; +$t->websocket_ok('/push') + ->message_ok->message_is('push') + ->message_ok->message_is('push') + ->message_ok->message_is('push'); +ok(!$websocket_controller_destroyed, + 'WebSocket controller remains alive while the connection is open'); +Mojo::IOLoop->remove($push_timer_id); +$t->finish_ok; +$t->get_ok('/plugin/upper_case')->status_is(200)->content_is('WELCOME aboard!'); +$t->get_ok('/plugin/camel_case')->status_is(200)->content_is('Welcome aboard!'); +$t->get_ok('/staged' => {'X-Pass' => 1})->status_is(200)->content_is('Welcome aboard!'); +$t->get_ok('/staged')->status_is(200)->content_is('Go away!'); +$t->get_ok('/suspended')->status_is(200)->content_is('

Have fun!

'); +my $stash; +$t->app->plugins->once(before_dispatch => sub { $stash = shift->stash }); +$t->get_ok('/longpoll')->status_is(200)->content_is('Poll!'); +Mojo::IOLoop->one_tick until $stash->{finished}; +unless ($stash->{destroyed}) { + for (1 .. 10) { + last if $stash->{destroyed}; + Mojo::IOLoop->one_tick; + } +} +is($stash->{finished}, 1, 'long-poll response has finished'); +ok($stash->{destroyed}, 'long-poll controller is destroyed after response finish'); +done_testing; diff --git a/src/test/resources/unit/mojolicious_websocket_controller_release.t b/src/test/resources/unit/mojolicious_websocket_controller_release.t new file mode 100644 index 0000000000..28114f3808 --- /dev/null +++ b/src/test/resources/unit/mojolicious_websocket_controller_release.t @@ -0,0 +1,53 @@ +use strict; +use warnings; + +BEGIN { $ENV{MOJO_REACTOR} = 'Mojo::Reactor::Poll' } + +use Test::More; + +BEGIN { + $ENV{MOJO_REACTOR} = 'Mojo::Reactor::Poll'; + plan skip_all => 'Test::Mojo server setup is not available in the interpreter backend' + if $ENV{JPERL_INTERPRETER}; + eval { + require IO::Socket::INET; + require Mojolicious::Lite; + Mojolicious::Lite->import; + require Test::Mojo; + require Mojo::IOLoop; + 1; + } or plan skip_all => 'Mojolicious and Test::Mojo are required'; + + my $probe = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => 0, + Listen => 1, + Proto => 'tcp', + ) or plan skip_all => 'loopback sockets are unavailable'; + close $probe; +} + +package WebSocketLifetimeController; +use Mojo::Base 'Mojolicious::Controller'; +sub DESTROY { $main::controller_destroyed = 1 } + +package main; +our $timer_id; +our $controller_destroyed = 0; +app->controller_class('WebSocketLifetimeController'); +websocket '/lifetime' => sub { + my $controller = shift; + $timer_id = Mojo::IOLoop->recurring(0.05 => sub { $controller->send('tick') }); +}; + +my $t = Test::Mojo->new; +$t->websocket_ok('/lifetime')->message_ok->message_is('tick'); +ok(!$controller_destroyed, 'controller stays alive while its WebSocket is open'); +Mojo::IOLoop->remove($timer_id); +$t->finish_ok; + +my $wait_expired = 0; +Mojo::IOLoop->timer(0.25 => sub { $wait_expired = 1 }); +Mojo::IOLoop->one_tick until $controller_destroyed || $wait_expired; +ok($controller_destroyed, 'controller is released after its WebSocket closes'); +done_testing; diff --git a/src/test/resources/unit/mojolicious_websocket_server_push_lifetime.t b/src/test/resources/unit/mojolicious_websocket_server_push_lifetime.t new file mode 100644 index 0000000000..0595afe5c3 --- /dev/null +++ b/src/test/resources/unit/mojolicious_websocket_server_push_lifetime.t @@ -0,0 +1,58 @@ +use strict; +use warnings; +use Test::More; + +BEGIN { + $ENV{MOJO_REACTOR} = 'Mojo::Reactor::Poll'; + eval { + require IO::Socket::INET; + require Mojolicious::Lite; + Mojolicious::Lite->import; + require Test::Mojo; + require Mojo::IOLoop; + 1; + } or plan skip_all => 'Mojolicious and Test::Mojo are required'; + + my $probe = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => 0, + Listen => 1, + Proto => 'tcp', + ) or plan skip_all => 'loopback sockets are unavailable'; + close $probe; +} + +package Issue1794WebSocketController; +use Mojo::Base 'Mojolicious::Controller'; +sub DESTROY { ++$main::controller_destroyed } + +package main; +app->controller_class('Issue1794WebSocketController'); +websocket '/push' => sub { + my $controller = shift; + my $timer = Mojo::IOLoop->recurring( + 0.1 => sub { ++$main::push_count; $controller->send('push') } + ); + $controller->on(finish => sub { Mojo::IOLoop->remove($timer) }); +}; + +our $controller_destroyed = 0; +our $push_count = 0; +my $test = Test::Mojo->new; +$test->websocket_ok('/push') + ->message_ok->message_is('push') + ->finish_ok; + +# Let the server process the WebSocket close and release the controller after +# the finish callback removes the recurring closure that captured it. The +# one-shot timer bounds the wait even if the close event is lost. +my $push_count_at_finish = $push_count; +my $wait_timer = Mojo::IOLoop->timer(0.5 => sub { Mojo::IOLoop->stop }); +Mojo::IOLoop->start unless $controller_destroyed; +Mojo::IOLoop->remove($wait_timer); +is($push_count, $push_count_at_finish, + 'finish callback stops recurring WebSocket pushes'); +ok($controller_destroyed > 0, + 'WebSocket server-push controller is destroyed after finish'); + +done_testing; diff --git a/src/test/resources/unit/refcount/aggregate_scalar_alias_refcount.t b/src/test/resources/unit/refcount/aggregate_scalar_alias_refcount.t new file mode 100644 index 0000000000..b84ed3008c --- /dev/null +++ b/src/test/resources/unit/refcount/aggregate_scalar_alias_refcount.t @@ -0,0 +1,46 @@ +use strict; +use warnings; +use B qw(svref_2object); +use Scalar::Util qw(weaken); +use Test::More tests => 8; +use feature 'refaliasing'; +no warnings 'experimental::refaliasing'; + +our $destroyed = 0; + +sub refcount { svref_2object($_[0])->REFCNT } + +my @array; +my $array_object = bless {}, 'AggregateScalarAliasRefcount::Tracked'; +my $array_weak = $array_object; +weaken($array_weak); +push @array, $array_object; +\my $array_alias = \$array[0]; + +is(refcount($array_object), 2, + 'an array element stores one owner regardless of scalar aliases'); +undef $array_object; +is(refcount($array_weak), 1, + 'the array element is the sole owner after lexical release'); +$array_alias = undef; +ok(!defined $array_weak, 'clearing an array-element alias releases the referent'); +is($destroyed, 1, 'the array-element alias triggers DESTROY exactly once'); + +my %hash; +my $hash_object = bless {}, 'AggregateScalarAliasRefcount::Tracked'; +my $hash_weak = $hash_object; +weaken($hash_weak); +$hash{entry} = $hash_object; +\my $hash_alias = \$hash{entry}; + +is(refcount($hash_object), 2, + 'a hash entry stores one owner regardless of scalar aliases'); +undef $hash_object; +is(refcount($hash_weak), 1, + 'the hash entry is the sole owner after lexical release'); +$hash_alias = undef; +ok(!defined $hash_weak, 'clearing a hash-element alias releases the referent'); +is($destroyed, 2, 'the hash-element alias triggers DESTROY exactly once'); + +package AggregateScalarAliasRefcount::Tracked; +sub DESTROY { ++$main::destroyed } diff --git a/src/test/resources/unit/refcount/aggregate_scalar_cell_cross_container_alias_lifetime.t b/src/test/resources/unit/refcount/aggregate_scalar_cell_cross_container_alias_lifetime.t new file mode 100644 index 0000000000..d62780f183 --- /dev/null +++ b/src/test/resources/unit/refcount/aggregate_scalar_cell_cross_container_alias_lifetime.t @@ -0,0 +1,120 @@ +use strict; +use warnings; +use B qw(svref_2object); +use Scalar::Util qw(weaken); +use feature 'refaliasing'; +no warnings 'experimental::refaliasing'; +use Test::More tests => 22; + +{ + package CrossAggregateAliasLifetime; + our $destroyed = 0; + sub DESTROY { ++$destroyed } +} + +sub refcount { svref_2object($_[0])->REFCNT } + +my @source; +my @target; +my $object = bless {}, 'CrossAggregateAliasLifetime'; +my $weak = $object; +weaken($weak); +$source[0] = $object; +\$target[0] = \$source[0]; + +is(refcount($object), 2, + 'one aliased scalar cell contributes one aggregate referent owner'); +undef $object; +is(refcount($weak), 1, 'the aliased cell remains the only strong owner'); + +my $popped = pop @source; +ok(defined $weak, 'removing one aggregate alias keeps the shared cell alive'); +undef $popped; +ok(defined $weak, + 'dropping the pop result leaves the other aggregate alias alive'); + +undef $target[0]; +ok(!defined $weak, 'clearing the final alias releases the referent'); +is($CrossAggregateAliasLifetime::destroyed, 1, + 'DESTROY runs exactly once after the last owning slot is cleared'); + +my %hash_source; +my @array_target; +my $hash_object = bless {}, 'CrossAggregateAliasLifetime'; +my $hash_weak = $hash_object; +weaken($hash_weak); +$hash_source{slot} = $hash_object; +\$array_target[0] = \$hash_source{slot}; + +is(refcount($hash_object), 2, + 'a hash cell aliased into an array contributes one referent owner'); +undef $hash_object; +is(refcount($hash_weak), 1, + 'the shared hash and array cell remains the only strong owner'); + +my $deleted = delete $hash_source{slot}; +ok(defined $hash_weak, 'deleting one aggregate alias keeps the shared cell alive'); +undef $deleted; +ok(defined $hash_weak, + 'dropping the delete result leaves the other aggregate alias alive'); + +undef $array_target[0]; +ok(!defined $hash_weak, 'clearing the final hash/array alias releases the referent'); +is($CrossAggregateAliasLifetime::destroyed, 2, + 'the cross-container referent is destroyed exactly once'); + +my @shift_source; +my @shift_target; +my $shift_object = bless {}, 'CrossAggregateAliasLifetime'; +my $shift_weak = $shift_object; +weaken($shift_weak); +$shift_source[0] = $shift_object; +\$shift_target[0] = \$shift_source[0]; + +is(refcount($shift_object), 2, + 'one scalar cell shared with a second array contributes one referent owner'); +undef $shift_object; +is(refcount($shift_weak), 1, 'the shared array cell remains the only strong owner'); + +my $shifted = shift @shift_source; +ok(defined $shift_weak, 'shifting one aggregate alias keeps the shared cell alive'); +undef $shifted; +ok(defined $shift_weak, + 'dropping the shift result leaves the other aggregate alias alive'); + +undef $shift_target[0]; +ok(!defined $shift_weak, 'clearing the final shifted alias releases the referent'); +is($CrossAggregateAliasLifetime::destroyed, 3, + 'the shifted cross-container referent is destroyed exactly once'); + +my @clear_source; +my @clear_target; +my $clear_array_object = bless {}, 'CrossAggregateAliasLifetime'; +my $clear_array_weak = $clear_array_object; +weaken($clear_array_weak); +$clear_source[0] = $clear_array_object; +\$clear_target[0] = \$clear_source[0]; +undef $clear_array_object; +@clear_source = (); +ok(defined $clear_array_weak, + 'clearing an array preserves another aggregate alias'); +undef $clear_target[0]; +ok(!defined $clear_array_weak, + 'clearing the final array alias releases the referent'); + +my %clear_hash_source; +my @clear_hash_target; +my $clear_hash_object = bless {}, 'CrossAggregateAliasLifetime'; +my $clear_hash_weak = $clear_hash_object; +weaken($clear_hash_weak); +$clear_hash_source{slot} = $clear_hash_object; +\$clear_hash_target[0] = \$clear_hash_source{slot}; +undef $clear_hash_object; +%clear_hash_source = (); +ok(defined $clear_hash_weak, + 'clearing a hash preserves another aggregate alias'); +undef $clear_hash_target[0]; +ok(!defined $clear_hash_weak, + 'clearing the final hash alias releases the referent'); + +done_testing; diff --git a/src/test/resources/unit/refcount/array_delete_borrowed_alias_lifetime.t b/src/test/resources/unit/refcount/array_delete_borrowed_alias_lifetime.t new file mode 100644 index 0000000000..10e22ccc90 --- /dev/null +++ b/src/test/resources/unit/refcount/array_delete_borrowed_alias_lifetime.t @@ -0,0 +1,28 @@ +use strict; +use warnings; +use Test::More; + +{ + package ArrayDeleteBorrowedAliasProbe; + our $DESTROYED = 0; + sub new { bless {}, shift } + sub DESTROY { ++$DESTROYED } +} + +sub delete_middle_argument { + delete $_[1]; + is($ArrayDeleteBorrowedAliasProbe::DESTROYED, 0, + 'deleting a borrowed middle argument does not release its caller owner'); +} + +my $left = []; +my $object = ArrayDeleteBorrowedAliasProbe->new; +my $right = []; +delete_middle_argument($left, $object, $right); +is($ArrayDeleteBorrowedAliasProbe::DESTROYED, 0, + 'caller still owns object after the subroutine returns'); +undef $object; +is($ArrayDeleteBorrowedAliasProbe::DESTROYED, 1, + 'object is destroyed when its caller owner is released'); + +done_testing; diff --git a/src/test/resources/unit/refcount/array_delete_local_borrowed_alias_lifetime.t b/src/test/resources/unit/refcount/array_delete_local_borrowed_alias_lifetime.t new file mode 100644 index 0000000000..e46dbc5e82 --- /dev/null +++ b/src/test/resources/unit/refcount/array_delete_local_borrowed_alias_lifetime.t @@ -0,0 +1,28 @@ +use strict; +use warnings; +use Test::More; + +{ + package ArrayDeleteLocalBorrowedAliasProbe; + our $DESTROYED = 0; + sub new { bless {}, shift } + sub DESTROY { ++$DESTROYED } +} + +sub local_delete_middle_argument { + delete local $_[1]; + is($ArrayDeleteLocalBorrowedAliasProbe::DESTROYED, 0, + 'localized delete preserves the borrowed caller argument'); +} + +my $left = []; +my $object = ArrayDeleteLocalBorrowedAliasProbe->new; +my $right = []; +local_delete_middle_argument($left, $object, $right); +is($ArrayDeleteLocalBorrowedAliasProbe::DESTROYED, 0, + 'caller still owns object after localized delete returns'); +undef $object; +is($ArrayDeleteLocalBorrowedAliasProbe::DESTROYED, 1, + 'caller release destroys localized-delete object'); + +done_testing; diff --git a/src/test/resources/unit/refcount/array_delete_local_owned_slot_lifetime.t b/src/test/resources/unit/refcount/array_delete_local_owned_slot_lifetime.t new file mode 100644 index 0000000000..6a45410d23 --- /dev/null +++ b/src/test/resources/unit/refcount/array_delete_local_owned_slot_lifetime.t @@ -0,0 +1,34 @@ +use strict; +use warnings; +use Test::More; +use Scalar::Util qw(weaken); + +{ + package ArrayDeleteLocalOwnedSlotProbe; + our $DESTROYED = 0; + sub new { bless {}, shift } + sub DESTROY { ++$DESTROYED } +} + +sub local_delete_middle_array_slot { + my ($array) = @_; + my $deleted = delete local $array->[1]; + is($ArrayDeleteLocalOwnedSlotProbe::DESTROYED, 0, + 'localized delete keeps the returned owned element alive'); + undef $deleted; + is($ArrayDeleteLocalOwnedSlotProbe::DESTROYED, 0, + 'restored array slot retains ownership after result release'); +} + +my @values = ([], ArrayDeleteLocalOwnedSlotProbe->new, []); +my $weak = $values[1]; +weaken($weak); +local_delete_middle_array_slot(\@values); +is(ref($weak), 'ArrayDeleteLocalOwnedSlotProbe', + 'restored owning array slot keeps the object alive'); +undef $values[1]; +is($ArrayDeleteLocalOwnedSlotProbe::DESTROYED, 1, + 'clearing the restored array slot releases its owner'); +ok(!defined($weak), 'weak reference clears after the restored slot is cleared'); + +done_testing; diff --git a/src/test/resources/unit/refcount/array_element_integer_store_releases_referent.t b/src/test/resources/unit/refcount/array_element_integer_store_releases_referent.t new file mode 100644 index 0000000000..94178bdc6b --- /dev/null +++ b/src/test/resources/unit/refcount/array_element_integer_store_releases_referent.t @@ -0,0 +1,19 @@ +use strict; +use warnings; +use Test::More tests => 3; +use Scalar::Util qw(weaken); + +our $destroyed = 0; +my $object = bless {}, 'ArrayElementIntegerStore::Object'; +my $weak = $object; +weaken($weak); +my @array = ($object); +undef $object; + +ok(defined $weak, 'array element retains its referent before replacement'); +$array[0] = 7; +ok(!defined $weak, 'integer assignment releases the array element referent'); +is($destroyed, 1, 'integer assignment runs the referent destructor once'); + +package ArrayElementIntegerStore::Object; +sub DESTROY { ++$main::destroyed } diff --git a/src/test/resources/unit/refcount/array_element_scalar_reference_lifetime.t b/src/test/resources/unit/refcount/array_element_scalar_reference_lifetime.t new file mode 100644 index 0000000000..1318320ad9 --- /dev/null +++ b/src/test/resources/unit/refcount/array_element_scalar_reference_lifetime.t @@ -0,0 +1,26 @@ +use strict; +use warnings; +use Test::More tests => 2; +use Scalar::Util qw(weaken); + +{ + package ArrayElementScalarReferenceLifetime::Tracked; + sub DESTROY { } +} + +my @elements; +my $slot = \$elements[0]; +my $object = bless {}, 'ArrayElementScalarReferenceLifetime::Tracked'; +my $weak = $object; +weaken($weak); + +$$slot = $object; +undef $object; +undef $slot; + +ok(defined $weak, + 'an element assigned through a scalar reference remains owned by its array'); + +undef $elements[0]; +ok(!defined $weak, + 'clearing the array element releases its final referent owner'); diff --git a/src/test/resources/unit/refcount/array_element_scalar_reference_refcount.t b/src/test/resources/unit/refcount/array_element_scalar_reference_refcount.t new file mode 100644 index 0000000000..3c7ce05bfb --- /dev/null +++ b/src/test/resources/unit/refcount/array_element_scalar_reference_refcount.t @@ -0,0 +1,33 @@ +use strict; +use warnings; +use B qw(svref_2object); +use Scalar::Util qw(weaken); +use Test::More tests => 6; + +our $destroyed = 0; + +sub refcount { svref_2object($_[0])->REFCNT } + +my @elements; +my $slot = \$elements[0]; +my $object = bless {}, 'ArrayElementScalarReferenceRefcount::Tracked'; +my $weak = $object; +weaken($weak); + +is(refcount($object), 1, 'the lexical begins as the only strong owner'); + +$$slot = $object; +is(refcount($object), 2, + 'storing through a scalar reference adds the array element owner'); + +undef $object; +is(refcount($weak), 1, + 'the array element is the sole strong owner after lexical release'); +ok(defined $weak, 'the weak observer remains while the array element owns the object'); + +undef $elements[0]; +ok(!defined $weak, 'clearing the array element clears the weak observer'); +is($destroyed, 1, 'clearing the array element runs DESTROY exactly once'); + +package ArrayElementScalarReferenceRefcount::Tracked; +sub DESTROY { ++$main::destroyed } diff --git a/src/test/resources/unit/refcount/array_middle_delete_releases_owner.t b/src/test/resources/unit/refcount/array_middle_delete_releases_owner.t new file mode 100644 index 0000000000..ed8022fd74 --- /dev/null +++ b/src/test/resources/unit/refcount/array_middle_delete_releases_owner.t @@ -0,0 +1,37 @@ +use strict; +use warnings; +use Test::More; +use Scalar::Util qw(weaken); + +{ + package ArrayMiddleDeleteProbe; + our $DESTROYED = 0; + sub new { bless {}, shift } + sub DESTROY { ++$DESTROYED } +} + +my $weak; +my $deleted; +{ + my @values = (ArrayMiddleDeleteProbe->new, + ArrayMiddleDeleteProbe->new, + ArrayMiddleDeleteProbe->new); + $weak = $values[1]; + weaken($weak); + + $deleted = delete $values[1]; + is($ArrayMiddleDeleteProbe::DESTROYED, 0, + 'deleted value stays alive while delete result is held'); + is(ref($weak), 'ArrayMiddleDeleteProbe', + 'weak reference observes the held delete result'); + + undef $deleted; + is($ArrayMiddleDeleteProbe::DESTROYED, 1, + 'middle element owner is released after delete result is dropped'); + ok(!defined($weak), 'weak reference clears when deleted value is destroyed'); +} + +is($ArrayMiddleDeleteProbe::DESTROYED, 3, + 'remaining array elements are destroyed at scope exit'); + +done_testing; diff --git a/src/test/resources/unit/refcount/hash_clear_preserves_scalar_ref_owner.t b/src/test/resources/unit/refcount/hash_clear_preserves_scalar_ref_owner.t new file mode 100644 index 0000000000..52105ec44b --- /dev/null +++ b/src/test/resources/unit/refcount/hash_clear_preserves_scalar_ref_owner.t @@ -0,0 +1,93 @@ +use strict; +use warnings; +use Test::More; +use Scalar::Util qw(weaken); + +{ + package HashClearScalarRefOwnerProbe; + our $DESTROYED = 0; + sub new { bless {}, shift } + sub DESTROY { ++$DESTROYED } +} + +my %strong_values; +my $strong_object = HashClearScalarRefOwnerProbe->new; +my $strong_weak = $strong_object; +weaken($strong_weak); +$strong_values{item} = $strong_object; +my $strong_alias = \$strong_values{item}; +undef $strong_object; +undef %strong_values; +is($HashClearScalarRefOwnerProbe::DESTROYED, 0, + 'strong scalar reference keeps the hash value alive after clear'); +is(ref($strong_weak), 'HashClearScalarRefOwnerProbe', + 'weak observer remains live through the scalar reference'); +undef $strong_alias; +is($HashClearScalarRefOwnerProbe::DESTROYED, 1, + 'destroy runs after the final scalar reference is released'); +ok(!defined($strong_weak), 'weak observer clears after final scalar reference release'); + +my %weak_values; +my $weak_object = HashClearScalarRefOwnerProbe->new; +my $weak_observer = $weak_object; +weaken($weak_observer); +$weak_values{item} = $weak_object; +my $weak_alias = \$weak_values{item}; +weaken($weak_alias); +undef $weak_object; +undef %weak_values; +is($HashClearScalarRefOwnerProbe::DESTROYED, 2, + 'weak scalar reference does not retain the hash value after clear'); +ok(!defined($weak_observer), 'weak observer clears after hash clear'); + +my %deleted_values; +my $deleted_object = HashClearScalarRefOwnerProbe->new; +my $deleted_weak = $deleted_object; +weaken($deleted_weak); +$deleted_values{item} = $deleted_object; +my $deleted_alias = \$deleted_values{item}; +undef $deleted_object; +my $deleted_value = delete $deleted_values{item}; +undef $deleted_value; +is($HashClearScalarRefOwnerProbe::DESTROYED, 2, + 'delete preserves the detached scalar cell while a strong refalias remains'); +ok(defined($deleted_weak), 'the deleted value remains alive through its refalias'); +undef $deleted_alias; +is($HashClearScalarRefOwnerProbe::DESTROYED, 3, + 'releasing the final deleted-cell refalias runs DESTROY'); +ok(!defined($deleted_weak), 'the deleted value weak observer clears after refalias release'); + +my @array_values; +my $array_object = HashClearScalarRefOwnerProbe->new; +my $array_weak = $array_object; +weaken($array_weak); +$array_values[0] = $array_object; +my $array_alias = \$array_values[0]; +undef $array_object; +undef @array_values; +is($HashClearScalarRefOwnerProbe::DESTROYED, 3, + 'clearing an array preserves the detached scalar cell through a refalias'); +ok(defined($array_weak), 'the array value remains alive through its refalias'); +undef $array_alias; +is($HashClearScalarRefOwnerProbe::DESTROYED, 4, + 'releasing the final array-cell refalias runs DESTROY'); +ok(!defined($array_weak), 'the array value weak observer clears after refalias release'); + +my @deleted_array_values; +my $deleted_array_object = HashClearScalarRefOwnerProbe->new; +my $deleted_array_weak = $deleted_array_object; +weaken($deleted_array_weak); +$deleted_array_values[0] = $deleted_array_object; +my $deleted_array_alias = \$deleted_array_values[0]; +undef $deleted_array_object; +my $deleted_array_value = delete $deleted_array_values[0]; +undef $deleted_array_value; +is($HashClearScalarRefOwnerProbe::DESTROYED, 4, + 'array delete preserves the detached scalar cell through a refalias'); +ok(defined($deleted_array_weak), 'the deleted array value remains alive through its refalias'); +undef $deleted_array_alias; +is($HashClearScalarRefOwnerProbe::DESTROYED, 5, + 'releasing the final deleted array-cell refalias runs DESTROY'); +ok(!defined($deleted_array_weak), 'the deleted array value weak observer clears after refalias release'); + +done_testing; diff --git a/src/test/resources/unit/refcount/hash_element_integer_store_releases_referent.t b/src/test/resources/unit/refcount/hash_element_integer_store_releases_referent.t new file mode 100644 index 0000000000..a3ff0c82be --- /dev/null +++ b/src/test/resources/unit/refcount/hash_element_integer_store_releases_referent.t @@ -0,0 +1,19 @@ +use strict; +use warnings; +use Test::More tests => 3; +use Scalar::Util qw(weaken); + +our $destroyed = 0; +my $object = bless {}, 'HashElementIntegerStore::Object'; +my $weak = $object; +weaken($weak); +my %hash = (value => $object); +undef $object; + +ok(defined $weak, 'hash entry retains its referent before replacement'); +$hash{value} = 7; +ok(!defined $weak, 'integer assignment releases the hash entry referent'); +is($destroyed, 1, 'integer assignment runs the referent destructor once'); + +package HashElementIntegerStore::Object; +sub DESTROY { ++$main::destroyed } diff --git a/src/test/resources/unit/refcount/hash_element_scalar_reference_lifetime.t b/src/test/resources/unit/refcount/hash_element_scalar_reference_lifetime.t new file mode 100644 index 0000000000..2a7d14f312 --- /dev/null +++ b/src/test/resources/unit/refcount/hash_element_scalar_reference_lifetime.t @@ -0,0 +1,28 @@ +use strict; +use warnings; +use Scalar::Util qw(weaken); +use Test::More tests => 3; + +our $destroyed = 0; + +my %elements; +my $slot = \$elements{entry}; +my $object = bless {}, 'HashElementScalarReferenceLifetime::Tracked'; +my $weak = $object; +weaken($weak); + +$$slot = $object; +undef $object; +undef $slot; + +ok(defined $weak, + 'a hash element assigned through a scalar reference remains owned by its hash'); + +undef $elements{entry}; +ok(!defined $weak, + 'clearing the hash element releases its final referent owner'); +is($destroyed, 1, + 'clearing the hash element runs the referent destructor exactly once'); + +package HashElementScalarReferenceLifetime::Tracked; +sub DESTROY { ++$main::destroyed } diff --git a/src/test/resources/unit/refcount/hash_element_scalar_reference_refcount.t b/src/test/resources/unit/refcount/hash_element_scalar_reference_refcount.t new file mode 100644 index 0000000000..e09a11f609 --- /dev/null +++ b/src/test/resources/unit/refcount/hash_element_scalar_reference_refcount.t @@ -0,0 +1,33 @@ +use strict; +use warnings; +use B qw(svref_2object); +use Scalar::Util qw(weaken); +use Test::More tests => 6; + +our $destroyed = 0; + +sub refcount { svref_2object($_[0])->REFCNT } + +my %elements; +my $slot = \$elements{entry}; +my $object = bless {}, 'HashElementScalarReferenceRefcount::Tracked'; +my $weak = $object; +weaken($weak); + +is(refcount($object), 1, 'the lexical begins as the only strong owner'); + +$$slot = $object; +is(refcount($object), 2, + 'storing through a scalar reference adds the hash element owner'); + +undef $object; +is(refcount($weak), 1, + 'the hash element is the sole strong owner after lexical release'); +ok(defined $weak, 'the weak observer remains while the hash entry owns the object'); + +undef $elements{entry}; +ok(!defined $weak, 'deleting the hash entry clears the weak observer'); +is($destroyed, 1, 'deleting the hash entry runs DESTROY exactly once'); + +package HashElementScalarReferenceRefcount::Tracked; +sub DESTROY { ++$main::destroyed } diff --git a/src/test/resources/unit/refcount/internals_svrefcnt_write.t b/src/test/resources/unit/refcount/internals_svrefcnt_write.t new file mode 100644 index 0000000000..57f0e47db3 --- /dev/null +++ b/src/test/resources/unit/refcount/internals_svrefcnt_write.t @@ -0,0 +1,23 @@ +use strict; +use warnings; +use Test::More; + +my $scalar = 42; +is(Internals::SvREFCNT($scalar, 7), 7, + 'setting a plain scalar refcount returns the requested count'); +is(Internals::SvREFCNT($scalar), 7, + 'a plain scalar refcount write is visible to the next read'); + +my $array = []; +is(Internals::SvREFCNT($array, 7), 7, + 'setting an array referent refcount returns the requested count'); +is(Internals::SvREFCNT($array), 7, + 'an array referent refcount write is visible to the next read'); + +my $hash = {}; +is(Internals::SvREFCNT($hash, 7), 7, + 'setting a hash referent refcount returns the requested count'); +is(Internals::SvREFCNT($hash), 7, + 'a hash referent refcount write is visible to the next read'); + +done_testing; diff --git a/src/test/resources/unit/refcount/issue_1649_abandoned_method_invocant_hold.t b/src/test/resources/unit/refcount/issue_1649_abandoned_method_invocant_hold.t new file mode 100644 index 0000000000..47a140cd7b --- /dev/null +++ b/src/test/resources/unit/refcount/issue_1649_abandoned_method_invocant_hold.t @@ -0,0 +1,26 @@ +use strict; +use warnings; +use Test::More; + +our $destroyed = 0; + +{ + package AbandonedMethodHold; + sub new { bless {}, shift } + sub DESTROY { $main::destroyed++ } + sub invoke { die "method must not run" } +} + +my $object = AbandonedMethodHold->new; +my $root = { object => $object }; +$object = undef; + +eval { $root->{object}->invoke(do { die "argument failed" }); 1 }; +like($@, qr/argument failed/, 'argument evaluation aborts the method chain'); +is($destroyed, 0, + 'aggregate owner keeps the invocant alive after abandoned hold release'); + +undef $root->{object}; +is($destroyed, 1, 'removing the last aggregate owner runs DESTROY'); + +done_testing(); diff --git a/src/test/resources/unit/refcount/issue_1649_assignment_undef_weak_tree_release.t b/src/test/resources/unit/refcount/issue_1649_assignment_undef_weak_tree_release.t new file mode 100644 index 0000000000..cefc6b9622 --- /dev/null +++ b/src/test/resources/unit/refcount/issue_1649_assignment_undef_weak_tree_release.t @@ -0,0 +1,42 @@ +use strict; +use warnings; +use Scalar::Util qw(isweak weaken); +use Test::More; + +our (@WEAK_NODES, $DESTROYED); +$DESTROYED = 0; + +{ + package Issue1649ExplicitUndefNode; + + sub new { + my ($class) = @_; + my $node = bless { children => [] }, $class; + push @main::WEAK_NODES, $node; + Scalar::Util::weaken($main::WEAK_NODES[-1]); + return $node; + } + + sub DESTROY { ++$main::DESTROYED } +} + +sub make_tree { + my $root = Issue1649ExplicitUndefNode->new; + my $child = Issue1649ExplicitUndefNode->new; + push @{ $root->{children} }, $child; + $child->{parent} = $root; + weaken($child->{parent}); + return $root; +} + +my $root = make_tree(); +is(scalar(grep { defined $_ } @WEAK_NODES), 2, + 'the root owns its child through the children array'); +ok(isweak($WEAK_NODES[1]{parent}), 'the child parent link is weak'); + +$root = undef; +is(scalar(grep { defined $_ } @WEAK_NODES), 0, + 'assigning undef to the last root releases the weakly linked tree'); +is($DESTROYED, 2, 'both nodes are destroyed once'); + +done_testing; diff --git a/src/test/resources/unit/refcount/issue_1649_captured_scalar_array_argument.t b/src/test/resources/unit/refcount/issue_1649_captured_scalar_array_argument.t new file mode 100644 index 0000000000..2ca920b6c3 --- /dev/null +++ b/src/test/resources/unit/refcount/issue_1649_captured_scalar_array_argument.t @@ -0,0 +1,43 @@ +use strict; +use warnings; +use Scalar::Util qw(weaken); +use Test::More; + +our @WEAK_NODES; +our $DESTROYED = 0; + +{ + package Issue1649ArrayArgumentNode; + + sub new { + my ($class) = @_; + my $node = bless {}, $class; + push @main::WEAK_NODES, $node; + Scalar::Util::weaken($main::WEAK_NODES[-1]); + return $node; + } + + sub DESTROY { ++$main::DESTROYED } +} + +sub build_node { + my $input = ['array argument']; + my ($builder, $node); + $builder = sub { + my $argument = $_[0]; + $node = Issue1649ArrayArgumentNode->new; + return $node; + }; + $node = $builder->($input); + undef $builder; + return $node; +} + +my $node = build_node(); +$node = undef; + +is(scalar(grep { defined $_ } @WEAK_NODES), 0, + 'array argument and released closure do not retain the returned object'); +is($DESTROYED, 1, 'returned object is destroyed once'); + +done_testing; diff --git a/src/test/resources/unit/refcount/issue_1649_void_hash_discard_owner_cleanup.t b/src/test/resources/unit/refcount/issue_1649_void_hash_discard_owner_cleanup.t new file mode 100644 index 0000000000..c0c52501e4 --- /dev/null +++ b/src/test/resources/unit/refcount/issue_1649_void_hash_discard_owner_cleanup.t @@ -0,0 +1,99 @@ +use strict; +use warnings; +use Scalar::Util qw(isweak); +use Test::More; + +our @WEAK_NODES; +our $DESTROYED = 0; +our $HOLDER; + +{ + package Issue1649VoidHashNode; + + sub new { + my ($class, $tag) = @_; + my $node = bless { _tag => $tag }, $class; + push @main::WEAK_NODES, $node; + Scalar::Util::weaken($main::WEAK_NODES[-1]); + return $node; + } + + sub DESTROY { ++$main::DESTROYED } +} + +# Preserve the recursive closure and captured scratch pad from the HTML::Tree +# builder shape while keeping the test independent of that distribution. +sub new_from_lol { + my $class = shift; + $class = ref($class) || $class; + my $lol = $_[0]; + my @ancestor_lols; + my ($sub, $k, $v, $node); + $sub = sub { + my $lol = $_[0]; + return unless @$lol; + my (@attributes, @children); + die "cyclic lol" if grep($_ eq $lol, @ancestor_lols); + push @ancestor_lols, $lol; + my $tag_name = 'null'; + for (my $i = 0; $i < @$lol; ++$i) { + if (ref($lol->[$i]) eq 'ARRAY') { + push @children, $sub->($lol->[$i]); + } elsif (!ref($lol->[$i])) { + if ($i == 0) { + $tag_name = $lol->[$i]; + } else { + push @children, $lol->[$i]; + } + } elsif (ref($lol->[$i]) eq 'HASH') { + keys %{ $lol->[$i] }; + while (($k, $v) = each %{ $lol->[$i] }) { + push @attributes, $k, $v if defined $v; + } + } else { + die "unsupported node"; + } + } + pop @ancestor_lols; + $node = $class->new($tag_name); + if (@attributes) { + my %attributes = @attributes; + @{$node}{keys %attributes} = values %attributes; + } + if (@children) { + $node->{_content} = \@children; + foreach my $child (@children) { + next unless ref $child; + $child->{_parent} = $node; + Scalar::Util::weaken($child->{_parent}); + } + } + return $node; + }; + $node = $sub->($lol); + undef $sub; + return $node; +} + +sub store_body_in_global_hash { + $HOLDER = { body => $WEAK_NODES[3] }; +} + +my $tree = new_from_lol(Issue1649VoidHashNode => [ + 'html', ['head', ['title', 'title text']], ['body', ['p', 'paragraph text']], +]); +my ($body) = grep { defined($_) && $_->{_tag} eq 'body' } @WEAK_NODES; + +store_body_in_global_hash(); +$tree = undef; +is(scalar(grep { defined $_ } @WEAK_NODES), 2, + 'void-context hash result preserves a global aggregate owner'); +$body = undef; +is(scalar(grep { defined $_ } @WEAK_NODES), 2, + 'the global hash retains the escaped subtree after the lexical is cleared'); +undef $HOLDER; +is(scalar(grep { defined $_ } @WEAK_NODES), 0, + 'releasing the global hash frees the escaped subtree'); +is($DESTROYED, 5, 'all nodes are destroyed once'); + +done_testing; diff --git a/src/test/resources/unit/refcount/issue_1794_controller_owner_release.t b/src/test/resources/unit/refcount/issue_1794_controller_owner_release.t new file mode 100644 index 0000000000..1eab25998f --- /dev/null +++ b/src/test/resources/unit/refcount/issue_1794_controller_owner_release.t @@ -0,0 +1,66 @@ +use strict; +use warnings; + +use Test::More; +use Scalar::Util qw(weaken); + +{ + package Issue1794Transaction; + + sub new { bless { events => {} }, shift } + + sub on { + my ($self, $name, $callback) = @_; + push @{$self->{events}{$name}}, $callback; + return $self; + } + + sub finish { shift->emit('finish') } + + sub emit { + my ($self, $name, @args) = @_; + $_->($self, @args) for @{$self->{events}{$name} // []}; + return $self; + } +} + +{ + package Issue1794Controller; + use Scalar::Util qw(weaken); + + sub new { + my ($class, $tx) = @_; + my $self = bless { tx => $tx }, $class; + weaken($self->{tx}); + return $self; + } + + sub on { + my ($self, $name, $callback) = @_; + my $tx = $self->{tx}; + return $tx->on($name => sub { shift; $self->$callback(@_) }); + } + + sub DESTROY { $main::destroyed++ } +} + +our $destroyed = 0; +my $weak_controller; +{ + my $tx = Issue1794Transaction->new; + my $controller = Issue1794Controller->new($tx); + $weak_controller = $controller; + weaken($weak_controller); + + $controller->on(finish => sub { shift }); + $tx->finish; + undef $controller; + + is($destroyed, 0, 'transaction callback keeps controller alive before teardown'); + undef $tx; +} + +is($destroyed, 1, 'controller DESTROY runs when its transaction releases callbacks'); +is($weak_controller, undef, 'controller weak reference clears after transaction teardown'); + +done_testing; diff --git a/src/test/resources/unit/refcount/ordinary_aggregate_element_owner_refcount.t b/src/test/resources/unit/refcount/ordinary_aggregate_element_owner_refcount.t new file mode 100644 index 0000000000..43c59c84f7 --- /dev/null +++ b/src/test/resources/unit/refcount/ordinary_aggregate_element_owner_refcount.t @@ -0,0 +1,49 @@ +use strict; +use warnings; +use B qw(svref_2object); +use Test::More; + +{ + package OrdinaryAggregateOwnerProbe; + our $destroyed = 0; + sub DESTROY { ++$destroyed } +} + +sub rc { svref_2object($_[0])->REFCNT } + +my $object = bless {}, 'OrdinaryAggregateOwnerProbe'; +is(rc($object), 1, 'the lexical starts as the only strong owner'); + +my @array = ($object); +is(rc($object), 2, 'an array element adds one owner'); +push @array, $object; +is(rc($object), 3, 'push adds one independent array-element owner'); +shift @array; +is(rc($object), 2, 'shift releases only the removed array-element owner'); + +my $array_replacement = bless {}, 'OrdinaryAggregateOwnerProbe'; +$array[0] = $array_replacement; +is(rc($object), 1, 'replacing an array value releases the displaced owner'); +is(rc($array_replacement), 2, 'the replacement array value gains one owner'); +$array[0] = $object; +is(rc($array_replacement), 1, 'replacing an array value releases its previous owner'); +is(rc($object), 2, 'the new array value gains one owner'); + +my %hash = (left => $object, right => $object); +is(rc($object), 4, 'two hash entries add two independent owners'); +delete $hash{left}; +is(rc($object), 3, 'delete releases only the removed hash-entry owner'); +$hash{right} = bless {}, 'OrdinaryAggregateOwnerProbe'; +is(rc($object), 2, 'replacing a hash value releases the displaced owner'); + +undef @array; +is(rc($object), 1, 'clearing the array releases its final element owner'); +undef %hash; +is($OrdinaryAggregateOwnerProbe::destroyed, 1, + 'the replaced hash value is destroyed exactly once'); +is(rc($object), 1, 'clearing aggregates does not release the lexical owner'); +undef $object; +is($OrdinaryAggregateOwnerProbe::destroyed, 2, + 'the final lexical owner releases the remaining object exactly once'); + +done_testing; diff --git a/src/test/resources/unit/refcount/owner_trace_snapshot.t b/src/test/resources/unit/refcount/owner_trace_snapshot.t index aa7ef47089..b771e82029 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=\[\] semanticCaptureOwners=0\z/, +like($snapshot, qr/ pending=\[\] transient=\[\] semanticCaptureOwners=0 hashEntryOwners=0 arrayElementOwners=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=\[\] semanticCaptureOwners=0\z/, +like($nested_snapshot, qr/ transient=\[\] semanticCaptureOwners=0 hashEntryOwners=0 arrayElementOwners=0\z/, 'scalar-reference contents hold is balanced after release'); done_testing; diff --git a/src/test/resources/unit/refcount/scalar_reference_contents_alias_lifetime.t b/src/test/resources/unit/refcount/scalar_reference_contents_alias_lifetime.t new file mode 100644 index 0000000000..a0eaa5f973 --- /dev/null +++ b/src/test/resources/unit/refcount/scalar_reference_contents_alias_lifetime.t @@ -0,0 +1,62 @@ +use strict; +use warnings; +use B qw(svref_2object); +use Scalar::Util qw(weaken); +use Test::More tests => 12; + +{ + package Local::ScalarReferenceAliasLifetime; + our $destroyed = 0; + + sub new { bless {}, shift } + sub DESTROY { ++$destroyed } +} + +sub refcount { svref_2object($_[0])->REFCNT } + +sub make_scalar_reference_aliases { + my $value = Local::ScalarReferenceAliasLifetime->new; + my $first = \$value; + my $second = $first; + return ($first, $second); +} + +my ($first, $second) = make_scalar_reference_aliases(); +my $weak_contents = $$second; +weaken($weak_contents); +is($Local::ScalarReferenceAliasLifetime::destroyed, 0, + 'aliases keep a returned scalar cell and its contents alive'); +ok(defined $weak_contents, + 'the scalar contents remain alive after the declaring scope exits'); +is(refcount($$second), 1, + 'two aliases to one scalar cell contribute one contents owner'); + +undef $first; +is($Local::ScalarReferenceAliasLifetime::destroyed, 0, + 'dropping one alias does not release the shared scalar contents'); +ok(defined $weak_contents, + 'the remaining alias still keeps the scalar cell contents alive'); +is(refcount($$second), 1, + 'dropping one alias does not change the scalar cell contents count'); + +my $replacement = Local::ScalarReferenceAliasLifetime->new; +my $weak_replacement = $replacement; +weaken($weak_replacement); +$$second = $replacement; +undef $replacement; +is($Local::ScalarReferenceAliasLifetime::destroyed, 1, + 'replacing the shared scalar cell releases its previous contents once'); +ok(!defined $weak_contents, + 'the replaced contents weak reference clears'); +ok(defined $weak_replacement, + 'the replacement remains alive through the remaining scalar reference'); +is(refcount($$second), 1, + 'the shared scalar cell contributes one owner for replacement contents'); + +undef $second; +is($Local::ScalarReferenceAliasLifetime::destroyed, 2, + 'dropping the final alias releases the current contents once'); +ok(!defined $weak_replacement, + 'the replacement weak reference clears after the final alias is released'); + +done_testing; diff --git a/src/test/resources/unit/refcount/scalar_reference_contents_retarget.t b/src/test/resources/unit/refcount/scalar_reference_contents_retarget.t new file mode 100644 index 0000000000..2a9cc89255 --- /dev/null +++ b/src/test/resources/unit/refcount/scalar_reference_contents_retarget.t @@ -0,0 +1,46 @@ +use strict; +use warnings; +use Scalar::Util qw(weaken); +use Test::More tests => 7; + +{ + package Local::ScalarReferenceContentsRetarget; + our $destroyed = 0; + + sub new { + my ($class) = @_; + return bless {}, $class; + } + + sub DESTROY { ++$destroyed } +} + +sub return_scalar_cell { + my $value = Local::ScalarReferenceContentsRetarget->new; + return \$value; +} + +my $cell_ref = return_scalar_cell(); +my $weak_first = $$cell_ref; +weaken($weak_first); +is($Local::ScalarReferenceContentsRetarget::destroyed, 0, + 'returned scalar reference keeps its current contents alive'); +ok(defined $weak_first, 'first referent remains alive after its lexical scope exits'); + +my $second = Local::ScalarReferenceContentsRetarget->new; +my $weak_second = $second; +weaken($weak_second); +$$cell_ref = $second; +undef $second; + +is($Local::ScalarReferenceContentsRetarget::destroyed, 1, + 'overwriting the referenced scalar releases its previous contents'); +ok(!defined $weak_first, 'previous contents weak reference clears on replacement'); +ok(defined $weak_second, 'replacement contents remain owned by the scalar reference'); + +undef $cell_ref; +is($Local::ScalarReferenceContentsRetarget::destroyed, 2, + 'releasing the scalar reference destroys the replacement contents once'); +ok(!defined $weak_second, 'final scalar-reference release clears the replacement weak observer'); + +done_testing; diff --git a/src/test/resources/unit/refcount/scalar_reference_localized_owner_lifetime.t b/src/test/resources/unit/refcount/scalar_reference_localized_owner_lifetime.t new file mode 100644 index 0000000000..0bc1e01d50 --- /dev/null +++ b/src/test/resources/unit/refcount/scalar_reference_localized_owner_lifetime.t @@ -0,0 +1,47 @@ +use strict; +use warnings; +use B qw(svref_2object); +use Scalar::Util qw(weaken); +use Test::More tests => 6; + +{ + package Local::LocalizedScalarReferenceOwner; + our $destroyed = 0; + + sub new { + my ($class) = @_; + return bless {}, $class; + } + + sub DESTROY { + ++$destroyed; + } +} + +our $localized_reference; +my $weak; +sub ref_count { svref_2object($_[0])->REFCNT } + +sub return_local_scalar_reference { + my $object = Local::LocalizedScalarReferenceOwner->new; + $weak = $object; + weaken($weak); + return \$object; +} + +{ + local $localized_reference = return_local_scalar_reference(); + is($Local::LocalizedScalarReferenceOwner::destroyed, 0, + 'localized scalar reference keeps returned lexical contents alive'); + ok(defined $weak, 'localized scalar reference keeps the weak observer live'); + is(ref_count($$localized_reference), 1, + 'localization preserves Perl refcount for scalar-reference contents'); +} + +is($Local::LocalizedScalarReferenceOwner::destroyed, 1, + 'restoring the package scalar releases the final owner exactly once'); +ok(!defined $weak, 'localization unwind clears the weak observer'); +is($Local::LocalizedScalarReferenceOwner::destroyed, 1, + 'later cleanup does not repeat destruction'); + +done_testing; diff --git a/src/test/resources/unit/refcount/scalar_reference_return_owner_lifetime.t b/src/test/resources/unit/refcount/scalar_reference_return_owner_lifetime.t new file mode 100644 index 0000000000..78161fa753 --- /dev/null +++ b/src/test/resources/unit/refcount/scalar_reference_return_owner_lifetime.t @@ -0,0 +1,49 @@ +use strict; +use warnings; +use Scalar::Util qw(weaken); +use Test::More tests => 7; + +{ + package Local::ScalarReferenceReturnOwner; + our $destroyed = 0; + + sub new { + my ($class) = @_; + return bless {}, $class; + } + + sub DESTROY { + ++$destroyed; + } +} + +my $weak; +sub return_scalar_reference { + my $object = Local::ScalarReferenceReturnOwner->new; + $weak = $object; + weaken($weak); + return \$object; +} + +my $value_ref = return_scalar_reference(); +is($Local::ScalarReferenceReturnOwner::destroyed, 0, + 'returning a scalar reference keeps its lexical contents alive'); +ok(defined $weak, 'the returned scalar reference keeps the weak observer live'); + +my @array_owners = ($value_ref); +my %hash_owners = (value => $value_ref); +undef $value_ref; +is($Local::ScalarReferenceReturnOwner::destroyed, 0, + 'array and hash owners keep returned scalar-reference contents alive'); + +undef @array_owners; +is($Local::ScalarReferenceReturnOwner::destroyed, 0, + 'releasing the array owner preserves the hash owner'); +undef %hash_owners; +is($Local::ScalarReferenceReturnOwner::destroyed, 1, + 'releasing the final hash owner destroys the contents exactly once'); +ok(!defined $weak, 'final scalar-reference release clears the weak observer'); +is($Local::ScalarReferenceReturnOwner::destroyed, 1, + 'repeated cleanup does not dispatch DESTROY again'); + +done_testing; diff --git a/src/test/resources/unit/stat_filehandle_lifetime.t b/src/test/resources/unit/stat_filehandle_lifetime.t new file mode 100644 index 0000000000..ee058e5fc7 --- /dev/null +++ b/src/test/resources/unit/stat_filehandle_lifetime.t @@ -0,0 +1,24 @@ +use strict; +use warnings; +use Test::More; +use Scalar::Util qw(weaken); + +BEGIN { + eval { require File::Temp; 1 } + or plan skip_all => 'File::Temp is not installed'; +} + +my $tempfile = File::Temp->new(); +my $path = $tempfile->filename; +my $weak_tempfile = $tempfile; +weaken($weak_tempfile); + +my @stat = stat($tempfile); +ok(@stat, 'stat succeeds on the open tempfile'); +ok(defined($weak_tempfile), 'the lexical tempfile remains alive while owned'); + +undef $tempfile; +ok(!defined($weak_tempfile), 'stat cache does not retain the tempfile object'); +ok(!-e $path, 'the tempfile is unlinked when its last Perl owner is released'); + +done_testing; diff --git a/src/test/resources/unit/unused_outer_lexical_callback_lifetime.t b/src/test/resources/unit/unused_outer_lexical_callback_lifetime.t new file mode 100644 index 0000000000..07295bfe6b --- /dev/null +++ b/src/test/resources/unit/unused_outer_lexical_callback_lifetime.t @@ -0,0 +1,35 @@ +use strict; +use warnings; +use Test::More; +use Scalar::Util qw(weaken); + +our $destroyed; +our @callbacks; + +{ + package UnusedOuterLexicalLifetime::Owner; + sub DESTROY { $main::destroyed++ } +} + +$destroyed = 0; +{ + my $owner = bless {}, 'UnusedOuterLexicalLifetime::Owner'; + my $weak_owner = $owner; + weaken($weak_owner); + + my $register_callback = sub { + my $controller = shift; + push @main::callbacks, sub { + my $event = shift; + return $event; + }; + }; + + $register_callback->($owner); + undef $owner; + is($destroyed, 1, 'owner is destroyed when callback does not use outer lexical'); + ok(!defined($weak_owner), 'stored callback does not retain unrelated outer object'); +} + +is($callbacks[0]->('finished'), 'finished', 'stored callback remains callable'); +done_testing;