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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions dev/design/jcpan-critic-inline-timezone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# jcpan compiler and tooling compatibility

## Goal

Fix reusable PerlOnJava compiler/runtime/tooling failures encountered by
`Perl::Critic::More`, `Module::Install::InlineModule`,
`TimeZone::TimeZoneDB`, and their dependency chains without distribution
preferences. Native functionality should reuse bundled Java libraries where
possible.

## Progress Tracking

### Current Status: implementation and target validation complete (2026-08-10)

### Completed Phases

- [x] Baseline and system-Perl comparison (2026-08-10)
- Confirmed `Perl::Critic::More` and `Module::Install::InlineModule` are
runnable upstream targets.
- Confirmed the `TimeZone::TimeZoneDB` distribution test suite fails under
system Perl and its dependency metadata contains a circular chain:
`Params::Get` -> `Test::Returns` -> `Return::Set` -> `Params::Get`.
- [x] Native dependency replacement (2026-08-10)
- Added a Java-backed `Digest::JHash` implementation matching the upstream
XS signed-byte and unsigned-32-bit behavior.
- Files: `DigestJHash.java`, `Digest/JHash.pm`, bundled module tests.
- [x] CPAN and MakeMaker tooling (2026-08-10)
- CPAN's metadata fallback now runs a separate generated Makefile.PL instead
of overwriting a distribution's possibly read-only file.
- MakeMaker now emits AutoSplit subprocesses only for modules that actually
use AutoLoader, avoiding hundreds of JVM startups for POD-only modules.
- [x] Core-header and stash compatibility (2026-08-10)
- `Config.pm` materializes the standard `CORE/keywords.h` probe required by
B::Keywords.
- Startup no longer exposes nonexistent `$^B`, `$^G`, `$^J`, `$^K`, `$^Q`,
`$^U`, `$^Y`, and `$^Z` globals to stash inspection.
- [x] Weak-reference lifecycle performance (2026-08-10)
- Deferred targeted weak sweeps until temporary assignment roots are gone.
- Avoided redundant global walks after ordinary destruction and restricted
rescued-object cleanup to genuine aggregate rescue cases.
- [x] Full-suite runtime isolation (2026-08-10)
- Explicit registered-warning bits now override the broader `all` bit, so
`no warnings 'Category'` remains effective through native warning helpers.
- Virtual descriptor recycling now skips occupied descriptors and closing an
older borrowed alias cannot unregister a newer live descriptor owner.

### Validation

- System Perl: Digest::JHash upstream tests pass (2 files, 6 tests).
- System Perl: B::Keywords upstream tests pass (3 files, 553 tests).
- PerlOnJava: B::Keywords upstream tests pass (3 files).
- PerlOnJava: `jcpan -t Perl::Critic::More` passes (8 files, 55 tests).
- PerlOnJava: `jcpan -t Module::Install::InlineModule` passes (2 files, 1 test).
- PerlOnJava: the PPI round-trip stress test reached assertion 2,101 without a
failure before its 900-second guard. Before the reachability fixes it reached
only 82 assertions in 120 seconds; this remains a long-running dependency
test rather than a complete pass.
- Full `make` passes all compilation and unit-test shards.

### Next Steps

1. Continue profiling PPI if a sub-15-minute full round-trip run is required.

### Open Questions

- `TimeZone::TimeZoneDB` remains excluded because its upstream suite fails on
system Perl and its published prerequisites contain the circular chain
documented above. No distribution preference was added.

## Related documentation and skills

- `docs/reference/bundled-modules.md`
- `.agents/skills/debug-perlonjava/SKILL.md`
- `.agents/skills/profile-perlonjava/SKILL.md`
- `.agents/skills/port-cpan-module/SKILL.md`
10 changes: 10 additions & 0 deletions docs/about/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ Release history of PerlOnJava. See [Roadmap](roadmap.md) for future plans.

## Work in progress

- CPAN: add a Java-backed `Digest::JHash` XS replacement for CHI and
`TimeZone::TimeZoneDB` dependency chains, and make CPAN's generated
Makefile fallback work when a distribution ships a read-only Makefile.PL.
- CPAN tooling: avoid launching AutoSplit for POD-only modules, materialize
`CORE/keywords.h` for build-time probes, and expose only real control-letter
globals through `%main::` stash enumeration.
- Runtime: avoid redundant global reachability walks while releasing weak
references in large object trees, substantially reducing PPI teardown cost.
- Runtime: honor explicit custom-warning mask bits and prevent stale recycled
descriptors from replacing live borrowed-handle mappings.
- Add Perl taint mode with `-T` on both JVM and interpreter backends, including
external-input provenance, scalar and regex propagation, capture-based
untainting, and security checks for process execution, code loading, file
Expand Down
1 change: 1 addition & 0 deletions docs/reference/bundled-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Recent CPAN compatibility additions include:
| `Devel::LexAlias` | Perl facade over Java lexical cells | Rebinds local and captured scalar, array, and hash lexicals |
| `Encode::Locale` | Pure Perl facade over bundled locale support | Provides LWP and XML::Parser with locale encoding aliases; includes `Encode::Alias` |
| `Crypt::Twofish2` | Java XS bridge | BouncyCastle Twofish engine with ECB, CBC, and CFB1 compatibility |
| `Digest::JHash` | Java XS bridge | Jenkins 32-bit hash used by CHI and TimeZone::TimeZoneDB |
| `B::Flags` | Pure Perl over portable `B` objects | Named OP/SV flags without access to Perl C structures |

---
Expand Down
98 changes: 98 additions & 0 deletions src/main/java/org/perlonjava/runtime/perlmodule/DigestJHash.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package org.perlonjava.runtime.perlmodule;

import org.perlonjava.frontend.parser.StringParser;
import org.perlonjava.runtime.runtimetypes.GlobalVariable;
import org.perlonjava.runtime.runtimetypes.RuntimeArray;
import org.perlonjava.runtime.runtimetypes.RuntimeList;
import org.perlonjava.runtime.runtimetypes.RuntimeScalar;
import org.perlonjava.runtime.runtimetypes.RuntimeScalarType;

import java.nio.charset.StandardCharsets;

/** Java replacement for the small XS core of Digest::JHash. */
public class DigestJHash extends PerlModuleBase {
private static final int GOLDEN_RATIO = 0x9e3779b9;

public DigestJHash() {
super("Digest::JHash", false);
}

public static void initialize() {
DigestJHash module = new DigestJHash();
GlobalVariable.getGlobalVariable("Digest::JHash::VERSION").set(new RuntimeScalar("0.10"));
try {
module.registerMethod("jhash", "$");
} catch (NoSuchMethodException e) {
System.err.println("Warning: Missing Digest::JHash method: " + e.getMessage());
}
}

public static RuntimeList jhash(RuntimeArray args, int ctx) {
if (args.isEmpty() || args.get(0).type == RuntimeScalarType.UNDEF) {
return new RuntimeScalar(0L).getList();
}

String value = args.get(0).toString();
StringParser.assertNoWideCharacters(value, "jhash");
byte[] data = value.getBytes(StandardCharsets.ISO_8859_1);
if (data.length == 0) {
return new RuntimeScalar(0L).getList();
}

int a = GOLDEN_RATIO;
int b = GOLDEN_RATIO;
int c = 0;
int offset = 0;
int remaining = data.length;

while (remaining >= 12) {
a += word(data, offset);
b += word(data, offset + 4);
c += word(data, offset + 8);
int[] mixed = mix(a, b, c);
a = mixed[0];
b = mixed[1];
c = mixed[2];
offset += 12;
remaining -= 12;
}

c += data.length;
switch (remaining) {
case 11: c += data[offset + 10] << 24;
case 10: c += data[offset + 9] << 16;
case 9: c += data[offset + 8] << 8;
case 8: b += data[offset + 7] << 24;
case 7: b += data[offset + 6] << 16;
case 6: b += data[offset + 5] << 8;
case 5: b += data[offset + 4];
case 4: a += data[offset + 3] << 24;
case 3: a += data[offset + 2] << 16;
case 2: a += data[offset + 1] << 8;
case 1: a += data[offset];
default: break;
}
c = mix(a, b, c)[2];
return new RuntimeScalar(Integer.toUnsignedLong(c)).getList();
}

private static int word(byte[] data, int offset) {
return data[offset]
+ (data[offset + 1] << 8)
+ (data[offset + 2] << 16)
+ (data[offset + 3] << 24);
}

private static int[] mix(int a, int b, int c) {
a -= b; a -= c; a ^= c >>> 13;
b -= c; b -= a; b ^= a << 8;
c -= a; c -= b; c ^= b >>> 13;
a -= b; a -= c; a ^= c >>> 12;
b -= c; b -= a; b ^= a << 16;
c -= a; c -= b; c ^= b >>> 5;
a -= b; a -= c; a ^= c >>> 3;
b -= c; b -= a; b ^= a << 10;
c -= a; c -= b; c ^= b >>> 15;
return new int[] {a, b, c};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,11 @@ public static void initializeGlobals(CompilerOptions compilerOptions) {
RuntimeRegex.initialize();

// Initialize scalar variables
for (char c = 'A'; c <= 'Z'; c++) {
// Initialize $^A.. $^Z
// Only create the control-letter globals that Perl actually defines.
// Eagerly populating every $^A..$^Z makes nonexistent typeglobs visible
// through %main:: (notably to B::Keywords and other stash inspectors).
for (char c : new char[] {'A', 'C', 'D', 'E', 'F', 'H', 'I', 'L', 'M',
'N', 'O', 'P', 'R', 'S', 'T', 'V', 'W', 'X'}) {
String varName = "main::" + Character.toString(c - 'A' + 1);
GlobalVariable.getGlobalVariable(varName);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1185,7 +1185,13 @@ private static void maybeAutoSweepIfRequested() {
}

private static void maybeAutoSweepAtStatementBoundary(boolean topLevel) {
if (!targetedWeakSweepReferents.isEmpty()) {
// RuntimeScalar.setLargeRefCounted() flushes while protecting the old
// and new values as temporary roots. That is an assignment-internal
// flush, not a safe Perl statement boundary. Defer targeted sweeps
// until the emitted boundary flush after those roots are removed;
// otherwise every assignment of a DESTROY-able object performs a full
// root walk when any weak reference exists anywhere in the program.
if (!targetedWeakSweepReferents.isEmpty() && !hasTemporaryRoots()) {
Set<RuntimeBase> targets = Collections.newSetFromMap(new IdentityHashMap<>());
targets.addAll(targetedWeakSweepReferents);
targetedWeakSweepReferents.clear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1514,29 +1514,27 @@ && isCapturedByWeakBackrefCode(referent)) {
*/
public static int sweepReleasedWeakReferents(Set<RuntimeBase> referents) {
if (referents == null || referents.isEmpty()) return 0;
Set<RuntimeBase> pending = Collections.newSetFromMap(new IdentityHashMap<>());
for (RuntimeBase referent : referents) {
if (referent == null || referent.currentlyDestroying) continue;
// Normal decrement-to-zero destruction already clears the
// referent's weak observers and recursively releases contained
// objects in DestroyDispatch.callDestroy(). Re-walking every
// root after that completed path is both redundant and quadratic
// for object trees such as PPI's AST. A rescued object is handled
// by the rescue-specific cleanup path after the assignment.
if (referent.destroyFired || referent.refCount == Integer.MIN_VALUE) continue;
pending.add(referent);
}
if (pending.isEmpty()) return 0;

Set<RuntimeBase> live = new ReachabilityWalker().walk();
int cleared = 0;
boolean releasedObjectNeedsCascade = false;
for (RuntimeBase referent : referents) {
for (RuntimeBase referent : pending) {
if (referent == null || referent.currentlyDestroying) {
continue;
}
if (referent.destroyFired || referent.refCount == Integer.MIN_VALUE) {
// A DESTROY body may have rescued the object by storing $self
// into another live container. Rescue-specific cleanup runs
// after the undef assignment and decides whether to clear only
// the object's own weak refs or its contained graph. Starting a
// generic fixed-point cascade here clears live children first.
if (DestroyDispatch.isRescued(referent)) {
continue;
}
// An eager sweep in RuntimeScalar.undefine() may already have
// destroyed this wrapper using a liveness snapshot taken
// before its tied/container edges were released. Re-walk
// below so newly unreachable dependants are collected too.
releasedObjectNeedsCascade = true;
continue;
}
if (live.contains(referent)) continue;
if ((referent instanceof RuntimeHash || referent instanceof RuntimeArray)
&& referent.localBindingExists) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ public void clear() {
}
}

private boolean isPackageRootedHash() {
boolean isPackageRootedHash() {
return isPackageGlobalRoot || isGlobalPackageHash;
}

Expand Down
22 changes: 14 additions & 8 deletions src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeIO.java
Original file line number Diff line number Diff line change
Expand Up @@ -288,21 +288,23 @@ public int assignFileno() {
* Returns -1 if none available.
*/
private static int tryRecycleLowestFd() {
List<Integer> candidates = new ArrayList<>();
SortedSet<Integer> candidates = new TreeSet<>();
Integer recycled;
while ((recycled = recycledFds.poll()) != null) {
if (recycled >= 3) {
// Aliased/borrowed handles can keep a descriptor live after another
// wrapper releases it. Discard stale recycle entries rather than
// letting a new socket overwrite the current fd owner.
if (recycled >= 3 && !filenoToIO.containsKey(recycled)) {
candidates.add(recycled);
}
}
if (candidates.isEmpty()) {
return -1;
}
Collections.sort(candidates);
int fd = candidates.get(0);
int fd = candidates.first();
// Put back the rest
for (int i = 1; i < candidates.size(); i++) {
recycledFds.add(candidates.get(i));
for (int candidate : candidates.tailSet(fd + 1)) {
recycledFds.add(candidate);
}
return fd;
}
Expand Down Expand Up @@ -336,6 +338,7 @@ public void registerExternalFd(int fd) {
}
filenoToIO.put(fd, this);
ioToFileno.put(this, fd);
recycledFds.removeIf(candidate -> candidate == fd);
// Advance nextFileno past this fd to avoid collisions
nextFileno.updateAndGet(current -> Math.max(current, fd + 1));
}
Expand All @@ -347,11 +350,14 @@ public void registerExternalFd(int fd) {
public void unregisterFileno() {
Integer fd = ioToFileno.remove(this);
if (fd != null) {
filenoToIO.remove(fd);
// Only the RuntimeIO that still owns the fd mapping may release it.
// Borrowed aliases intentionally share a descriptor; closing an
// older alias must not unregister or recycle the newer live owner.
boolean released = filenoToIO.remove(fd, this);
// Return fd to the recycle pool so it can be reused (POSIX: lowest available).
// Descriptors 0, 1, and 2 are reserved for stdin/stdout/stderr and must
// never be assigned to lazily-numbered regular filehandles.
if (fd >= 3) {
if (released && fd >= 3) {
recycledFds.add(fd);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1706,8 +1706,18 @@ private RuntimeScalar setLargeRefCounted(RuntimeScalar value) {
// $source->{schema} = $self (overwriting weak ref with strong ref)
// But avoids false positives from:
// my $self = shift (new local variable, oldBase is null)
if (DestroyDispatch.currentDestroyTarget != null
if (thisWasWeak
&& DestroyDispatch.currentDestroyTarget != null
&& oldBase == DestroyDispatch.currentDestroyTarget
// Package weak maps (PPI's global parent indexes are the
// common case) may briefly replace/reuse an entry while its
// target is being destroyed. That is handled by the normal
// refCount>0 resurrection path below; the special rescued-
// object lifecycle is only for an owning aggregate such as a
// DBIC ResultSource saving its Schema through a weak slot.
&& containerOwner != null
&& !(containerOwner instanceof RuntimeHash owner
&& owner.isPackageRootedHash())
&& this.value instanceof RuntimeBase base
&& base == DestroyDispatch.currentDestroyTarget) {
DestroyDispatch.destroyTargetRescued = true;
Expand Down Expand Up @@ -1877,7 +1887,9 @@ private RuntimeScalar setLargeRefCounted(RuntimeScalar value) {
// DESTROY, clear weak refs reachable from it now so DBIC-style callbacks
// observe that the user's schema lexical is gone. Do not drain all
// rescued objects here; DBIC can have other live schemas pending.
if (shouldClearRescuedAfterUndefAssignment && !ModuleInitGuard.inModuleInit()) {
if (shouldClearRescuedAfterUndefAssignment
&& DestroyDispatch.isRescued(oldBase)
&& !ModuleInitGuard.inModuleInit()) {
boolean externallyReachable =
ReachabilityWalker.isReachableFromExternalRootExcludingRescued(oldBase);
if (System.getenv("JPERL_PHASE_D_DBG") != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,9 +409,12 @@ public static boolean isEnabledInBits(String bits, String category) {
int byteIndex = bitPos / 8;
int bitInByte = bitPos % 8;

// Check the specific bit if it's within range
if (byteIndex < bits.length() && (bits.charAt(byteIndex) & (1 << bitInByte)) != 0) {
return true;
// If the mask is long enough to contain the registered category then
// its bit is authoritative. A clear bit can be an explicit
// `no warnings 'Category'` and must not be re-enabled by the broader
// `all` bit below.
if (byteIndex < bits.length()) {
return (bits.charAt(byteIndex) & (1 << bitInByte)) != 0;
}

// For custom categories, fall back to checking if "all" is enabled.
Expand Down Expand Up @@ -457,9 +460,9 @@ public static boolean isFatalInBits(String bits, String category) {
int byteIndex = bitPos / 8;
int bitInByte = bitPos % 8;

// Check the specific bit if it's within range
if (byteIndex < bits.length() && (bits.charAt(byteIndex) & (1 << bitInByte)) != 0) {
return true;
// As for the enabled bit, an in-range clear fatal bit is explicit.
if (byteIndex < bits.length()) {
return (bits.charAt(byteIndex) & (1 << bitInByte)) != 0;
}

// For custom categories, fall back to checking if "all" is fatal
Expand Down
Loading
Loading