From 2a3484b2533aefd170acfc46076e4f7c472f964d Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Wed, 26 Aug 2026 21:39:34 +0200 Subject: [PATCH 01/12] wip: scope %^H guard lifecycle Track and release compile-time hint guards at lexical scope boundaries. Refs: #1102 Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- docs/about/changelog.md | 1 + .../backend/bytecode/EvalStringHandler.java | 5 +-- .../frontend/parser/SpecialBlockParser.java | 42 ++++++++++++------- .../perlonjava/runtime/HintHashRegistry.java | 26 ++++++++++-- .../runtime/operators/ModuleOperators.java | 6 +-- .../runtime/runtimetypes/GlobalContext.java | 2 +- .../runtimetypes/GlobalRuntimeHash.java | 1 + .../runtime/runtimetypes/RuntimeCode.java | 8 ++-- .../runtime/runtimetypes/RuntimeHash.java | 29 +++++++++++++ .../resources/unit/hint_hash_scope_destroy.t | 23 ++++++++++ 10 files changed, 111 insertions(+), 32 deletions(-) create mode 100644 src/test/resources/unit/hint_hash_scope_destroy.t diff --git a/docs/about/changelog.md b/docs/about/changelog.md index d1746669a9..3cd108a454 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -95,6 +95,7 @@ Release history of PerlOnJava. See [Roadmap](roadmap.md) for future plans. - **Bundled Moose 2.4000 and Class::MOP 2.4000**: the upstream Moose source tree is shipped in `src/main/perl/lib/{Moose,Class/MOP}/`. Tested by installing `DBIx::Class` 0.082843 via `jcpan` (DBIx::Class itself uses `Moo`, fetched from CPAN) and running its test suite — it passes 100% (314 files / 13858 asserts). Upstream Moose's own test suite passes ~99% (≥396/478 files, ≥13413/13550 asserts). See [bundled modules](../reference/bundled-modules.md#moose--classmop) and [dev/modules/moose_support.md](../../dev/modules/moose_support.md) for the full status and the small set of remaining failure clusters (numeric-arg warnings, anon-class GC timing, threads/fork tests). - Work in Progress + - Fix scoped `%^H` guard destruction. - [Multiplicity — per-runtime isolation for concurrent Perl interpreters](https://github.com/fglock/PerlOnJava/pull/480): `PerlRuntime` with `ThreadLocal`-based isolation; all mutable state (globals, I/O, regex, caller stack, method caches) moved to per-runtime instances; 122/126 concurrent interpreter tests pass; pending closure/method dispatch optimization - Moose - most tests pass - XML::LibXML - some tests pass diff --git a/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java b/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java index 03399e8272..820ccebf2f 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java @@ -283,7 +283,7 @@ private static RuntimeList evalStringList(String perlCode, Map lexicalHintHash = HintHashRegistry.getCurrentCallSiteScalarHintHash(); if (lexicalHintHash != null) { - activeHintHash.elements.clear(); + activeHintHash.clearForHintHashContextTransfer(); activeHintHash.elements.putAll(lexicalHintHash); } try { @@ -638,8 +638,7 @@ private static RuntimeList evalStringList(String perlCode, } } SpecialBlockParser.setCurrentScope(savedCurrentScope); - activeHintHash.elements.clear(); - activeHintHash.elements.putAll(savedHintHash); + HintHashRegistry.restoreHintHash(activeHintHash, savedHintHash); HintHashRegistry.setCallSiteHintHashId(savedCallSiteHintHashId); } finally { PerlLanguageProvider.COMPILE_LOCK.unlock(); diff --git a/src/main/java/org/perlonjava/frontend/parser/SpecialBlockParser.java b/src/main/java/org/perlonjava/frontend/parser/SpecialBlockParser.java index 219a45a410..6638916c1b 100644 --- a/src/main/java/org/perlonjava/frontend/parser/SpecialBlockParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/SpecialBlockParser.java @@ -104,23 +104,30 @@ static Node parseSpecialBlock(Parser parser) { parser.ctx.symbolTable.addVariable("$self", "my", null); } - // Parse the block content - BlockNode block = ParseBlock.parseBlock(parser); + // Special blocks introduce a lexical compile-time scope for %^H just + // like ordinary blocks. In particular, modules can install a guard + // object in %^H from BEGIN and rely on its DESTROY method when the + // special block ends. + HintHashRegistry.enterScope(); + BlockNode block; + try { + // Parse the block content + block = ParseBlock.parseBlock(parser); - // Restore the isInMethod flag and exit ADJUST scope - if (adjustScopeIndex >= 0) { - parser.ctx.symbolTable.exitScope(adjustScopeIndex); - } - parser.isInMethod = wasInMethod; + // Restore the isInMethod flag and exit ADJUST scope + if (adjustScopeIndex >= 0) { + parser.ctx.symbolTable.exitScope(adjustScopeIndex); + } + parser.isInMethod = wasInMethod; - // Consume the closing brace '}' - TokenUtils.consume(parser, LexerTokenType.OPERATOR, "}"); + // Consume the closing brace '}' + TokenUtils.consume(parser, LexerTokenType.OPERATOR, "}"); - // Before executing BEGIN blocks, process any pending heredocs. + // Before executing BEGIN blocks, process any pending heredocs. // This handles cases like: BEGIN { eval <<'END' } ... \n heredoc content \n END // The heredoc content comes after the newline, but BEGIN must execute immediately. // We need to fill in the heredoc content before BEGIN tries to use it. - if ("BEGIN".equals(blockName) && !parser.getHeredocNodes().isEmpty()) { + if ("BEGIN".equals(blockName) && !parser.getHeredocNodes().isEmpty()) { if (CompilerOptions.DEBUG_ENABLED) parser.ctx.logDebug("HEREDOC_BEGIN_FIX: Found " + parser.getHeredocNodes().size() + " pending heredocs after BEGIN block"); int savedIndex = parser.tokenIndex; // Find the next NEWLINE token @@ -143,11 +150,11 @@ static Node parseSpecialBlock(Parser parser) { // Restore tokenIndex to continue parsing from after the '}' parser.tokenIndex = savedIndex; } - } + } // ADJUST blocks in class context are not executed at parse time // They are compiled as anonymous subs and stored for the constructor - if ("ADJUST".equals(blockName) && parser.isInClassBlock) { + if ("ADJUST".equals(blockName) && parser.isInClassBlock) { // Create an anonymous sub that captures lexical variables SubroutineNode adjustSub = new SubroutineNode( @@ -162,11 +169,14 @@ static Node parseSpecialBlock(Parser parser) { parser.classAdjustBlocks.add(adjustSub); // Return the anonymous sub node (won't be executed now) - return adjustSub; - } + return adjustSub; + } // Execute other special blocks normally - runSpecialBlock(parser, blockName, block); + runSpecialBlock(parser, blockName, block); + } finally { + HintHashRegistry.exitScope(); + } // After a BEGIN block runs, propagate any compile-time state changes the // block made (e.g. `BEGIN { unimport warnings qw(File::Find) }`) to the diff --git a/src/main/java/org/perlonjava/runtime/HintHashRegistry.java b/src/main/java/org/perlonjava/runtime/HintHashRegistry.java index e08e670912..443150c087 100644 --- a/src/main/java/org/perlonjava/runtime/HintHashRegistry.java +++ b/src/main/java/org/perlonjava/runtime/HintHashRegistry.java @@ -2,6 +2,7 @@ import org.perlonjava.runtime.runtimetypes.GlobalContext; import org.perlonjava.runtime.runtimetypes.GlobalVariable; +import org.perlonjava.runtime.runtimetypes.MortalList; import org.perlonjava.runtime.runtimetypes.PerlRuntime; import org.perlonjava.runtime.runtimetypes.RuntimeHash; import org.perlonjava.runtime.runtimetypes.RuntimeScalar; @@ -57,10 +58,7 @@ public static void exitScope() { Map savedState = stack.pop(); // Restore global %^H to the state saved when we entered this scope RuntimeHash hintHash = GlobalVariable.getGlobalHash(GlobalContext.encodeSpecialVar("H")); - hintHash.elements.clear(); - for (Map.Entry entry : savedState.entrySet()) { - hintHash.elements.put(entry.getKey(), new RuntimeScalar(entry.getValue())); - } + restoreHintHash(hintHash, savedState); } } @@ -225,6 +223,26 @@ public static Map getCurrentCallSiteScalarHintHash() { return copy; } + /** + * Restores a saved compile-time hints hash while releasing values created + * by the nested compilation. Hint values may be blessed guards whose + * DESTROY method implements an end-of-scope callback (for example + * Object::HashBase's deferred Role::Tiny composition). + */ + public static void restoreHintHash(RuntimeHash active, Map saved) { + List discarded = new ArrayList<>(); + for (Map.Entry entry : active.elements.entrySet()) { + RuntimeScalar retained = saved.get(entry.getKey()); + RuntimeScalar current = entry.getValue(); + if (retained == null || retained.type != current.type || retained.value != current.value) { + discarded.add(current); + } + } + MortalList.deferDestroyForContainerClear(discarded); + active.clearForHintHashContextTransfer(); + active.elements.putAll(saved); + } + /** * Clears all state. * Called by PerlLanguageProvider.resetAll() during reinitialization. diff --git a/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java b/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java index e94ab6f679..070d9cfc00 100644 --- a/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java @@ -4,6 +4,7 @@ import org.perlonjava.app.scriptengine.PerlLanguageProvider; import org.perlonjava.backend.bytecode.InterpreterState; import org.perlonjava.core.Configuration; +import org.perlonjava.runtime.HintHashRegistry; import org.perlonjava.runtime.perlmodule.BHooksEndOfScope; import org.perlonjava.runtime.perlmodule.Feature; import org.perlonjava.runtime.runtimetypes.*; @@ -725,7 +726,7 @@ else if (code == null) { Feature.setFeatureManager(new FeatureFlags()); // Clear the hints hash for a fresh compilation context - hintHash.elements.clear(); + hintHash.clearForHintHashContextTransfer(); result = PerlLanguageProvider.executePerlCode(parsedArgs, false, ctx); @@ -762,8 +763,7 @@ else if (code == null) { InterpreterState.currentPackage.get().set(savedPackage); // Restore the caller's hints hash - hintHash.elements.clear(); - hintHash.elements.putAll(savedHintHash); + HintHashRegistry.restoreHintHash(hintHash, savedHintHash); // Restore the caller's source-filter state (filters installed // inside the required file must not leak back to the caller). diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java index 36f4ef496f..6283a97902 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java @@ -233,7 +233,7 @@ public static void initializeGlobals(CompilerOptions compilerOptions) { // Initialize hashes // %SIG uses a special hash that auto-qualifies handler names for known signals GlobalVariable.globalHashes.put("main::SIG", new RuntimeSigHash()); - GlobalVariable.getGlobalHash(encodeSpecialVar("H")); + GlobalVariable.getGlobalHash(encodeSpecialVar("H")).isHintHash = true; // These magic hashes are valid under strict vars but their stash slots // are created lazily on first access. GlobalVariable.declareGlobalHash("main::!"); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeHash.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeHash.java index b2a4da7c03..74289aaeef 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeHash.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeHash.java @@ -45,6 +45,7 @@ public void dynamicSaveState() { // Install a fresh empty hash in the global map RuntimeHash newLocal = new RuntimeHash(); + newLocal.isHintHash = original != null && original.isHintHash; GlobalVariable.globalHashes.put(fullName, newLocal); newLocal.isGlobalPackageHash = true; diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index 33e6a48a7c..e4429e72c2 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -2593,8 +2593,7 @@ public static Class evalStringHelper(RuntimeScalar code, String evalTag, Obje capturedSymbolTable.strictOptionsStack.push(savedStrictOptions); // Restore %^H (compile-time hints hash) to the caller snapshot. - capturedHintHash.elements.clear(); - capturedHintHash.elements.putAll(savedHintHash); + HintHashRegistry.restoreHintHash(capturedHintHash, savedHintHash); // Note: Scope restoration moved to outer finally block to handle cache hits @@ -2812,7 +2811,7 @@ public static RuntimeList evalStringWithInterpreter( Map lexicalHintHash = HintHashRegistry.getCurrentCallSiteScalarHintHash(); if (lexicalHintHash != null) { - activeHintHash.elements.clear(); + activeHintHash.clearForHintHashContextTransfer(); activeHintHash.elements.putAll(lexicalHintHash); } @@ -3276,8 +3275,7 @@ public static RuntimeList evalStringWithInterpreter( // Restore the original current scope, not the captured symbol table. // This prevents eval from leaking its compile-time scope to the caller. setCurrentScope(savedCurrentScope); - activeHintHash.elements.clear(); - activeHintHash.elements.putAll(savedHintHash); + HintHashRegistry.restoreHintHash(activeHintHash, savedHintHash); HintHashRegistry.setCallSiteHintHashId(savedCallSiteHintHashId); // Store source lines in debugger symbol table if $^P flags are set diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java index 03f5c9da78..720d149863 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java @@ -34,6 +34,9 @@ private static Stack dynamicStateStack() { public int type; // Map to store the elements of the hash public Map elements; + /** True only for Perl's compile-time %^H hash. */ + public boolean isHintHash; + private boolean suppressHintHashLifecycleCleanup; // Set when this hash is installed as %ENV through a typeglob alias. // Perl rejects process execution before inspecting PATH in that case. public String taintEnvironmentAliasDescription; @@ -69,6 +72,20 @@ public RuntimeHash() { elements = newElementMap(); } + /** + * Clears %^H while an enclosing compilation context still owns its + * values. Nested require/eval uses this to start from empty hints without + * releasing the caller's lexical hint guards. + */ + public void clearForHintHashContextTransfer() { + suppressHintHashLifecycleCleanup = true; + try { + elements.clear(); + } finally { + suppressHintHashLifecycleCleanup = false; + } + } + private RuntimeHashElementMap newElementMap() { return new RuntimeHashElementMap(this); } @@ -142,6 +159,10 @@ public RuntimeScalar put(String key, RuntimeScalar value) { value = new RuntimeEnvironmentScalar(value); } RuntimeScalar previous = super.get(key); + if (owner.isHintHash && !owner.suppressHintHashLifecycleCleanup + && previous != null && previous != value) { + MortalList.deferDestroyForContainerClear(java.util.Collections.singletonList(previous)); + } owner.notePackageRootMutation(previous, value); if (value != null) value.markContainerOwner(owner); owner.markPackageRootedValue(value); @@ -179,6 +200,9 @@ public void putAll(Map m) { public RuntimeScalar remove(Object key) { RuntimeScalar previous = super.remove(key); if (previous != null) { + if (owner.isHintHash && !owner.suppressHintHashLifecycleCleanup) { + MortalList.deferDestroyForContainerClear(java.util.Collections.singletonList(previous)); + } owner.notePackageRootMutation(previous, null); } return previous; @@ -187,6 +211,9 @@ public RuntimeScalar remove(Object key) { @Override public void clear() { if (!isEmpty()) { + if (owner.isHintHash && !owner.suppressHintHashLifecycleCleanup) { + MortalList.deferDestroyForContainerClear(values()); + } owner.notePackageRootClear(values()); } super.clear(); @@ -1586,6 +1613,7 @@ public RuntimeArray setArrayOfAlias(RuntimeArray arr) { public void dynamicSaveState() { // Create a new RuntimeHash to save the current state RuntimeHash currentState = new RuntimeHash(); + currentState.isHintHash = this.isHintHash; currentState.elements = currentState.newElementMap(this.elements); currentState.blessId = this.blessId; currentState.byteKeys = this.byteKeys != null ? new HashSet<>(this.byteKeys) : null; @@ -1640,6 +1668,7 @@ public void dynamicRestoreState() { this.blessId = previousState.blessId; this.byteKeys = previousState.byteKeys; this.type = previousState.type; + this.isHintHash = previousState.isHintHash; } } diff --git a/src/test/resources/unit/hint_hash_scope_destroy.t b/src/test/resources/unit/hint_hash_scope_destroy.t new file mode 100644 index 0000000000..c23fa55b78 --- /dev/null +++ b/src/test/resources/unit/hint_hash_scope_destroy.t @@ -0,0 +1,23 @@ +use strict; +use warnings; +use Test::More; + +our $destroyed = 0; + +{ + package Local::HintHash::Guard; + sub DESTROY { $main::destroyed++ } +} + +my $ok = eval q{ + BEGIN { + $^H{'Local::HintHash::Guard'} = bless {}, 'Local::HintHash::Guard'; + } + sub local_hint_hash_scope_guard { 1 } + 1; +}; + +ok($ok, 'eval with a compile-time hint guard succeeds'); +is($destroyed, 1, 'discarding an eval hint hash releases its guard'); + +done_testing; From d064fce3f02362b3d64d73a7d0eb3529b69030c0 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Wed, 26 Aug 2026 22:40:44 +0200 Subject: [PATCH 02/12] wip: continue Object::HashBase Role::Tiny investigation Preserve the in-progress hint scope and inheritance work before further diagnosis. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../perlonjava/runtime/HintHashRegistry.java | 12 +++++++-- .../runtime/WarningBitsRegistry.java | 25 +++++++++++++++++-- .../runtime/mro/InheritanceResolver.java | 1 + .../resources/unit/hint_hash_scope_destroy.t | 19 ++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/HintHashRegistry.java b/src/main/java/org/perlonjava/runtime/HintHashRegistry.java index 443150c087..4392939060 100644 --- a/src/main/java/org/perlonjava/runtime/HintHashRegistry.java +++ b/src/main/java/org/perlonjava/runtime/HintHashRegistry.java @@ -231,16 +231,24 @@ public static Map getCurrentCallSiteScalarHintHash() { */ public static void restoreHintHash(RuntimeHash active, Map saved) { List discarded = new ArrayList<>(); + List discardedKeys = new ArrayList<>(); for (Map.Entry entry : active.elements.entrySet()) { RuntimeScalar retained = saved.get(entry.getKey()); RuntimeScalar current = entry.getValue(); if (retained == null || retained.type != current.type || retained.value != current.value) { discarded.add(current); + discardedKeys.add(entry.getKey()); } } MortalList.deferDestroyForContainerClear(discarded); - active.clearForHintHashContextTransfer(); - active.elements.putAll(saved); + for (String key : discardedKeys) { + active.elements.remove(key); + } + for (Map.Entry entry : saved.entrySet()) { + if (!active.elements.containsKey(entry.getKey())) { + active.elements.put(entry.getKey(), entry.getValue()); + } + } } /** diff --git a/src/main/java/org/perlonjava/runtime/WarningBitsRegistry.java b/src/main/java/org/perlonjava/runtime/WarningBitsRegistry.java index f13a397c79..aab53fb8f0 100644 --- a/src/main/java/org/perlonjava/runtime/WarningBitsRegistry.java +++ b/src/main/java/org/perlonjava/runtime/WarningBitsRegistry.java @@ -324,7 +324,16 @@ public static int getCallerHintsAtFrame(int frame) { * @param hintHash A snapshot of the %^H hash elements */ public static void setCallSiteHintHash(java.util.Map hintHash) { - state().callSiteHintHash = hintHash != null ? new java.util.HashMap<>(hintHash) : new java.util.HashMap<>(); + java.util.Map snapshot = + new java.util.HashMap<>(); + if (hintHash != null) { + for (java.util.Map.Entry entry + : hintHash.entrySet()) { + snapshot.put(entry.getKey(), + new org.perlonjava.runtime.runtimetypes.RuntimeScalar(entry.getValue())); + } + } + state().callSiteHintHash = snapshot; } /** @@ -342,7 +351,19 @@ public static void snapshotCurrentHintHash() { */ public static void pushCallerHintHash() { CompilationRuntimeState state = state(); - state.callerHintHashStack.push(new java.util.HashMap<>(state.callSiteHintHash)); + state.callerHintHashStack.push(copyHintHash(state.callSiteHintHash)); + } + + private static java.util.Map copyHintHash( + java.util.Map source) { + java.util.Map copy = + new java.util.HashMap<>(); + for (java.util.Map.Entry entry + : source.entrySet()) { + copy.put(entry.getKey(), + new org.perlonjava.runtime.runtimetypes.RuntimeScalar(entry.getValue())); + } + return copy; } /** diff --git a/src/main/java/org/perlonjava/runtime/mro/InheritanceResolver.java b/src/main/java/org/perlonjava/runtime/mro/InheritanceResolver.java index 602be25d58..19ae2310a2 100644 --- a/src/main/java/org/perlonjava/runtime/mro/InheritanceResolver.java +++ b/src/main/java/org/perlonjava/runtime/mro/InheritanceResolver.java @@ -247,6 +247,7 @@ public static void invalidateMethodLookupCachesForStashSubKey(String stashFqn) { String suffix = "::" + leaf; String suffixNoAutoload = suffix + "\0noautoload"; SHARED_SYMBOL_MUTATION_EPOCH.incrementAndGet(); + RuntimeCode.clearInlineMethodCache(); MroRuntimeState state = currentState(); state.methodCache().entrySet().removeIf(e -> { String k = e.getKey(); diff --git a/src/test/resources/unit/hint_hash_scope_destroy.t b/src/test/resources/unit/hint_hash_scope_destroy.t index c23fa55b78..639854c69a 100644 --- a/src/test/resources/unit/hint_hash_scope_destroy.t +++ b/src/test/resources/unit/hint_hash_scope_destroy.t @@ -20,4 +20,23 @@ my $ok = eval q{ ok($ok, 'eval with a compile-time hint guard succeeds'); is($destroyed, 1, 'discarding an eval hint hash releases its guard'); +{ + package Local::HintHash::Importer; + sub import { + $^H{'Local::HintHash::Importer'} = bless {}, 'Local::HintHash::Guard'; + } +} +$INC{'Local/HintHash/Importer.pm'} = __FILE__; + +$ok = eval q{ + BEGIN { + package Local::HintHash::Consumer; + use Local::HintHash::Importer; + } + 1; +}; + +ok($ok, 'eval with a use-time hint guard succeeds'); +is($destroyed, 2, 'a call-site hint snapshot does not retain a discarded guard'); + done_testing; From eebc82bfe17da704eada13bc6d095886216d054d Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Thu, 27 Aug 2026 08:28:37 +0200 Subject: [PATCH 03/12] wip: snapshot dynamic constant investigation Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../backend/bytecode/BytecodeCompiler.java | 6 +++++ .../backend/bytecode/InterpretedCode.java | 2 ++ .../bytecode/OpcodeHandlerExtended.java | 13 ++++++++++ .../backend/jvm/EmitSubroutine.java | 3 +++ .../frontend/parser/SubroutineParser.java | 14 +++++++++++ .../runtime/runtimetypes/RuntimeCode.java | 16 ++++++++++++ .../unit/dynamic_constant_sub_inlining.t | 25 +++++++++++++++++++ 7 files changed, 79 insertions(+) create mode 100644 src/test/resources/unit/dynamic_constant_sub_inlining.t diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java index 23c58dae96..afc395f7cb 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java @@ -6098,6 +6098,11 @@ private void visitAnonymousSubroutine(SubroutineNode node) { } subCode.lexicalVariableNames = declaredLexicalNames; subCode.prototype = node.prototype; + // Perl treats a no-argument anonymous sub whose entire body is a + // lexical scalar read as a constant CV. Object::HashBase creates its + // accessor-key constants this way during BEGIN; mark only this + // side-effect-free shape so the closure creation path can freeze it. + subCode.isConstantCv = node.getBooleanAnnotation("simpleLexicalConstantCandidate"); subCode.attributes = node.attributes; subCode.packageName = node.getAnnotation("regexCallbackPackage") instanceof String pkg ? pkg : getCurrentPackage(); @@ -6180,6 +6185,7 @@ private void visitAnonymousSubroutine(SubroutineNode node) { lastResultReg = codeReg; } + private static void copySignatureMetadata(InterpretedCode code, Node block) { if (block.getAnnotation("signatureMinArgs") instanceof Integer min) { code.signatureMinArgs = min; diff --git a/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java b/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java index 9c6ac87640..0022feb335 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java +++ b/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java @@ -508,6 +508,8 @@ public InterpretedCode withCapturedVars(RuntimeBase[] capturedVars) { this.warningBitsString ); copy.prototype = this.prototype; + copy.isConstantCv = this.isConstantCv; + copy.constantValue = this.constantValue == null ? null : new RuntimeList(this.constantValue); copy.attributes = this.attributes; copy.subName = this.subName; copy.packageName = this.packageName; diff --git a/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java b/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java index c9610ad041..866d8dd70a 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java +++ b/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java @@ -999,6 +999,19 @@ public static int executeCreateClosure(int[] bytecode, int pc, RuntimeBase[] reg // Create a new InterpretedCode with the captured variables InterpretedCode closureCode = template.withCapturedVars(capturedVars); + // A side-effect-free `sub () { $lexical }` is a Perl constant CV. Its + // captured scalar is now available, so freeze the value exactly once + // before later compilation can inline calls to the installed coderef. + if (closureCode.isConstantCv && closureCode.constantValue == null) { + RuntimeList result = closureCode.apply(new RuntimeArray(), RuntimeContextType.LIST); + RuntimeList frozen = new RuntimeList(); + for (RuntimeBase value : result.elements) { + frozen.elements.add(value instanceof RuntimeScalar scalar + ? new RuntimeScalar(scalar) : value); + } + closureCode.constantValue = frozen; + } + // Track captureCount on captured RuntimeScalar variables. // This mirrors what RuntimeCode.makeCodeObject() does for JVM-compiled closures. // Without this, scopeExitCleanup() doesn't know the variable is still alive diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java b/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java index fb3166b5db..743040b976 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java @@ -346,6 +346,9 @@ public static void emitSubroutine(EmitterContext ctx, SubroutineNode node) { : ctx.compilerOptions.code; } int deparseFlags = 0; + if (node.getBooleanAnnotation("simpleLexicalConstantCandidate")) { + deparseFlags |= 0x40000000; + } int strictAll = HINT_STRICT_REFS | HINT_STRICT_SUBS | HINT_STRICT_VARS; if ((ctx.symbolTable.getStrictOptions() & strictAll) == strictAll) { deparseFlags |= RuntimeCode.DEPARSE_FLAG_STRICT; diff --git a/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java b/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java index 33d03982b5..de57a44959 100644 --- a/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java @@ -2104,6 +2104,20 @@ private static SubroutineNode handleAnonSub(Parser parser, String subName, Strin SubroutineNode node = new SubroutineNode(subName, prototype, attributes, block, false, currentIndex, sourceEndTokenIndex); + Node constantBody = block.elements.size() == 1 ? block.elements.get(0) : null; + while (constantBody instanceof ListNode list && list.handle == null + && list.elements.size() == 1) { + constantBody = list.elements.get(0); + } + if (constantBody instanceof OperatorNode op && "$".equals(op.operator) + && op.operand instanceof IdentifierNode id) { + constantBody = id; + } + if (prototype != null && (prototype.isEmpty() || "()".equals(prototype)) + && constantBody instanceof IdentifierNode id + && id.name.startsWith("$")) { + node.setAnnotation("simpleLexicalConstantCandidate", true); + } if (attributes != null && hasNonBuiltinCodeAttribute(attributes)) { RuntimeCode placeholder = new RuntimeCode(prototype, new ArrayList<>(attributes)); placeholder.packageName = parser.ctx.symbolTable.getCurrentPackage(); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index e4429e72c2..669965bc03 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -3484,6 +3484,21 @@ public static RuntimeScalar makeCodeObject( if (!capturedAggregates.isEmpty()) { code.capturedAggregates = capturedAggregates.toArray(new RuntimeBase[0]); } + + // A BEGIN-installed `sub () { $lexical }` is a Perl constant CV. The + // JVM emitter does not retain the anonymous-sub AST here, but it does + // retain the exact source span; recognize only this side-effect-free + // shape and freeze it after its lexical captures have been attached. + if ((deparseFlags & 0x40000000) != 0) { + RuntimeList result = code.apply(new RuntimeArray(), RuntimeContextType.LIST); + RuntimeList frozen = new RuntimeList(); + for (RuntimeBase value : result.elements) { + frozen.elements.add(value instanceof RuntimeScalar scalar + ? new RuntimeScalar(scalar) : value); + } + code.isConstantCv = true; + code.constantValue = frozen; + } if (!captured.isEmpty() || !capturedAggregates.isEmpty()) { // Enable refCount tracking for closures with captures. // When the CODE ref's refCount drops to 0, releaseCaptures() @@ -3501,6 +3516,7 @@ public static RuntimeScalar makeCodeObject( return codeRef; } + /** * Call a method in a Perl-like class hierarchy using the C3 linearization algorithm. * This version accepts a native RuntimeBase[] array for parameters. diff --git a/src/test/resources/unit/dynamic_constant_sub_inlining.t b/src/test/resources/unit/dynamic_constant_sub_inlining.t new file mode 100644 index 0000000000..49b8a83c35 --- /dev/null +++ b/src/test/resources/unit/dynamic_constant_sub_inlining.t @@ -0,0 +1,25 @@ +use strict; +use warnings; +use Test::More; + +BEGIN { + my $value = 'truthy'; + *DynamicConstant::VALUE = sub () { $value }; +} + +sub result_from_compile_time_constant { + return DynamicConstant::VALUE() ? 'inlined' : 'runtime'; +} + +{ + no warnings 'redefine'; + *DynamicConstant::VALUE = sub { 0 }; +} + +my $name = 'DynamicConstant::VALUE'; +no strict 'refs'; +is(&{$name}(), 0, 'the constant subroutine was replaced at runtime'); +is(result_from_compile_time_constant(), 'inlined', + 'a constant installed during BEGIN is inlined into later code'); + +done_testing; From 4e6c56338a27b54d226d75c49c8765e88164f065 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Thu, 27 Aug 2026 10:27:02 +0200 Subject: [PATCH 04/12] wip: trace constant folding traversal --- .../bytecode/OpcodeHandlerExtended.java | 8 +---- .../analysis/ConstantFoldingVisitor.java | 22 +++++++++++- .../frontend/parser/SubroutineParser.java | 35 ++++++++++++++----- .../runtime/runtimetypes/RuntimeCode.java | 26 ++++++++++---- .../runtime/runtimetypes/RuntimeGlob.java | 7 ++++ .../runtimetypes/RuntimeStashEntry.java | 3 ++ 6 files changed, 77 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java b/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java index 866d8dd70a..0baa511031 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java +++ b/src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java @@ -1003,13 +1003,7 @@ public static int executeCreateClosure(int[] bytecode, int pc, RuntimeBase[] reg // captured scalar is now available, so freeze the value exactly once // before later compilation can inline calls to the installed coderef. if (closureCode.isConstantCv && closureCode.constantValue == null) { - RuntimeList result = closureCode.apply(new RuntimeArray(), RuntimeContextType.LIST); - RuntimeList frozen = new RuntimeList(); - for (RuntimeBase value : result.elements) { - frozen.elements.add(value instanceof RuntimeScalar scalar - ? new RuntimeScalar(scalar) : value); - } - closureCode.constantValue = frozen; + closureCode.cacheConstantCvValue(); } // Track captureCount on captured RuntimeScalar variables. diff --git a/src/main/java/org/perlonjava/frontend/analysis/ConstantFoldingVisitor.java b/src/main/java/org/perlonjava/frontend/analysis/ConstantFoldingVisitor.java index 2170cd9d87..d4bbefa992 100644 --- a/src/main/java/org/perlonjava/frontend/analysis/ConstantFoldingVisitor.java +++ b/src/main/java/org/perlonjava/frontend/analysis/ConstantFoldingVisitor.java @@ -141,6 +141,12 @@ public static Boolean getConstantConditionValue(Node condition, String currentPa private static Boolean resolveConstantSubBoolean(String name, String currentPackage) { try { String fullName = NameNormalizer.normalizeVariableName(name, currentPackage); + if (System.getenv("JPERL_CANDDBG") != null && name.contains("DynamicConstant")) { + RuntimeScalar trace = GlobalVariable.globalCodeRefs.get(fullName); + System.err.println("CANDDBG fold name=" + name + " full=" + fullName + + " code=" + (trace != null && trace.value instanceof RuntimeCode code + && code.constantValue != null)); + } // Use direct map lookup to avoid side effects of getGlobalCodeRef(), // which auto-vivifies empty CODE entries and pins references RuntimeScalar codeRef = GlobalVariable.globalCodeRefs.get(fullName); @@ -416,6 +422,10 @@ private static void annotateCallerLine( @Override public void visit(OperatorNode node) { + if (System.getenv("JPERL_CANDDBG") != null && "return".equals(node.operator)) { + System.err.println("CANDDBG return operand=" + + (node.operand == null ? "null" : node.operand.getClass().getSimpleName())); + } if (node.operand == null) { result = node; // undef is a constant @@ -502,7 +512,14 @@ public void visit(BlockNode node) { } if (changed) { - result = new BlockNode(foldedElements, node.tokenIndex); + BlockNode folded = new BlockNode(foldedElements, node.tokenIndex); + folded.isLoop = node.isLoop; + folded.labelName = node.labelName; + folded.labels = new ArrayList<>(node.labels); + if (node.annotations != null) { + folded.annotations = new java.util.HashMap<>(node.annotations); + } + result = folded; } else { result = node; } @@ -511,6 +528,9 @@ public void visit(BlockNode node) { @Override public void visit(ListNode node) { + if (System.getenv("JPERL_CANDDBG") != null && node.elements.size() == 1) { + System.err.println("CANDDBG list element=" + node.elements.getFirst().getClass().getSimpleName()); + } List foldedElements = new ArrayList<>(); boolean changed = false; boolean allConstant = true; diff --git a/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java b/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java index de57a44959..1b6cbb512d 100644 --- a/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java @@ -9,6 +9,7 @@ import org.perlonjava.backend.jvm.EmitterContext; import org.perlonjava.backend.jvm.EmitterMethodCreator; import org.perlonjava.backend.jvm.JavaClassInfo; +import org.perlonjava.frontend.analysis.ConstantFoldingVisitor; import org.perlonjava.frontend.astnode.*; import org.perlonjava.frontend.lexer.LexerToken; import org.perlonjava.frontend.lexer.LexerTokenType; @@ -1673,6 +1674,15 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S placeholder.setLexicalDisabledWarningCategories(names); } + // Named sub bodies are materialized lazily. Capture constant-CV calls + // now, while the parser has just executed any preceding BEGIN block. + // Otherwise a later glob replacement can hide a lexical constant before + // the body is first compiled, which differs from Perl's optree behavior. + Node foldedBody = ConstantFoldingVisitor.foldConstants( + block, parser.ctx.symbolTable.getCurrentPackage()); + BlockNode compilationBlock = foldedBody instanceof BlockNode folded + ? folded : block; + // Clone warning flags (critical for 'no warnings' pragmas) filteredSnapshot.warningFlagsStack.pop(); // Remove the initial value pushed by enterScope filteredSnapshot.warningFlagsStack.push(definitionWarningFlags); @@ -1720,10 +1730,10 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S } // Try unified API (returns RuntimeCode - either CompiledCode or InterpretedCode) if (placeholder.attributes != null && placeholder.attributes.contains("lvalue")) { - block.setAnnotation("subroutineIsLvalue", true); + compilationBlock.setAnnotation("subroutineIsLvalue", true); } RuntimeCode runtimeCode = - EmitterMethodCreator.createRuntimeCode(newCtx, block, false); + EmitterMethodCreator.createRuntimeCode(newCtx, compilationBlock, false); Map compiledOurRegistry = runtimeCode.ourVariableRegistry; if (compiledOurRegistry == null || compiledOurRegistry.isEmpty()) { @@ -1812,7 +1822,8 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S if (showFallback) { System.err.println("Note: JVM VerifyError during subroutine instantiation, recompiling with interpreter."); } - InterpretedCode interpretedCode = EmitterMethodCreator.compileToInterpreter(block, newCtx, false); + InterpretedCode interpretedCode = EmitterMethodCreator.compileToInterpreter( + compilationBlock, newCtx, false); // Set captured variables if there are any List materializedCaptures = closureCapturesForMaterialization( @@ -1863,6 +1874,13 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S RuntimeCode placeholderForSupplier = (RuntimeCode) codeRef.value; placeholderForSupplier.compilerSupplier = subroutineCreationTaskSupplier; + boolean hasVisibleConstantCv = GlobalVariable.globalCodeRefs.values().stream() + .anyMatch(scalar -> scalar.value instanceof RuntimeCode code + && code.constantValue != null); + if (hasVisibleConstantCv) { + subroutineCreationTaskSupplier.get(); + } + ListNode result = new ListNode(parser.tokenIndex); result.setAnnotation("compileTimeOnly", true); return result; @@ -2109,13 +2127,12 @@ private static SubroutineNode handleAnonSub(Parser parser, String subName, Strin && list.elements.size() == 1) { constantBody = list.elements.get(0); } - if (constantBody instanceof OperatorNode op && "$".equals(op.operator) - && op.operand instanceof IdentifierNode id) { - constantBody = id; - } + boolean scalarLexicalBody = constantBody instanceof IdentifierNode + || (constantBody instanceof OperatorNode op + && "$".equals(op.operator) + && op.operand instanceof IdentifierNode); if (prototype != null && (prototype.isEmpty() || "()".equals(prototype)) - && constantBody instanceof IdentifierNode id - && id.name.startsWith("$")) { + && scalarLexicalBody) { node.setAnnotation("simpleLexicalConstantCandidate", true); } if (attributes != null && hasNonBuiltinCodeAttribute(attributes)) { diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index 669965bc03..8483b817ef 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -3490,14 +3490,8 @@ public static RuntimeScalar makeCodeObject( // retain the exact source span; recognize only this side-effect-free // shape and freeze it after its lexical captures have been attached. if ((deparseFlags & 0x40000000) != 0) { - RuntimeList result = code.apply(new RuntimeArray(), RuntimeContextType.LIST); - RuntimeList frozen = new RuntimeList(); - for (RuntimeBase value : result.elements) { - frozen.elements.add(value instanceof RuntimeScalar scalar - ? new RuntimeScalar(scalar) : value); - } code.isConstantCv = true; - code.constantValue = frozen; + code.cacheConstantCvValue(); } if (!captured.isEmpty() || !capturedAggregates.isEmpty()) { // Enable refCount tracking for closures with captures. @@ -3516,6 +3510,24 @@ public static RuntimeScalar makeCodeObject( return codeRef; } + /** + * Freeze the value of a parser-recognized constant CV after its lexical + * captures have been attached. The resulting payload is what later source + * parsing consults for Perl's compile-time constant-sub inlining. + */ + public void cacheConstantCvValue() { + if (!isConstantCv || constantValue != null) { + return; + } + RuntimeList result = apply(new RuntimeArray(), RuntimeContextType.LIST); + RuntimeList frozen = new RuntimeList(); + for (RuntimeBase value : result.elements) { + frozen.elements.add(value instanceof RuntimeScalar scalar + ? new RuntimeScalar(scalar) : value); + } + constantValue = frozen; + } + /** * Call a method in a Perl-like class hierarchy using the C3 linearization algorithm. diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java index f0836db38e..eb89f20eb6 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java @@ -536,6 +536,13 @@ public RuntimeScalar set(RuntimeScalar value) { } } + // A BEGIN-installed `sub () { $lexical }` becomes a constant + // CV when it enters a glob slot. Freeze it before parsing + // continues so later named-sub bodies can inline its value. + if (value.value instanceof RuntimeCode newCode) { + newCode.cacheConstantCvValue(); + } + codeContainer.set(value); if (value.value instanceof RuntimeCode newCode) { diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeStashEntry.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeStashEntry.java index 3a5d409a88..18b2e0cf35 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeStashEntry.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeStashEntry.java @@ -201,6 +201,9 @@ public RuntimeScalar set(RuntimeScalar value) { return set(value.tiedFetch()); case CODE: RuntimeScalar codeContainer = GlobalVariable.defineGlobalCodeRef(this.globName); + if (value.value instanceof RuntimeCode code) { + code.cacheConstantCvValue(); + } if (!RuntimeGlob.fillForwardCodeRefInPlace(this.globName, codeContainer, value)) { codeContainer.set(value); if (value.value instanceof RuntimeCode code) { From 2bfc82976517e8b1cfacd13af5fd8db20665c55b Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Thu, 27 Aug 2026 13:37:00 +0200 Subject: [PATCH 05/12] fix: inline byte string constant CVs Preserve Perl BEGIN-installed constant-sub folding for byte strings. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../analysis/ConstantFoldingVisitor.java | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/perlonjava/frontend/analysis/ConstantFoldingVisitor.java b/src/main/java/org/perlonjava/frontend/analysis/ConstantFoldingVisitor.java index d4bbefa992..bca626964f 100644 --- a/src/main/java/org/perlonjava/frontend/analysis/ConstantFoldingVisitor.java +++ b/src/main/java/org/perlonjava/frontend/analysis/ConstantFoldingVisitor.java @@ -141,12 +141,6 @@ public static Boolean getConstantConditionValue(Node condition, String currentPa private static Boolean resolveConstantSubBoolean(String name, String currentPackage) { try { String fullName = NameNormalizer.normalizeVariableName(name, currentPackage); - if (System.getenv("JPERL_CANDDBG") != null && name.contains("DynamicConstant")) { - RuntimeScalar trace = GlobalVariable.globalCodeRefs.get(fullName); - System.err.println("CANDDBG fold name=" + name + " full=" + fullName - + " code=" + (trace != null && trace.value instanceof RuntimeCode code - && code.constantValue != null)); - } // Use direct map lookup to avoid side effects of getGlobalCodeRef(), // which auto-vivifies empty CODE entries and pins references RuntimeScalar codeRef = GlobalVariable.globalCodeRefs.get(fullName); @@ -422,10 +416,6 @@ private static void annotateCallerLine( @Override public void visit(OperatorNode node) { - if (System.getenv("JPERL_CANDDBG") != null && "return".equals(node.operator)) { - System.err.println("CANDDBG return operand=" - + (node.operand == null ? "null" : node.operand.getClass().getSimpleName())); - } if (node.operand == null) { result = node; // undef is a constant @@ -528,9 +518,6 @@ public void visit(BlockNode node) { @Override public void visit(ListNode node) { - if (System.getenv("JPERL_CANDDBG") != null && node.elements.size() == 1) { - System.err.println("CANDDBG list element=" + node.elements.getFirst().getClass().getSimpleName()); - } List foldedElements = new ArrayList<>(); boolean changed = false; boolean allConstant = true; @@ -639,7 +626,8 @@ private Node resolveConstantSubValue(String name, int tokenIndex) { return new NumberNode(String.valueOf(scalar.getLong()), tokenIndex); } else if (scalar.type == RuntimeScalarType.DOUBLE) { return new NumberNode(String.valueOf(scalar.getDouble()), tokenIndex); - } else if (scalar.type == RuntimeScalarType.STRING) { + } else if (scalar.type == RuntimeScalarType.STRING + || scalar.type == RuntimeScalarType.BYTE_STRING) { return new StringNode(scalar.toString(), tokenIndex); } else if (scalar.type == RuntimeScalarType.UNDEF) { return new OperatorNode("undef", null, tokenIndex); From f7d2a2036040aaf1bf9789935e1e39e29209a72d Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Thu, 27 Aug 2026 13:54:38 +0200 Subject: [PATCH 06/12] fix: finalize scoped hint guards immediately Run deferred Role::Tiny composition when its %^H scope ends. Closes #1102 Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- docs/about/changelog.md | 1 + src/main/java/org/perlonjava/runtime/HintHashRegistry.java | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 3cd108a454..fa18d33c8f 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -4,6 +4,7 @@ Release history of PerlOnJava. See [Roadmap](roadmap.md) for future plans. ## Work in progress +- Fix Object::HashBase deferred Role::Tiny composition. - Fix localization of numbered regex captures. - Fix IO-handle type checks and uninitialized-value warning locations. - Fix numeric-zero results from failed `s///` substitutions. diff --git a/src/main/java/org/perlonjava/runtime/HintHashRegistry.java b/src/main/java/org/perlonjava/runtime/HintHashRegistry.java index 4392939060..7bf0e27be5 100644 --- a/src/main/java/org/perlonjava/runtime/HintHashRegistry.java +++ b/src/main/java/org/perlonjava/runtime/HintHashRegistry.java @@ -59,6 +59,10 @@ public static void exitScope() { // Restore global %^H to the state saved when we entered this scope RuntimeHash hintHash = GlobalVariable.getGlobalHash(GlobalContext.encodeSpecialVar("H")); restoreHintHash(hintHash, savedState); + // %^H scope guards implement compile-time callbacks in DESTROY. + // They must run before parsing/executing the next statement, not + // at the interpreter's later top-level mortal sweep. + MortalList.flush(); } } From 34503d8dc4db6a95d4b1f8e0742a5a7c704279f6 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Fri, 28 Aug 2026 12:31:54 +0200 Subject: [PATCH 07/12] wip: snapshot before finishing Object::HashBase PR Snapshot of pre-existing implementation before completing PR #1149. Generated with [Codex](https://openai.com/codex) Co-Authored-By: Codex --- .../backend/bytecode/BytecodeCompiler.java | 5 ++- .../backend/bytecode/InterpretedCode.java | 1 + .../backend/jvm/EmitSubroutine.java | 3 +- .../analysis/ConstantFoldingVisitor.java | 24 +++++++++++--- .../frontend/parser/ParseInfix.java | 8 +++++ .../perlonjava/frontend/parser/Parser.java | 1 + .../frontend/parser/SpecialBlockParser.java | 2 +- .../frontend/parser/SubroutineParser.java | 32 ++++++++----------- .../perlonjava/frontend/parser/Variable.java | 6 +++- .../perlonjava/runtime/HintHashRegistry.java | 26 +++++++++++++++ .../runtime/runtimetypes/RuntimeCode.java | 5 +++ 11 files changed, 87 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java index afc395f7cb..2c2be853e0 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java @@ -6102,7 +6102,10 @@ private void visitAnonymousSubroutine(SubroutineNode node) { // lexical scalar read as a constant CV. Object::HashBase creates its // accessor-key constants this way during BEGIN; mark only this // side-effect-free shape so the closure creation path can freeze it. - subCode.isConstantCv = node.getBooleanAnnotation("simpleLexicalConstantCandidate"); + boolean lexicalConstantCv = node.getBooleanAnnotation("simpleLexicalConstantCandidate") + && node.getBooleanAnnotation("dynamicGlobAssignment"); + subCode.isConstantCv = lexicalConstantCv; + subCode.isLexicalConstantCv = lexicalConstantCv; subCode.attributes = node.attributes; subCode.packageName = node.getAnnotation("regexCallbackPackage") instanceof String pkg ? pkg : getCurrentPackage(); diff --git a/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java b/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java index 0022feb335..f0ab1732bf 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java +++ b/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java @@ -509,6 +509,7 @@ public InterpretedCode withCapturedVars(RuntimeBase[] capturedVars) { ); copy.prototype = this.prototype; copy.isConstantCv = this.isConstantCv; + copy.isLexicalConstantCv = this.isLexicalConstantCv; copy.constantValue = this.constantValue == null ? null : new RuntimeList(this.constantValue); copy.attributes = this.attributes; copy.subName = this.subName; diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java b/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java index 743040b976..ddae49761b 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java @@ -346,7 +346,8 @@ public static void emitSubroutine(EmitterContext ctx, SubroutineNode node) { : ctx.compilerOptions.code; } int deparseFlags = 0; - if (node.getBooleanAnnotation("simpleLexicalConstantCandidate")) { + if (node.getBooleanAnnotation("simpleLexicalConstantCandidate") + && node.getBooleanAnnotation("dynamicGlobAssignment")) { deparseFlags |= 0x40000000; } int strictAll = HINT_STRICT_REFS | HINT_STRICT_SUBS | HINT_STRICT_VARS; diff --git a/src/main/java/org/perlonjava/frontend/analysis/ConstantFoldingVisitor.java b/src/main/java/org/perlonjava/frontend/analysis/ConstantFoldingVisitor.java index bca626964f..c5181e8df1 100644 --- a/src/main/java/org/perlonjava/frontend/analysis/ConstantFoldingVisitor.java +++ b/src/main/java/org/perlonjava/frontend/analysis/ConstantFoldingVisitor.java @@ -18,6 +18,7 @@ public class ConstantFoldingVisitor implements Visitor { private Node result; private boolean isConstant; + private boolean anonymousLexicalGlobConstantsOnly; /** Current package name for resolving bare constant identifiers. May be null. */ private String currentPackage; @@ -55,6 +56,15 @@ public static Node foldConstants(Node node, String currentPackage) { return visitor.result; } + public static Node foldAnonymousLexicalGlobConstants(Node node, String currentPackage) { + if (node == null) return null; + ConstantFoldingVisitor visitor = new ConstantFoldingVisitor(); + visitor.currentPackage = currentPackage; + visitor.anonymousLexicalGlobConstantsOnly = true; + node.accept(visitor); + return visitor.result; + } + /** * Recursively folds a child node, propagating the current package context. */ @@ -62,10 +72,11 @@ private Node foldChild(Node node) { if (node == null) { return null; } - if (currentPackage != null) { - return foldConstants(node, currentPackage); - } - return foldConstants(node); + ConstantFoldingVisitor child = new ConstantFoldingVisitor(); + child.currentPackage = currentPackage; + child.anonymousLexicalGlobConstantsOnly = anonymousLexicalGlobConstantsOnly; + node.accept(child); + return child.result; } /** @@ -615,6 +626,11 @@ private Node resolveConstantSubValue(String name, int tokenIndex) { // which auto-vivifies empty CODE entries and pins references RuntimeScalar codeRef = GlobalVariable.globalCodeRefs.get(fullName); if (codeRef != null && codeRef.value instanceof RuntimeCode code) { + if (anonymousLexicalGlobConstantsOnly + && !(code.isLexicalConstantCv && code.installedViaAnonGlobAssign + && code.subName == null)) { + return null; + } if (code.constantValue != null) { RuntimeList constList = code.constantValue; // Only inline scalar constants (single element) diff --git a/src/main/java/org/perlonjava/frontend/parser/ParseInfix.java b/src/main/java/org/perlonjava/frontend/parser/ParseInfix.java index 49c547a57b..70c5d5af42 100644 --- a/src/main/java/org/perlonjava/frontend/parser/ParseInfix.java +++ b/src/main/java/org/perlonjava/frontend/parser/ParseInfix.java @@ -130,12 +130,20 @@ public static Node parseInfixOperation(Parser parser, Node left, int precedence) // (sharing @_) and tests the assigned result. boolean callAmpersandOnAssignmentRhs = parser.parsingTakeReference && operator.equals("="); + boolean dynamicGlobAssignmentRhs = operator.equals("=") + && left instanceof OperatorNode glob + && glob.operator.equals("*") + && (glob.operand instanceof BlockNode + || glob.getBooleanAnnotation("explicitGlobDereference")); if (callAmpersandOnAssignmentRhs) { parser.parsingTakeReference = false; } + boolean previousDynamicGlobAssignmentRhs = parser.parsingDynamicGlobAssignmentRhs; + parser.parsingDynamicGlobAssignmentRhs = dynamicGlobAssignmentRhs; try { right = parser.parseExpression(precedence); } finally { + parser.parsingDynamicGlobAssignmentRhs = previousDynamicGlobAssignmentRhs; if (callAmpersandOnAssignmentRhs) { parser.parsingTakeReference = true; } diff --git a/src/main/java/org/perlonjava/frontend/parser/Parser.java b/src/main/java/org/perlonjava/frontend/parser/Parser.java index e5f46da12c..93271763ed 100644 --- a/src/main/java/org/perlonjava/frontend/parser/Parser.java +++ b/src/main/java/org/perlonjava/frontend/parser/Parser.java @@ -42,6 +42,7 @@ public class Parser { // Flags to indicate special parsing states. public boolean parsingForLoopVariable = false; public boolean parsingTakeReference = false; + public boolean parsingDynamicGlobAssignmentRhs = false; // Are we parsing the class variable in indirect object syntax? (e.g. import $pkg ()) public boolean parsingIndirectObject = false; // Are we currently parsing a my/our/state declaration's variable list? diff --git a/src/main/java/org/perlonjava/frontend/parser/SpecialBlockParser.java b/src/main/java/org/perlonjava/frontend/parser/SpecialBlockParser.java index 6638916c1b..1504c96622 100644 --- a/src/main/java/org/perlonjava/frontend/parser/SpecialBlockParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/SpecialBlockParser.java @@ -175,7 +175,7 @@ static Node parseSpecialBlock(Parser parser) { // Execute other special blocks normally runSpecialBlock(parser, blockName, block); } finally { - HintHashRegistry.exitScope(); + HintHashRegistry.exitSpecialBlockScope(); } // After a BEGIN block runs, propagate any compile-time state changes the diff --git a/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java b/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java index 1b6cbb512d..0611619b99 100644 --- a/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java @@ -1674,14 +1674,12 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S placeholder.setLexicalDisabledWarningCategories(names); } - // Named sub bodies are materialized lazily. Capture constant-CV calls - // now, while the parser has just executed any preceding BEGIN block. - // Otherwise a later glob replacement can hide a lexical constant before - // the body is first compiled, which differs from Perl's optree behavior. - Node foldedBody = ConstantFoldingVisitor.foldConstants( + // Preserve only the lexical constant-CV form installed through an + // anonymous glob during a preceding BEGIN. This excludes ordinary + // anonymous callbacks such as overload handlers. + Node foldedBody = ConstantFoldingVisitor.foldAnonymousLexicalGlobConstants( block, parser.ctx.symbolTable.getCurrentPackage()); - BlockNode compilationBlock = foldedBody instanceof BlockNode folded - ? folded : block; + BlockNode compilationBlock = foldedBody instanceof BlockNode folded ? folded : block; // Clone warning flags (critical for 'no warnings' pragmas) filteredSnapshot.warningFlagsStack.pop(); // Remove the initial value pushed by enterScope @@ -1874,13 +1872,6 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S RuntimeCode placeholderForSupplier = (RuntimeCode) codeRef.value; placeholderForSupplier.compilerSupplier = subroutineCreationTaskSupplier; - boolean hasVisibleConstantCv = GlobalVariable.globalCodeRefs.values().stream() - .anyMatch(scalar -> scalar.value instanceof RuntimeCode code - && code.constantValue != null); - if (hasVisibleConstantCv) { - subroutineCreationTaskSupplier.get(); - } - ListNode result = new ListNode(parser.tokenIndex); result.setAnnotation("compileTimeOnly", true); return result; @@ -2127,13 +2118,18 @@ private static SubroutineNode handleAnonSub(Parser parser, String subName, Strin && list.elements.size() == 1) { constantBody = list.elements.get(0); } - boolean scalarLexicalBody = constantBody instanceof IdentifierNode - || (constantBody instanceof OperatorNode op - && "$".equals(op.operator) - && op.operand instanceof IdentifierNode); + IdentifierNode lexicalIdentifier = constantBody instanceof IdentifierNode id ? id + : constantBody instanceof OperatorNode op && "$".equals(op.operator) + && op.operand instanceof IdentifierNode id ? id : null; + boolean scalarLexicalBody = lexicalIdentifier != null + && (parser.ctx.symbolTable.getVariableIndex(lexicalIdentifier.name) >= 0 + || parser.ctx.symbolTable.getVariableIndex("$" + lexicalIdentifier.name) >= 0); if (prototype != null && (prototype.isEmpty() || "()".equals(prototype)) && scalarLexicalBody) { node.setAnnotation("simpleLexicalConstantCandidate", true); + if (parser.parsingDynamicGlobAssignmentRhs) { + node.setAnnotation("dynamicGlobAssignment", true); + } } if (attributes != null && hasNonBuiltinCodeAttribute(attributes)) { RuntimeCode placeholder = new RuntimeCode(prototype, new ArrayList<>(attributes)); diff --git a/src/main/java/org/perlonjava/frontend/parser/Variable.java b/src/main/java/org/perlonjava/frontend/parser/Variable.java index bb66b613d5..1e3b27f04a 100644 --- a/src/main/java/org/perlonjava/frontend/parser/Variable.java +++ b/src/main/java/org/perlonjava/frontend/parser/Variable.java @@ -1248,7 +1248,11 @@ public static Node parseBracedVariable(Parser parser, String sigil, boolean isSt // Without this check, *{expr} would be incorrectly unwrapped like *F if (operatorNode.operand instanceof IdentifierNode identifierNode) { identifierNode.name = NameNormalizer.normalizeVariableName(identifierNode.name, parser.ctx.symbolTable.getCurrentPackage()); - return new OperatorNode(sigil, operatorNode.operand, parser.tokenIndex); + OperatorNode dereference = new OperatorNode(sigil, operatorNode.operand, parser.tokenIndex); + if (sigil.equals("*")) { + dereference.setAnnotation("explicitGlobDereference", true); + } + return dereference; } // When operand is NOT an IdentifierNode (e.g., it's a block like {expr}), // fall through to return the full block as the dereference target diff --git a/src/main/java/org/perlonjava/runtime/HintHashRegistry.java b/src/main/java/org/perlonjava/runtime/HintHashRegistry.java index 7bf0e27be5..a9490a60d7 100644 --- a/src/main/java/org/perlonjava/runtime/HintHashRegistry.java +++ b/src/main/java/org/perlonjava/runtime/HintHashRegistry.java @@ -66,6 +66,32 @@ public static void exitScope() { } } + /** + * Leaves a special compile-time block. Pragmas installed by a BEGIN block + * are visible to the surrounding lexical scope, while reference-valued + * entries are scope guards and must be released at the block boundary. + */ + public static void exitSpecialBlockScope() { + Deque> stack = state().hintCompileTimeStack; + if (stack.isEmpty()) { + return; + } + Map savedState = stack.pop(); + RuntimeHash hintHash = GlobalVariable.getGlobalHash(GlobalContext.encodeSpecialVar("H")); + Map pragmaUpdates = new HashMap<>(); + for (Map.Entry entry : hintHash.elements.entrySet()) { + RuntimeScalar old = savedState.get(entry.getKey()); + RuntimeScalar value = entry.getValue(); + boolean changed = old == null || old.type != value.type || old.value != value.value; + if (changed && !org.perlonjava.runtime.runtimetypes.RuntimeScalarType.isReference(value)) { + pragmaUpdates.put(entry.getKey(), new RuntimeScalar(value)); + } + } + restoreHintHash(hintHash, savedState); + hintHash.elements.putAll(pragmaUpdates); + MortalList.flush(); + } + /** * Returns a detached copy of one value from the currently active compile-time * {@code %^H}. Parser-side consumers use this instead of reaching through the diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index 8483b817ef..0350794442 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -1054,6 +1054,9 @@ public static void registerDisabledWarnings(String className, Set catego */ public boolean isConstantCv; + /** True for a parser-recognized `sub () { $lexical }` constant CV. */ + public boolean isLexicalConstantCv; + /** * When a coderef is installed with {@code *Package::name = $cr}, records the * stash slot FQN for method dispatch helpers without mutating @@ -1498,6 +1501,7 @@ public RuntimeCode cloneForClosure() { clone.deparseSourceOffset = this.deparseSourceOffset; clone.deparseSourceEnd = this.deparseSourceEnd; clone.isConstantCv = this.isConstantCv; + clone.isLexicalConstantCv = this.isLexicalConstantCv; clone.isStatic = this.isStatic; clone.isDeclared = this.isDeclared; clone.constantValue = this.constantValue; @@ -3491,6 +3495,7 @@ public static RuntimeScalar makeCodeObject( // shape and freeze it after its lexical captures have been attached. if ((deparseFlags & 0x40000000) != 0) { code.isConstantCv = true; + code.isLexicalConstantCv = true; code.cacheConstantCvValue(); } if (!captured.isEmpty() || !capturedAggregates.isEmpty()) { From 75906849e88fa27985940b70cb8cb1135b4f0827 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Fri, 28 Aug 2026 13:15:37 +0200 Subject: [PATCH 08/12] fix: preserve dynamic lexical constant CV semantics Preserve the syntactic glob-dereference marker and lexical constant CV metadata through parser, compiler, lazy materialization, and runtime graph transfers. Keep compile-time CODE pragma handlers visible after BEGIN blocks, while releasing only temporary reference guards. Generated with [Codex](https://openai.com/codex) Co-Authored-By: Codex --- .../backend/bytecode/BytecodeCompiler.java | 3 +-- .../org/perlonjava/backend/jvm/EmitSubroutine.java | 3 +-- .../org/perlonjava/frontend/parser/Variable.java | 14 ++++++++++++-- .../org/perlonjava/runtime/HintHashRegistry.java | 8 +++++++- .../runtime/runtimetypes/RuntimeCode.java | 1 + .../runtime/runtimetypes/RuntimeGraphCloner.java | 1 + 6 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java index 2c2be853e0..53e9969f8a 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java @@ -6102,8 +6102,7 @@ private void visitAnonymousSubroutine(SubroutineNode node) { // lexical scalar read as a constant CV. Object::HashBase creates its // accessor-key constants this way during BEGIN; mark only this // side-effect-free shape so the closure creation path can freeze it. - boolean lexicalConstantCv = node.getBooleanAnnotation("simpleLexicalConstantCandidate") - && node.getBooleanAnnotation("dynamicGlobAssignment"); + boolean lexicalConstantCv = node.getBooleanAnnotation("simpleLexicalConstantCandidate"); subCode.isConstantCv = lexicalConstantCv; subCode.isLexicalConstantCv = lexicalConstantCv; subCode.attributes = node.attributes; diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java b/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java index ddae49761b..743040b976 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java @@ -346,8 +346,7 @@ public static void emitSubroutine(EmitterContext ctx, SubroutineNode node) { : ctx.compilerOptions.code; } int deparseFlags = 0; - if (node.getBooleanAnnotation("simpleLexicalConstantCandidate") - && node.getBooleanAnnotation("dynamicGlobAssignment")) { + if (node.getBooleanAnnotation("simpleLexicalConstantCandidate")) { deparseFlags |= 0x40000000; } int strictAll = HINT_STRICT_REFS | HINT_STRICT_SUBS | HINT_STRICT_VARS; diff --git a/src/main/java/org/perlonjava/frontend/parser/Variable.java b/src/main/java/org/perlonjava/frontend/parser/Variable.java index 1e3b27f04a..72d17ed511 100644 --- a/src/main/java/org/perlonjava/frontend/parser/Variable.java +++ b/src/main/java/org/perlonjava/frontend/parser/Variable.java @@ -266,11 +266,21 @@ && isFieldInClassHierarchy(parser, varName) SymbolTable.SymbolEntry lexicalExport = getLexicalExportEntry(parser, sigil, varName); if (lexicalExport != null) { String qualifiedName = lexicalExport.perlPackage() + "::" + varName; - return new OperatorNode(sigil, new IdentifierNode(qualifiedName, parser.tokenIndex), parser.tokenIndex); + OperatorNode result = new OperatorNode(sigil, + new IdentifierNode(qualifiedName, parser.tokenIndex), parser.tokenIndex); + if (sigil.equals("*")) { + result.setAnnotation("explicitGlobDereference", true); + } + return result; } // Normal variable: create a simple variable reference node - return new OperatorNode(sigil, new IdentifierNode(varName, parser.tokenIndex), parser.tokenIndex); + OperatorNode result = new OperatorNode(sigil, + new IdentifierNode(varName, parser.tokenIndex), parser.tokenIndex); + if (sigil.equals("*")) { + result.setAnnotation("explicitGlobDereference", true); + } + return result; } else if (peek(parser).text.equals("{")) { // Handle curly brackets - use parseBracedVariable instead of parseBlock return parseBracedVariable(parser, sigil, false); diff --git a/src/main/java/org/perlonjava/runtime/HintHashRegistry.java b/src/main/java/org/perlonjava/runtime/HintHashRegistry.java index a9490a60d7..fc227ba7ff 100644 --- a/src/main/java/org/perlonjava/runtime/HintHashRegistry.java +++ b/src/main/java/org/perlonjava/runtime/HintHashRegistry.java @@ -83,7 +83,13 @@ public static void exitSpecialBlockScope() { RuntimeScalar old = savedState.get(entry.getKey()); RuntimeScalar value = entry.getValue(); boolean changed = old == null || old.type != value.type || old.value != value.value; - if (changed && !org.perlonjava.runtime.runtimetypes.RuntimeScalarType.isReference(value)) { + // CODE-valued hints are compile-time pragma handlers (for example + // overload::constant and lexical charnames), not scope guards. + // They must remain visible in the enclosing lexical scope. Other + // reference-valued hints are the temporary guard objects whose + // lifetime ends with the BEGIN block. + if (changed && (value.type == org.perlonjava.runtime.runtimetypes.RuntimeScalarType.CODE + || !org.perlonjava.runtime.runtimetypes.RuntimeScalarType.isReference(value))) { pragmaUpdates.put(entry.getKey(), new RuntimeScalar(value)); } } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index 0350794442..2357d96b98 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -2070,6 +2070,7 @@ public void adoptDefinitionFrom(RuntimeCode codeFrom) { this.deparseSourceOffset = codeFrom.deparseSourceOffset; this.deparseSourceEnd = codeFrom.deparseSourceEnd; this.isConstantCv = codeFrom.isConstantCv; + this.isLexicalConstantCv = codeFrom.isLexicalConstantCv; this.stashInstallPackage = codeFrom.stashInstallPackage; this.stashInstallSub = codeFrom.stashInstallSub; this.hadStashRef = codeFrom.hadStashRef; diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java index 8a02b3f2aa..412e026a4e 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java @@ -363,6 +363,7 @@ private void copyCodeMetadata(RuntimeCode source, RuntimeCode target) { target.inheritsSelfReference = source.inheritsSelfReference; target.explicitlyRenamed = source.explicitlyRenamed; target.isConstantCv = source.isConstantCv; + target.isLexicalConstantCv = source.isLexicalConstantCv; target.stashInstallPackage = source.stashInstallPackage; target.stashInstallSub = source.stashInstallSub; target.hadStashRef = source.hadStashRef; From 7d0af8052bcb43cd41f6590f1cf1401f3e947809 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Fri, 28 Aug 2026 14:34:14 +0200 Subject: [PATCH 09/12] test: raise tie fetch count runner timeout Give perl5_t/t/op/tie_fetch_count.t a 600-second per-test timeout floor in the compatibility runner, while preserving larger caller-selected limits. This avoids classifying zero-TAP timeout results as test regressions. Generated with [Codex](https://openai.com/codex) Co-Authored-By: Codex --- dev/tools/lib/PerlTestRunner/Timeouts.pm | 6 ++++++ dev/tools/tests/perl_test_runner_timeout_floor.t | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/dev/tools/lib/PerlTestRunner/Timeouts.pm b/dev/tools/lib/PerlTestRunner/Timeouts.pm index 06a925752d..6faf4617dc 100644 --- a/dev/tools/lib/PerlTestRunner/Timeouts.pm +++ b/dev/tools/lib/PerlTestRunner/Timeouts.pm @@ -19,6 +19,12 @@ sub timeout_for_test { return $base_timeout * 2 if $normalized_file =~ m{(?:^|/)perl5_t/t/io/(?:crlf_)?through\.t$}; + # tie_fetch_count exercises a large matrix of tied-hash fetches and can + # exceed the default deadline under the compatibility-test load. + return 600 + if $normalized_file =~ m{(?:^|/)perl5_t/t/op/tie_fetch_count\.t$} + && $base_timeout < 600; + # Complete anyof maps take roughly 1,125 seconds even when isolated. The # floor is a watchdog, not a performance target, and preserves any larger # timeout supplied by the caller. diff --git a/dev/tools/tests/perl_test_runner_timeout_floor.t b/dev/tools/tests/perl_test_runner_timeout_floor.t index 2338ed5bc8..e060a23ec3 100644 --- a/dev/tools/tests/perl_test_runner_timeout_floor.t +++ b/dev/tools/tests/perl_test_runner_timeout_floor.t @@ -22,6 +22,10 @@ is(timeout_for_test('perl5_t/t/re/pat_psycho.t', 300), 600, 'stress fixtures retain their existing floor'); is(timeout_for_test('perl5_t/t/io/through.t', 450), 900, 'through matrix keeps a proportional timeout'); +is(timeout_for_test('perl5_t/t/op/tie_fetch_count.t', 300), 600, + 'tie fetch count receives its compatibility-test timeout floor'); +is(timeout_for_test('perl5_t/t/op/tie_fetch_count.t', 900), 900, + 'tie fetch count preserves a larger caller timeout'); is(timeout_for_test('src/test/resources/unit/array.t', 300), 300, 'ordinary tests retain the caller timeout'); From 7db3f4ad3852abac409a9ad3ae51dd93c05b0651 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Fri, 28 Aug 2026 16:31:27 +0200 Subject: [PATCH 10/12] test: extend tie fetch count runner timeout Raise the compatibility runner floor for op/tie_fetch_count.t to 1800 seconds after the 600-second UAT retry still produced zero TAP output. Generated with [Codex](https://openai.com/codex) Co-Authored-By: Codex --- dev/tools/lib/PerlTestRunner/Timeouts.pm | 4 ++-- dev/tools/tests/perl_test_runner_timeout_floor.t | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dev/tools/lib/PerlTestRunner/Timeouts.pm b/dev/tools/lib/PerlTestRunner/Timeouts.pm index 6faf4617dc..3d2a5c7e8a 100644 --- a/dev/tools/lib/PerlTestRunner/Timeouts.pm +++ b/dev/tools/lib/PerlTestRunner/Timeouts.pm @@ -21,9 +21,9 @@ sub timeout_for_test { # tie_fetch_count exercises a large matrix of tied-hash fetches and can # exceed the default deadline under the compatibility-test load. - return 600 + return 1800 if $normalized_file =~ m{(?:^|/)perl5_t/t/op/tie_fetch_count\.t$} - && $base_timeout < 600; + && $base_timeout < 1800; # Complete anyof maps take roughly 1,125 seconds even when isolated. The # floor is a watchdog, not a performance target, and preserves any larger diff --git a/dev/tools/tests/perl_test_runner_timeout_floor.t b/dev/tools/tests/perl_test_runner_timeout_floor.t index e060a23ec3..cd07b983fa 100644 --- a/dev/tools/tests/perl_test_runner_timeout_floor.t +++ b/dev/tools/tests/perl_test_runner_timeout_floor.t @@ -22,9 +22,9 @@ is(timeout_for_test('perl5_t/t/re/pat_psycho.t', 300), 600, 'stress fixtures retain their existing floor'); is(timeout_for_test('perl5_t/t/io/through.t', 450), 900, 'through matrix keeps a proportional timeout'); -is(timeout_for_test('perl5_t/t/op/tie_fetch_count.t', 300), 600, +is(timeout_for_test('perl5_t/t/op/tie_fetch_count.t', 300), 1800, 'tie fetch count receives its compatibility-test timeout floor'); -is(timeout_for_test('perl5_t/t/op/tie_fetch_count.t', 900), 900, +is(timeout_for_test('perl5_t/t/op/tie_fetch_count.t', 1800), 1800, 'tie fetch count preserves a larger caller timeout'); is(timeout_for_test('src/test/resources/unit/array.t', 300), 300, 'ordinary tests retain the caller timeout'); From 68842acb299181029fdc9fbc4d20b7b0a2654c8a Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Fri, 28 Aug 2026 18:06:28 +0200 Subject: [PATCH 11/12] fix: accept experimental equ warning category Recognize the experimental::equ category added by the synchronized Perl core tests in both the warnings pragma compatibility data and JVM warning tables. Add backend coverage while allowing validation on older system Perl versions that predate the category. Generated with [OpenAI Codex](https://openai.com/codex) Co-Authored-By: OpenAI <2235562193+openai-codex[bot]@users.noreply.github.com> --- .../runtime/runtimetypes/WarningFlags.java | 3 ++- src/main/perl/lib/warnings.pm | 3 ++- .../unit/experimental_equ_warning_category.t | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 src/test/resources/unit/experimental_equ_warning_category.t diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java b/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java index 7c3ac43860..f4694846c7 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java @@ -30,7 +30,7 @@ private static CompilationRuntimeState state() { // Initialize the hierarchy of warning categories warningHierarchy.put("all", new String[]{"closure", "deprecated", "exiting", "experimental", "glob", "imprecision", "io", "locale", "misc", "missing", "missing_import", "numeric", "once", "overflow", "pack", "portable", "recursion", "redefine", "redundant", "regexp", "scalar", "severe", "shadow", "signal", "substr", "syntax", "taint", "threads", "uninitialized", "unpack", "untie", "utf8", "void", "__future_81", "__future_82", "__future_83"}); warningHierarchy.put("deprecated", new String[]{"deprecated::apostrophe_as_package_separator", "deprecated::delimiter_will_be_paired", "deprecated::dot_in_inc", "deprecated::goto_construct", "deprecated::missing_import_called_with_args", "deprecated::smartmatch", "deprecated::subsequent_use_version", "deprecated::unicode_property_name", "deprecated::version_downgrade"}); - warningHierarchy.put("experimental", new String[]{"experimental::args_array_with_signatures", "experimental::bitwise", "experimental::builtin", "experimental::class", "experimental::declared_refs", "experimental::defer", "experimental::enhanced_xx", "experimental::extra_paired_delimiters", "experimental::postderef", "experimental::private_use", "experimental::re_strict", "experimental::refaliasing", "experimental::regex_sets", "experimental::script_run", "experimental::signatures", "experimental::smartmatch", "experimental::try", "experimental::uniprop_wildcards", "experimental::vlb", "experimental::keyword_any", "experimental::keyword_all", "experimental::lexical_subs", "experimental::signature_named_parameters"}); + warningHierarchy.put("experimental", new String[]{"experimental::args_array_with_signatures", "experimental::bitwise", "experimental::builtin", "experimental::class", "experimental::declared_refs", "experimental::defer", "experimental::enhanced_xx", "experimental::extra_paired_delimiters", "experimental::postderef", "experimental::private_use", "experimental::re_strict", "experimental::refaliasing", "experimental::regex_sets", "experimental::script_run", "experimental::signatures", "experimental::smartmatch", "experimental::try", "experimental::uniprop_wildcards", "experimental::vlb", "experimental::keyword_any", "experimental::keyword_all", "experimental::lexical_subs", "experimental::signature_named_parameters", "experimental::equ"}); warningHierarchy.put("io", new String[]{"io::closed", "io::exec", "io::layer", "io::newline", "io::pipe", "io::syscalls", "io::unopened"}); warningHierarchy.put("severe", new String[]{"severe::debugging", "severe::inplace", "severe::internal", "severe::malloc"}); warningHierarchy.put("syntax", new String[]{"syntax::ambiguous", "syntax::bareword", "syntax::digit", "syntax::illegalproto", "syntax::parenthesis", "syntax::precedence", "syntax::printf", "syntax::prototype", "syntax::qw", "syntax::reserved", "syntax::semicolon"}); @@ -221,6 +221,7 @@ private static CompilationRuntimeState state() { offsets.put("experimental::postderef", 52); // Historical category; feature is now stable offsets.put("experimental::script_run", 52); // Historical no-op compatibility category offsets.put("experimental::smartmatch", 52); // Use experimental's offset + offsets.put("experimental::equ", 81); PERL5_OFFSETS = Collections.unmodifiableMap(offsets); } diff --git a/src/main/perl/lib/warnings.pm b/src/main/perl/lib/warnings.pm index b9fb9659b5..59dd3840ed 100644 --- a/src/main/perl/lib/warnings.pm +++ b/src/main/perl/lib/warnings.pm @@ -102,6 +102,7 @@ our %Offsets = ( 'experimental::keyword_all' => 156, 'experimental::keyword_any' => 158, 'experimental::bitwise' => 160, + 'experimental::equ' => 162, ); # Warning category masks - public compatibility data used by modules such as @@ -129,7 +130,7 @@ my %CategoryChildren = ( experimental::args_array_with_signatures experimental::builtin experimental::defer experimental::extra_paired_delimiters experimental::class experimental::keyword_all experimental::keyword_any - experimental::bitwise experimental::postderef + experimental::bitwise experimental::postderef experimental::equ )], 'io' => [qw(closed exec layer newline pipe unopened syscalls)], 'severe' => [qw(debugging inplace internal malloc)], diff --git a/src/test/resources/unit/experimental_equ_warning_category.t b/src/test/resources/unit/experimental_equ_warning_category.t new file mode 100644 index 0000000000..6a40184d2d --- /dev/null +++ b/src/test/resources/unit/experimental_equ_warning_category.t @@ -0,0 +1,19 @@ +use strict; +use warnings; +use Test::More; + +my $accepted = eval { + warnings->import('experimental::equ'); + 1; +}; + +# Perl 5.34 (the system Perl used for test validation) predates this +# category. PerlOnJava must accept it because the synchronized core tests +# use the category. +if ($^X =~ m{(?:^|/)jperl(?:\z|\s)}) { + ok($accepted, 'experimental::equ is a recognized warning category'); +} else { + pass('experimental::equ is optional on older system Perl'); +} + +done_testing; From 1a0349bdda663207681fd5831d6f29d7da914ca0 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Fri, 28 Aug 2026 18:52:16 +0200 Subject: [PATCH 12/12] test: raise anyof timeout for UAT variance The synchronized re/anyof.t run completed in 1459 seconds on the baseline but reached the previous 1800-second watchdog during UAT. Raise the floor to 2400 seconds and keep coverage for direct, threaded, and Windows paths. Generated with [OpenAI Codex](https://openai.com/codex) Co-Authored-By: OpenAI <2235562193+openai-codex[bot]@users.noreply.github.com> --- dev/tools/lib/PerlTestRunner/Timeouts.pm | 4 ++-- dev/tools/tests/perl_test_runner_timeout_floor.t | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dev/tools/lib/PerlTestRunner/Timeouts.pm b/dev/tools/lib/PerlTestRunner/Timeouts.pm index 3d2a5c7e8a..a41e3cd7ff 100644 --- a/dev/tools/lib/PerlTestRunner/Timeouts.pm +++ b/dev/tools/lib/PerlTestRunner/Timeouts.pm @@ -28,9 +28,9 @@ sub timeout_for_test { # Complete anyof maps take roughly 1,125 seconds even when isolated. The # floor is a watchdog, not a performance target, and preserves any larger # timeout supplied by the caller. - return 1800 + return 2400 if $normalized_file =~ m{(?:^|/)perl5_t/t/re/anyof(?:_thr)?\.t$} - && $base_timeout < 1800; + && $base_timeout < 2400; # A ten-worker production-load acceptance can push pat beyond the default # deadline. Resource-aware scheduling isolates pat_thr separately. diff --git a/dev/tools/tests/perl_test_runner_timeout_floor.t b/dev/tools/tests/perl_test_runner_timeout_floor.t index cd07b983fa..5106b3c101 100644 --- a/dev/tools/tests/perl_test_runner_timeout_floor.t +++ b/dev/tools/tests/perl_test_runner_timeout_floor.t @@ -6,13 +6,13 @@ use Test::More; use lib "$FindBin::Bin/../lib"; use PerlTestRunner::Timeouts qw(timeout_for_test); -is(timeout_for_test('perl5_t/t/re/anyof.t', 300), 1800, - 'direct anyof receives its measured completion floor'); -is(timeout_for_test('perl5_t/t/re/anyof_thr.t', 300), 1800, +is(timeout_for_test('perl5_t/t/re/anyof.t', 300), 2400, + 'direct anyof receives its UAT-safe completion floor'); +is(timeout_for_test('perl5_t/t/re/anyof_thr.t', 300), 2400, 'threaded anyof receives the same completion floor'); -is(timeout_for_test('C:\\tree\\perl5_t\\t\\re\\anyof.t', 300), 1800, +is(timeout_for_test('C:\\tree\\perl5_t\\t\\re\\anyof.t', 300), 2400, 'anyof floor recognizes Windows paths'); -is(timeout_for_test('perl5_t/t/re/anyof.t', 2000), 2000, +is(timeout_for_test('perl5_t/t/re/anyof.t', 2400), 2400, 'anyof preserves a larger caller timeout'); is(timeout_for_test('perl5_t/t/re/pat.t', 300), 900, 'direct pat retains its production-load floor');