From 4d22734370b9103eeca70e238a25d4cf437381eb Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 10 Mar 2026 13:15:38 +0100 Subject: [PATCH] Fix: PerlLanguageProvider.executePerlCode terminates JVM on exit() Previously, when Perl code called exit(), it would call System.exit() and terminate the entire JVM. This prevented library/embedded use where the caller wants to continue execution after the script completes. Changes: - Add PerlExitException class for Perl's exit() semantics - WarnDie.exit() now throws PerlExitException instead of System.exit() - Main.main() catches PerlExitException and converts to System.exit() - exit() is never caught by Perl's eval{} (matching standard Perl) - Both JVM compiler and interpreter backends handle this correctly - END blocks still run before exit (matching standard Perl) Documentation: - Updated docs/guides/java-integration.md with "Handling Script Exit" section Fixes #291 Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin --- docs/guides/java-integration.md | 46 ++++++++ .../java/org/perlonjava/app/cli/Main.java | 4 + .../scriptengine/PerlLanguageProvider.java | 4 + .../backend/bytecode/BytecodeInterpreter.java | 9 +- .../perlonjava/runtime/operators/WarnDie.java | 26 +++-- .../runtime/perlmodule/JavaSystem.java | 10 +- .../runtimetypes/PerlExitException.java | 30 +++++ .../runtime/runtimetypes/RuntimeCode.java | 16 ++- .../org/perlonjava/PerlExitExceptionTest.java | 104 ++++++++++++++++++ 9 files changed, 230 insertions(+), 19 deletions(-) create mode 100644 src/main/java/org/perlonjava/runtime/runtimetypes/PerlExitException.java create mode 100644 src/test/java/org/perlonjava/PerlExitExceptionTest.java diff --git a/docs/guides/java-integration.md b/docs/guides/java-integration.md index 0ef22b205..5e589b2d3 100644 --- a/docs/guides/java-integration.md +++ b/docs/guides/java-integration.md @@ -77,6 +77,52 @@ try { } ``` +### Handling Script Exit + +When a Perl script calls `exit()`, PerlOnJava throws a `PerlExitException` instead of +terminating the JVM. This allows your Java application to handle script completion +gracefully and continue execution. + +Note: Like in standard Perl, `exit()` is not caught by Perl's `eval{}` blocks - it +always propagates to the Java caller. + +```java +import org.perlonjava.runtime.runtimetypes.PerlExitException; + +ScriptEngine engine = manager.getEngineByName("perl"); + +try { + engine.eval("print 'Processing...'; exit 0;"); +} catch (ScriptException e) { + if (e.getCause() instanceof PerlExitException exitEx) { + System.out.println("Script exited with code: " + exitEx.getExitCode()); + // Continue with other work... + } else { + throw e; + } +} +``` + +This is particularly important when running scripts like ExifTool that call `exit()` +after completing their work: + +```java +import org.perlonjava.app.cli.ArgumentParser; +import org.perlonjava.app.cli.CompilerOptions; +import org.perlonjava.app.scriptengine.PerlLanguageProvider; +import org.perlonjava.runtime.runtimetypes.PerlExitException; + +String[] scriptArgs = new String[]{"path/to/exiftool", "-ver"}; +CompilerOptions options = ArgumentParser.parseArguments(scriptArgs); + +try { + PerlLanguageProvider.executePerlCode(options, true); +} catch (PerlExitException e) { + System.out.println("ExifTool exited with code: " + e.getExitCode()); + // Script completed successfully, continue processing... +} +``` + ## Using PerlOnJava Directly For more control, you can use PerlOnJava's internal API directly. diff --git a/src/main/java/org/perlonjava/app/cli/Main.java b/src/main/java/org/perlonjava/app/cli/Main.java index 79da90829..9600e299f 100644 --- a/src/main/java/org/perlonjava/app/cli/Main.java +++ b/src/main/java/org/perlonjava/app/cli/Main.java @@ -3,6 +3,7 @@ import org.perlonjava.app.scriptengine.PerlLanguageProvider; import org.perlonjava.runtime.runtimetypes.ErrorMessageUtil; import org.perlonjava.runtime.runtimetypes.GlobalVariable; +import org.perlonjava.runtime.runtimetypes.PerlExitException; import org.perlonjava.runtime.runtimetypes.RuntimeScalar; import java.util.Locale; @@ -49,6 +50,9 @@ public static void main(String[] args) { if (rawChildStatus > 0 && rawChildStatus <= 255) { System.exit(rawChildStatus); } + } catch (PerlExitException e) { + // Perl's exit() throws PerlExitException - convert to real System.exit() for CLI + System.exit(e.getExitCode()); } catch (Throwable t) { if (parsedArgs.debugEnabled) { // Print full JVM stack diff --git a/src/main/java/org/perlonjava/app/scriptengine/PerlLanguageProvider.java b/src/main/java/org/perlonjava/app/scriptengine/PerlLanguageProvider.java index 37dc7d400..bd7119461 100644 --- a/src/main/java/org/perlonjava/app/scriptengine/PerlLanguageProvider.java +++ b/src/main/java/org/perlonjava/app/scriptengine/PerlLanguageProvider.java @@ -286,6 +286,10 @@ private static RuntimeList executeCode(RuntimeCode runtimeCode, EmitterContext c System.out.println(errorMessage); System.out.println("END failed--call queue aborted."); } + } catch (PerlExitException e) { + // PerlExitException already ran END blocks and closed handles in WarnDie.exit() + // Just re-throw for the caller to handle + throw e; } catch (Throwable t) { if (isMainProgram) { runEndBlocks(); diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java index 1ebfc94a2..ab0f7e853 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java @@ -1590,6 +1590,9 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c String javaLine = (st.length > 0) ? " [java:" + st[0].getFileName() + ":" + st[0].getLineNumber() + "]" : ""; String errorMessage = "ClassCastException" + bcContext + ": " + e.getMessage() + javaLine; throw new RuntimeException(formatInterpreterError(code, errorPc, new Exception(errorMessage)), e); + } catch (PerlExitException e) { + // exit() should NEVER be caught by eval{} - always propagate + throw e; } catch (Throwable e) { // Check if we're inside an eval block if (!evalCatchStack.isEmpty()) { @@ -1604,9 +1607,9 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c } // Not in eval block - propagate exception - // If it's already a PerlDieException, re-throw as-is for proper formatting - if (e instanceof PerlDieException) { - throw (PerlDieException) e; + // Re-throw RuntimeExceptions as-is (includes PerlDieException) + if (e instanceof RuntimeException re) { + throw re; } // Check if we're running inside an eval STRING context diff --git a/src/main/java/org/perlonjava/runtime/operators/WarnDie.java b/src/main/java/org/perlonjava/runtime/operators/WarnDie.java index 2f45ba7f5..026ff618c 100644 --- a/src/main/java/org/perlonjava/runtime/operators/WarnDie.java +++ b/src/main/java/org/perlonjava/runtime/operators/WarnDie.java @@ -34,10 +34,17 @@ private static Throwable unwrapException(Throwable throwable) { } /** - * Catches the exception in an eval-block + * Catches the exception in an eval-block. + * Note: PerlExitException should NEVER be caught by eval{} - it always propagates. */ public static RuntimeScalar catchEval(Throwable e) { e = unwrapException(e); + + // exit() should never be caught by eval{} - re-throw it + if (e instanceof PerlExitException) { + throw (PerlExitException) e; + } + RuntimeScalar err = getGlobalVariable("main::@"); if (e instanceof PerlDieException pde) { @@ -276,23 +283,28 @@ private static RuntimeBase dieEmptyMessage(RuntimeScalar oldErr, String fileName } /** - * Terminates the program + * Terminates the program by throwing PerlExitException. + *

+ * This allows embedded/library use where the calling Java application + * can catch the exception and continue execution. The CLI (Main.main()) + * catches this and converts it to a real System.exit() call. * * @param runtimeScalar with exit status - * @return nothing + * @return nothing (always throws) + * @throws PerlExitException always thrown with the exit code */ public static RuntimeScalar exit(RuntimeScalar runtimeScalar) { + int exitCode = runtimeScalar.getInt(); try { runEndBlocks(); - RuntimeIO.closeAllHandles(); } catch (Throwable t) { RuntimeIO.closeAllHandles(); String errorMessage = ErrorMessageUtil.stringifyException(t); System.err.println(errorMessage); - System.exit(1); + throw new PerlExitException(1); } - System.exit(runtimeScalar.getInt()); - return new RuntimeScalar(); // This line will never be reached + RuntimeIO.closeAllHandles(); + throw new PerlExitException(exitCode); } /** diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/JavaSystem.java b/src/main/java/org/perlonjava/runtime/perlmodule/JavaSystem.java index dd47b5862..91b780388 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/JavaSystem.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/JavaSystem.java @@ -167,13 +167,17 @@ public static RuntimeList gc(RuntimeArray args, int ctx) { } /** - * Exit the JVM + * Exit the JVM by throwing PerlExitException. + *

+ * This allows embedded/library use where the calling Java application + * can catch the exception and continue execution. * Usage: exit(0); + * + * @throws PerlExitException always thrown with the exit code */ public static RuntimeList exit(RuntimeArray args, int ctx) { int status = args.size() > 0 ? (int) args.get(0).getLong() : 0; - System.exit(status); - return scalarUndef.getList(); // Never reached + throw new PerlExitException(status); } /** diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlExitException.java b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlExitException.java new file mode 100644 index 000000000..0c7bd417e --- /dev/null +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlExitException.java @@ -0,0 +1,30 @@ +package org.perlonjava.runtime.runtimetypes; + +import java.io.Serial; + +/** + * Exception used to implement Perl's exit() semantics for embedded/library use. + *

+ * When Perl code calls exit(), this exception is thrown instead of calling + * System.exit(), allowing the calling Java application to handle the exit + * gracefully and continue execution. + *

+ * The CLI (Main.main()) catches this exception and converts it to a real + * System.exit() call, while library users can catch it and handle the exit + * code as needed. + */ +public class PerlExitException extends RuntimeException { + @Serial + private static final long serialVersionUID = 1L; + + private final int exitCode; + + public PerlExitException(int exitCode) { + super("exit " + exitCode); + this.exitCode = exitCode; + } + + public int getExitCode() { + return exitCode; + } +} diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index f20c8edbb..87c9d4ed4 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -1965,10 +1965,12 @@ public RuntimeList apply(RuntimeArray a, int callContext) { } } catch (InvocationTargetException e) { Throwable targetException = e.getTargetException(); - if (!(targetException instanceof RuntimeException)) { - throw new RuntimeException(targetException); + if (targetException instanceof RuntimeException re) { + throw re; } - throw (RuntimeException) targetException; + throw new RuntimeException(targetException); + } catch (RuntimeException e) { + throw e; } catch (Throwable e) { throw new RuntimeException(e); } @@ -2037,10 +2039,12 @@ public RuntimeList apply(String subroutineName, RuntimeArray a, int callContext) } } catch (InvocationTargetException e) { Throwable targetException = e.getTargetException(); - if (!(targetException instanceof RuntimeException)) { - throw new RuntimeException(targetException); + if (targetException instanceof RuntimeException re) { + throw re; } - throw (RuntimeException) targetException; + throw new RuntimeException(targetException); + } catch (RuntimeException e) { + throw e; } catch (Throwable e) { throw new RuntimeException(e); } diff --git a/src/test/java/org/perlonjava/PerlExitExceptionTest.java b/src/test/java/org/perlonjava/PerlExitExceptionTest.java new file mode 100644 index 000000000..0dd294e6d --- /dev/null +++ b/src/test/java/org/perlonjava/PerlExitExceptionTest.java @@ -0,0 +1,104 @@ +package org.perlonjava; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.perlonjava.app.cli.CompilerOptions; +import org.perlonjava.app.scriptengine.PerlLanguageProvider; +import org.perlonjava.runtime.runtimetypes.PerlExitException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test that PerlExitException is thrown when Perl code calls exit(), + * allowing library users to catch it and continue execution. + * + * This addresses GitHub issue #291 where executePerlCode() was + * terminating the JVM instead of returning. + */ +@Tag("unit") +public class PerlExitExceptionTest { + + @BeforeEach + void setUp() { + PerlLanguageProvider.resetAll(); + } + + @Test + void testExitZeroThrowsException() { + CompilerOptions options = new CompilerOptions(); + options.fileName = ""; + options.code = "exit 0;"; + + PerlExitException thrown = assertThrows(PerlExitException.class, () -> { + PerlLanguageProvider.executePerlCode(options, true); + }); + + assertEquals(0, thrown.getExitCode(), "exit 0 should have exit code 0"); + } + + @Test + void testExitNonZeroThrowsException() { + CompilerOptions options = new CompilerOptions(); + options.fileName = ""; + options.code = "exit 42;"; + + PerlExitException thrown = assertThrows(PerlExitException.class, () -> { + PerlLanguageProvider.executePerlCode(options, true); + }); + + assertEquals(42, thrown.getExitCode(), "exit 42 should have exit code 42"); + } + + @Test + void testExitAfterOutputThrowsException() { + CompilerOptions options = new CompilerOptions(); + options.fileName = ""; + options.code = "print 'hello'; exit 0;"; + + PerlExitException thrown = assertThrows(PerlExitException.class, () -> { + PerlLanguageProvider.executePerlCode(options, true); + }); + + assertEquals(0, thrown.getExitCode()); + } + + @Test + void testScriptWithoutExitReturnsNormally() throws Exception { + CompilerOptions options = new CompilerOptions(); + options.fileName = ""; + options.code = "my $x = 1 + 1;"; + + // Should not throw - script completes without calling exit() + assertDoesNotThrow(() -> { + PerlLanguageProvider.executePerlCode(options, true); + }); + } + + @Test + void testExceptionMessage() { + CompilerOptions options = new CompilerOptions(); + options.fileName = ""; + options.code = "exit 123;"; + + PerlExitException thrown = assertThrows(PerlExitException.class, () -> { + PerlLanguageProvider.executePerlCode(options, true); + }); + + assertEquals("exit 123", thrown.getMessage()); + } + + @Test + void testExitInsideEvalNotCaught() { + // In Perl, exit() should NOT be caught by eval{} - it should always exit + CompilerOptions options = new CompilerOptions(); + options.fileName = ""; + options.code = "eval { exit 99; }; print 'should not reach here';"; + + PerlExitException thrown = assertThrows(PerlExitException.class, () -> { + PerlLanguageProvider.executePerlCode(options, true); + }); + + assertEquals(99, thrown.getExitCode(), "exit inside eval should still throw PerlExitException"); + } +}