diff --git a/.eslintrc.js b/.eslintrc.js index f7f5ce4..ed5bf82 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,7 +34,8 @@ module.exports = { files: ["**/*.d.ts"], parser: "@typescript-eslint/parser", rules: { - "getter-return": "off" + "getter-return": "off", + "no-undef": "off" } } ], diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e29773..d215aa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add `timeout` and `killSignal` run options + ## [4.3.3] 2025-02-03 - Add types definition to make library compliant with typescript usage diff --git a/README.md b/README.md index 261e383..9aa8c8c 100644 --- a/README.md +++ b/README.md @@ -65,13 +65,15 @@ Example: `["-Xms256m", "--someflagwithvalue myVal", "-c"]` | Parameter | Description | Default | Example | |-----------|-------------|---------|---------| -| [detached](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options) | If set to true, node will node wait for the java command to be completed.
In that case, `childJavaProcess` property will be returned, but `stdout` and `stderr` may be empty, except if an error is triggered at command execution | `false` | `true` +| [detached](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options) | If set to true, node will not wait for the java command to be completed.
In that case, `childJavaProcess` property will be returned, but `stdout` and `stderr` may be empty, except if an error is triggered at command execution | `false` | `true` | [stdoutEncoding](https://nodejs.org/api/stream.html#readablesetencodingencoding) | Adds control on spawn process stdout | `utf8` | `ucs2` | | waitForErrorMs | If detached is true, number of milliseconds to wait to detect an error before exiting JavaCaller run | `500` | `2000` | | [cwd](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options) | You can override cwd of spawn called by JavaCaller runner | `process.cwd()` | `some/other/cwd/folder` | | javaArgs | List of arguments for JVM only, not the JAR or the class | `[]` | `['--add-opens=java.base/java.lang=ALL-UNNAMED']` | | [windowsVerbatimArguments](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options) | No quoting or escaping of arguments is done on Windows. Ignored on Unix. This is set to true automatically when shell is specified and is CMD. | `true` | `false` | | [windowless](https://docs.oracle.com/en/java/javase/17/docs/specs/man/java.html#:~:text=main()%20method.-,javaw,information%20if%20a%20launch%20fails.) | If windowless is true, JavaCaller calls javaw instead of java to not create any windows, useful when using detached on Windows. Ignored on Unix. | false | true +| [timeout](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options) | In milliseconds the maximum amount of time the process is allowed to run. | `undefined` | `1000` +| [killSignal](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options) | The signal value to be used when the spawned process will be killed by timeout or abort signal. | `SIGTERM` | `SIGINT` ## Examples diff --git a/lib/index.d.ts b/lib/index.d.ts index a007ab4..5d7714c 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -117,6 +117,20 @@ export interface JavaCallerRunOptions { * @default false */ windowless?: boolean; + + /** + * The number of milliseconds to wait before the Java process will time out. When this occurs, + * killSignal will ben + * @default undefined + */ + timeout?: number; + + /** + * If windowless is true, JavaCaller calls javaw instead of java to not create any windows, + * useful when using detached on Windows. Ignored on Unix. + * @default "SIGTERM" + */ + killSignal?: number | NodeJS.Signals; } /** diff --git a/lib/java-caller.js b/lib/java-caller.js index b15818e..1545ee6 100644 --- a/lib/java-caller.js +++ b/lib/java-caller.js @@ -69,12 +69,14 @@ class JavaCaller { * Runs java command of a JavaCaller instance * @param {string[]} [userArguments] - Java command line arguments * @param {object} [runOptions] - Run options - * @param {boolean} [runOptions.detached = false] - If set to true, node will node wait for the java command to be completed. In that case, childJavaProcess property will be returned, but stdout and stderr may be empty + * @param {boolean} [runOptions.detached = false] - If set to true, node will not wait for the java command to be completed. In that case, childJavaProcess property will be returned, but stdout and stderr may be empty * @param {string} [runOptions.stdoutEncoding = 'utf8'] - Adds control on spawn process stdout * @param {number} [runOptions.waitForErrorMs = 500] - If detached is true, number of milliseconds to wait to detect an error before exiting JavaCaller run * @param {string} [runOptions.cwd = .] - You can override cwd of spawn called by JavaCaller runner * @param {string} [runOptions.javaArgs = []] - You can override cwd of spawn called by JavaCaller runner * @param {string} [runOptions.windowsVerbatimArguments = true] - No quoting or escaping of arguments is done on Windows. Ignored on Unix. This is set to true automatically when shell is specified and is CMD. + * @param {number} [runOptions.timeout] - In milliseconds the maximum amount of time the process is allowed to run + * @param {NodeJS.Signals | number} [runOptions.killSignal = "SIGTERM"] - The signal value to be used when the spawned process will be killed by timeout or abort signal. * @return {Promise<{status:number, stdout:string, stderr:string, childJavaProcess:ChildProcess}>} - Command result (status, stdout, stderr, childJavaProcess) */ async run(userArguments, runOptions = {}) { @@ -84,6 +86,7 @@ class JavaCaller { runOptions.stdoutEncoding = typeof runOptions.stdoutEncoding === "undefined" ? "utf8" : runOptions.stdoutEncoding; runOptions.windowsVerbatimArguments = typeof runOptions.windowsVerbatimArguments === "undefined" ? true : runOptions.windowsVerbatimArguments; runOptions.windowless = typeof runOptions.windowless === "undefined" ? false : os.platform() !== "win32" ? false : runOptions.windowless; + runOptions.killSignal = typeof runOptions.killSignal === "undefined" ? "SIGTERM" : runOptions.killSignal; this.commandJavaArgs = (runOptions.javaArgs || []).concat(this.additionalJavaArgs); let javaExe = runOptions.windowless ? this.javaExecutableWindowless : this.javaExecutable; @@ -97,10 +100,13 @@ class JavaCaller { const javaExeToUse = this.javaExecutableFromNodeJavaCaller ?? javaExe; const classPathStr = this.buildClasspathStr(); - const javaArgs = this.buildArguments(classPathStr, (userArguments || []).concat(this.commandJavaArgs)); + const javaArgs = this.buildArguments(classPathStr, (userArguments || []).concat(this.commandJavaArgs), runOptions.windowsVerbatimArguments); let stdout = ""; let stderr = ""; let child; + let timeoutId; + let killedByTimeout = false; + const prom = new Promise((resolve) => { // Spawn java command line debug(`Java command: ${javaExeToUse} ${javaArgs.join(" ")}`); @@ -117,6 +123,19 @@ class JavaCaller { } child = spawn(javaExeToUse, javaArgs, spawnOptions); + if (runOptions.timeout) { + timeoutId = setTimeout(() => { + if (!child.killed) { + try { + child.kill(runOptions.killSignal); + killedByTimeout = true; + } catch (err) { + stderr += `Failed to kill process after ${runOptions.timeout}ms: ${err.message}`; + } + } + }, runOptions.timeout); + } + // Gather stdout and stderr if they must be returned if (spawnOptions.stdio === "pipe") { child.stdout.setEncoding(`${runOptions.stdoutEncoding}`); @@ -132,12 +151,28 @@ class JavaCaller { child.on("error", (data) => { this.status = 666; stderr += "Java spawn error: " + data; + + if (timeoutId) { + clearTimeout(timeoutId); + } + resolve(); }); // Catch status code child.on("close", (code) => { - this.status = code; + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (killedByTimeout) { + // Process was terminated because of the timeout + this.status = 666; + stderr += `Process timed out with ${runOptions.killSignal} after ${runOptions.timeout}ms.`; + } else { + this.status = code; + } + resolve(); }); diff --git a/package.json b/package.json index 549880d..8045c6c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ ], "scripts": { "lint:fix": "eslint **/*.js --fix && prettier --write \"./lib/**/*.{js,jsx,json}\" --tab-width 4 --print-width 150", - "java:compile": "javac -d test/java/dist -source 8 -target 1.8 test/java/src/com/nvuillam/javacaller/JavaCallerTester.java", + "java:compile": "javac -d test/java/dist --release 8 test/java/src/com/nvuillam/javacaller/JavaCallerTester.java", "java:jar": "cd test/java/dist && jar -cvfm ./../jar/JavaCallerTester.jar ./../jar/manifest/Manifest.txt com/nvuillam/javacaller/*.class && jar -cvfm ./../jar/JavaCallerTesterRunnable.jar ./../jar/manifest-runnable/Manifest.txt com/nvuillam/javacaller/*.class", "test": "mocha \"test/**/*.test.js\"", "test:coverage": "nyc npm run test", @@ -78,4 +78,4 @@ ], "all": true } -} +} \ No newline at end of file diff --git a/test/java-caller.test.js b/test/java-caller.test.js index 2fafb2f..a1229a0 100644 --- a/test/java-caller.test.js +++ b/test/java-caller.test.js @@ -32,9 +32,21 @@ describe("Call with classes", () => { classPath: 'test/java/dist', mainClass: 'com.nvuillam.javacaller.JavaCallerTester' }); + + // JavaCallerTester will sleep for 1000 ms + // After waitForErrorMs (500 ms), the promise will return const { status, stdout, stderr, childJavaProcess } = await java.run(['--sleep'], { detached: true }); - childJavaProcess.kill('SIGINT'); - checkStatus(0, status, stdout, stderr); + + // Java process is still running + checkStatus(null, status, stdout, stderr); + + return new Promise(resolve => { + // Java process has finished executing and the exit code can be read + childJavaProcess.on('exit', () => { + checkStatus(0, childJavaProcess.exitCode, stdout, stderr); + resolve(); + }); + }); }); it("should call JavaCallerTester.class using javaw", async () => { @@ -187,4 +199,38 @@ describe("Call with classes", () => { checkStatus(0, status, stdout, stderr); checkStdOutIncludes(`JavaCallerTester is called !`, stdout, stderr); }); + + it("should terminate once timeout is reached", async () => { + const java = new JavaCaller({ + classPath: 'test/java/dist', + mainClass: 'com.nvuillam.javacaller.JavaCallerTester' + }); + const { status, stdout, stderr } = await java.run(["--sleep"], { timeout: 500 }); + + checkStatus(666, status, stdout, stderr); + checkStdErrIncludes(`timed out`, stdout, stderr); + }); + + it("should not terminate if process finished before timeout is reached", async () => { + const java = new JavaCaller({ + classPath: 'test/java/dist', + mainClass: 'com.nvuillam.javacaller.JavaCallerTester' + }); + const { status, stdout, stderr } = await java.run(["--sleep"], { timeout: 1500 }); + + checkStatus(0, status, stdout, stderr); + checkStdOutIncludes(`JavaCallerTester is called !`, stdout, stderr); + }); + + it("should terminate with custom killSignal when timeout is reached", async () => { + const java = new JavaCaller({ + classPath: 'test/java/dist', + mainClass: 'com.nvuillam.javacaller.JavaCallerTester' + }); + const { status, stdout, stderr } = await java.run(["--sleep"], { timeout: 500, killSignal: "SIGINT" }); + + checkStatus(666, status, stdout, stderr); + checkStdErrIncludes(`timed out`, stdout, stderr); + checkStdErrIncludes(`SIGINT`, stdout, stderr); + }); }); diff --git a/test/java/dist/com/nvuillam/javacaller/JavaCallerTester.class b/test/java/dist/com/nvuillam/javacaller/JavaCallerTester.class index 0f905f7..ea7e8ac 100644 Binary files a/test/java/dist/com/nvuillam/javacaller/JavaCallerTester.class and b/test/java/dist/com/nvuillam/javacaller/JavaCallerTester.class differ diff --git a/test/java/jar/JavaCallerTester.jar b/test/java/jar/JavaCallerTester.jar index 8482fad..66abda7 100644 Binary files a/test/java/jar/JavaCallerTester.jar and b/test/java/jar/JavaCallerTester.jar differ diff --git a/test/java/jar/JavaCallerTesterRunnable.jar b/test/java/jar/JavaCallerTesterRunnable.jar index b2d6277..e12ebf2 100644 Binary files a/test/java/jar/JavaCallerTesterRunnable.jar and b/test/java/jar/JavaCallerTesterRunnable.jar differ diff --git a/test/java/src/com/nvuillam/javacaller/JavaCallerTester.java b/test/java/src/com/nvuillam/javacaller/JavaCallerTester.java index 4ca851d..66a4b30 100644 --- a/test/java/src/com/nvuillam/javacaller/JavaCallerTester.java +++ b/test/java/src/com/nvuillam/javacaller/JavaCallerTester.java @@ -9,16 +9,16 @@ public static void main(String[] args) { System.out.println("JavaCallerTester is called !"); System.out.println(java.util.Arrays.toString(args)); - if (args.length > 0 && args[0] != null && args[0] == "--sleep") { + if (args.length > 0 && args[0] != null && args[0].equals("--sleep")) { try { - TimeUnit.MINUTES.sleep(1); + TimeUnit.MILLISECONDS.sleep(1000); } catch (InterruptedException eInterrupt) { System.err.println("JavaCallerTester interrupted !"); } catch (Throwable t) { System.err.println("JavaCallerTester crashed !"); } } - System.out.println("Java runtime version "+getVersion()); + System.out.println("Java runtime version " + getVersion()); } private static int getVersion() {