RunProcess.java

package jasper.component.vm;

import jasper.errors.ScriptException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;

public class RunProcess {
	private static final Logger logger = LoggerFactory.getLogger(RunProcess.class);
	// Generous timeout to allow reader threads to drain remaining output after process termination
	private static final int READER_THREAD_JOIN_TIMEOUT_MS = 5_000;

	public static String runProcess(Process process, int timeoutMs) throws ScriptException {
		// StringBuffer is thread-safe, so reads stay consistent even if a reader thread outlives its join timeout
		final var output = new StringBuffer();
		final var errors = new StringBuffer();
		var outputThread = Thread.ofVirtual().start(() -> readStream(process.getInputStream(), output, "output"));
		var errorThread = Thread.ofVirtual().start(() -> readStream(process.getErrorStream(), errors, "error"));

		boolean finished;
		try {
			finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS);
		} catch (InterruptedException e) {
			destroyTree(process);
			joinReaders(outputThread, errorThread);
			Thread.currentThread().interrupt();
			throw new ScriptException("Script execution interrupted", ""+errors + output);
		}
		destroyTree(process);
		joinReaders(outputThread, errorThread);
		if (!finished) {
			throw new ScriptException("Script execution timed out", ""+errors + output);
		}
		var exitCode = process.exitValue();
		if (exitCode != 0) {
			throw new ScriptException("Script execution failed with exit code: " + exitCode, ""+errors + output);
		}
		return output.toString();
	}

	private static void joinReaders(Thread outputThread, Thread errorThread) {
		try {
			outputThread.join(READER_THREAD_JOIN_TIMEOUT_MS);
			errorThread.join(READER_THREAD_JOIN_TIMEOUT_MS);
		} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
		}
	}

	private static void readStream(InputStream stream, StringBuffer sink, String name) {
		try (var reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
			var buffer = new char[8192];
			int read;
			while ((read = reader.read(buffer)) != -1) {
				sink.append(buffer, 0, read);
			}
		} catch (IOException e) {
			logger.error("Error reading {} stream: {}", name, e.getMessage());
		}
	}

	private static void destroyTree(Process process) {
		// Snapshot descendants before killing the parent, since they reparent once it exits
		var descendants = process.descendants().toList();
		// Destroy the parent first so it cannot spawn new children
		process.destroy();
		descendants.forEach(ProcessHandle::destroy);
		try {
			if (!process.waitFor(1, TimeUnit.SECONDS)) {
				process.destroyForcibly();
			}
		} catch (InterruptedException e) {
			process.destroyForcibly();
			Thread.currentThread().interrupt();
		}
		descendants.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly);
	}
}