Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
59 changes: 55 additions & 4 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,32 @@ 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 wasKilledByTimeout = (code, signal) => {
if (!runOptions.timeout) {
return false;
}
if (killedByTimeout) {
return true;
}
if (signal && signal === runOptions.killSignal) {
return true;
}
const signals = os.constants && os.constants.signals ? os.constants.signals : {};
if (typeof runOptions.killSignal === "string" && signals[runOptions.killSignal] && code === 128 + signals[runOptions.killSignal]) {
return true;
}
if (typeof runOptions.killSignal === "number" && code === 128 + runOptions.killSignal) {
return true;
}
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 logic for detecting timeout based on exit code (128 + signal number) is Unix-specific and may not work correctly on Windows. On Windows, processes terminated by signals typically exit with different status codes. Consider testing this behavior on Windows and handling platform-specific differences, or rely solely on the killedByTimeout flag and signal parameter for detection.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback

return false;
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 wasKilledByTimeout function may incorrectly identify a process as timed out if it naturally exits with a signal that matches killSignal without actually being killed by the timeout. For example, if a Java process catches and handles SIGTERM gracefully and exits with that signal code, it would be incorrectly identified as timed out. Consider only relying on the killedByTimeout flag when it's set, and for other cases, only check the signal if you're certain the process was actually killed (e.g., by checking if the signal matches AND the timeout has actually elapsed).

Suggested change
if (killedByTimeout) {
return true;
}
if (signal && signal === runOptions.killSignal) {
return true;
}
const signals = os.constants && os.constants.signals ? os.constants.signals : {};
if (typeof runOptions.killSignal === "string" && signals[runOptions.killSignal] && code === 128 + signals[runOptions.killSignal]) {
return true;
}
if (typeof runOptions.killSignal === "number" && code === 128 + runOptions.killSignal) {
return true;
}
return false;
return killedByTimeout;

Copilot uses AI. Check for mistakes.
};
const prom = new Promise((resolve) => {
// Spawn java command line
debug(`Java command: ${javaExeToUse} ${javaArgs.join(" ")}`);
Expand All @@ -111,12 +136,27 @@ class JavaCaller {
stdio: this.output === "console" ? "inherit" : runOptions.detached ? "ignore" : "pipe",
windowsHide: true,
windowsVerbatimArguments: runOptions.windowsVerbatimArguments,
timeout: runOptions.timeout,
killSignal: runOptions.killSignal,
};
if (javaExeToUse.includes(" ")) {
spawnOptions.shell = true;
}
child = spawn(javaExeToUse, javaArgs, spawnOptions);

if (runOptions.timeout) {
timeoutId = setTimeout(() => {
if (!child.killed) {
killedByTimeout = true;
try {
child.kill(runOptions.killSignal);
} 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 @@ -136,8 +176,19 @@ class JavaCaller {
});
Comment on lines 151 to 160
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.

If a spawn error occurs (line 172), the timeout timer is not cleared before resolving. This means the timer will continue to run and attempt to kill the process even after the error handler has completed. The timeout should be cleared in the error handler as well to prevent this leak.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback


// Catch status code
child.on("close", (code) => {
this.status = code;
child.on("close", (code, signal) => {
if (timeoutId) {
clearTimeout(timeoutId);
}

if (wasKilledByTimeout(code, signal)) {
// Process was terminated because of the timeout, either via our fallback timer or the built-in spawn 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 () => {
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.

Inconsistent indentation: this test block has incorrect indentation (extra leading spaces) compared to other test blocks in the file. It should start at the same indentation level as the other it blocks.

Suggested change
it("should terminate once timeout is reached", async () => {
it("should terminate once timeout is reached", async () => {

Copilot uses AI. Check for mistakes.
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