Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion dev/architecture/weaken-destroy.md
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,33 @@ the `classHasDestroy()` check at bless time, which is cached per class. Code
that actually uses DESTROY classes pays the full tracking cost (increment/
decrement per reference assignment), but this is by design.

### Tracked-store optimization (2026-07-27)

`RuntimeScalar.setLargeRefCounted()` registered each newly owned reference
twice in `ScalarRefRegistry`. Once `weaken()` had activated the registry, each
registration was a synchronized `WeakHashMap.put()`, so tracked stores paid for
two monitor/map operations even though the second put replaced the same entry.

The optimized path:

- registers the scalar once, after DESTROY-rescue handling;
- uses the registry without a synchronization wrapper, consistent with
PerlOnJava's documented single-threaded runtime model;
- removes empty reverse weak-reference buckets during `unweaken()`;
- includes `dev/bench/benchmark_refcount_store.pl` for repeatable measurement.

Median CPU time over three fresh JVM runs of the tracked-store benchmark:

| Revision | CPU time | Change |
|----------|----------|--------|
| `master` (`168466619`) | 2.71 s | baseline |
| optimized branch | 2.41 s | **-11.1%** |

The ordinary method benchmark was unchanged at approximately 2.35 CPU seconds,
showing no measurable regression before weak-reference bookkeeping is active.
Correctness validation completed with `make` and the full DBIx::Class suite:
314 files, 13,121 tests, all successful in 679 wall-clock seconds.

### Memory Overhead

- **Per-referent:** `refCount` (int, 4 bytes) and `blessId` (int, 4 bytes) on
Expand All @@ -983,7 +1010,8 @@ decrement per reference assignment), but this is by design.
4 bytes), on `RuntimeScalar`. Always present; the JVM may pack the booleans
into existing object-alignment padding.
- **WeakRefRegistry:** External identity maps. Only allocated when `weaken()`
is called. Zero memory when no weak refs exist.
is called. Zero memory when no weak refs exist. Empty per-referent reverse
buckets are removed by overwrite, clear, and `unweaken()` paths.
- **DestroyDispatch caches:** `BitSet` + `ConcurrentHashMap`. Negligible.

---
Expand Down
1 change: 1 addition & 0 deletions dev/bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ under PerlOnJava (and optionally system Perl for comparison). Use them to:
| `benchmark_memory_delta.pl` | Memory growth / leak detection |
| `benchmark_method.pl` | Method dispatch (class, inheritance) |
| `benchmark_regex.pl` | Regex compilation and matching |
| `benchmark_refcount_store.pl` | Tracked reference stores after `weaken()` activates lifecycle bookkeeping |
| `benchmark_string.pl` | String operations (concat, substr, etc.) |

## Running
Expand Down
38 changes: 38 additions & 0 deletions dev/bench/benchmark_refcount_store.pl
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
use strict;
use warnings;
use Benchmark;
use Scalar::Util qw(weaken);

{
package RefcountStoreBench;

sub DESTROY {
$main::destroyed++;
}
}

our $destroyed = 0;
my $object = bless { value => 1 }, 'RefcountStoreBench';
my $weak = $object;
weaken($weak);

# Keep enough container slots active to exercise tracked overwrite ownership
# without measuring allocation of a fresh aggregate on every iteration.
my @slots = map { $object } 0 .. 1023;
my $sink = 0;

sub loop_refcount_stores {
for my $i (0 .. 999_999) {
$slots[$i & 1023] = $object;
$sink += $slots[$i & 1023]{value};
}
}

timethis(30, sub {
$sink = 0;
loop_refcount_stores();
});

die "tracked object destroyed during benchmark\n"
if $destroyed || !defined $weak;
print "done $sink\n";
16 changes: 14 additions & 2 deletions dev/modules/dbix_class.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,9 +543,21 @@ can't ship a regression behind that flag.

---

## Non-Bug Warnings (informational)
## Environment Warning Fixes (2026-07-27)

- **Generated Exception::Class version mismatches**: Exception::Class 1.45
gives generated subclasses version 1.1 while registering
`Exception/Class.pm` (version 1.45) as their `%INC` source. The bundled CPAN
patch now aligns generated subclass versions with the registered file.
- **Optional Getopt::Long::Descriptive base classes**: `base::import` still
throws the normal catchable "Base class package ... is empty" exception, but
no longer prints a duplicate message directly to stderr before throwing.
- **DynaLoader and bytes version mismatches**: bundled module files now expose
the same versions as their Java runtime implementations.
- Verified `t/00describe_environment.t` has none of these warnings.

## Remaining Non-Bug Warnings (informational)

- **`Mismatch of versions '1.1' and '1.45'`** in `t/00describe_environment.t` for `Params::ValidationCompiler::Exception::Named::Required`: Not a PerlOnJava bug. `Exception::Class` deliberately sets `$INC{$subclass.pm} = __FILE__` on every generated subclass.
- **`Subroutine is_bool redefined at Cpanel::JSON::XS line 2429`**: Triggered when Cpanel::JSON::XS loads through `@ISA` fallback. Cosmetic only.

---
Expand Down
16 changes: 16 additions & 0 deletions dev/modules/moo.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ All 841/841 Moo subtests now pass across all 71 test files.

## Completed Fixes

### Readonly Pad Constants Passed Through Constructors — FIXED (2026-07-27)

**2 test files, 6 subtests fixed (tests 17-19 in each accessor-weaken file).**

Root cause: a readonly scalar created by `\ "literal"` is strongly owned by
the defining subroutine's pad until that subroutine is replaced. Selective
refcounting cannot see that pad edge. When Moo passed the reference through
its constructor and weakened the attribute, the constructor temporary reached
zero and mortal processing cleared the weak reference immediately.

Fix: detect readonly scalars present in the pad constants of currently
installed named subroutines. Both immediate `weaken()` processing and deferred
mortal cleanup preserve those references. Replacing the subroutine continues
to clear them through the existing `clearPadConstantWeakRefs()` optree-reaping
hook.

### Category C: Optree Reaping — FIXED (2025-04-09)

**2 test files, 2 subtests fixed (test 19 in each accessor-weaken file).**
Expand Down
1 change: 0 additions & 1 deletion src/main/java/org/perlonjava/runtime/perlmodule/Base.java
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ public static RuntimeList importBase(RuntimeArray args, int ctx) {
// Java bridge stubs for IO::Handle::_sync, or DBIC's
// eval-created classes).
if (!GlobalVariable.isPackageLoaded(baseClassName)) {
System.err.println("Base class package \"" + baseClassName + "\" is empty.");
throw new PerlCompilerException("Base class package \"" + baseClassName + "\" is empty.");
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ public static void initialize() {
DynaLoader dynaLoader = new DynaLoader();
dynaLoader.initializeExporter();
dynaLoader.defineExport("EXPORT", "bootstrap");
// Set $DynaLoader::VERSION so CPAN dependency checks are satisfied
GlobalVariable.getGlobalVariable("DynaLoader::VERSION").set("1.54");
try {
dynaLoader.registerMethod("bootstrap", null);
dynaLoader.registerMethod("boot_DynaLoader", null);
Expand All @@ -36,7 +34,8 @@ public static void initialize() {
dynaLoader.registerMethod("dl_undef_symbols", "dl_empty", null);
dynaLoader.registerMethod("dl_error", "dl_error", null);

// Set $DynaLoader::VERSION so CPAN dependency checking works
// Match the bundled DynaLoader.pm so runtime and file-based
// dependency/version checks report the same value.
GlobalVariable.getGlobalVariable("DynaLoader::VERSION").set("1.56");
} catch (NoSuchMethodException e) {
System.err.println("Warning: Missing DynaLoader method: " + e.getMessage());
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,16 @@ private static void processDeferredBase(RuntimeBase base, boolean clearWeakRefsF
// generation is wiring Moo metadata. Clearing then makes
// nested dispatch call undefer_sub(undef).
base.refCount = 1;
} else if (base instanceof RuntimeScalarReadOnly
&& hasWeakRefs
&& RuntimeCode.isInstalledPadConstant(base)) {
// A readonly scalar referenced through \("literal") belongs to
// its defining subroutine's pad. Constructor/call temporaries
// are selectively counted, but that pad ownership is not.
// Preserve the weak ref while the sub remains installed;
// RuntimeCode.clearPadConstantWeakRefs() clears it when the
// sub's glob is replaced, matching Perl optree reaping.
base.refCount = 1;
} else if (base.blessId == 0
&& hasWeakRefs
&& (ReachabilityWalker.isReachableFromLiveCodeCaptures(base)
Expand Down
24 changes: 24 additions & 0 deletions src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,30 @@ public void clearPadConstantWeakRefs() {
}
}

/**
* Return whether a readonly scalar is owned by the optree/pad of a
* currently installed named subroutine.
*
* <p>Pad constants are JVM references that selective refcounting cannot
* observe. This targeted lookup lets mortal processing preserve a weak
* reference until the owning glob is replaced, when
* {@link #clearPadConstantWeakRefs()} performs Perl-compatible optree
* reaping.</p>
*/
public static boolean isInstalledPadConstant(RuntimeBase target) {
if (target == null) return false;
for (RuntimeScalar codeScalar : GlobalVariable.globalCodeRefs.values()) {
if (codeScalar == null || !(codeScalar.value instanceof RuntimeCode code)
|| code.padConstants == null) {
continue;
}
for (RuntimeBase constant : code.padConstants) {
if (constant == target) return true;
}
}
return false;
}

/**
* Release captured variable references. Called when this closure is being
* discarded (scope exit, undef, or reassignment of the variable holding
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1540,12 +1540,6 @@ private RuntimeScalar setLargeRefCounted(RuntimeScalar value) {
}
}

// Phase B1 (refcount_alignment_52leaks_plan.md): track this
// scalar so the reachability walker can enumerate live lexicals.
if (newOwned) {
ScalarRefRegistry.registerRef(this);
}

int preAssignType = this.type;
Object preAssignValue = this.value;

Expand Down Expand Up @@ -1597,10 +1591,10 @@ private RuntimeScalar setLargeRefCounted(RuntimeScalar value) {
newOwned = true;
}

// Phase B1 (refcount_alignment_52leaks_plan.md): register this
// scalar in ScalarRefRegistry so the reachability walker can
// enumerate live ref-holding RuntimeScalars on demand. No-op
// when no weaken() has ever been called.
// Phase B1 (refcount_alignment_52leaks_plan.md): register this scalar
// once, after rescue handling has had a chance to acquire ownership.
// This used to run both before and after the assignment, causing two
// WeakHashMap.put() calls for every ordinary tracked reference store.
if (newOwned) {
ScalarRefRegistry.registerRef(this);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
package org.perlonjava.runtime.runtimetypes;

import java.lang.ref.WeakReference;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;

/**
Expand All @@ -31,15 +28,15 @@ public class ScalarRefRegistry {
// hashCode/equals — RuntimeScalar uses Object's defaults, so this
// is effectively IdentityHashMap-with-weak-keys.
private static final Map<RuntimeScalar, Boolean> scalarRegistry =
Collections.synchronizedMap(new WeakHashMap<>());
new WeakHashMap<>();

// Phase E: optional per-scalar registerRef call-site stacks.
// Populated only when JPERL_REGISTER_STACKS=1 is set. Uses a
// WeakHashMap with the same scalar as key, so entries are pruned
// automatically when the scalar is JVM-GC'd. Lookup via
// stackFor() is O(1).
private static final Map<RuntimeScalar, Throwable> registerStacks =
Collections.synchronizedMap(new WeakHashMap<>());
new WeakHashMap<>();

// Phase B1 performance toggle: when set, skip all registry
// maintenance. Useful for benchmarks; does NOT affect correctness
Expand Down Expand Up @@ -109,9 +106,7 @@ public static Throwable stackFor(RuntimeScalar sc) {
* unreachable entries first (e.g., freshly-exited lexical scopes).
*/
public static java.util.List<RuntimeScalar> snapshot() {
synchronized (scalarRegistry) {
return new java.util.ArrayList<>(scalarRegistry.keySet());
}
return new java.util.ArrayList<>(scalarRegistry.keySet());
}

/**
Expand Down Expand Up @@ -149,8 +144,6 @@ public static java.util.List<RuntimeScalar> forceGcAndSnapshot() {
* hold? (Subject to JVM GC between calls.)
*/
public static int approximateSize() {
synchronized (scalarRegistry) {
return scalarRegistry.size();
}
return scalarRegistry.size();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ public static void weaken(RuntimeScalar ref) {
&& hasWeakRefsTo(base)
&& (ReachabilityWalker.isReachableFromRoots(base)
|| ReachabilityWalker.isReachableFromLiveScalarRegistry(base)
|| ReachabilityWalker.isReachableFromLiveCodeCaptures(base))) {
|| ReachabilityWalker.isReachableFromLiveCodeCaptures(base)
|| RuntimeCode.isInstalledPadConstant(base))) {
// A temporary probe can be weakened without owning the last
// strong Perl reference. Test::Refcount does this when it
// weakens a local copy before calling B::svref_2object().
Expand All @@ -170,6 +171,12 @@ && hasWeakRefsTo(base)
// (new RuntimeScalar(RuntimeScalar)) is mitigated by the fact that such
// copies don't decrement refCount on cleanup (refCountOwned=false), so
// they can't cause false-positive refCount==0 destruction.
} else if (base.refCount == -1
&& base instanceof RuntimeScalarReadOnly
&& RuntimeCode.isInstalledPadConstant(base)) {
// The defining subroutine's pad is the strong owner. Its ownership
// is not represented in selective refCount, and glob replacement
// clears the weak ref explicitly via clearPadConstantWeakRefs().
} else if (base.refCount == -1 && !(base instanceof RuntimeCode)) {
// Untracked non-CODE object: transition to WEAKLY_TRACKED so that
// undefine() and scopeExitCleanup() can clear weak refs
Expand Down Expand Up @@ -237,7 +244,10 @@ public static void unweaken(RuntimeScalar ref) {
if (!weakScalars.remove(ref)) return;
if (ref.value instanceof RuntimeBase base) {
Set<RuntimeScalar> weakRefs = referentToWeakRefs.get(base);
if (weakRefs != null) weakRefs.remove(ref);
if (weakRefs != null) {
weakRefs.remove(ref);
if (weakRefs.isEmpty()) referentToWeakRefs.remove(base);
}
if (base.refCount >= 0) {
base.refCount++; // restore strong count
ref.refCountOwned = true; // restore ownership
Expand Down
2 changes: 2 additions & 0 deletions src/main/perl/lib/CPAN/Config.pm
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ sub _bootstrap_patches {
'PerlOnJava/CpanPatches/DBI-1.647/DBI.pm.patch' ],
[ 'DBI-1.647/PurePerl.pm.patch',
'PerlOnJava/CpanPatches/DBI-1.647/PurePerl.pm.patch' ],
[ 'Exception-Class-1.45/GeneratedSubclassVersion.patch',
'PerlOnJava/CpanPatches/Exception-Class-1.45/GeneratedSubclassVersion.patch' ],
[ 'Net-Server-2.018/Proto.pm.patch',
'PerlOnJava/CpanPatches/Net-Server-2.018/Proto.pm.patch' ],
[ 'Device-SerialPort-1.04/NoXsBitsFallback.patch',
Expand Down
2 changes: 1 addition & 1 deletion src/main/perl/lib/DynaLoader.pm
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ package DynaLoader;
# XSLoader, so DynaLoader's bootstrap() is not needed for normal use.
#

our $VERSION = '1.54';
our $VERSION = '1.56';

# Only define bootstrap if not already registered by Java
BEGIN {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ comment: |
PerlOnJava distroprefs for Exception::Class.

Exception::Class is pure Perl and is needed by Params::ValidationCompiler.
Its t/ignore.t suite has one PerlOnJava stack-frame-filter mismatch, but
the module otherwise builds and installs.
Its t/ignore.t suite has one PerlOnJava stack-frame-filter mismatch. It
also assigns generated exception classes version 1.1 while mapping their
%INC entries to Exception/Class.pm (version 1.45). This makes consumers
such as DBIx::Class report false module/file version mismatches. Keep the
generated class version aligned with the file registered in %INC.

Keep the test output visible for signal, but allow CPAN to install it as
a DateTime prerequisite.
match:
distribution: "^DROLSKY/Exception-Class-"
patches:
- "Exception-Class-1.45/GeneratedSubclassVersion.patch"
test:
commandline: "PERLONJAVA_TEST_IGNORE_FAILURES"
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
--- lib/Exception/Class.pm.orig
+++ lib/Exception/Class.pm
@@ -137,7 +137,7 @@ package $subclass;

use base qw($isa);

-our \$$version_name = '1.1';
+our \$$version_name = '$VERSION';

1;

1 change: 1 addition & 0 deletions src/main/perl/lib/bytes.pm
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package bytes;

our $VERSION = '1.08';
our $hint_bits = 0x00000008;

sub import {
Expand Down
Loading
Loading