Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ module.exports = {
files: ["**/*.d.ts"],
parser: "@typescript-eslint/parser",
rules: {
"getter-return": "off"
"getter-return": "off",
"no-undef": "off"
}
}
],
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br/>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.<br/>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

Expand Down
14 changes: 14 additions & 0 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
41 changes: 38 additions & 3 deletions lib/java-caller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand All @@ -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;
Expand All @@ -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(" ")}`);
Expand All @@ -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);
}
Comment on lines 126 to 137
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation creates both a fallback setTimeout timer and passes timeout to the spawn options, which means Node.js's built-in timeout mechanism will also be active. This could result in redundant timeout handling. The built-in spawn timeout should be sufficient in most cases. Consider removing the custom setTimeout implementation (lines 147-158) since spawn already handles timeout internally, or document why both mechanisms are necessary.

Copilot uses AI. Check for mistakes.

// Gather stdout and stderr if they must be returned
if (spawnOptions.stdio === "pipe") {
child.stdout.setEncoding(`${runOptions.stdoutEncoding}`);
Expand All @@ -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.`;
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message uses template literal syntax but doesn't include a newline or proper formatting. When concatenating to existing stderr content, this could result in the timeout message being appended directly to previous error output without separation. Consider adding a newline at the beginning of the message for better readability, similar to how other error messages are formatted in this codebase.

Suggested change
stderr += `Process timed out with ${runOptions.killSignal} after ${runOptions.timeout}ms.`;
stderr += `\nProcess timed out with ${runOptions.killSignal} after ${runOptions.timeout}ms.`;

Copilot uses AI. Check for mistakes.
} else {
this.status = code;
}

resolve();
});

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -78,4 +78,4 @@
],
"all": true
}
}
}
50 changes: 48 additions & 2 deletions test/java-caller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
Binary file modified test/java/dist/com/nvuillam/javacaller/JavaCallerTester.class
Binary file not shown.
Binary file modified test/java/jar/JavaCallerTester.jar
Binary file not shown.
Binary file modified test/java/jar/JavaCallerTesterRunnable.jar
Binary file not shown.
6 changes: 3 additions & 3 deletions test/java/src/com/nvuillam/javacaller/JavaCallerTester.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading