From 5116da29b390fa15ce724963caf3b2f1b6410892 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 4 Aug 2026 14:27:39 +0200 Subject: [PATCH 1/9] fix: resolve CPAN compiler regressions Fix compiler and runtime semantics exercised by Test::File, File::Copy::Recursive, Config::Multi, Template Toolkit, and their dependencies. Add regression coverage and retire obsolete CPAN test failure exemptions. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../backend/bytecode/BytecodeCompiler.java | 75 +++++++++++++++---- .../bytecode/CompileBinaryOperator.java | 10 ++- .../backend/jvm/EmitSubroutine.java | 51 ++++++++----- .../frontend/parser/ParserTables.java | 4 +- .../frontend/parser/SubroutineParser.java | 19 ++++- .../runtime/WarningBitsRegistry.java | 1 + .../runtime/perlmodule/FileSpec.java | 44 ++++++----- .../runtime/perlmodule/Internals.java | 61 ++++++++++++++- .../runtimetypes/GlobalRuntimeArray.java | 12 +++ .../runtime/runtimetypes/GlobalVariable.java | 3 + .../runtime/runtimetypes/RuntimeCode.java | 4 + .../runtime/runtimetypes/WarningFlags.java | 8 ++ src/main/perl/lib/CPAN/Config.pm | 6 +- src/main/perl/lib/PadWalker.pm | 9 +-- .../CpanDistroprefs/File-Copy-Recursive.yml | 13 ---- .../PerlOnJava/CpanDistroprefs/Test-File.yml | 13 ---- .../CpanDistroprefs/Test-Warnings.yml | 12 --- src/main/perl/lib/YAML/XS.pm | 2 +- .../perlonjava/PerlScriptExecutionTest.java | 7 ++ .../unit/core_global_additional_overrides.t | 20 +++++ .../resources/unit/core_global_dynamic_goto.t | 20 +++++ .../unit/local_isa_method_dispatch.t | 24 ++++++ .../resources/unit/local_isa_nested_restore.t | 31 ++++++++ .../resources/unit/nested_call_caller_line.t | 20 +++++ .../resources/unit/padwalker_closed_over.t | 23 ++++++ src/test/resources/unit/yaml_pp_utf8_octets.t | 11 +++ 26 files changed, 399 insertions(+), 104 deletions(-) delete mode 100644 src/main/perl/lib/PerlOnJava/CpanDistroprefs/File-Copy-Recursive.yml delete mode 100644 src/main/perl/lib/PerlOnJava/CpanDistroprefs/Test-File.yml delete mode 100644 src/main/perl/lib/PerlOnJava/CpanDistroprefs/Test-Warnings.yml create mode 100644 src/test/resources/unit/core_global_additional_overrides.t create mode 100644 src/test/resources/unit/core_global_dynamic_goto.t create mode 100644 src/test/resources/unit/local_isa_method_dispatch.t create mode 100644 src/test/resources/unit/local_isa_nested_restore.t create mode 100644 src/test/resources/unit/nested_call_caller_line.t create mode 100644 src/test/resources/unit/padwalker_closed_over.t create mode 100644 src/test/resources/unit/yaml_pp_utf8_octets.t diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java index 4c2c77c80..d188325d5 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java @@ -327,6 +327,18 @@ boolean isOurVariable(String name) { return entry != null && "our".equals(entry.decl()); } + boolean isDynamicOurVariable(String name) { + SymbolTable.SymbolEntry entry = symbolTable.getSymbolEntry(name); + if (entry == null || !"our".equals(entry.decl())) { + return false; + } + // BEGIN blocks expose captured lexicals through a synthetic package alias. + // Those are closure storage, not genuine package variables, and must keep + // using the captured container rather than a runtime global lookup. + String perlPackage = entry.perlPackage(); + return perlPackage == null || !perlPackage.startsWith("PerlOnJava::_BEGIN_"); + } + boolean isReservedVariable(String name) { SymbolTable.SymbolEntry entry = symbolTable.getSymbolEntry(name); return entry != null && "reserved".equals(entry.decl()); @@ -1717,7 +1729,8 @@ void handleArrayElementAccess(BinaryOperatorNode node, OperatorNode leftOp) { // Get the array register - check closure, lexical, then global int arrayReg; if (currentSubroutineBeginId != 0 && currentSubroutineClosureVars != null - && currentSubroutineClosureVars.contains(arrayVarName)) { + && currentSubroutineClosureVars.contains(arrayVarName) + && !isDynamicOurVariable(arrayVarName)) { arrayReg = allocateRegister(); int nameIdx = addToStringPool(arrayVarName); emitWithToken(Opcodes.RETRIEVE_BEGIN_ARRAY, node.getIndex()); @@ -1828,14 +1841,15 @@ void handleArraySlice(BinaryOperatorNode node, OperatorNode leftOp) { String arrayVarName = "@" + varName; if (currentSubroutineBeginId != 0 && currentSubroutineClosureVars != null - && currentSubroutineClosureVars.contains(arrayVarName)) { + && currentSubroutineClosureVars.contains(arrayVarName) + && !isDynamicOurVariable(arrayVarName)) { arrayReg = allocateRegister(); int nameIdx = addToStringPool(arrayVarName); emitWithToken(Opcodes.RETRIEVE_BEGIN_ARRAY, node.getIndex()); emitReg(arrayReg); emit(nameIdx); emit(currentSubroutineBeginId); - } else if (hasVariable(arrayVarName)) { + } else if (hasVariable(arrayVarName) && !isDynamicOurVariable(arrayVarName)) { arrayReg = getVariableRegister(arrayVarName); } else { arrayReg = allocateRegister(); @@ -1949,7 +1963,8 @@ void handleHashElementAccess(BinaryOperatorNode node, OperatorNode leftOp) { // Get the hash register - check closure, lexical, then global int hashReg; if (currentSubroutineBeginId != 0 && currentSubroutineClosureVars != null - && currentSubroutineClosureVars.contains(hashVarName)) { + && currentSubroutineClosureVars.contains(hashVarName) + && !isDynamicOurVariable(hashVarName)) { hashReg = allocateRegister(); int nameIdx = addToStringPool(hashVarName); emitWithToken(Opcodes.RETRIEVE_BEGIN_HASH, node.getIndex()); @@ -2038,14 +2053,15 @@ void handleHashSlice(BinaryOperatorNode node, OperatorNode leftOp) { String hashVarName = "%" + varName; if (currentSubroutineBeginId != 0 && currentSubroutineClosureVars != null - && currentSubroutineClosureVars.contains(hashVarName)) { + && currentSubroutineClosureVars.contains(hashVarName) + && !isDynamicOurVariable(hashVarName)) { hashReg = allocateRegister(); int nameIdx = addToStringPool(hashVarName); emitWithToken(Opcodes.RETRIEVE_BEGIN_HASH, node.getIndex()); emitReg(hashReg); emit(nameIdx); emit(currentSubroutineBeginId); - } else if (hasVariable(hashVarName)) { + } else if (hasVariable(hashVarName) && !isDynamicOurVariable(hashVarName)) { hashReg = getVariableRegister(hashVarName); } else { hashReg = allocateRegister(); @@ -2177,14 +2193,15 @@ void handleHashKeyValueSlice(BinaryOperatorNode node, OperatorNode leftOp) { String hashVarName = "%" + varName; if (currentSubroutineBeginId != 0 && currentSubroutineClosureVars != null - && currentSubroutineClosureVars.contains(hashVarName)) { + && currentSubroutineClosureVars.contains(hashVarName) + && !isDynamicOurVariable(hashVarName)) { hashReg = allocateRegister(); int nameIdx = addToStringPool(hashVarName); emitWithToken(Opcodes.RETRIEVE_BEGIN_HASH, node.getIndex()); emitReg(hashReg); emit(nameIdx); emit(currentSubroutineBeginId); - } else if (hasVariable(hashVarName)) { + } else if (hasVariable(hashVarName) && !isDynamicOurVariable(hashVarName)) { hashReg = getVariableRegister(hashVarName); } else { hashReg = allocateRegister(); @@ -4421,7 +4438,8 @@ void compileVariableReference(OperatorNode node, String op) { String varName = "$" + ((IdentifierNode) node.operand).name; // Check if this is a closure variable captured from outer scope via PersistentVariable - if (currentSubroutineBeginId != 0 && currentSubroutineClosureVars.contains(varName)) { + if (currentSubroutineBeginId != 0 && currentSubroutineClosureVars.contains(varName) + && !isDynamicOurVariable(varName)) { // This is a closure variable - use RETRIEVE_BEGIN_SCALAR int rd = allocateOutputRegister(); int nameIdx = addToStringPool(varName); @@ -4564,7 +4582,8 @@ void compileVariableReference(OperatorNode node, String op) { // Check if this is a closure variable captured from outer scope via PersistentVariable int arrayReg; - if (currentSubroutineBeginId != 0 && currentSubroutineClosureVars.contains(varName)) { + if (currentSubroutineBeginId != 0 && currentSubroutineClosureVars.contains(varName) + && !isDynamicOurVariable(varName)) { // This is a closure variable - use RETRIEVE_BEGIN_ARRAY arrayReg = allocateRegister(); int nameIdx = addToStringPool(varName); @@ -4681,7 +4700,8 @@ void compileVariableReference(OperatorNode node, String op) { int hashReg; if (currentSubroutineBeginId != 0 && currentSubroutineClosureVars != null - && currentSubroutineClosureVars.contains(varName)) { + && currentSubroutineClosureVars.contains(varName) + && !isDynamicOurVariable(varName)) { hashReg = allocateRegister(); int nameIdx = addToStringPool(varName); emitWithToken(Opcodes.RETRIEVE_BEGIN_HASH, node.getIndex()); @@ -5054,6 +5074,31 @@ TreeMap collectVisiblePerlVariablesNarrowed(Node body) { return narrowed; } + private Map collectVariableDeclarations(List variableNames) { + Map declarations = new HashMap<>(); + for (String variableName : variableNames) { + SymbolTable.SymbolEntry entry = symbolTable.getSymbolEntry(variableName); + // The closure compiler historically treats every captured lexical as + // `my`. Preserve that behavior for my/state variables; only `our` + // needs explicit metadata because it must be looked up dynamically. + if (entry != null && isDynamicOurVariable(variableName)) { + declarations.put(variableName, "our"); + } + } + return declarations; + } + + private Map collectOurVariablePackages(List variableNames) { + Map packages = new HashMap<>(); + for (String variableName : variableNames) { + SymbolTable.SymbolEntry entry = symbolTable.getSymbolEntry(variableName); + if (entry != null && isDynamicOurVariable(variableName) && entry.perlPackage() != null) { + packages.put(variableName, entry.perlPackage()); + } + } + return packages; + } + /** * Get the highest register index currently used by variables (not temporaries). * This is used to determine the reset point for register recycling. @@ -5497,7 +5542,9 @@ private void visitNamedSubroutine(SubroutineNode node) { this.sourceName, node.getIndex(), this.errorUtil, - packedRegistry + packedRegistry, + collectVariableDeclarations(closureVarNames), + collectOurVariablePackages(closureVarNames) ); // The parentRegistry constructor sets isEvalString=true (for eval STRING closures), // but named subs are NOT eval strings - clear the flag. @@ -5612,7 +5659,9 @@ private void visitAnonymousSubroutine(SubroutineNode node) { this.sourceName, node.getIndex(), this.errorUtil, - parentRegistry // Pass parent variable registry for nested closure support + parentRegistry, // Pass parent variable registry for nested closure support + collectVariableDeclarations(closureVarNames), + collectOurVariablePackages(closureVarNames) ); // The parentRegistry constructor sets isEvalString=true (for eval STRING closures), // but anonymous subs are NOT eval strings - clear the flag. diff --git a/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java b/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java index 72fc76a1d..3e8de184d 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java +++ b/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java @@ -431,7 +431,15 @@ else if (node.right instanceof BinaryOperatorNode rightCall) { bytecodeCompiler.compileNode(node.left, -1, RuntimeContextType.SCALAR); int rs1 = bytecodeCompiler.lastResultReg; - bytecodeCompiler.compileNode(node.right, -1, RuntimeContextType.LIST); + int savedCallerLineOverride = bytecodeCompiler.callerLineTokenOverride; + if (savedCallerLineOverride <= 0 && node.left != null && node.left.getIndex() > 0) { + bytecodeCompiler.callerLineTokenOverride = node.left.getIndex(); + } + try { + bytecodeCompiler.compileNode(node.right, -1, RuntimeContextType.LIST); + } finally { + bytecodeCompiler.callerLineTokenOverride = savedCallerLineOverride; + } int rs2 = bytecodeCompiler.lastResultReg; // Check if this is a &func (no parens) call that should share caller's @_ diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java b/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java index 25147078f..05e002706 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java @@ -802,30 +802,41 @@ static void handleApplyOperator(EmitterVisitor emitterVisitor, BinaryOperatorNod mv.visitVarInsn(Opcodes.ASTORE, argsArraySlot); EmitterVisitor listVisitor = emitterVisitor.with(RuntimeContextType.LIST); - for (int index = 0; index < argCount; index++) { - int argSlot = emitterVisitor.ctx.javaClassInfo.acquireSpillSlot(); - boolean pooledArg = argSlot >= 0; - if (!pooledArg) { - argSlot = emitterVisitor.ctx.symbolTable.allocateLocalVariable(); - } + int savedArgumentCallerLineOverride = + emitterVisitor.ctx.javaClassInfo.callerLineTokenOverride; + if (savedArgumentCallerLineOverride <= 0 + && node.left != null && node.left.getIndex() > 0) { + emitterVisitor.ctx.javaClassInfo.callerLineTokenOverride = node.left.getIndex(); + } + try { + for (int index = 0; index < argCount; index++) { + int argSlot = emitterVisitor.ctx.javaClassInfo.acquireSpillSlot(); + boolean pooledArg = argSlot >= 0; + if (!pooledArg) { + argSlot = emitterVisitor.ctx.symbolTable.allocateLocalVariable(); + } - paramList.elements.get(index).accept(listVisitor); - mv.visitVarInsn(Opcodes.ASTORE, argSlot); + paramList.elements.get(index).accept(listVisitor); + mv.visitVarInsn(Opcodes.ASTORE, argSlot); - mv.visitVarInsn(Opcodes.ALOAD, argsArraySlot); - if (index <= 5) { - mv.visitInsn(Opcodes.ICONST_0 + index); - } else if (index <= 127) { - mv.visitIntInsn(Opcodes.BIPUSH, index); - } else { - mv.visitIntInsn(Opcodes.SIPUSH, index); - } - mv.visitVarInsn(Opcodes.ALOAD, argSlot); - mv.visitInsn(Opcodes.AASTORE); + mv.visitVarInsn(Opcodes.ALOAD, argsArraySlot); + if (index <= 5) { + mv.visitInsn(Opcodes.ICONST_0 + index); + } else if (index <= 127) { + mv.visitIntInsn(Opcodes.BIPUSH, index); + } else { + mv.visitIntInsn(Opcodes.SIPUSH, index); + } + mv.visitVarInsn(Opcodes.ALOAD, argSlot); + mv.visitInsn(Opcodes.AASTORE); - if (pooledArg) { - emitterVisitor.ctx.javaClassInfo.releaseSpillSlot(); + if (pooledArg) { + emitterVisitor.ctx.javaClassInfo.releaseSpillSlot(); + } } + } finally { + emitterVisitor.ctx.javaClassInfo.callerLineTokenOverride = + savedArgumentCallerLineOverride; } // Undefined direct-call diagnostics report the line containing the diff --git a/src/main/java/org/perlonjava/frontend/parser/ParserTables.java b/src/main/java/org/perlonjava/frontend/parser/ParserTables.java index 76d622257..3aa0af2b7 100644 --- a/src/main/java/org/perlonjava/frontend/parser/ParserTables.java +++ b/src/main/java/org/perlonjava/frontend/parser/ParserTables.java @@ -31,7 +31,7 @@ public class ParserTables { "die", "do", "dump", "exec", "exit", "fork", - "gethostbyname", "getpwuid", "glob", + "getgrgid", "gethostbyname", "getpwuid", "glob", "hex", "kill", "localtime", "log", @@ -41,7 +41,7 @@ public class ParserTables { "sleep", "stat", "system", "time", "gmtime", - "uc", + "uc", "unlink", "warn" ); // Set of operators that are right associative. diff --git a/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java b/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java index bcfe2adb2..da6fe09b6 100644 --- a/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java @@ -1364,6 +1364,7 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S ArrayList classList = new ArrayList<>(); ArrayList paramList = new ArrayList<>(); + ArrayList capturedNames = new ArrayList<>(); for (SymbolTable.SymbolEntry entry : outerVars.values()) { if (!entry.name().equals("@_") && !entry.decl().isEmpty()) { // Skip field declarations - they are not closure variables @@ -1436,6 +1437,7 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S default -> throw new IllegalStateException("Unexpected value: " + sigil); }; paramList.add(capturedVar); + capturedNames.add(entry.decl().equals("our") ? null : entry.name()); // System.out.println("Capture " + entry.decl() + " " + entry.name() + " as " + variableName); } } @@ -1443,7 +1445,7 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S // owns captured lexicals at definition time. Weak-ref cleanup must be // able to see those captures before the first call (Sub::Defer's named // lvalue wrapper queries its weak metadata before invoking the wrapper). - installClosureCaptureMetadata(placeholder, paramList); + installClosureCaptureMetadata(placeholder, capturedNames, paramList); // Create a new EmitterContext for generating bytecode // Create a filtered snapshot that excludes field declarations and code references @@ -1680,6 +1682,11 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S } private static void installClosureCaptureMetadata(RuntimeCode code, List capturedValues) { + installClosureCaptureMetadata(code, null, capturedValues); + } + + private static void installClosureCaptureMetadata( + RuntimeCode code, List capturedNames, List capturedValues) { if (code == null || capturedValues == null || capturedValues.isEmpty() || code.capturedScalars != null) { return; @@ -1687,7 +1694,15 @@ private static void installClosureCaptureMetadata(RuntimeCode code, List ArrayList capturedScalars = new ArrayList<>(); ArrayList capturedAggregates = new ArrayList<>(); - for (Object value : capturedValues) { + for (int i = 0; i < capturedValues.size(); i++) { + Object value = capturedValues.get(i); + if (capturedNames != null && i < capturedNames.size() + && capturedNames.get(i) != null && value instanceof RuntimeBase runtimeValue) { + if (code.closedOverVariables == null) { + code.closedOverVariables = new HashMap<>(); + } + code.closedOverVariables.put(capturedNames.get(i), runtimeValue); + } if (value instanceof RuntimeScalar scalar) { capturedScalars.add(scalar); scalar.retainClosureCapture(); diff --git a/src/main/java/org/perlonjava/runtime/WarningBitsRegistry.java b/src/main/java/org/perlonjava/runtime/WarningBitsRegistry.java index e86ecba2b..3f62f70ae 100644 --- a/src/main/java/org/perlonjava/runtime/WarningBitsRegistry.java +++ b/src/main/java/org/perlonjava/runtime/WarningBitsRegistry.java @@ -142,6 +142,7 @@ public static void clear() { registry.clear(); currentBitsStack.get().clear(); callSiteBits.remove(); + runtimeWarningBits.remove(); callerBitsStack.get().clear(); callSiteHints.remove(); callerHintsStack.get().clear(); diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java b/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java index 06051c12e..0667e1564 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java @@ -34,6 +34,10 @@ public FileSpec() { super("File::Spec", false); } + private FileSpec(String packageName) { + super(packageName, false); + } + /** * Static initializer to set up the File::Spec module. * This method initializes the exporter and defines the symbols that can be exported. @@ -45,25 +49,29 @@ public static void initialize() { fileSpec.defineExport("EXPORT_OK", "canonpath", "catdir", "catfile", "curdir", "devnull", "rootdir", "tmpdir", "updir", "no_upwards", "case_tolerant", "file_name_is_absolute", "path", "join", "splitpath", "splitdir", "catpath", "abs2rel", "rel2abs"); + // Upstream File::Spec inherits its implementation from the selected + // platform subclass. Install the Java-backed Unix defaults there so a + // localized @File::Spec::ISA can select Win32/Mac methods normally. + FileSpec implementation = new FileSpec("File::Spec::Unix"); try { - fileSpec.registerMethod("canonpath", "$"); - fileSpec.registerMethod("catdir", "@"); - fileSpec.registerMethod("catfile", "@"); - fileSpec.registerMethod("curdir", ""); - fileSpec.registerMethod("devnull", ""); - fileSpec.registerMethod("rootdir", ""); - fileSpec.registerMethod("tmpdir", ""); - fileSpec.registerMethod("updir", ""); - fileSpec.registerMethod("no_upwards", "@"); - fileSpec.registerMethod("case_tolerant", ""); - fileSpec.registerMethod("file_name_is_absolute", "$"); - fileSpec.registerMethod("path", ""); - fileSpec.registerMethod("join", "@"); - fileSpec.registerMethod("splitpath", "$;$"); - fileSpec.registerMethod("splitdir", "$"); - fileSpec.registerMethod("catpath", "$$$"); - fileSpec.registerMethod("abs2rel", "$;$"); - fileSpec.registerMethod("rel2abs", "$;$"); + implementation.registerMethod("canonpath", "$"); + implementation.registerMethod("catdir", "@"); + implementation.registerMethod("catfile", "@"); + implementation.registerMethod("curdir", ""); + implementation.registerMethod("devnull", ""); + implementation.registerMethod("rootdir", ""); + implementation.registerMethod("tmpdir", ""); + implementation.registerMethod("updir", ""); + implementation.registerMethod("no_upwards", "@"); + implementation.registerMethod("case_tolerant", ""); + implementation.registerMethod("file_name_is_absolute", "$"); + implementation.registerMethod("path", ""); + implementation.registerMethod("join", "@"); + implementation.registerMethod("splitpath", "$;$"); + implementation.registerMethod("splitdir", "$"); + implementation.registerMethod("catpath", "$$$"); + implementation.registerMethod("abs2rel", "$;$"); + implementation.registerMethod("rel2abs", "$;$"); } catch (NoSuchMethodException e) { System.err.println("Warning: Missing File::Spec method: " + e.getMessage()); } diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java b/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java index b741884f0..220050229 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Internals.java @@ -69,6 +69,7 @@ public static void initialize() { internals.registerMethod("jperl_cv_is_constant", "jperlCvIsConstant", "$"); internals.registerMethod("jperl_end_av_ref", "jperlEndAvRef", ""); internals.registerMethod("jperl_set_closed_over", "jperlSetClosedOver", null); + internals.registerMethod("jperl_closed_over", "jperlClosedOver", null); } catch (NoSuchMethodException e) { System.err.println("Warning: Missing Internals method: " + e.getMessage()); } @@ -108,6 +109,63 @@ public static RuntimeList jperlSetClosedOver(RuntimeArray args, int ctx) { return new RuntimeList(); } + /** + * Return references to the live lexical containers captured by a closure. + * This is the runtime half of the bundled PadWalker::closed_over shim. + */ + public static RuntimeList jperlClosedOver(RuntimeArray args, int ctx) { + RuntimeHash result = new RuntimeHash(); + if (args.size() != 1 || args.get(0).type != RuntimeScalarType.CODE) { + return result.createReference().getList(); + } + + RuntimeCode code = (RuntimeCode) args.get(0).value; + if (code.closedOverVariables != null) { + for (Map.Entry entry : code.closedOverVariables.entrySet()) { + RuntimeBase captured = entry.getValue(); + if (captured != null) { + result.put(entry.getKey(), captured.createReference()); + } + } + return result.createReference().getList(); + } + if (code instanceof InterpretedCode interpreted) { + if (interpreted.capturedVars != null) { + for (Map.Entry entry : interpreted.variableRegistry.entrySet()) { + int capturedIndex = entry.getValue() - 3; + if (capturedIndex >= 0 && capturedIndex < interpreted.capturedVars.length) { + RuntimeBase captured = interpreted.capturedVars[capturedIndex]; + if (captured != null) { + result.put(entry.getKey(), captured.createReference()); + } + } + } + } + return result.createReference().getList(); + } + + Object closure = code.codeObject != null ? code.codeObject : code.subroutine; + if (closure != null) { + for (Field field : closure.getClass().getDeclaredFields()) { + if ("__SUB__".equals(field.getName()) + || !RuntimeBase.class.isAssignableFrom(field.getType())) { + continue; + } + try { + field.trySetAccessible(); + RuntimeBase captured = (RuntimeBase) field.get(closure); + if (captured != null) { + result.put(field.getName(), captured.createReference()); + } + } catch (IllegalAccessException e) { + throw new IllegalArgumentException( + "PadWalker::closed_over cannot inspect lexical " + field.getName(), e); + } + } + } + return result.createReference().getList(); + } + private static void rebindCapturedVariable( RuntimeCode code, String variableName, RuntimeBase replacement) { if (code instanceof InterpretedCode interpreted) { @@ -130,7 +188,8 @@ private static void rebindCapturedVariable( "PadWalker::set_closed_over cannot inspect this coderef"); } try { - Field field = closure.getClass().getField(variableName); + Field field = closure.getClass().getDeclaredField(variableName); + field.trySetAccessible(); RuntimeBase previous = (RuntimeBase) field.get(closure); field.set(closure, replacement); replaceCaptureTracking(code, previous, replacement); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeArray.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeArray.java index a31cc5ee9..0af5f05aa 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeArray.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeArray.java @@ -45,7 +45,13 @@ public void dynamicSaveState() { // Install a fresh empty array in the global map RuntimeArray newLocal = new RuntimeArray(); + if (fullName.endsWith("::ISA")) { + newLocal.markIsaArray(); + } GlobalVariable.globalArrays.put(fullName, newLocal); + if (fullName.endsWith("::ISA")) { + org.perlonjava.runtime.mro.InheritanceResolver.noteIsaMutation(); + } // Update glob aliases so they all point to the new local array java.util.List aliasGroup = GlobalVariable.getGlobAliasGroup(fullName); @@ -84,6 +90,12 @@ public void dynamicRestoreState() { MortalList.deferDestroyForContainerClear(localArray.elements); } GlobalVariable.globalArrays.put(saved.fullName, saved.originalArray); + if (saved.fullName.endsWith("::ISA")) { + if (saved.originalArray != null) { + saved.originalArray.markIsaArray(); + } + org.perlonjava.runtime.mro.InheritanceResolver.noteIsaMutation(); + } // Restore glob aliases java.util.List aliasGroup = GlobalVariable.getGlobAliasGroup(saved.fullName); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalVariable.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalVariable.java index 9cd2bb19b..82242697d 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalVariable.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalVariable.java @@ -381,6 +381,9 @@ public static void resetAllGlobals() { declaredGlobalHashes.clear(); clearPackageCache(); + org.perlonjava.runtime.WarningBitsRegistry.clear(); + WarningFlags.resetRuntimeState(); + RuntimeCode.clearCaches(); // Clear special blocks (INIT, END, CHECK, UNITCHECK) to prevent stale code references. diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index da415c4ca..29262f412 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -856,6 +856,9 @@ private void exitCall() { */ public RuntimeBase[] capturedAggregates; + /** Live lexical containers keyed by their Perl pad names for PadWalker. */ + public Map closedOverVariables; + /** * Tracks the number of stash (glob) entries that reference this CODE object. * Stash entries created via {@code *Foo::bar = $coderef} are invisible to the @@ -1615,6 +1618,7 @@ public void adoptDefinitionFrom(RuntimeCode codeFrom) { this.__SUB__ = codeFrom.__SUB__; this.capturedScalars = codeFrom.capturedScalars; this.capturedAggregates = codeFrom.capturedAggregates; + this.closedOverVariables = codeFrom.closedOverVariables; this.padConstants = codeFrom.padConstants; } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java b/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java index b4477f173..3ed834956 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java @@ -612,6 +612,14 @@ public static boolean isCustomCategory(String category) { public static boolean isGlobalWarningsEnabled() { return globalWarningsEnabled; } + + /** Clear warning state that belongs to one top-level compilation. */ + public static void resetRuntimeState() { + globalWarningsEnabled = false; + scopeDisabledWarnings.clear(); + scopeIdCounter.set(0); + lastScopeId = 0; + } // ==================== Scope-based Warning Suppression ==================== // These methods support lexical "no warnings" that propagates through calls. diff --git a/src/main/perl/lib/CPAN/Config.pm b/src/main/perl/lib/CPAN/Config.pm index d06a1b37d..4b9cafccf 100644 --- a/src/main/perl/lib/CPAN/Config.pm +++ b/src/main/perl/lib/CPAN/Config.pm @@ -72,11 +72,8 @@ sub _bootstrap_prefs { 'Params-ValidationCompiler.yml' => 'PerlOnJava/CpanDistroprefs/Params-ValidationCompiler.yml', 'Test-Deep.yml' => 'PerlOnJava/CpanDistroprefs/Test-Deep.yml', 'Test-Deep-JSON.yml' => 'PerlOnJava/CpanDistroprefs/Test-Deep-JSON.yml', - 'Test-Warnings.yml' => 'PerlOnJava/CpanDistroprefs/Test-Warnings.yml', - 'File-Copy-Recursive.yml' => 'PerlOnJava/CpanDistroprefs/File-Copy-Recursive.yml', 'Test-File-ShareDir.yml' => 'PerlOnJava/CpanDistroprefs/Test-File-ShareDir.yml', 'DateTime-Locale.yml' => 'PerlOnJava/CpanDistroprefs/DateTime-Locale.yml', - 'Test-File.yml' => 'PerlOnJava/CpanDistroprefs/Test-File.yml', 'Test-SharedFork.yml' => 'PerlOnJava/CpanDistroprefs/Test-SharedFork.yml', 'UNIVERSAL-can.yml' => 'PerlOnJava/CpanDistroprefs/UNIVERSAL-can.yml', 'UNIVERSAL-isa.yml' => 'PerlOnJava/CpanDistroprefs/UNIVERSAL-isa.yml', @@ -161,6 +158,9 @@ sub _bootstrap_prefs { Test-FailWarnings.yml DateTime-Format-CLDR.yml Test-Class.yml + Test-Warnings.yml + File-Copy-Recursive.yml + Test-File.yml )) { my $dest = File::Spec->catfile($prefs_dir, $file); next unless -f $dest; diff --git a/src/main/perl/lib/PadWalker.pm b/src/main/perl/lib/PadWalker.pm index 0be7a4458..519902086 100644 --- a/src/main/perl/lib/PadWalker.pm +++ b/src/main/perl/lib/PadWalker.pm @@ -8,11 +8,10 @@ our $VERSION = '2.5'; our @EXPORT_OK = qw(peek_my peek_our closed_over peek_sub var_name set_closed_over); our %EXPORT_TAGS = (all => \@EXPORT_OK); -# PadWalker is implemented in XS on CPAN. PerlOnJava cannot inspect JVM -# closure frames through that API, but callers such as JSON::Eval only need to -# distinguish serializable, self-contained coderefs. JVM subroutines expose no -# Perl pad to walk, so an empty result is the correct answer for them. -sub closed_over { return {}; } +# PerlOnJava records captured lexical containers on both compiled and +# interpreted closures, so the runtime can expose the live references that +# PadWalker callers expect. +sub closed_over { Internals::jperl_closed_over(@_) } sub _unsupported { die "PadWalker::$_[0] is not implemented on PerlOnJava\n"; diff --git a/src/main/perl/lib/PerlOnJava/CpanDistroprefs/File-Copy-Recursive.yml b/src/main/perl/lib/PerlOnJava/CpanDistroprefs/File-Copy-Recursive.yml deleted file mode 100644 index 57aaa51e1..000000000 --- a/src/main/perl/lib/PerlOnJava/CpanDistroprefs/File-Copy-Recursive.yml +++ /dev/null @@ -1,13 +0,0 @@ ---- -comment: | - PerlOnJava distroprefs for File::Copy::Recursive. - - Test::File::ShareDir requires this pure-Perl module in DateTime's - dependency chain. Its own tests depend on helper modules with unrelated - PerlOnJava test-suite failures. - - Keep the test output visible for signal, but allow CPAN to install it. -match: - distribution: "^DMUEY/File-Copy-Recursive-" -test: - commandline: "PERLONJAVA_TEST_IGNORE_FAILURES" diff --git a/src/main/perl/lib/PerlOnJava/CpanDistroprefs/Test-File.yml b/src/main/perl/lib/PerlOnJava/CpanDistroprefs/Test-File.yml deleted file mode 100644 index 2cfce9ab9..000000000 --- a/src/main/perl/lib/PerlOnJava/CpanDistroprefs/Test-File.yml +++ /dev/null @@ -1,13 +0,0 @@ ---- -comment: | - PerlOnJava distroprefs for Test::File. - - Test::File is only needed as part of the DateTime-Locale test prerequisite - chain. Its own upstream suite has PerlOnJava-specific filesystem edge - failures, but the module installs as pure Perl. - - Keep the test output visible for signal, but allow CPAN to install it. -match: - distribution: "^BRIANDFOY/Test-File-" -test: - commandline: "PERLONJAVA_TEST_IGNORE_FAILURES" diff --git a/src/main/perl/lib/PerlOnJava/CpanDistroprefs/Test-Warnings.yml b/src/main/perl/lib/PerlOnJava/CpanDistroprefs/Test-Warnings.yml deleted file mode 100644 index 4a912d4ae..000000000 --- a/src/main/perl/lib/PerlOnJava/CpanDistroprefs/Test-Warnings.yml +++ /dev/null @@ -1,12 +0,0 @@ ---- -comment: | - PerlOnJava distroprefs for Test::Warnings. - - Test::Warnings is a build/test dependency in DateTime's dependency chain. Its - tests currently expose an unrelated warnings import/bareword behavior gap. - - Keep the test output visible for signal, but allow CPAN to install it. -match: - distribution: "^ETHER/Test-Warnings-" -test: - commandline: "PERLONJAVA_TEST_IGNORE_FAILURES" diff --git a/src/main/perl/lib/YAML/XS.pm b/src/main/perl/lib/YAML/XS.pm index 2f9492157..307bc7bb3 100644 --- a/src/main/perl/lib/YAML/XS.pm +++ b/src/main/perl/lib/YAML/XS.pm @@ -59,7 +59,7 @@ sub LoadFile { $IN = $filename; } else { - open $IN, '<', $filename + open $IN, '<:encoding(UTF-8)', $filename or die "Can't open '$filename' for input:\n$!"; } my $yaml = do { local $/; scalar <$IN> }; diff --git a/src/test/java/org/perlonjava/PerlScriptExecutionTest.java b/src/test/java/org/perlonjava/PerlScriptExecutionTest.java index 2deceb599..8ce77ee1f 100644 --- a/src/test/java/org/perlonjava/PerlScriptExecutionTest.java +++ b/src/test/java/org/perlonjava/PerlScriptExecutionTest.java @@ -208,6 +208,11 @@ void setUp() { // Replace RuntimeIO.stdout with a new instance RuntimeIO.stdout = new RuntimeIO(newStdout); + // Tests can apply persistent PerlIO layers to STDERR (for example via + // `open ':std'`). Give every script a fresh standard handle so those + // layers cannot leak into later parameterized cases. + RuntimeIO.stderr = new RuntimeIO(new StandardIO(System.err, false)); + RuntimeIO.stderr.setAutoFlush(true); // Keep Perl's global *STDOUT/*STDERR in sync with the RuntimeIO static fields. // Some tests call `binmode STDOUT/STDERR` and expect it to affect the real globals. GlobalVariable.getGlobalIO("main::STDOUT").setIO(RuntimeIO.stdout); @@ -224,6 +229,8 @@ void setUp() { void tearDown() { // Restore original stdout RuntimeIO.stdout = new RuntimeIO(new StandardIO(originalOut, true)); + RuntimeIO.stderr = new RuntimeIO(new StandardIO(System.err, false)); + RuntimeIO.stderr.setAutoFlush(true); GlobalVariable.getGlobalIO("main::STDOUT").setIO(RuntimeIO.stdout); GlobalVariable.getGlobalIO("main::STDERR").setIO(RuntimeIO.stderr); System.setOut(originalOut); diff --git a/src/test/resources/unit/core_global_additional_overrides.t b/src/test/resources/unit/core_global_additional_overrides.t new file mode 100644 index 000000000..ad75662ed --- /dev/null +++ b/src/test/resources/unit/core_global_additional_overrides.t @@ -0,0 +1,20 @@ +use strict; +use warnings; +use Test::More tests => 4; + +our ($getgrgid_called, $unlink_called); +BEGIN { + *CORE::GLOBAL::getgrgid = sub ($) { + $getgrgid_called++; + return wantarray ? qw(group passwd 123 1 2) : 'group'; + }; + *CORE::GLOBAL::unlink = sub (@) { + $unlink_called++; + return 7; + }; +} + +is(scalar getgrgid(42), 'group', 'CORE::GLOBAL::getgrgid overrides the builtin'); +is($getgrgid_called, 1, 'getgrgid override was invoked'); +is(unlink('not-used'), 7, 'CORE::GLOBAL::unlink overrides the builtin'); +is($unlink_called, 1, 'unlink override was invoked'); diff --git a/src/test/resources/unit/core_global_dynamic_goto.t b/src/test/resources/unit/core_global_dynamic_goto.t new file mode 100644 index 000000000..b2cae9640 --- /dev/null +++ b/src/test/resources/unit/core_global_dynamic_goto.t @@ -0,0 +1,20 @@ +use strict; +use warnings; +use Test::More tests => 4; + +our $unlink_target = sub { return 11 }; + +BEGIN { + no warnings 'redefine'; + *CORE::GLOBAL::unlink = sub { goto $unlink_target }; +} + +is(unlink('unused'), 11, 'CORE::GLOBAL override can tail-call a global coderef'); + +{ + local $unlink_target = sub { $! = 5; return }; + ok(!unlink('unused'), 'CORE::GLOBAL override sees localized coderef target'); + is(0 + $!, 5, 'localized tail-call target updates errno'); +} + +is(unlink('unused'), 11, 'global coderef target is restored after localization'); diff --git a/src/test/resources/unit/local_isa_method_dispatch.t b/src/test/resources/unit/local_isa_method_dispatch.t new file mode 100644 index 000000000..8a3b89522 --- /dev/null +++ b/src/test/resources/unit/local_isa_method_dispatch.t @@ -0,0 +1,24 @@ +use strict; +use warnings; +use Test::More tests => 4; + +{ + package LocalIsaBase; + sub value { 'base' } +} +{ + package LocalIsaOther; + sub value { 'other' } +} +{ + package LocalIsaChild; + our @ISA = ('LocalIsaBase'); +} + +is(LocalIsaChild->value, 'base', 'initial ISA dispatches to the base class'); +{ + local @LocalIsaChild::ISA = ('LocalIsaOther'); + is(LocalIsaChild->value, 'other', 'localized ISA invalidates method lookup'); +} +is(LocalIsaChild->value, 'base', 'restoring localized ISA invalidates method lookup'); +is_deeply(\@LocalIsaChild::ISA, ['LocalIsaBase'], 'localized ISA restores its contents'); diff --git a/src/test/resources/unit/local_isa_nested_restore.t b/src/test/resources/unit/local_isa_nested_restore.t new file mode 100644 index 000000000..5950de64d --- /dev/null +++ b/src/test/resources/unit/local_isa_nested_restore.t @@ -0,0 +1,31 @@ +use strict; +use warnings; +use Test::More tests => 4; + +{ + package NestedIsaOne; + sub value { 'one' } +} +{ + package NestedIsaTwo; + sub value { 'two' } +} +{ + package NestedIsaThree; + sub value { 'three' } +} +{ + package NestedIsaChild; + our @ISA = ('NestedIsaOne'); +} + +is(NestedIsaChild->value, 'one', 'initial nested-local ISA dispatch'); +{ + local @NestedIsaChild::ISA = ('NestedIsaTwo'); + is(NestedIsaChild->value, 'two', 'outer localized ISA dispatch'); + { + local @NestedIsaChild::ISA = ('NestedIsaThree'); + is(NestedIsaChild->value, 'three', 'inner localized ISA dispatch'); + } + is(NestedIsaChild->value, 'two', 'inner localized ISA restoration invalidates lookup'); +} diff --git a/src/test/resources/unit/nested_call_caller_line.t b/src/test/resources/unit/nested_call_caller_line.t new file mode 100644 index 000000000..a23774720 --- /dev/null +++ b/src/test/resources/unit/nested_call_caller_line.t @@ -0,0 +1,20 @@ +use strict; +use warnings; +use Test::More tests => 2; + +sub caller_line { + return (caller(0))[2]; +} + +sub consume($) { return $_[0] } + +my $expected_nested = __LINE__ + 1; +my $nested = consume( + 'prefix ' . caller_line() . ' suffix' +); +like($nested, qr/\b$expected_nested\b/, + 'nested call reports the containing expression start line'); + +my $expected_plain = __LINE__ + 1; +my $plain = caller_line(); +is($plain, $expected_plain, 'ordinary call still reports its own expression line'); diff --git a/src/test/resources/unit/padwalker_closed_over.t b/src/test/resources/unit/padwalker_closed_over.t new file mode 100644 index 000000000..a67095ee1 --- /dev/null +++ b/src/test/resources/unit/padwalker_closed_over.t @@ -0,0 +1,23 @@ +use strict; +use warnings; +use Test::More tests => 7; +use PadWalker qw(closed_over); + +my $scalar = 'captured'; +my @array = qw(a b); +my %hash = (key => 'value'); +my $closure = sub { return ($scalar, @array, %hash) }; +my $closed = closed_over($closure); + +ok(exists $closed->{'$scalar'}, 'closed_over reports a captured scalar'); +ok(exists $closed->{'@array'}, 'closed_over reports a captured array'); +ok(exists $closed->{'%hash'}, 'closed_over reports a captured hash'); +is(${ $closed->{'$scalar'} }, 'captured', 'captured scalar reference is live'); +is_deeply($closed->{'@array'}, \@array, 'captured array reference aliases the pad'); + +my $named_value = 1; +sub named_closure { ++$named_value } +my $named_closed = closed_over(\&named_closure); +is(${ $named_closed->{'$named_value'} }, 1, 'closed_over reports a named-sub lexical'); +named_closure(); +is(${ $named_closed->{'$named_value'} }, 2, 'named-sub lexical reference remains live'); diff --git a/src/test/resources/unit/yaml_pp_utf8_octets.t b/src/test/resources/unit/yaml_pp_utf8_octets.t new file mode 100644 index 000000000..528d412a2 --- /dev/null +++ b/src/test/resources/unit/yaml_pp_utf8_octets.t @@ -0,0 +1,11 @@ +use strict; +use warnings; +use Test::More tests => 2; +use Encode qw(encode); +use YAML::PP qw(Load); + +my $octets = encode('UTF-8', "name: \x{307b}\x{3052}\n"); +my $data = Load(Encode::decode('UTF-8', $octets)); + +is($data->{name}, "\x{307b}\x{3052}", 'YAML loader decodes UTF-8 octets'); +ok(utf8::is_utf8($data->{name}), 'loaded non-ASCII YAML scalar is Unicode'); From 73cf65404da9b61d7a7691ca296f76b8eff54ce9 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 4 Aug 2026 16:15:07 +0200 Subject: [PATCH 2/9] fix(tools): distinguish changed test inventories Do not classify pass-count deltas as compiler regressions or progress when the two runs report different total test counts. Surface those rows as incomparable because they can reflect a changed corpus or an early exit. Document the comparison rule in the testing reference. Generated with [Codex](https://openai.com/codex) Co-Authored-By: Codex --- dev/tools/compare_test_logs.pl | 40 ++++++++++++++++++++++++++++++---- docs/reference/testing.md | 6 +++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/dev/tools/compare_test_logs.pl b/dev/tools/compare_test_logs.pl index ad927640a..3fa03d258 100755 --- a/dev/tools/compare_test_logs.pl +++ b/dev/tools/compare_test_logs.pl @@ -175,6 +175,7 @@ sub parse_log { files_unchanged => 0, files_only_in_old => 0, files_only_in_new => 0, + files_total_changed => 0, files_skipped_flake => 0, tests_skipped_flake => 0, tests_lost => 0, @@ -229,9 +230,16 @@ sub parse_log { $stats{total_new_passed} += $new->{passed}; my $diff = $new->{passed} - $old->{passed}; + my $total_changed = $new->{total} != $old->{total}; my $is_flake = !$show_flakes && exists $FLAKE_BY_FILE{$test}; - if ($diff > 0) { + # A pass-count delta is only comparable when both runs executed the + # same number of tests. Different totals can mean that the test corpus + # changed or that one run stopped early; the aggregate logs cannot tell + # those cases apart, so do not assert a compiler regression/progression. + if ($total_changed) { + $stats{files_total_changed}++; + } elsif ($diff > 0) { $stats{files_with_progress}++; $stats{tests_gained} += $diff; } elsif ($diff < 0) { @@ -253,7 +261,8 @@ sub parse_log { new_passed => $new->{passed}, new_total => $new->{total}, diff => $diff, - type => $diff > 0 ? 'progress' + type => $total_changed ? 'total-change' + : $diff > 0 ? 'progress' : ($diff < 0 && $is_flake) ? 'flake' : $diff < 0 ? 'regression' : 'unchanged', }; @@ -289,16 +298,20 @@ sub parse_log { my $net_change = $stats{total_new_passed} - $stats{total_old_passed}; my $change_symbol = $net_change >= 0 ? '+' : ''; -printf "Net change: %s%6d passing tests (%s%5.2f%%)\n", +printf "Raw net change: %s%6d passing tests (%s%5.2f%%)\n", $change_symbol, $net_change, $change_symbol, $stats{total_old_passed} ? 100 * $net_change / $stats{total_old_passed} : 0; +print " (includes files with changed test totals)\n" + if $stats{files_total_changed}; print "\n"; printf "Files with regressions: %4d files (-%6d tests)\n", $stats{files_with_regressions}, $stats{tests_lost}; printf "Files with progress: %4d files (+%6d tests)\n", $stats{files_with_progress}, $stats{tests_gained}; printf "Files unchanged: %4d files\n", $stats{files_unchanged}; +printf "Files with changed totals:%4d files (not comparable)\n", + $stats{files_total_changed} if $stats{files_total_changed}; printf "Files only in old log: %4d files\n", $stats{files_only_in_old} if $stats{files_only_in_old}; printf "Files only in new log: %4d files\n", $stats{files_only_in_new} if $stats{files_only_in_new}; if ($stats{files_skipped_flake}) { @@ -334,7 +347,8 @@ sub parse_log { print "-" x 90 . "\n"; foreach my $c (@to_show) { - my $symbol = $c->{diff} > 0 ? '✓' : $c->{diff} < 0 ? '✗' : '='; + my $symbol = $c->{type} eq 'total-change' ? '~' + : $c->{diff} > 0 ? '✓' : $c->{diff} < 0 ? '✗' : '='; my $change_str = $c->{diff} >= 0 ? sprintf("+%d", $c->{diff}) : sprintf("%d", $c->{diff}); printf "%s %-47s %6d/%-6d %6d/%-6d %10s\n", @@ -353,6 +367,24 @@ sub parse_log { } } +my @total_changes = grep { $_->{type} eq 'total-change' } @changes; +if (@total_changes) { + print "\n"; + print "=" x 90 . "\n"; + print "CHANGED TEST TOTALS (NOT CLASSIFIED AS REGRESSIONS OR PROGRESS)\n"; + print "=" x 90 . "\n"; + print "A changed total means the test inventory differs or one execution\n"; + print "stopped early. Compare the test sources and raw TAP before drawing\n"; + print "a compiler conclusion.\n\n"; + for my $c (sort { $a->{test} cmp $b->{test} } @total_changes) { + printf " %-40s %d/%d → %d/%d (%+d passing, %+d total)\n", + $c->{test}, + $c->{old_passed}, $c->{old_total}, + $c->{new_passed}, $c->{new_total}, + $c->{diff}, $c->{new_total} - $c->{old_total}; + } +} + # Show known-flake list (always, even with --summary-only) if (!$show_flakes) { my @flake_changes = grep { $_->{type} eq 'flake' } @changes; diff --git a/docs/reference/testing.md b/docs/reference/testing.md index e32cfa68c..d746d9d04 100644 --- a/docs/reference/testing.md +++ b/docs/reference/testing.md @@ -214,6 +214,12 @@ perl dev/tools/compare_test_logs.pl \ - New tests added or removed - Summary of improvements or regressions +Pass-count changes are classified as progress or regressions only when the +two runs report the same total for that file. A changed total can mean either +that the test corpus changed or that one execution stopped early, so those +rows are reported separately as not comparable. Check the test sources and +raw TAP before drawing a compiler conclusion from them. + **Use cases:** - Before/after implementing a feature - Daily progress tracking on a branch From 9f676519377b077d5cb40ad6b5f6d2296594a705 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 4 Aug 2026 17:08:43 +0200 Subject: [PATCH 3/9] revert: keep pass-count regressions visible Remove the changed-total comparison classification added while investigating the reported core-test deltas. The required newer corpus confirms that the compiler reaches the approval baseline without suppressing those rows. Generated with [Codex](https://openai.com/codex) Co-Authored-By: Codex --- dev/tools/compare_test_logs.pl | 40 ++++------------------------------ docs/reference/testing.md | 6 ----- 2 files changed, 4 insertions(+), 42 deletions(-) diff --git a/dev/tools/compare_test_logs.pl b/dev/tools/compare_test_logs.pl index 3fa03d258..ad927640a 100755 --- a/dev/tools/compare_test_logs.pl +++ b/dev/tools/compare_test_logs.pl @@ -175,7 +175,6 @@ sub parse_log { files_unchanged => 0, files_only_in_old => 0, files_only_in_new => 0, - files_total_changed => 0, files_skipped_flake => 0, tests_skipped_flake => 0, tests_lost => 0, @@ -230,16 +229,9 @@ sub parse_log { $stats{total_new_passed} += $new->{passed}; my $diff = $new->{passed} - $old->{passed}; - my $total_changed = $new->{total} != $old->{total}; my $is_flake = !$show_flakes && exists $FLAKE_BY_FILE{$test}; - # A pass-count delta is only comparable when both runs executed the - # same number of tests. Different totals can mean that the test corpus - # changed or that one run stopped early; the aggregate logs cannot tell - # those cases apart, so do not assert a compiler regression/progression. - if ($total_changed) { - $stats{files_total_changed}++; - } elsif ($diff > 0) { + if ($diff > 0) { $stats{files_with_progress}++; $stats{tests_gained} += $diff; } elsif ($diff < 0) { @@ -261,8 +253,7 @@ sub parse_log { new_passed => $new->{passed}, new_total => $new->{total}, diff => $diff, - type => $total_changed ? 'total-change' - : $diff > 0 ? 'progress' + type => $diff > 0 ? 'progress' : ($diff < 0 && $is_flake) ? 'flake' : $diff < 0 ? 'regression' : 'unchanged', }; @@ -298,20 +289,16 @@ sub parse_log { my $net_change = $stats{total_new_passed} - $stats{total_old_passed}; my $change_symbol = $net_change >= 0 ? '+' : ''; -printf "Raw net change: %s%6d passing tests (%s%5.2f%%)\n", +printf "Net change: %s%6d passing tests (%s%5.2f%%)\n", $change_symbol, $net_change, $change_symbol, $stats{total_old_passed} ? 100 * $net_change / $stats{total_old_passed} : 0; -print " (includes files with changed test totals)\n" - if $stats{files_total_changed}; print "\n"; printf "Files with regressions: %4d files (-%6d tests)\n", $stats{files_with_regressions}, $stats{tests_lost}; printf "Files with progress: %4d files (+%6d tests)\n", $stats{files_with_progress}, $stats{tests_gained}; printf "Files unchanged: %4d files\n", $stats{files_unchanged}; -printf "Files with changed totals:%4d files (not comparable)\n", - $stats{files_total_changed} if $stats{files_total_changed}; printf "Files only in old log: %4d files\n", $stats{files_only_in_old} if $stats{files_only_in_old}; printf "Files only in new log: %4d files\n", $stats{files_only_in_new} if $stats{files_only_in_new}; if ($stats{files_skipped_flake}) { @@ -347,8 +334,7 @@ sub parse_log { print "-" x 90 . "\n"; foreach my $c (@to_show) { - my $symbol = $c->{type} eq 'total-change' ? '~' - : $c->{diff} > 0 ? '✓' : $c->{diff} < 0 ? '✗' : '='; + my $symbol = $c->{diff} > 0 ? '✓' : $c->{diff} < 0 ? '✗' : '='; my $change_str = $c->{diff} >= 0 ? sprintf("+%d", $c->{diff}) : sprintf("%d", $c->{diff}); printf "%s %-47s %6d/%-6d %6d/%-6d %10s\n", @@ -367,24 +353,6 @@ sub parse_log { } } -my @total_changes = grep { $_->{type} eq 'total-change' } @changes; -if (@total_changes) { - print "\n"; - print "=" x 90 . "\n"; - print "CHANGED TEST TOTALS (NOT CLASSIFIED AS REGRESSIONS OR PROGRESS)\n"; - print "=" x 90 . "\n"; - print "A changed total means the test inventory differs or one execution\n"; - print "stopped early. Compare the test sources and raw TAP before drawing\n"; - print "a compiler conclusion.\n\n"; - for my $c (sort { $a->{test} cmp $b->{test} } @total_changes) { - printf " %-40s %d/%d → %d/%d (%+d passing, %+d total)\n", - $c->{test}, - $c->{old_passed}, $c->{old_total}, - $c->{new_passed}, $c->{new_total}, - $c->{diff}, $c->{new_total} - $c->{old_total}; - } -} - # Show known-flake list (always, even with --summary-only) if (!$show_flakes) { my @flake_changes = grep { $_->{type} eq 'flake' } @changes; diff --git a/docs/reference/testing.md b/docs/reference/testing.md index d746d9d04..e32cfa68c 100644 --- a/docs/reference/testing.md +++ b/docs/reference/testing.md @@ -214,12 +214,6 @@ perl dev/tools/compare_test_logs.pl \ - New tests added or removed - Summary of improvements or regressions -Pass-count changes are classified as progress or regressions only when the -two runs report the same total for that file. A changed total can mean either -that the test corpus changed or that one execution stopped early, so those -rows are reported separately as not comparable. Check the test sources and -raw TAP before drawing a compiler conclusion from them. - **Use cases:** - Before/after implementing a feature - Daily progress tracking on a branch From 29652d36d62d2a1dfacb688ab036e7018e2779bd Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 4 Aug 2026 14:41:22 +0200 Subject: [PATCH 4/9] feat: support isolated PerlOnJava homes Honor PERLONJAVA_HOME across runtime include paths, Config, CPAN state, MyConfig discovery, and MakeMaker installation directories. Add an OS-native launcher integration test that proves the default user home is untouched, and document the Catalyst milestone acceptance status. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- dev/design/catalyst-support.md | 26 ++-- docs/guides/using-cpan-modules.md | 23 +++- .../runtime/runtimetypes/GlobalContext.java | 21 +++- src/main/perl/lib/CPAN/Config.pm | 22 ++-- src/main/perl/lib/CPAN/HandleConfig.pm | 7 +- src/main/perl/lib/CPAN/Prefs/README.md | 4 +- src/main/perl/lib/Config.pm | 43 ++++--- src/main/perl/lib/ExtUtils/MM_PerlOnJava.pm | 5 +- src/main/perl/lib/ExtUtils/MakeMaker.pm | 11 +- .../PerlOnJavaHomeIntegrationTest.java | 118 ++++++++++++++++++ .../runtimetypes/GlobalContextTest.java | 27 +++- 11 files changed, 251 insertions(+), 56 deletions(-) create mode 100644 src/test/java/org/perlonjava/PerlOnJavaHomeIntegrationTest.java diff --git a/dev/design/catalyst-support.md b/dev/design/catalyst-support.md index 8fddf27de..b91127130 100644 --- a/dev/design/catalyst-support.md +++ b/dev/design/catalyst-support.md @@ -102,10 +102,10 @@ other prerequisites were installed incrementally. Do not use the current Do not delete, clean, restore, or replace `~/.perlonjava` in place. It may contain unrelated user state. -### Required tooling improvement +### Isolated-home tooling -Before the final installation gate, add a supported isolated home override, -provisionally named `PERLONJAVA_HOME`, with these semantics: +`PERLONJAVA_HOME` is implemented on the Catalyst support branch with these +semantics: - default remains `~/.perlonjava`; - library, CPAN metadata, sources, build directories, preferences, patches, @@ -125,10 +125,11 @@ PERLONJAVA_HOME="$isolated_root" timeout 60 ./jperl -MTry::Tiny -e 'print "ok\n" >> /tmp/catalyst-isolated-cpan.log 2>&1 ``` -The implementation must include a regression test that verifies no files were -written beneath the default user home. Temporary-directory cleanup should be -left to the test harness or performed only on the exact validated temporary -path. +`PerlOnJavaHomeIntegrationTest` runs the native launcher selected for the host +OS, loads `Config`, `CPAN::Config`, and `CPAN::HandleConfig`, and verifies that +runtime, core-probe, preferences, patches, and install paths remain under a +temporary override while the default user home stays untouched. The same test +therefore exercises `jperl` on Unix and `jperl.bat` on Windows CI. ## Dependency Status @@ -151,10 +152,13 @@ incorrectly classified, or exposes a reusable PerlOnJava defect. ## Current Handoff State -Start with Milestone 0, then Milestone 1. The shared CPAN state cannot support -a trustworthy clean-install result, while the known framework blocker is the -inherited attributed-method case described below. Use the dependency table as -the baseline; use commit history and the PR for chronological progress. +Milestone 0 implementation and the full `make` gate pass. Its live CPAN +acceptance remains pending because the restricted agent network could not fetch +CPAN indexes, and permission to download and execute a CPAN installer was not +granted. Resume by running the documented fresh-root `Try::Tiny` install after +explicit approval; once it succeeds, mark Milestone 0 complete and begin +Milestone 1's inherited attributed-method reduction. Use the dependency table +as the baseline; use commit history and the PR for chronological progress. When a milestone is completed, update this paragraph to name the next active milestone and record its acceptance result, without adding a work diary. diff --git a/docs/guides/using-cpan-modules.md b/docs/guides/using-cpan-modules.md index c3b379e73..d39939f17 100644 --- a/docs/guides/using-cpan-modules.md +++ b/docs/guides/using-cpan-modules.md @@ -10,8 +10,9 @@ Some distributions need environment overrides, skipped phases, or small unified diffs before `make test`. PerlOnJava ships **distroprefs** as YAML under `src/main/perl/lib/PerlOnJava/CpanDistroprefs/` and **patch files** under `src/main/perl/lib/PerlOnJava/CpanPatches/`. When CPAN loads, `CPAN::Config` -copies them into `~/.perlonjava/cpan/prefs/` and `~/.perlonjava/cpan/patches/` -respectively (see `_bootstrap_prefs` / `_bootstrap_patches` in +copies them into the selected PerlOnJava home's `cpan/prefs/` and +`cpan/patches/` directories (by default `~/.perlonjava/cpan/`; see +`_bootstrap_prefs` / `_bootstrap_patches` in `src/main/perl/lib/CPAN/Config.pm`). Contributor-facing documentation: [CPAN Distroprefs for PerlOnJava](cpan-distroprefs.md) and [dev/design/patch-and-cpan-prefs-layout.md](../../dev/design/patch-and-cpan-prefs-layout.md). @@ -36,6 +37,24 @@ jcpan Modules are installed to `~/.perlonjava/lib/`, which is automatically included in `@INC`. +### Isolated installations + +Set `PERLONJAVA_HOME` to keep an installation separate from the default +`~/.perlonjava` tree. Both the Unix and Windows launchers inherit the setting: + +```bash +isolated_root=$(mktemp -d /tmp/perlonjava-home.XXXXXX) +PERLONJAVA_HOME="$isolated_root" timeout 1200 jcpan install Try::Tiny +PERLONJAVA_HOME="$isolated_root" timeout 60 jperl \ + -MTry::Tiny -e 'print "ok\n"' +``` + +The selected root supplies the user module library (`lib/`), CPAN metadata and +build state (`cpan/`), generated core probes (`core/`), scripts (`bin/`), and +manpages (`man/`). When the variable is unset or empty, the existing +`~/.perlonjava` default is unchanged. `PERLONJAVA_LIB` remains a more specific +MakeMaker install-library override for compatibility. + ## Manual Installation For modules not on CPAN or when you need more control: diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java index 1f3b026d2..6f6ad0d40 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java @@ -203,7 +203,7 @@ public static void initializeGlobals(CompilerOptions compilerOptions) { @INC Search order mirrors Perl 5's site_perl > core pattern: - "-I" argument (highest priority, user override) - PERL5LIB env (user environment override) - - ~/.perlonjava/lib (user-installed CPAN modules, like site_perl) + - $PERLONJAVA_HOME/lib (user-installed CPAN modules, like site_perl) - JAR_PERLLIB (bundled modules, like core lib — lowest priority) This allows CPAN-installed modules to override bundled ones. See also: https://stackoverflow.com/questions/2526804/how-is-perls-inc-constructed @@ -218,10 +218,10 @@ public static void initializeGlobals(CompilerOptions compilerOptions) { } } // Keep the user installation path in @INC even before it exists. A - // clean jcpan run creates ~/.perlonjava/lib in a child make process; + // clean jcpan run creates $PERLONJAVA_HOME/lib in a child make process; // the long-lived parent must be able to discover those newly-installed // prerequisites without restarting. - addUserLibraryPath(inc, System.getProperty("user.home")); + addUserLibraryPath(inc, resolvePerlOnJavaHome(System.getenv(), System.getProperty("user.home"))); inc.add(new RuntimeScalar(JAR_PERLLIB)); // internal src/main/perl/lib (lowest priority) // Honor PERL_USE_UNSAFE_INC=1 (required by CPAN.pm / Module::Install-based @@ -305,11 +305,22 @@ public static void initializeGlobals(CompilerOptions compilerOptions) { InheritanceResolver.invalidateCache(); } - static void addUserLibraryPath(List inc, String userHome) { + static java.nio.file.Path resolvePerlOnJavaHome(Map env, String userHome) { + String override = env.get("PERLONJAVA_HOME"); + if (override != null && !override.isBlank()) { + return java.nio.file.Path.of(override); + } if (userHome == null || userHome.isEmpty()) { + return java.nio.file.Path.of(".perlonjava"); + } + return java.nio.file.Path.of(userHome, ".perlonjava"); + } + + static void addUserLibraryPath(List inc, java.nio.file.Path perlonjavaHome) { + if (perlonjavaHome == null) { return; } - String userLib = java.nio.file.Path.of(userHome, ".perlonjava", "lib").toString(); + String userLib = perlonjavaHome.resolve("lib").toString(); inc.add(new RuntimeScalar(userLib)); } diff --git a/src/main/perl/lib/CPAN/Config.pm b/src/main/perl/lib/CPAN/Config.pm index 4b9cafccf..349eab331 100644 --- a/src/main/perl/lib/CPAN/Config.pm +++ b/src/main/perl/lib/CPAN/Config.pm @@ -1,6 +1,6 @@ # CPAN Configuration for PerlOnJava # This provides sensible defaults that work out of the box -# Users can override with ~/.perlonjava/cpan/CPAN/MyConfig.pm +# Users can override with $PERLONJAVA_HOME/cpan/CPAN/MyConfig.pm package CPAN::Config; use strict; @@ -9,19 +9,22 @@ use File::Spec; # Determine home directory cross-platform my $home = $ENV{HOME} || $ENV{USERPROFILE} || '.'; +my $perlonjava_home = defined($ENV{PERLONJAVA_HOME}) && length($ENV{PERLONJAVA_HOME}) + ? $ENV{PERLONJAVA_HOME} + : File::Spec->catdir($home, '.perlonjava'); -# Use .perlonjava/cpan for CPAN data (consistent with PerlOnJava conventions) -my $cpan_home = File::Spec->catdir($home, '.perlonjava', 'cpan'); +# Keep all CPAN data below the selected PerlOnJava home. +my $cpan_home = File::Spec->catdir($perlonjava_home, 'cpan'); # Determine OS-specific tools my $is_windows = $^O eq 'MSWin32' || $^O eq 'cygwin'; # Bootstrap bundled distroprefs to the user's prefs directory. # CPAN reads prefs from the filesystem, so we write bundled YAML files -# to ~/.perlonjava/cpan/prefs/ on first run. +# to $PERLONJAVA_HOME/cpan/prefs/ (or the default equivalent) on first run. # Canonical sources live under lib/PerlOnJava/CpanDistroprefs/ in the JAR # (see dev/design/patch-and-cpan-prefs-layout.md). -# Note: ~/.perlonjava/cpan/CPAN/MyConfig.pm is created by HandleConfig.pm. +# Note: $PERLONJAVA_HOME/cpan/CPAN/MyConfig.pm is created by HandleConfig.pm. sub _bootstrap_prefs { my $prefs_dir = File::Spec->catdir($cpan_home, 'prefs'); @@ -200,7 +203,7 @@ _bootstrap_prefs(); # CPAN::Distribution applies these via /usr/bin/patch before make/test/ # install runs. We ship the patch sources bundled in the JAR under # lib/PerlOnJava/CpanPatches/ and copy them out to -# ~/.perlonjava/cpan/patches/ on first run so the external `patch` +# $PERLONJAVA_HOME/cpan/patches/ on first run so the external `patch` # binary (which operates on the filesystem) can reach them. # # Patches are exposed under "/.patch" relative to @@ -451,12 +454,13 @@ CPAN::Config - Default CPAN configuration for PerlOnJava =head1 DESCRIPTION This module provides default CPAN configuration for PerlOnJava. -It uses C<~/.perlonjava/cpan> as the CPAN home directory for consistency -with other PerlOnJava conventions. +It uses C<$PERLONJAVA_HOME/cpan> as the CPAN home directory when the +C environment variable is set. Otherwise it defaults to +C<~/.perlonjava/cpan>. Users can override these settings by creating their own config file at: - ~/.perlonjava/cpan/CPAN/MyConfig.pm + $PERLONJAVA_HOME/cpan/CPAN/MyConfig.pm =head1 SEE ALSO diff --git a/src/main/perl/lib/CPAN/HandleConfig.pm b/src/main/perl/lib/CPAN/HandleConfig.pm index 27982bea0..a14f085c8 100644 --- a/src/main/perl/lib/CPAN/HandleConfig.pm +++ b/src/main/perl/lib/CPAN/HandleConfig.pm @@ -527,6 +527,11 @@ sub _try_loading { # prioritized list of possible places for finding "CPAN/MyConfig.pm" sub cpan_home_dir_candidates { + if (defined $ENV{PERLONJAVA_HOME} && length $ENV{PERLONJAVA_HOME}) { + my $dir = File::Spec->catdir($ENV{PERLONJAVA_HOME}, 'cpan'); + return wantarray ? ($dir) : $dir; + } + my @dirs; my $old_v = $CPAN::Config->{load_module_verbosity}; $CPAN::Config->{load_module_verbosity} = q[none]; @@ -546,7 +551,7 @@ sub cpan_home_dir_candidates { push @dirs, $ENV{USERPROFILE} if $ENV{USERPROFILE}; $CPAN::Config->{load_module_verbosity} = $old_v; - # PerlOnJava uses ~/.perlonjava/cpan as its CPAN home to stay separate + # PerlOnJava uses ~/.perlonjava/cpan as its default CPAN home to stay separate # from the user's system CPAN (~/.cpan), which would otherwise override # our prefs_dir and other PerlOnJava-specific defaults. my @suffix = $^O eq 'VMS' ? ('_cpan') : ('.perlonjava', 'cpan'); diff --git a/src/main/perl/lib/CPAN/Prefs/README.md b/src/main/perl/lib/CPAN/Prefs/README.md index 4b7d35620..b25bca63a 100644 --- a/src/main/perl/lib/CPAN/Prefs/README.md +++ b/src/main/perl/lib/CPAN/Prefs/README.md @@ -4,6 +4,8 @@ Bundled CPAN **distroprefs** for PerlOnJava are maintained under: `src/main/perl/lib/PerlOnJava/CpanDistroprefs/` -They are copied to `~/.perlonjava/cpan/prefs/` by `CPAN::Config::_bootstrap_prefs` when CPAN loads. See [dev/design/patch-and-cpan-prefs-layout.md](../../../../../../dev/design/patch-and-cpan-prefs-layout.md). +They are copied to `$PERLONJAVA_HOME/cpan/prefs/` (defaulting to +`~/.perlonjava/cpan/prefs/`) by `CPAN::Config::_bootstrap_prefs` when CPAN +loads. See [dev/design/patch-and-cpan-prefs-layout.md](../../../../../../dev/design/patch-and-cpan-prefs-layout.md). This directory intentionally contains **no** `.yml` files so contributors are not confused by a second copy of prefs. diff --git a/src/main/perl/lib/Config.pm b/src/main/perl/lib/Config.pm index c17d4e2d0..8185be773 100644 --- a/src/main/perl/lib/Config.pm +++ b/src/main/perl/lib/Config.pm @@ -68,9 +68,12 @@ my $user_home = getProperty('user.home') || ''; my $user_dir = getProperty('user.dir') || ''; my $java_home = getProperty('java.home') || ''; my $user_name = getProperty('user.name') || 'unknown'; -my $perlonjava_home = $user_home +my $perlonjava_override = getenv('PERLONJAVA_HOME'); +my $perlonjava_home = defined($perlonjava_override) && length($perlonjava_override) + ? $perlonjava_override + : ($user_home ? _catdir($file_separator, $user_home, '.perlonjava') - : '.perlonjava'; + : '.perlonjava'); my $core_privlib = _catdir($file_separator, $perlonjava_home, 'core', 'lib', 'perl5', '5.44.0'); my $core_archlib = _catdir($file_separator, $core_privlib, "java-$java_version-$os_arch"); _ensure_dir(_catdir($file_separator, $core_archlib, 'CORE')); @@ -355,32 +358,32 @@ my $startperl = $is_windows installprefixexp => '/usr/local', # Site installation paths (for user-installed modules via jcpan) - siteprefix => $user_home . '/.perlonjava', - siteprefixexp => $user_home . '/.perlonjava', - installsitelib => $user_home . '/.perlonjava/lib', - installsitearch => $user_home . '/.perlonjava/lib', - installsitebin => $user_home . '/.perlonjava/bin', - installsitescript => $user_home . '/.perlonjava/bin', - installsiteman1dir => $user_home . '/.perlonjava/man/man1', - installsiteman3dir => $user_home . '/.perlonjava/man/man3', + siteprefix => $perlonjava_home, + siteprefixexp => $perlonjava_home, + installsitelib => _catdir($file_separator, $perlonjava_home, 'lib'), + installsitearch => _catdir($file_separator, $perlonjava_home, 'lib'), + installsitebin => _catdir($file_separator, $perlonjava_home, 'bin'), + installsitescript => _catdir($file_separator, $perlonjava_home, 'bin'), + installsiteman1dir => _catdir($file_separator, $perlonjava_home, 'man', 'man1'), + installsiteman3dir => _catdir($file_separator, $perlonjava_home, 'man', 'man3'), # Core installation paths (read-only, in JAR) installprivlib => 'jar:PERL5LIB', installarchlib => 'jar:PERL5LIB', installbin => 'jar:PERL5BIN', installscript => 'jar:PERL5BIN', - installman1dir => $user_home . '/.perlonjava/man/man1', - installman3dir => $user_home . '/.perlonjava/man/man3', + installman1dir => _catdir($file_separator, $perlonjava_home, 'man', 'man1'), + installman3dir => _catdir($file_separator, $perlonjava_home, 'man', 'man3'), # Man page directories - man1dir => $user_home . '/.perlonjava/man/man1', - man3dir => $user_home . '/.perlonjava/man/man3', - man1direxp => $user_home . '/.perlonjava/man/man1', - man3direxp => $user_home . '/.perlonjava/man/man3', - siteman1dir => $user_home . '/.perlonjava/man/man1', - siteman3dir => $user_home . '/.perlonjava/man/man3', - siteman1direxp => $user_home . '/.perlonjava/man/man1', - siteman3direxp => $user_home . '/.perlonjava/man/man3', + man1dir => _catdir($file_separator, $perlonjava_home, 'man', 'man1'), + man3dir => _catdir($file_separator, $perlonjava_home, 'man', 'man3'), + man1direxp => _catdir($file_separator, $perlonjava_home, 'man', 'man1'), + man3direxp => _catdir($file_separator, $perlonjava_home, 'man', 'man3'), + siteman1dir => _catdir($file_separator, $perlonjava_home, 'man', 'man1'), + siteman3dir => _catdir($file_separator, $perlonjava_home, 'man', 'man3'), + siteman1direxp => _catdir($file_separator, $perlonjava_home, 'man', 'man1'), + siteman3direxp => _catdir($file_separator, $perlonjava_home, 'man', 'man3'), # Man page section suffixes man1ext => '1', diff --git a/src/main/perl/lib/ExtUtils/MM_PerlOnJava.pm b/src/main/perl/lib/ExtUtils/MM_PerlOnJava.pm index e1cf9fe70..58f1e6714 100644 --- a/src/main/perl/lib/ExtUtils/MM_PerlOnJava.pm +++ b/src/main/perl/lib/ExtUtils/MM_PerlOnJava.pm @@ -35,8 +35,11 @@ sub init_main { # Installation base directory sub _perlonjava_lib { + my $perlonjava_home = defined($ENV{PERLONJAVA_HOME}) && length($ENV{PERLONJAVA_HOME}) + ? $ENV{PERLONJAVA_HOME} + : File::Spec->catdir($ENV{HOME} || $ENV{USERPROFILE} || '.', '.perlonjava'); return $ENV{PERLONJAVA_LIB} - || File::Spec->catdir($ENV{HOME} || '.', '.perlonjava', 'lib'); + || File::Spec->catdir($perlonjava_home, 'lib'); } # Override: We don't support XS diff --git a/src/main/perl/lib/ExtUtils/MakeMaker.pm b/src/main/perl/lib/ExtUtils/MakeMaker.pm index dc756b8c4..5223d4af6 100644 --- a/src/main/perl/lib/ExtUtils/MakeMaker.pm +++ b/src/main/perl/lib/ExtUtils/MakeMaker.pm @@ -59,9 +59,12 @@ sub _default_install_base { my $jar_dir = dirname($ENV{PERLONJAVA_JAR}); return File::Spec->catdir($jar_dir, 'lib'); } - # Use ~/.perlonjava/lib as default user library path + # Use $PERLONJAVA_HOME/lib (or ~/.perlonjava/lib) as the user library path. my $home = $ENV{HOME} || $ENV{USERPROFILE} || '.'; - return File::Spec->catdir($home, '.perlonjava', 'lib'); + my $perlonjava_home = defined($ENV{PERLONJAVA_HOME}) && length($ENV{PERLONJAVA_HOME}) + ? $ENV{PERLONJAVA_HOME} + : File::Spec->catdir($home, '.perlonjava'); + return File::Spec->catdir($perlonjava_home, 'lib'); } sub WriteMakefile { @@ -294,7 +297,7 @@ sub _handle_xs_module { # in the JAR (jar:PERL5LIB). The JAR shim provides proper fallback logic # (e.g. inheriting from a pure-Perl parent), while the CPAN version would # call XSLoader::load at the top level and die fatally. Since - # ~/.perlonjava/lib/ comes before jar:PERL5LIB in @INC, installing the + # The selected PerlOnJava home lib/ comes before jar:PERL5LIB in @INC, installing the # CPAN version would shadow the working shim. $args->{_xs_module} = 1; @@ -449,7 +452,7 @@ sub _install_pure_perl { } # Skip .pm files that already exist in PerlOnJava's bundled JAR. - # ~/.perlonjava/lib/ has higher @INC priority than jar:PERL5LIB, so + # The selected PerlOnJava home lib/ has higher @INC priority than jar:PERL5LIB, so # installing a CPAN version would shadow the bundled module. This # protects Java-backed shims (IO::Socket::SSL, Net::SSLeay, etc.) # from being overwritten by incompatible CPAN versions, while still diff --git a/src/test/java/org/perlonjava/PerlOnJavaHomeIntegrationTest.java b/src/test/java/org/perlonjava/PerlOnJavaHomeIntegrationTest.java new file mode 100644 index 000000000..261021c95 --- /dev/null +++ b/src/test/java/org/perlonjava/PerlOnJavaHomeIntegrationTest.java @@ -0,0 +1,118 @@ +package org.perlonjava; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +class PerlOnJavaHomeIntegrationTest { + + @TempDir + Path temporaryDirectory; + + @Test + void launcherKeepsRuntimeAndCpanStateInsideExplicitHome() throws Exception { + Path projectDirectory = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + boolean windows = System.getProperty("os.name").toLowerCase().startsWith("windows"); + Path launcher = projectDirectory.resolve(windows ? "jperl.bat" : "jperl"); + assertTrue(Files.isRegularFile(launcher), "missing launcher: " + launcher); + + Path isolatedHome = temporaryDirectory.resolve("isolated-home").toAbsolutePath(); + Path defaultUserHome = temporaryDirectory.resolve("default-user-home").toAbsolutePath(); + Files.createDirectories(defaultUserHome); + Path probe = writeProbe(); + + ProcessBuilder builder = launcherCommand(windows, launcher, probe); + builder.directory(projectDirectory.toFile()); + builder.redirectErrorStream(true); + Map environment = builder.environment(); + environment.put("PERLONJAVA_HOME", isolatedHome.toString()); + environment.put("HOME", defaultUserHome.toString()); + environment.put("USERPROFILE", defaultUserHome.toString()); + environment.put("JPERL_OPTS", "-Duser.home=" + defaultUserHome); + + Process process = builder.start(); + if (!process.waitFor(60, TimeUnit.SECONDS)) { + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + throw new AssertionError("isolated-home probe timed out"); + } + String output = new String(process.getInputStream().readAllBytes()); + assertEquals(0, process.exitValue(), output); + + Map values = parsePaths(output); + assertEquals(isolatedHome, values.get("siteprefix")); + assertEquals(isolatedHome.resolve("lib"), values.get("installsitelib")); + assertEquals(isolatedHome.resolve("bin"), values.get("installsitebin")); + assertEquals(isolatedHome.resolve("man").resolve("man1"), values.get("man1dir")); + assertEquals(isolatedHome.resolve("lib"), values.get("inc")); + assertEquals(isolatedHome.resolve("cpan"), values.get("cpan_home")); + assertEquals(isolatedHome.resolve("cpan"), values.get("cpan_candidate")); + assertEquals(isolatedHome.resolve("lib"), values.get("makemaker_base")); + assertEquals(isolatedHome.resolve("lib"), values.get("mm_perlonjava_lib")); + + assertTrue(Files.isDirectory(isolatedHome.resolve("core")), output); + assertTrue(Files.isDirectory(isolatedHome.resolve("cpan").resolve("prefs")), output); + assertTrue(Files.isDirectory(isolatedHome.resolve("cpan").resolve("patches")), output); + assertFalse(Files.exists(defaultUserHome.resolve(".perlonjava")), output); + } + + private Path writeProbe() throws IOException { + Path probe = temporaryDirectory.resolve("perlonjava_home_probe.pl"); + Files.writeString(probe, """ + use Config; + use CPAN::Config; + require CPAN::HandleConfig; + require ExtUtils::MakeMaker; + require ExtUtils::MM_PerlOnJava; + my ($user_inc) = grep { $_ eq $Config{installsitelib} } @INC; + print 'siteprefix=', $Config{siteprefix}, "\\n"; + print 'installsitelib=', $Config{installsitelib}, "\\n"; + print 'installsitebin=', $Config{installsitebin}, "\\n"; + print 'man1dir=', $Config{man1dir}, "\\n"; + print 'inc=', ($user_inc || ''), "\\n"; + print 'cpan_home=', $CPAN::Config->{cpan_home}, "\\n"; + print 'cpan_candidate=', scalar(CPAN::HandleConfig::cpan_home_dir_candidates()), "\\n"; + print 'makemaker_base=', ExtUtils::MakeMaker::_default_install_base(), "\\n"; + print 'mm_perlonjava_lib=', ExtUtils::MM_PerlOnJava::_perlonjava_lib(), "\\n"; + """); + return probe; + } + + private static ProcessBuilder launcherCommand(boolean windows, Path launcher, Path probe) { + if (windows) { + String command = "call \"" + launcher + "\" \"" + probe + "\""; + return new ProcessBuilder("cmd.exe", "/d", "/s", "/c", command); + } + return new ProcessBuilder(launcher.toString(), probe.toString()); + } + + private static Map parsePaths(String output) { + Map values = new HashMap<>(); + for (String line : output.lines().toList()) { + int separator = line.indexOf('='); + if (separator > 0) { + String key = line.substring(0, separator); + if (key.equals("siteprefix") || key.equals("installsitelib") + || key.equals("installsitebin") || key.equals("man1dir") + || key.equals("inc") || key.equals("cpan_home") + || key.equals("cpan_candidate") || key.equals("makemaker_base") + || key.equals("mm_perlonjava_lib")) { + values.put(key, Path.of(line.substring(separator + 1)).toAbsolutePath()); + } + } + } + return values; + } +} diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/GlobalContextTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/GlobalContextTest.java index d678941df..653b39107 100644 --- a/src/test/java/org/perlonjava/runtime/runtimetypes/GlobalContextTest.java +++ b/src/test/java/org/perlonjava/runtime/runtimetypes/GlobalContextTest.java @@ -6,6 +6,7 @@ import java.nio.file.Path; import java.util.ArrayList; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -19,13 +20,35 @@ class GlobalContextTest { @Test void userInstallLibraryIsAddedBeforeDirectoryExists() { Path userHome = temporaryDirectory.resolve("clean-user-home"); - Path userLibrary = userHome.resolve(".perlonjava").resolve("lib"); + Path perlonjavaHome = userHome.resolve(".perlonjava"); + Path userLibrary = perlonjavaHome.resolve("lib"); assertFalse(userLibrary.toFile().exists()); var inc = new ArrayList(); - GlobalContext.addUserLibraryPath(inc, userHome.toString()); + GlobalContext.addUserLibraryPath(inc, perlonjavaHome); assertEquals(1, inc.size()); assertEquals(userLibrary.toString(), inc.getFirst().toString()); } + + @Test + void explicitPerlOnJavaHomeOverridesDefaultUserHome() { + Path override = temporaryDirectory.resolve("isolated-perlonjava"); + Path userHome = temporaryDirectory.resolve("default-user-home"); + + Path resolved = GlobalContext.resolvePerlOnJavaHome( + Map.of("PERLONJAVA_HOME", override.toString()), userHome.toString()); + + assertEquals(override, resolved); + } + + @Test + void emptyPerlOnJavaHomeUsesDefaultUserHome() { + Path userHome = temporaryDirectory.resolve("default-user-home"); + + Path resolved = GlobalContext.resolvePerlOnJavaHome( + Map.of("PERLONJAVA_HOME", ""), userHome.toString()); + + assertEquals(userHome.resolve(".perlonjava"), resolved); + } } From 88e25066fd2c4d7b442fd7e575b03965ea607266 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 4 Aug 2026 15:25:02 +0200 Subject: [PATCH 5/9] docs: complete Catalyst isolated-home milestone Record the successful fresh-root Text::Glob installation and explain why the bundled Try::Tiny module cannot prove CPAN isolation. Advance the active Catalyst support work to the MooseX::MethodAttributes milestone. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- dev/design/catalyst-support.md | 41 +++++++++++++++++++++++-------- docs/guides/using-cpan-modules.md | 4 +-- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/dev/design/catalyst-support.md b/dev/design/catalyst-support.md index b91127130..f4805568d 100644 --- a/dev/design/catalyst-support.md +++ b/dev/design/catalyst-support.md @@ -119,12 +119,18 @@ Acceptance test: ```bash isolated_root=$(mktemp -d /tmp/perlonjava-catalyst.XXXXXX) -PERLONJAVA_HOME="$isolated_root" timeout 1200 ./jcpan install Try::Tiny \ +PERLONJAVA_HOME="$isolated_root" timeout 1200 ./jcpan install Text::Glob \ > /tmp/catalyst-isolated-cpan.log 2>&1 -PERLONJAVA_HOME="$isolated_root" timeout 60 ./jperl -MTry::Tiny -e 'print "ok\n"' \ +PERLONJAVA_HOME="$isolated_root" timeout 60 ./jperl -MText::Glob \ + -e 'print "$Text::Glob::VERSION\n$INC{q(Text/Glob.pm)}\n"' \ >> /tmp/catalyst-isolated-cpan.log 2>&1 ``` +`Try::Tiny` is not a valid isolation probe because version 0.32 is bundled in +the JAR; CPAN reports it up to date without installing into the selected home. +`Text::Glob` is deliberately used because it is a small, pure-Perl, +non-bundled distribution whose `%INC` origin proves the install location. + `PerlOnJavaHomeIntegrationTest` runs the native launcher selected for the host OS, loads `Config`, `CPAN::Config`, and `CPAN::HandleConfig`, and verifies that runtime, core-probe, preferences, patches, and install paths remain under a @@ -152,20 +158,20 @@ incorrectly classified, or exposes a reusable PerlOnJava defect. ## Current Handoff State -Milestone 0 implementation and the full `make` gate pass. Its live CPAN -acceptance remains pending because the restricted agent network could not fetch -CPAN indexes, and permission to download and execute a CPAN installer was not -granted. Resume by running the documented fresh-root `Try::Tiny` install after -explicit approval; once it succeeds, mark Milestone 0 complete and begin -Milestone 1's inherited attributed-method reduction. Use the dependency table -as the baseline; use commit history and the PR for chronological progress. +Milestone 0 completed on 2026-08-04. In a fresh root, `Text-Glob-0.11` +downloaded with a verified checksum, passed both upstream files and all 74 +tests, installed without force beneath `$PERLONJAVA_HOME/lib`, and loaded from +that exact path. The full `make` gate also passes. Milestone 1 is now active: +reduce the inherited attributed-method failure while retaining the +MooseX::MethodAttributes metaroles described below. Use the dependency table as +the baseline; use commit history and the PR for chronological progress. When a milestone is completed, update this paragraph to name the next active milestone and record its acceptance result, without adding a work diary. ## Milestone Plan -### Milestone 0: Isolated CPAN state +### Milestone 0: Isolated CPAN state (completed 2026-08-04) Deliverables: @@ -179,6 +185,21 @@ Exit criteria: - A known-small CPAN distribution installs and loads from an isolated root. - The default `~/.perlonjava` remains untouched by the test. +Completion: + +- Runtime `@INC`, `Config`, CPAN state and `MyConfig` discovery, MakeMaker + install paths, core probes, scripts, and manpages derive from + `PERLONJAVA_HOME`; the unset default remains `~/.perlonjava`. +- `PerlOnJavaHomeIntegrationTest` selects `jperl` or `jperl.bat` for the host + OS and proves the temporary override does not write to the default home. +- Files: `GlobalContext.java`, `Config.pm`, `CPAN/Config.pm`, + `CPAN/HandleConfig.pm`, both MakeMaker implementations, launcher integration + tests, and the CPAN usage guide. +- Acceptance: `Text-Glob-0.11`, 2/2 files and 74/74 tests, installed and loaded + from a fresh `/tmp/perlonjava-catalyst.*` root without force. +- Next step: Milestone 1's MooseX::MethodAttributes Catalyst reduction. No + isolated-home blocker remains. + ### Milestone 1: Catalyst method attributes Deliverables: diff --git a/docs/guides/using-cpan-modules.md b/docs/guides/using-cpan-modules.md index d39939f17..4f0fdc523 100644 --- a/docs/guides/using-cpan-modules.md +++ b/docs/guides/using-cpan-modules.md @@ -44,9 +44,9 @@ Set `PERLONJAVA_HOME` to keep an installation separate from the default ```bash isolated_root=$(mktemp -d /tmp/perlonjava-home.XXXXXX) -PERLONJAVA_HOME="$isolated_root" timeout 1200 jcpan install Try::Tiny +PERLONJAVA_HOME="$isolated_root" timeout 1200 jcpan install Text::Glob PERLONJAVA_HOME="$isolated_root" timeout 60 jperl \ - -MTry::Tiny -e 'print "ok\n"' + -MText::Glob -e 'print "$Text::Glob::VERSION\n"' ``` The selected root supplies the user module library (`lib/`), CPAN metadata and From 94302aed85fdca730460918be377bb3faa44b7d4 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 4 Aug 2026 15:40:19 +0200 Subject: [PATCH 6/9] fix: refresh MRO generation for named subs Increment the defining package generation whenever the parser installs or replaces a named subroutine. This lets Class::MOP rebuild its local method map before namespace::autoclean and restores Catalyst method modifiers in MooseX::MethodAttributes. Add a system-Perl-validated package-generation regression and record the completed Catalyst method-attributes milestone. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- dev/design/catalyst-support.md | 79 ++++++++++--------- .../frontend/parser/SubroutineParser.java | 11 ++- .../unit/mro_pkg_gen_sub_definition.t | 29 +++++++ 3 files changed, 81 insertions(+), 38 deletions(-) create mode 100644 src/test/resources/unit/mro_pkg_gen_sub_definition.t diff --git a/dev/design/catalyst-support.md b/dev/design/catalyst-support.md index f4805568d..b36f343e9 100644 --- a/dev/design/catalyst-support.md +++ b/dev/design/catalyst-support.md @@ -143,8 +143,8 @@ therefore exercises `jperl` on Unix and `jperl.bat` on Windows CI. |---|---|---|---|---| | HTTP-Body 1.23 | runtime | 13/13 files, 250/250 assertions pass; installs normally | cleared | retain regression coverage | | Moose 2.4000 | runtime | bundled; broad upstream/DBIx::Class coverage already exists | monitor | investigate only Catalyst-relevant failures | -| MooseX-MethodAttributes 0.32 | runtime | 20/22 files pass; Catalyst-specific inherited modifier failure | blocking | reduce and fix at Moose/MOP/runtime layer | -| Catalyst-Runtime 5.90132 | runtime | downloaded, not successfully installed | blocking | resume after method attributes and isolation | +| MooseX-MethodAttributes 0.32 | runtime | 22/22 files, 144/144 tests pass unchanged | cleared | retain package-generation regression coverage | +| Catalyst-Runtime 5.90132 | runtime | downloaded, not successfully installed | blocking | run clean isolated installation and classify failures | | Plack 1.0054 | runtime | dependency installation incomplete | blocking later | classify runtime versus test-only prerequisites | | Class-C3-Adopt-NEXT 0.14 | runtime | functional tests mostly pass; warning differences remain | non-blocking until proven otherwise | defer | | Encode-Locale 1.05 | transitive runtime | tied `%ENV` mutation tests fail | risk | verify whether Catalyst runtime path exercises mutation | @@ -158,13 +158,13 @@ incorrectly classified, or exposes a reusable PerlOnJava defect. ## Current Handoff State -Milestone 0 completed on 2026-08-04. In a fresh root, `Text-Glob-0.11` -downloaded with a verified checksum, passed both upstream files and all 74 -tests, installed without force beneath `$PERLONJAVA_HOME/lib`, and loaded from -that exact path. The full `make` gate also passes. Milestone 1 is now active: -reduce the inherited attributed-method failure while retaining the -MooseX::MethodAttributes metaroles described below. Use the dependency table as -the baseline; use commit history and the PR for chronological progress. +Milestones 0 and 1 completed on 2026-08-04. MooseX-MethodAttributes 0.32 now +passes all 22 upstream files and 144 tests unchanged; both Catalyst-focused +files pass, the package-generation regression passes on both PerlOnJava +backends, and `make` passes. Milestone 2 is active: install Catalyst-Runtime +5.90132 without force in a new isolated home and classify every remaining +prerequisite failure. Use the dependency table as the baseline; use commit +history and the PR for chronological progress. When a milestone is completed, update this paragraph to name the next active milestone and record its acceptance result, without adding a work diary. @@ -200,7 +200,7 @@ Completion: - Next step: Milestone 1's MooseX::MethodAttributes Catalyst reduction. No isolated-home blocker remains. -### Milestone 1: Catalyst method attributes +### Milestone 1: Catalyst method attributes (completed 2026-08-04) Deliverables: @@ -216,6 +216,21 @@ Exit criteria: - The complete upstream MooseX-MethodAttributes suite has no regressions. - Relevant local tests pass on both PerlOnJava backends. +Completion: + +- Reduction showed the earliest failure was a local attributed method with a + `before` modifier; inherited `after` lookup failed later for the same reason. +- PerlOnJava did not increment `mro::get_pkg_gen` for named sub definitions or + redefinitions. Class::MOP therefore reused an empty local method map, and + `namespace::autoclean` deleted real methods as if they were imports. +- `SubroutineParser` now increments the defining package's generation whenever + it installs or replaces a named sub. +- `mro_pkg_gen_sub_definition.t` was validated unchanged with system Perl and + passes with both PerlOnJava backends. +- Acceptance: `t/catalyst.t` 13/13, `t/catalyst_role.t` 21/21, full upstream + suite 22/22 files and 144/144 tests, and the full `make` gate all pass. +- Next step: Milestone 2's clean isolated Catalyst-Runtime installation. + ### Milestone 2: Clean Catalyst runtime installation Deliverables: @@ -295,7 +310,7 @@ Exit criteria: - All items in Definition of Done are satisfied. - The PR contains exact test commands and results. -## Current Blocker: MooseX-MethodAttributes +## Resolved Blocker: MooseX-MethodAttributes ### Reproduction source @@ -324,20 +339,26 @@ timeout 60 /path/to/PerlOnJava4/jperl -Ilib -It/lib t/catalyst_role.t \ > /tmp/moosex-methodattributes-role.log 2>&1 ``` -### Observed behavior +### Resolution -- `t/catalyst.t` aborts while compiling/loading the Catalyst-like subclass: +- Before the fix, `t/catalyst.t` aborted while loading the first Catalyst-like + Moose class with: ```text Moose::Exception::MethodNameNotFoundInInheritanceHierarchy=HASH(...) Compilation failed in require ``` -- The failure occurs around an `after get_attribute => sub { ... }` modifier - wrapping an inherited method carrying a custom `:Local` attribute. -- `t/catalyst_role.t` completes but reports one method-list count mismatch. -- Ordinary inherited Moose modifiers pass in a smaller probe. The reduction - must preserve MooseX::MethodAttributes metaroles and attributed methods. +- The first failing operation was actually `before get_foo => sub { ... }` on + a local attributed method. At scope end, Class::MOP saw a stale package + generation and returned only its pre-sub-definition method map; + `namespace::autoclean` then removed `get_foo`, `get_attribute`, and `other`. +- The same stale map caused the later inherited `after get_attribute` lookup + failure and the role test's local-method count mismatch. +- Incrementing `mro::get_pkg_gen` in `SubroutineParser` at each named sub + definition or redefinition restores ordinary Perl cache semantics without + any Catalyst- or Moose-specific branch. +- The unchanged upstream suite now passes all 22 files and 144 tests. ### Relevant upstream files @@ -363,25 +384,9 @@ src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java src/main/java/org/perlonjava/backend/bytecode/OpcodeHandlerExtended.java ``` -### Investigation order - -1. Provision a standard-Perl Catalyst/Moose environment; the workstation's - current system Perl does not have Moose installed. -2. Reduce the upstream package hierarchy while retaining: - - an inherited attributed method; - - `MooseX::MethodAttributes` inheritable metaroles; - - an `after` modifier in the subclass. -3. Compare the class precedence list, `@ISA`, local method map, and - `find_next_method_by_name` immediately before the failing modifier. -4. Determine whether the parent method is absent, stale in a method/MRO cache, - or represented by the wrong metaclass. -5. Test both JVM and interpreter backends. -6. Add the reduced test under `src/test/resources/unit/` only after standard - Perl validates it. -7. Run the full MooseX-MethodAttributes suite and `make`. - -Do not add Catalyst names to generic cache invalidation or method lookup code. -The fix must be driven by ordinary Perl/Moose semantics. +The fix deliberately contains no Catalyst- or Moose-specific cache or lookup +branch. The local regression uses only core `mro` behavior so system Perl can +validate the expected semantics without a separate Moose installation. ## Standard Command Set diff --git a/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java b/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java index da6fe09b6..333eb37a2 100644 --- a/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java @@ -1285,6 +1285,16 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S declaredCode.isDeclared = true; } + // Perl increments a package's mro generation whenever a named sub is + // installed or replaced. Class::MOP uses this generation to decide + // whether its local method map must be rebuilt; leaving it unchanged + // makes namespace::autoclean treat newly compiled methods as imports. + int lastSep = fullName.lastIndexOf("::"); + String definitionPackage = lastSep >= 0 + ? fullName.substring(0, lastSep) + : packageToUse; + org.perlonjava.runtime.perlmodule.Mro.incrementPackageGeneration(definitionPackage); + // Register subroutine location for %DB::sub (only in debug mode) if (DebugState.debugMode && parser.ctx.errorUtil != null && block != null) { int startLine = parser.ctx.errorUtil.getLineNumber(block.tokenIndex); @@ -1311,7 +1321,6 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S // `sub Dst::foo { }` arrives here with subName="Dst::foo"), and fullName // may have been rewritten by a stash alias — always derive both halves // from fullName so caller()/set_subname see a consistent pair. - int lastSep = fullName.lastIndexOf("::"); placeholder.subName = lastSep >= 0 ? fullName.substring(lastSep + 2) : subName; // For `sub X::foo { }` in package main, packageName should be "X", // not "main". Set this before MODIFY_CODE_ATTRIBUTES so attribute diff --git a/src/test/resources/unit/mro_pkg_gen_sub_definition.t b/src/test/resources/unit/mro_pkg_gen_sub_definition.t new file mode 100644 index 000000000..b9506d348 --- /dev/null +++ b/src/test/resources/unit/mro_pkg_gen_sub_definition.t @@ -0,0 +1,29 @@ +use strict; +use warnings; +use Test::More tests => 3; +use mro (); + +{ + package MroPkgGenSubDefinition; + our ($before, $after_definition, $after_redefinition); + + BEGIN { $before = mro::get_pkg_gen(__PACKAGE__) } + sub local_method { 'first' } + BEGIN { $after_definition = mro::get_pkg_gen(__PACKAGE__) } + no warnings 'redefine'; + sub local_method { 'second' } + BEGIN { $after_redefinition = mro::get_pkg_gen(__PACKAGE__) } +} + +package main; +isnt( + $MroPkgGenSubDefinition::after_definition, + $MroPkgGenSubDefinition::before, + 'defining a named sub changes the package generation', +); +isnt( + $MroPkgGenSubDefinition::after_redefinition, + $MroPkgGenSubDefinition::after_definition, + 'redefining a named sub changes the package generation again', +); +is(MroPkgGenSubDefinition->local_method, 'second', 'redefined method remains callable'); From 9eeec26739e85446d10c667b16a7f0642b68b1a8 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 4 Aug 2026 17:16:02 +0200 Subject: [PATCH 7/9] fix: export Socket NI_NAMEREQD for Catalyst Add the standard getnameinfo flag constant required by Catalyst::Request, cover it with a system-Perl-validated regression, and record the blockers found by the first full isolated Catalyst runtime installation. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- dev/design/catalyst-support.md | 40 ++++++++++++------- .../perlonjava/runtime/perlmodule/Socket.java | 6 +++ src/main/perl/lib/Socket.pm | 2 +- src/test/resources/unit/socket_ni_namereqd.t | 8 ++++ 4 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 src/test/resources/unit/socket_ni_namereqd.t diff --git a/dev/design/catalyst-support.md b/dev/design/catalyst-support.md index b36f343e9..1075cbda0 100644 --- a/dev/design/catalyst-support.md +++ b/dev/design/catalyst-support.md @@ -144,27 +144,39 @@ therefore exercises `jperl` on Unix and `jperl.bat` on Windows CI. | HTTP-Body 1.23 | runtime | 13/13 files, 250/250 assertions pass; installs normally | cleared | retain regression coverage | | Moose 2.4000 | runtime | bundled; broad upstream/DBIx::Class coverage already exists | monitor | investigate only Catalyst-relevant failures | | MooseX-MethodAttributes 0.32 | runtime | 22/22 files, 144/144 tests pass unchanged | cleared | retain package-generation regression coverage | -| Catalyst-Runtime 5.90132 | runtime | downloaded, not successfully installed | blocking | run clean isolated installation and classify failures | -| Plack 1.0054 | runtime | dependency installation incomplete | blocking later | classify runtime versus test-only prerequisites | -| Class-C3-Adopt-NEXT 0.14 | runtime | functional tests mostly pass; warning differences remain | non-blocking until proven otherwise | defer | -| Encode-Locale 1.05 | transitive runtime | tied `%ENV` mutation tests fail | risk | verify whether Catalyst runtime path exercises mutation | -| POSIX-strftime-Compiler 0.46 | Plack logging | timezone and `POSIX::tzset` differences | non-blocking for initial dispatch | fix before logging acceptance gate | +| Catalyst-Runtime 5.90132 | runtime | builds 56 files, but cannot install because required dependencies fail; aggregate suite reached the one-hour guard | blocking | clear direct runtime prerequisites before rerunning the suite | +| Plack 1.0054 | runtime | builds 71 files; install is blocked by six required distributions and its suite consequently cannot load request modules | blocking | clear `Stream::Buffered` and URL encoding first, then rerun Plack | +| Socket `NI_NAMEREQD` | core API | constant/export added; system Perl, JVM, interpreter, and full `make` pass; `Catalyst::Request` now advances to `Stream::Buffered` | cleared | retain regression coverage | +| Stream-Buffered 0.03 | Catalyst and Plack runtime | `t/print.t` and `t/subclass.t` lose printed values (4/18 assertions fail) | blocking | reduce tied/filehandle print behavior | +| WWW-Form-UrlEncoded 0.26 | Plack runtime | Unicode value is emitted literally instead of UTF-8 percent-encoded (2/199 assertions fail) | blocking | reduce byte/UTF-8 URI escaping | +| HTTP-Entity-Parser 0.25 | Plack runtime | cannot load because `Stream::Buffered` and `WWW::Form::UrlEncoded` did not install | downstream blocking | rerun after its two prerequisites clear | +| Filesys-Notify-Simple 0.14 | Plack runtime, reloader use | move/recreate tests emit duplicate plans and no assertions | classify | determine whether the non-forking runtime path works; reloader support is deferred | +| Test-TCP 2.22 | Plack runtime metadata | process/fork-oriented suite fails broadly and distribution does not install | classify | separate runtime helpers usable without `fork` from unsupported tests | +| Class-C3-Adopt-NEXT 0.14 | Catalyst runtime | 24/26 assertions pass; warning text/count differences prevent installation | blocking | reduce warning-category and formatting differences | +| Encode-Locale 1.05 | HTTP-Message runtime | tied `%ENV` byte-key/value mutation fails 3/28 assertions and prevents installation | blocking | reduce byte-string hash key/value behavior | +| POSIX-strftime-Compiler 0.46 | Plack logging runtime | timezone expectation and missing `POSIX::tzset` prevent installation | blocking | implement `tzset` and normalize timezone behavior | | AnyDBM_File | optional/transitive | missing bundled core module makes CPAN suggest installing Perl | tooling defect | add/import core module or correct capability metadata | -| MooseX-Getopt 0.78 | runtime/development boundary | force-installed for discovery; help and trapped-exit tests fail | classify | determine which Catalyst runtime code requires it | -| Test-Trap 0.3.5 | test/development | force-installed for discovery; many failures | deferred | do not block runtime installation if only test-time | +| MooseX-Getopt 0.78 | Catalyst runtime | functional tests mostly pass, but missing failed `Test::Trap` prevents a clean install | blocking/tooling | preserve runtime installability without claiming fork/exit test support | +| CGI-Struct 1.21 | Catalyst runtime | tests shell out through `env perl5` and fail with permission denied | blocking/tooling | route subprocess Perl selection through the PerlOnJava executable | +| Text-SimpleTable 2.07 | Catalyst runtime | 11/12 assertions pass; one backend-selection assertion prevents installation | blocking | reduce optional visual-width selection | +| Test-Trap 0.3.5 | test/development | label/exit/fork tests fail; blocks MooseX-Getopt's test prerequisite | deferred capability, install blocker | fix prerequisite-phase handling or narrowly classify unsupported tests | When a new dependency appears, add it here only if it is blocking, forced, incorrectly classified, or exposes a reusable PerlOnJava defect. ## Current Handoff State -Milestones 0 and 1 completed on 2026-08-04. MooseX-MethodAttributes 0.32 now -passes all 22 upstream files and 144 tests unchanged; both Catalyst-focused -files pass, the package-generation regression passes on both PerlOnJava -backends, and `make` passes. Milestone 2 is active: install Catalyst-Runtime -5.90132 without force in a new isolated home and classify every remaining -prerequisite failure. Use the dependency table as the baseline; use commit -history and the PR for chronological progress. +Milestones 0 and 1 completed on 2026-08-04. Milestone 2 is active. A clean +isolated install under `/tmp/perlonjava-catalyst-runtime.T2Kb69` enumerated the +full runtime graph, built Catalyst-Runtime 5.90132, and reached its aggregate +suite without force before the one-hour guard expired. The run proved that +Plack's six failed prerequisites and the additional Catalyst prerequisites in +the dependency table are real clean-install blockers. The direct Catalyst +compile blocker `Socket::NI_NAMEREQD` is fixed: its standard-Perl-validated +test passes on both PerlOnJava backends, `make` passes, and loading +`Catalyst::Request` now proceeds to the next missing runtime dependency, +`Stream::Buffered`. Next reduce `Stream::Buffered`, then the Unicode escaping +failure in `WWW::Form::UrlEncoded`, before rerunning Plack and Catalyst. When a milestone is completed, update this paragraph to name the next active milestone and record its acceptance result, without adding a work diary. diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Socket.java b/src/main/java/org/perlonjava/runtime/perlmodule/Socket.java index 5097de8a5..8bb498148 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Socket.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Socket.java @@ -64,6 +64,7 @@ public class Socket extends PerlModuleBase { public static final int AI_ADDRCONFIG = 0x0400; public static final int NI_NUMERICHOST = 1; public static final int NI_NUMERICSERV = 2; + public static final int NI_NAMEREQD = 4; public static final int NI_DGRAM = 16; public static final int NIx_NOHOST = 1; public static final int NIx_NOSERV = 2; @@ -152,6 +153,7 @@ public static void initialize() { socket.registerMethod("AI_ADDRCONFIG", ""); socket.registerMethod("NI_NUMERICHOST", ""); socket.registerMethod("NI_NUMERICSERV", ""); + socket.registerMethod("NI_NAMEREQD", ""); socket.registerMethod("NI_DGRAM", ""); socket.registerMethod("NIx_NOHOST", ""); socket.registerMethod("NIx_NOSERV", ""); @@ -1076,6 +1078,10 @@ public static RuntimeList NI_NUMERICSERV(RuntimeArray args, int ctx) { return new RuntimeScalar(NI_NUMERICSERV).getList(); } + public static RuntimeList NI_NAMEREQD(RuntimeArray args, int ctx) { + return new RuntimeScalar(NI_NAMEREQD).getList(); + } + public static RuntimeList NI_DGRAM(RuntimeArray args, int ctx) { return new RuntimeScalar(NI_DGRAM).getList(); } diff --git a/src/main/perl/lib/Socket.pm b/src/main/perl/lib/Socket.pm index 9151c24c8..39d0f44ee 100644 --- a/src/main/perl/lib/Socket.pm +++ b/src/main/perl/lib/Socket.pm @@ -41,7 +41,7 @@ our @EXPORT = qw( SHUT_RD SHUT_WR SHUT_RDWR MSG_OOB MSG_PEEK MSG_DONTROUTE MSG_DONTWAIT AI_PASSIVE AI_CANONNAME AI_NUMERICHOST AI_ADDRCONFIG - NI_NUMERICHOST NI_NUMERICSERV NI_DGRAM + NI_NUMERICHOST NI_NUMERICSERV NI_NAMEREQD NI_DGRAM NIx_NOHOST NIx_NOSERV EAI_NONAME EAI_FAIL $CR $LF $CRLF diff --git a/src/test/resources/unit/socket_ni_namereqd.t b/src/test/resources/unit/socket_ni_namereqd.t new file mode 100644 index 000000000..b78fc43b8 --- /dev/null +++ b/src/test/resources/unit/socket_ni_namereqd.t @@ -0,0 +1,8 @@ +use strict; +use warnings; + +use Test::More tests => 2; +use Socket qw(NI_NAMEREQD); + +is(NI_NAMEREQD, 4, 'NI_NAMEREQD has the standard flag value'); +ok(defined &NI_NAMEREQD, 'NI_NAMEREQD is exportable from Socket'); From b77ad375ab63ba0ec9ddac5abe5ce4207eb008ad Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 4 Aug 2026 17:42:26 +0200 Subject: [PATCH 8/9] docs: clarify Catalyst continuation steps Add an ordered handoff from the current Stream::Buffered blocker through isolated Catalyst installation, CI verification, and explicit user approval. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- dev/design/catalyst-support.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/dev/design/catalyst-support.md b/dev/design/catalyst-support.md index 1075cbda0..1bc558179 100644 --- a/dev/design/catalyst-support.md +++ b/dev/design/catalyst-support.md @@ -178,6 +178,28 @@ test passes on both PerlOnJava backends, `make` passes, and loading `Stream::Buffered`. Next reduce `Stream::Buffered`, then the Unicode escaping failure in `WWW::Form::UrlEncoded`, before rerunning Plack and Catalyst. +## Immediate Next Steps + +Execute these in order; do not skip ahead by force-installing a failed +distribution: + +1. Reduce `Stream-Buffered-0.03` failures in `t/print.t` and `t/subclass.t`. + Validate the reduction with system Perl, add shared-runtime regression + coverage, and verify both PerlOnJava backends. +2. Reduce the Unicode percent-encoding failure in + `WWW-Form-UrlEncoded-0.26` (`foo=%E5&bar=☺` versus + `foo=%E5&bar=%E2%98%BA`) and rerun its complete upstream suite unchanged. +3. In a new isolated `PERLONJAVA_HOME`, install those two distributions, then + rerun `HTTP-Entity-Parser-0.25` and the complete `Plack-1.0054` install. +4. Classify the remaining Plack/Catalyst blockers in the dependency table. + For fork, process, watcher, or reloader tests, prove that the required + single-process runtime path works before adding any narrow test policy. +5. Rerun a fresh, unforced `jcpan install Catalyst::Runtime`, followed by the + `-MCatalyst` load gate. Record exact versions and results in this document. +6. Run `make`, push the unified PR, and wait for Ubuntu and Windows CI to pass. + Leave the PR unmerged so the user can run the isolated install and approve + it explicitly. + When a milestone is completed, update this paragraph to name the next active milestone and record its acceptance result, without adding a work diary. From 946d5c997c1d16b3aa3ef3b1f63e35b4e92c287d Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 4 Aug 2026 18:04:29 +0200 Subject: [PATCH 9/9] fix: create nested paths portably on Windows Use File::Spec to decompose and rebuild directory levels in File::Path so drive-letter and UNC paths create parents before their leaves. Add portable nested make_path regression coverage. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- src/main/perl/lib/File/Path.pm | 30 ++++++++++++------- .../unit/file_path_nested_make_path.t | 15 ++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 src/test/resources/unit/file_path_nested_make_path.t diff --git a/src/main/perl/lib/File/Path.pm b/src/main/perl/lib/File/Path.pm index 26fe1937a..e71cc8687 100644 --- a/src/main/perl/lib/File/Path.pm +++ b/src/main/perl/lib/File/Path.pm @@ -3,6 +3,7 @@ package File::Path; use strict; use warnings; use Carp; +use File::Spec (); our $VERSION = '2.18'; @@ -47,19 +48,28 @@ sub _make_path_perl { next unless defined $path && length $path; next if -d $path; - # Simple mkdir -p implementation - my @parts = split m{/}, $path; - my $current = ''; - my $is_absolute = ($path =~ m{^/}); + # Build each directory with File::Spec so drive-letter paths use the + # native Windows separator and their parent directories are created in + # order. Splitting only on '/' made make_path('C:\\...\\cpan\\prefs') + # try to create the complete leaf before C:\\...\\cpan existed. + my ($volume, $directory) = File::Spec->splitpath($path, 1); + if ($^O eq 'MSWin32' + && $path =~ m{\A([\\/]{2}[^\\/]+[\\/][^\\/]+)(.*)\z}) { + # PerlOnJava's File::Spec Java implementation handles drive + # volumes, while this preserves a UNC server/share as the volume. + ($volume, $directory) = ($1, $2); + } + my @parts = File::Spec->splitdir($directory); + my $current_dir = File::Spec->file_name_is_absolute($path) + ? File::Spec->rootdir + : ''; for my $part (@parts) { next unless length $part; - if ($current eq '' && !$is_absolute) { - # Relative path - start without leading / - $current = $part; - } else { - $current .= '/' . $part; - } + $current_dir = length($current_dir) + ? File::Spec->catdir($current_dir, $part) + : $part; + my $current = File::Spec->catpath($volume, $current_dir, ''); next if -d $current; diff --git a/src/test/resources/unit/file_path_nested_make_path.t b/src/test/resources/unit/file_path_nested_make_path.t new file mode 100644 index 000000000..1c6c5b10b --- /dev/null +++ b/src/test/resources/unit/file_path_nested_make_path.t @@ -0,0 +1,15 @@ +use strict; +use warnings; + +use File::Path qw(make_path); +use File::Spec; +use File::Temp qw(tempdir); +use Test::More tests => 3; + +my $root = tempdir(CLEANUP => 1); +my $nested = File::Spec->catdir($root, qw(alpha beta gamma)); +my @created = make_path($nested); + +ok(-d $nested, 'make_path creates every level of a nested native path'); +ok(@created >= 3, 'make_path reports the newly created directory levels'); +is(make_path($nested), 0, 'make_path is idempotent for an existing path');