TypeScriptWorker.java
package org.egothor.methodatlas.discovery.typescript;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* A single long-lived Node.js worker process that scans TypeScript files on
* demand.
*
* <p>
* Each {@code TypeScriptWorker} owns exactly one Node.js child process. The
* process stays alive across multiple scan requests; it is replaced only when
* it exits unexpectedly or produces an invalid response. A fresh worker is
* created by {@link TypeScriptWorkerPool} after each restart event.
* </p>
*
* <h2>Protocol</h2>
*
* <p>
* Requests are written to the worker's {@code stdin} as a single JSON line:
* </p>
* <pre>
* {"requestId":"<uuid>","filePath":"<abs-path>","functionNames":["test","it"]}
* </pre>
*
* <p>
* Responses are read from {@code stdout} as a single JSON line per request:
* </p>
* <pre>
* {"requestId":"<uuid>","methods":[...],"error":null}
* </pre>
*
* <p>
* The {@code stderr} of the worker process is read asynchronously and logged
* at {@code FINE} level so that Node.js warnings do not block the scan.
* </p>
*
* <h2>Timeout enforcement</h2>
*
* <p>
* If the worker does not produce a response line within
* {@code timeoutMillis}, the worker process is forcibly killed and a
* {@link WorkerException} is thrown. The killing decision is logged with the
* request ID, file path, and elapsed time so audit teams can identify
* problematic source files.
* </p>
*
* <h2>Thread safety</h2>
*
* <p>
* Instances are not thread-safe. Each instance must be used by at most one
* thread at a time. {@link TypeScriptWorkerPool} ensures this through its
* worker-borrowing protocol.
* </p>
*/
final class TypeScriptWorker {
private static final Logger LOG = Logger.getLogger(TypeScriptWorker.class.getName());
private static final ObjectMapper MAPPER = new ObjectMapper();
private final Path bundlePath;
private final NodeEnvironment nodeEnv;
private final long timeoutMillis;
private final int workerIndex;
private Process process;
private BufferedWriter stdin;
private BufferedReader stdout;
@SuppressWarnings("PMD.DoNotUseThreads")
private Thread stderrDrainer;
private long pid = -1;
/**
* Creates a worker descriptor. The actual Node.js process is not started
* until {@link #start()} is called.
*
* @param bundlePath path to the verified, extracted bundle JS file
* @param nodeEnv Node.js environment information
* @param timeoutMillis per-request timeout in milliseconds
* @param workerIndex zero-based index within the pool (used in log messages)
*/
/* default */ TypeScriptWorker(Path bundlePath, NodeEnvironment nodeEnv, long timeoutMillis, int workerIndex) {
this.bundlePath = bundlePath;
this.nodeEnv = nodeEnv;
this.timeoutMillis = timeoutMillis;
this.workerIndex = workerIndex;
}
/**
* Starts the Node.js worker process.
*
* <p>
* Constructs the command line (with optional filesystem permission flags
* when Node.js 20 is detected), starts the process, and launches an
* async stderr-draining thread.
* </p>
*
* @throws IOException if the process cannot be started
*/
/* default */ void start() throws IOException {
List<String> cmd = buildCommand(null);
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(false); // keep stderr separate so we can drain it
process = pb.start();
pid = process.pid();
stdin = new BufferedWriter(
new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
stdout = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
stderrDrainer = startStderrDrainer(process, workerIndex, pid);
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO,
"TypeScript scanner worker[{0}] started — node={1}, pid={2}, bundle={3}",
new Object[] { workerIndex, nodeEnv.versionString(), pid, bundlePath });
}
}
/**
* Starts the Node.js worker process with the given root path for
* file-system sandboxing.
*
* <p>
* When Node.js 20 or later is detected the process is started with
* {@code --experimental-permission --allow-fs-read=<root>} to restrict
* the worker to reading only files under {@code allowedRoot}.
* </p>
*
* @param allowedRoot scan root path used for permission sandboxing;
* {@code null} means no restriction beyond the default
* @throws IOException if the process cannot be started
*/
/* default */ void start(Path allowedRoot) throws IOException {
List<String> cmd = buildCommand(allowedRoot);
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(false);
process = pb.start();
pid = process.pid();
stdin = new BufferedWriter(
new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
stdout = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
stderrDrainer = startStderrDrainer(process, workerIndex, pid);
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO,
"TypeScript scanner worker[{0}] started — node={1}, pid={2}, "
+ "bundle={3}, allowed-root={4}",
new Object[] { workerIndex, nodeEnv.versionString(), pid,
bundlePath, allowedRoot != null ? allowedRoot : "unrestricted" });
}
}
/**
* Sends a scan request to the worker and waits for the response.
*
* @param filePath absolute path to the TypeScript file to scan
* @param functionNames function-call names that identify test methods
* @return list of raw method descriptors from the worker response
* @throws WorkerException if the worker does not respond within the
* configured timeout, returns an error response, or the
* underlying process has died
* @throws IOException if writing to stdin or reading from stdout fails
*/
/* default */ List<MethodDescriptor> scan(Path filePath, List<String> functionNames)
throws IOException, WorkerException {
if (process == null || !process.isAlive()) {
throw new WorkerException("Worker process is not alive");
}
String requestId = UUID.randomUUID().toString();
String requestLine = buildRequestLine(requestId, filePath, functionNames);
stdin.write(requestLine);
stdin.newLine();
stdin.flush();
String responseLine = readWithTimeout(filePath, requestId);
return parseResponse(responseLine, requestId, filePath);
}
/**
* Returns {@code true} when the underlying Node.js process is alive.
*
* @return {@code true} if the worker can process requests
*/
/* default */ boolean isAlive() {
return process != null && process.isAlive();
}
/**
* Returns the OS process ID of the worker, or {@code -1} if not started.
*
* @return OS PID
*/
/* default */ long pid() {
return pid;
}
/**
* Kills the worker process and interrupts the stderr-drainer thread.
*
* <p>
* This method is idempotent: calling it on an already-dead process is
* safe. The kill decision is logged at {@code INFO} level.
* </p>
*
* @param reason human-readable reason for the kill (included in the log)
*/
/* default */ void kill(String reason) {
if (process != null) {
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO,
"TypeScript scanner worker[{0}] killed — pid={1}, reason={2}",
new Object[] { workerIndex, pid, reason });
}
process.destroyForcibly();
}
if (stderrDrainer != null) {
stderrDrainer.interrupt();
}
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Builds the Node.js command line. When Node.js 20 or later is detected
* and {@code allowedRoot} is not {@code null}, filesystem permission flags
* are prepended.
*
* <h4>Permission model compatibility</h4>
* <p>Node.js 20–21 used {@code --experimental-permission}; Node.js 22
* and later uses the stable {@code --permission} flag. The correct name is
* returned by {@link NodeEnvironment#permissionFlagName()}.</p>
*
* <p>Each allowed path is passed as a separate {@code --allow-fs-read}
* argument rather than comma-separated values, because the comma-separated
* form was not reliably supported across all Node.js versions.</p>
*
* <p>The scan root is suffixed with {@code /**} so that Node.js grants
* recursive read access to all files beneath it. Without this glob,
* Node.js 22+ treats a bare directory path as permission to stat the
* directory itself only — not its contents.</p>
*
* <p>All paths in {@code --allow-fs-read} use forward slashes regardless
* of the host OS, because the Node.js permission model on Windows requires
* consistent path separators for reliable prefix matching.</p>
*
* @param allowedRoot scan root; {@code null} means no sandboxing
* @return command-line token list
*/
private List<String> buildCommand(Path allowedRoot) {
List<String> cmd = new ArrayList<>();
cmd.add("node");
if (nodeEnv.isPermissionModelSupported() && allowedRoot != null) {
cmd.add(nodeEnv.permissionFlagName());
// Use separate --allow-fs-read flags (comma-separated form is unreliable
// across Node.js versions). Convert backslashes to forward slashes so
// the permission model's path matching works correctly on Windows.
// Append /** to the scan root so recursive access is granted (Node.js v22+
// requires explicit glob or trailing slash for directory recursion).
String bundleStr = bundlePath.toAbsolutePath().toString().replace('\\', '/');
String rootStr = allowedRoot.toAbsolutePath().toString().replace('\\', '/');
cmd.add("--allow-fs-read=" + bundleStr);
cmd.add("--allow-fs-read=" + rootStr + "/**");
}
cmd.add(bundlePath.toAbsolutePath().toString());
return cmd;
}
/**
* Serialises a scan request as a single JSON line.
*
* @param requestId unique request identifier
* @param filePath file to scan
* @param functionNames test-function names
* @return JSON line (no trailing newline)
* @throws IOException if JSON serialisation fails (should never happen)
*/
private static String buildRequestLine(String requestId, Path filePath,
List<String> functionNames) throws IOException {
ObjectNode req = MAPPER.createObjectNode();
req.put("requestId", requestId);
req.put("filePath", filePath.toAbsolutePath().toString());
ArrayNode fns = req.putArray("functionNames");
for (String fn : functionNames) {
fns.add(fn);
}
return MAPPER.writeValueAsString(req);
}
/**
* Reads one response line from the worker's stdout, waiting at most
* {@link #timeoutMillis} milliseconds.
*
* <p>
* Because {@link BufferedReader#readLine()} is blocking, a separate
* reader thread is used so that a timeout can be enforced with
* {@link Thread#join(long)}.
* </p>
*
* @param filePath file being scanned (for log messages)
* @param requestId request ID (for log messages)
* @return the response JSON line
* @throws WorkerException if the timeout elapses or the stream closes
* unexpectedly
* @throws IOException if the reader thread itself throws
*/
@SuppressWarnings("PMD.DoNotUseThreads")
private String readWithTimeout(Path filePath, String requestId)
throws IOException, WorkerException {
// Shared result container accessed from both this thread and the reader thread.
final String[] result = { null };
final IOException[] ioError = { null };
Thread reader = new Thread(() -> {
try {
result[0] = stdout.readLine();
} catch (IOException e) {
ioError[0] = e;
}
}, "ts-worker-reader-" + workerIndex);
reader.setDaemon(true);
reader.start();
try {
reader.join(timeoutMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
reader.interrupt();
throw new WorkerException("Interrupted while waiting for worker response", e);
}
if (reader.isAlive()) {
// Timeout elapsed — kill the worker and the reader thread.
reader.interrupt();
kill("per-file timeout of " + timeoutMillis + " ms exceeded for " + filePath
+ " (requestId=" + requestId + ")");
throw new WorkerException("Worker timeout after " + timeoutMillis
+ " ms scanning " + filePath);
}
if (ioError[0] != null) {
throw ioError[0];
}
if (result[0] == null) {
throw new WorkerException(
"Worker stdout closed unexpectedly while scanning " + filePath);
}
return result[0];
}
/**
* Parses a worker response JSON line into a list of method descriptors.
*
* @param responseLine raw JSON line from the worker
* @param requestId expected request ID (validated against the response)
* @param filePath file being scanned (for error messages)
* @return list of method descriptors; never {@code null}
* @throws WorkerException if the response contains an error or the request
* ID does not match
* @throws IOException if JSON parsing fails
*/
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
private static List<MethodDescriptor> parseResponse(String responseLine, String requestId,
Path filePath) throws IOException, WorkerException {
JsonNode root = MAPPER.readTree(responseLine);
// Validate request ID to catch out-of-sync responses (protocol error).
String responseId = root.path("requestId").asString(null);
if (!requestId.equals(responseId)) {
throw new WorkerException(
"Response requestId mismatch: expected=" + requestId
+ ", got=" + responseId + " for file " + filePath);
}
// Surface worker-side errors.
JsonNode errorNode = root.path("error");
if (!errorNode.isNull() && !errorNode.isMissingNode()) {
String errorMsg = errorNode.asString();
if (errorMsg != null && !errorMsg.isBlank()) {
throw new WorkerException("Worker reported error scanning " + filePath
+ ": " + errorMsg);
}
}
// Parse the methods array.
List<MethodDescriptor> methods = new ArrayList<>();
JsonNode methodsNode = root.path("methods");
if (methodsNode.isArray()) {
for (JsonNode m : methodsNode) {
String name = m.path("name").asString("<anonymous>");
List<String> describe = null;
JsonNode descNode = m.path("describe");
if (descNode.isArray() && !descNode.isEmpty()) {
describe = new ArrayList<>();
for (JsonNode d : descNode) {
describe.add(d.asString());
}
}
int beginLine = m.path("beginLine").asInt(0);
int endLine = m.path("endLine").asInt(0);
int loc = m.path("loc").asInt(1);
methods.add(new MethodDescriptor(name, describe, beginLine, endLine, loc));
}
}
return methods;
}
/**
* Starts a daemon thread that drains the worker's stderr stream and logs
* each line at {@code FINE} level.
*
* @param proc worker process
* @param workerIndex worker index (for log messages)
* @param processId OS PID (for log messages)
* @return the started drainer thread
*/
@SuppressWarnings("PMD.DoNotUseThreads")
private static Thread startStderrDrainer(Process proc, int workerIndex, long processId) {
Thread t = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(proc.getErrorStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("worker[" + workerIndex + "](pid=" + processId + ") stderr: " + line);
}
}
} catch (IOException e) {
if (LOG.isLoggable(Level.FINE)) {
LOG.log(Level.FINE, "stderr drainer for worker[" + workerIndex + "] closed", e);
}
}
}, "ts-worker-stderr-" + workerIndex);
t.setDaemon(true);
t.start();
return t;
}
// -------------------------------------------------------------------------
// Nested types
// -------------------------------------------------------------------------
/**
* Immutable data transfer object carrying the raw scan result for a single
* test method as reported by the Node.js worker.
*
* @param name test name extracted from the first argument of the
* test-function call
* @param describe ordered list of enclosing describe-block names, or
* {@code null} when the test is at the top level
* @param beginLine 1-based line number of the call expression start
* @param endLine 1-based line number of the call expression end
* @param loc inclusive line count (at least 1)
*/
/* default */ record MethodDescriptor(
String name,
List<String> describe,
int beginLine,
int endLine,
int loc) {}
/**
* Signals that the worker process failed to produce a valid response.
* The caller ({@link TypeScriptWorkerPool}) catches this exception, kills
* and replaces the worker, and records the restart in the circuit breaker.
*/
/* default */ static final class WorkerException extends Exception {
private static final long serialVersionUID = 1L;
/* default */ WorkerException(String message) {
super(message);
}
/* default */ WorkerException(String message, Throwable cause) {
super(message, cause);
}
}
}