Skip to content

Commit cd9bba5

Browse files
authored
Merge pull request #977 from fglock/fix/cpan-tooling-perl6-greple
Fix reusable CPAN compiler and process tooling
2 parents 499ae75 + 40a0aff commit cd9bba5

21 files changed

Lines changed: 562 additions & 11 deletions
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# CPAN compiler/tooling batch: Perl::ToPerl6 through Devel::SlowBless
2+
3+
## Goal
4+
5+
Classify and repair the reusable compiler, regex, runtime, and CPAN-tooling
6+
defects exposed by `jcpan -t` for Perl::ToPerl6,
7+
Game::HeroesVsAliens::Alien, App::Greple::update, GCJ::Cni,
8+
Data::Semantic, Text::HTML::CollapseWhitespace, DBM::Deep::Blue, and
9+
Devel::SlowBless. Distributions that fail under the same local system Perl are
10+
out of scope, as requested.
11+
12+
## System Perl classification (2026-08-16)
13+
14+
| Target | Classification |
15+
|---|---|
16+
| Perl::ToPerl6 | Ignore: system Perl also fails `t/05_utils.t` test 136. |
17+
| Game::HeroesVsAliens::Alien | Ignore: system Perl cannot build Alien::SDL/SDL; the native dependency download/build fails. |
18+
| App::Greple::update | In scope: system Perl passes 2 files / 4 tests. |
19+
| GCJ::Cni | Ignore: system Perl cannot find the obsolete `gcj/cni.h` toolchain. |
20+
| Data::Semantic | In scope: system Perl passes 15 files / 16 tests. |
21+
| Text::HTML::CollapseWhitespace | Ignore: its Text-HTML-Turndown distribution also fails under system Perl (`t/01-turndown.t` test 46). |
22+
| DBM::Deep::Blue | Ignore: its native source includes unavailable `malloc.h` and fails compilation on this host. |
23+
| Devel::SlowBless | In scope: system Perl passes 1 file / 4 tests. |
24+
25+
Full baseline output is captured in `/tmp/jcpan_*_baseline.log` and
26+
`/tmp/system_perl_*` logs.
27+
28+
## Progress Tracking
29+
30+
### Current Status: Complete (2026-08-16)
31+
32+
### Completed Phases
33+
34+
- [x] Phase 1: Baseline and system-Perl classification (2026-08-16)
35+
- Ran every requested `jcpan -t` target sequentially with hard timeouts.
36+
- Installed missing system-Perl prerequisites into an isolated `/tmp` tree
37+
and tested the exact unpacked distributions.
38+
- [x] Phase 2: Root-cause reduction (2026-08-16)
39+
- App::Greple::update requires named-capture regex conditionals, which the
40+
vendored Joni engine supports but the backend router did not select.
41+
- Data::Semantic exposes a lazy generated-sub VerifyError after module
42+
registration side effects; file-level interpreter retry then duplicates
43+
those registrations.
44+
- Devel::SlowBless needs its two XS generation-counter functions backed by
45+
runtime-owned Java state.
46+
- [x] Phase 3: Implementation (2026-08-16)
47+
- Routed named-capture conditionals to Joni.
48+
- Translated Perl brace-form named backreferences for Joni and covered
49+
recursive named patterns.
50+
- Added eager lazy-sub verification so only the invalid sub falls back.
51+
- Added the Devel::SlowBless Java XS bridge and runtime sub-generation
52+
counter.
53+
- Corrected string typeglob aliases and ampersand calls inside
54+
`delete`/`exists` subscripts.
55+
- Implemented registered-child handling for `wait`/`waitpid`, made pipe
56+
output pumps drain before close returns, and isolated replay-process STDIN
57+
until the fork point. These fixes make Perl's `open HANDLE, '|-'` filter
58+
pattern reliable without distribution-specific changes.
59+
- Added system-Perl-validated regression tests.
60+
- [x] Phase 4: End-to-end validation (2026-08-16)
61+
- `App::Greple::update`: 2 files / 4 tests, PASS.
62+
- `Data::Semantic`: 15 files / 16 tests, PASS.
63+
- `Devel::SlowBless`: bundled upstream suite, 1 file / 4 tests, PASS.
64+
- All new unit tests pass under system Perl and both PerlOnJava backends.
65+
- Full `make` completed successfully.
66+
67+
### Files Changed
68+
69+
- Compiler/backend: `EmitSubroutine.java`, `ParseInfix.java`.
70+
- Regex: `JoniRegexPattern.java`.
71+
- Process and IO runtime: `PerlRuntime.java`, `RuntimeIO.java`,
72+
`PipeOutputChannel.java`, `WaitpidOperator.java`.
73+
- Runtime semantics: `RuntimeGlob.java`, `MroRuntimeState.java`, `Mro.java`.
74+
- Java module bridge: `DevelSlowBless.java`, `Devel/SlowBless.pm`.
75+
- Regression coverage: eight focused tests in `src/test/resources/unit`.
76+
77+
### Next Steps
78+
79+
1. Merge after CI and review.
80+
81+
### Open Questions
82+
83+
- None. Native distributions excluded by the system-Perl rule remain
84+
documented rather than hidden behind distribution preferences.
85+
86+
## Related References
87+
88+
- `.agents/skills/debug-perlonjava/SKILL.md`
89+
- `docs/guides/module-porting.md`

src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import org.perlonjava.runtime.runtimetypes.NameNormalizer;
1515
import org.perlonjava.runtime.runtimetypes.GlobalVariable;
1616
import org.perlonjava.runtime.runtimetypes.PerlCompilerException;
17+
import org.perlonjava.runtime.runtimetypes.RuntimeArray;
1718
import org.perlonjava.runtime.runtimetypes.RuntimeBase;
1819
import org.perlonjava.runtime.runtimetypes.RuntimeCode;
1920
import org.perlonjava.runtime.runtimetypes.RuntimeContextType;
@@ -262,6 +263,22 @@ public static void emitSubroutine(EmitterContext ctx, SubroutineNode node) {
262263
EmitterMethodCreator.createClassWithMethod(
263264
subCtx, node.block, node.useTryCatch
264265
);
266+
try {
267+
// HotSpot can defer verification of a generated lazy sub until
268+
// it is first invoked. If that happens after the enclosing file
269+
// body has made writes, a file-level retry replays those writes.
270+
// Resolve apply() now so only the invalid sub falls back.
271+
generatedClass.getDeclaredMethod(
272+
"apply", RuntimeArray.class, int.class);
273+
} catch (VerifyError | ClassFormatError verificationFailure) {
274+
InterpretedCode interpreted = EmitterMethodCreator.compileToInterpreter(
275+
node.block, subCtx, node.useTryCatch);
276+
throw new InterpreterFallbackException(interpreted, newEnv);
277+
} catch (NoSuchMethodException reflectionFailure) {
278+
throw new PerlCompilerException(
279+
"Failed to resolve generated subroutine: "
280+
+ reflectionFailure.getMessage());
281+
}
265282
String newClassNameDot = subCtx.javaClassInfo.javaClassName.replace('/', '.');
266283
if (CompilerOptions.DEBUG_ENABLED) ctx.logDebug("Generated class name: " + newClassNameDot + " internal " + subCtx.javaClassInfo.javaClassName);
267284
if (CompilerOptions.DEBUG_ENABLED) ctx.logDebug("Generated class env: " + Arrays.toString(newEnv));

src/main/java/org/perlonjava/frontend/parser/ParseInfix.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,17 @@ private static List<Node> parseArraySubscript(Parser parser) {
522522
// backtrack
523523
parser.tokenIndex = currentIndex;
524524

525-
return ListParser.parseList(parser, "]", 1);
525+
// A subscript is an ordinary expression context even when its outer
526+
// operator is delete/exists/defined (or reference-taking syntax).
527+
// In particular, delete $array[&index] must invoke &index with the
528+
// caller's @_, not turn it into a CODE value.
529+
boolean savedParsingTakeReference = parser.parsingTakeReference;
530+
parser.parsingTakeReference = false;
531+
try {
532+
return ListParser.parseList(parser, "]", 1);
533+
} finally {
534+
parser.parsingTakeReference = savedParsingTakeReference;
535+
}
526536

527537
}
528538

@@ -573,7 +583,17 @@ static List<Node> parseHashSubscript(Parser parser) {
573583
// backtrack
574584
parser.tokenIndex = currentIndex;
575585

576-
return ListParser.parseList(parser, "}", 1);
586+
// The reference-taking mode used while parsing delete/exists applies
587+
// to their direct operand, not to expressions inside a subscript.
588+
// Getopt::EX relies on delete $hash{&CONSTANT_SUB} calling the sub and
589+
// using its return value as the key.
590+
boolean savedParsingTakeReference = parser.parsingTakeReference;
591+
parser.parsingTakeReference = false;
592+
try {
593+
return ListParser.parseList(parser, "}", 1);
594+
} finally {
595+
parser.parsingTakeReference = savedParsingTakeReference;
596+
}
577597
}
578598

579599
/**

src/main/java/org/perlonjava/runtime/io/PipeOutputChannel.java

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ public ThreadInheritancePolicy threadInheritancePolicy() {
8181
*/
8282
private BufferedReader outputReader;
8383

84+
/** Pumps must finish before close() returns or callers can restore STDOUT
85+
* and lose the final child output. */
86+
private Thread outputThread;
87+
private Thread errorThread;
88+
private RuntimeIO outputDestination;
89+
private RuntimeIO errorDestination;
90+
8491
/**
8592
* Tracks whether the pipe has been closed
8693
*/
@@ -180,6 +187,15 @@ private void setupProcess(ProcessBuilder processBuilder) throws IOException {
180187

181188
private void setupProcess(ProcessBuilder processBuilder, Map<String, String> environmentOverrides)
182189
throws IOException {
190+
// Capture the destinations before IOOperator installs this pipe into
191+
// the target glob. For `open STDOUT, '|-'`, looking up STDOUT from the
192+
// pump thread later would find this same pipe and feed child output
193+
// back into its stdin instead of forwarding it to the saved stdout.
194+
outputDestination = borrowedDestination(
195+
GlobalVariable.getGlobalIO("main::STDOUT").getRuntimeIO());
196+
errorDestination = borrowedDestination(
197+
GlobalVariable.getGlobalIO("main::STDERR").getRuntimeIO());
198+
183199
// Set working directory to current directory
184200
String userDir = org.perlonjava.runtime.runtimetypes.RuntimeEnvironment.currentDirectory();
185201
processBuilder.directory(new File(userDir));
@@ -201,13 +217,13 @@ private void setupProcess(ProcessBuilder processBuilder, Map<String, String> env
201217
// Start threads to consume stdout and stderr and route through Perl handles
202218
// This ensures Perl-level redirections are honored
203219
PerlRuntime runtime = PerlRuntime.current();
204-
Thread outputThread = new Thread(() -> {
220+
outputThread = new Thread(() -> {
205221
try (PerlRuntime.Binding ignored = runtime.bind();
206222
BufferedReader out = outputReader) {
207223
String line;
208224
while ((line = out.readLine()) != null) {
209225
try {
210-
RuntimeIO perlStdout = GlobalVariable.getGlobalIO("main::STDOUT").getRuntimeIO();
226+
RuntimeIO perlStdout = outputDestination;
211227
if (perlStdout != null) {
212228
perlStdout.write(line + "\n");
213229
} else {
@@ -224,13 +240,13 @@ private void setupProcess(ProcessBuilder processBuilder, Map<String, String> env
224240
outputThread.setDaemon(true);
225241
outputThread.start();
226242

227-
Thread errorThread = new Thread(() -> {
243+
errorThread = new Thread(() -> {
228244
try (PerlRuntime.Binding ignored = runtime.bind();
229245
BufferedReader err = errorReader) {
230246
String line;
231247
while ((line = err.readLine()) != null) {
232248
try {
233-
RuntimeIO perlStderr = GlobalVariable.getGlobalIO("main::STDERR").getRuntimeIO();
249+
RuntimeIO perlStderr = errorDestination;
234250
if (perlStderr != null) {
235251
perlStderr.write(line + "\n");
236252
} else {
@@ -248,6 +264,13 @@ private void setupProcess(ProcessBuilder processBuilder, Map<String, String> env
248264
errorThread.start();
249265
}
250266

267+
private static RuntimeIO borrowedDestination(RuntimeIO destination) {
268+
if (destination == null) return null;
269+
RuntimeIO borrowed = new RuntimeIO(destination.ioHandle);
270+
borrowed.globName = destination.globName;
271+
return borrowed;
272+
}
273+
251274
/**
252275
* Writes a string to the process stdin.
253276
*
@@ -334,6 +357,8 @@ public RuntimeScalar close() {
334357
exitCode = -1;
335358
}
336359
}
360+
joinPump(outputThread);
361+
joinPump(errorThread);
337362
getGlobalVariable("main::?").set(exitCode << 8);
338363

339364
isClosed = true;
@@ -343,6 +368,15 @@ public RuntimeScalar close() {
343368
}
344369
}
345370

371+
private static void joinPump(Thread pump) {
372+
if (pump == null || pump == Thread.currentThread()) return;
373+
try {
374+
pump.join();
375+
} catch (InterruptedException e) {
376+
Thread.currentThread().interrupt();
377+
}
378+
}
379+
346380
/**
347381
* EOF for output pipes - always true since you can't read from write-only pipes.
348382
*

src/main/java/org/perlonjava/runtime/mro/MroRuntimeState.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public final class MroRuntimeState {
3131
private InheritanceResolver.MROAlgorithm defaultMro = InheritanceResolver.MROAlgorithm.DFS;
3232
private boolean autoloadEnabled = true;
3333
private long isaGeneration;
34+
private long subGeneration = 1;
3435
private long isaRevGeneration = -1;
3536
private long observedSymbolMutationEpoch;
3637
private long observedIsaMutationEpoch;
@@ -87,6 +88,14 @@ public long isaGeneration() {
8788
return isaGeneration;
8889
}
8990

91+
public long subGeneration() {
92+
return subGeneration;
93+
}
94+
95+
public void incrementSubGeneration() {
96+
subGeneration++;
97+
}
98+
9099
public long isaRevGeneration() {
91100
return isaRevGeneration;
92101
}

src/main/java/org/perlonjava/runtime/operators/WaitpidOperator.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ private static RuntimeScalar waitpidPosix(int pid, int flags) {
6767
if (javaProcess != null) {
6868
return waitpidJavaProcess(pid, javaProcess, flags);
6969
}
70+
} else {
71+
RuntimeScalar javaChildResult = waitForAnyJavaProcess(flags);
72+
if (javaChildResult != null) {
73+
return javaChildResult;
74+
}
7075
}
7176
try {
7277
int[] status = new int[1];
@@ -90,6 +95,38 @@ private static RuntimeScalar waitpidPosix(int pid, int flags) {
9095
}
9196
}
9297

98+
/**
99+
* Wait for a child created by pipe open or another Java ProcessBuilder path.
100+
* On POSIX the JDK has its own native reaper, so calling libc waitpid(-1)
101+
* after reading a pipe can report ECHILD even though PerlOnJava still owns
102+
* an unreaped logical child. Prefer the registered Process objects before
103+
* falling through to native children.
104+
*/
105+
private static RuntimeScalar waitForAnyJavaProcess(int flags) {
106+
Map<Long, Process> children = RuntimeIO.childProcessesSnapshot();
107+
if (children.isEmpty()) return null;
108+
109+
boolean nonBlocking = (flags & WNOHANG) != 0;
110+
for (Map.Entry<Long, Process> entry : children.entrySet()) {
111+
if (!entry.getValue().isAlive()) {
112+
return waitpidJavaProcess(entry.getKey().intValue(), entry.getValue(), flags);
113+
}
114+
}
115+
if (nonBlocking) return new RuntimeScalar(0);
116+
117+
java.util.concurrent.CompletableFuture<?>[] exits = children.values().stream()
118+
.map(Process::onExit)
119+
.toArray(java.util.concurrent.CompletableFuture[]::new);
120+
java.util.concurrent.CompletableFuture.anyOf(exits).join();
121+
122+
for (Map.Entry<Long, Process> entry : RuntimeIO.childProcessesSnapshot().entrySet()) {
123+
if (!entry.getValue().isAlive()) {
124+
return waitpidJavaProcess(entry.getKey().intValue(), entry.getValue(), flags);
125+
}
126+
}
127+
return new RuntimeScalar(-1);
128+
}
129+
93130
private static RuntimeScalar waitpidJavaProcess(int pid, Process process, int flags) {
94131
boolean nonBlocking = (flags & WNOHANG) != 0;
95132
boolean chldIgnore = isChldIgnored();
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package org.perlonjava.runtime.perlmodule;
2+
3+
import org.perlonjava.runtime.runtimetypes.PerlRuntime;
4+
import org.perlonjava.runtime.runtimetypes.RuntimeArray;
5+
import org.perlonjava.runtime.runtimetypes.RuntimeList;
6+
import org.perlonjava.runtime.runtimetypes.RuntimeScalar;
7+
8+
/** Java XS implementation of Devel::SlowBless's generation counters. */
9+
public class DevelSlowBless extends PerlModuleBase {
10+
11+
public static final String XS_VERSION = "0.06";
12+
13+
public DevelSlowBless() {
14+
super("Devel::SlowBless", false);
15+
}
16+
17+
public static void initialize() {
18+
DevelSlowBless module = new DevelSlowBless();
19+
try {
20+
module.registerMethod("sub_gen", null);
21+
module.registerMethod("amg_gen", null);
22+
} catch (NoSuchMethodException e) {
23+
throw new RuntimeException(e);
24+
}
25+
}
26+
27+
public static RuntimeList sub_gen(RuntimeArray args, int ctx) {
28+
return new RuntimeScalar(PerlRuntime.current().mroState().subGeneration()).getList();
29+
}
30+
31+
public static RuntimeList amg_gen(RuntimeArray args, int ctx) {
32+
// PL_amagic_generation was removed from Perl in 5.17.1. The XS
33+
// distribution returns zero on all modern Perls.
34+
return new RuntimeScalar(0).getList();
35+
}
36+
}

src/main/java/org/perlonjava/runtime/perlmodule/Mro.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,9 +443,10 @@ public static RuntimeList get_pkg_gen(RuntimeArray args, int ctx) {
443443
* @param packageName The name of the package.
444444
*/
445445
public static void incrementPackageGeneration(String packageName) {
446-
Map<String, Integer> packageGenerations =
447-
PerlRuntime.current().mroState().packageGenerations();
446+
var state = PerlRuntime.current().mroState();
447+
Map<String, Integer> packageGenerations = state.packageGenerations();
448448
Integer current = packageGenerations.getOrDefault(packageName, 1);
449449
packageGenerations.put(packageName, current + 1);
450+
state.incrementSubGeneration();
450451
}
451452
}

0 commit comments

Comments
 (0)