TypeScriptWorkerPool.java

package org.egothor.methodatlas.discovery.typescript;

import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.egothor.methodatlas.util.WorkerCircuitBreaker;
import org.egothor.methodatlas.util.WorkerPool;

/**
 * Manages a pool of long-lived Node.js worker processes for TypeScript file
 * scanning.
 *
 * <p>
 * The generic lifecycle (lazy on-demand creation, the idle-worker queue,
 * circuit-breaker-tracked restarts, and shutdown-hook teardown) is provided by
 * {@link WorkerPool}.  This subclass supplies only the Node.js-specific worker
 * creation and termination and a strongly-typed {@link #scan} entry point.
 * The per-request creation context is the scan root {@link Path}, which is
 * forwarded to {@link TypeScriptWorker#start(Path)} for filesystem sandboxing
 * when a fresh worker is started.
 * </p>
 *
 * @see WorkerPool
 * @see TypeScriptWorker
 */
final class TypeScriptWorkerPool extends WorkerPool<TypeScriptWorker, Path> {

    private static final Logger LOG = Logger.getLogger(TypeScriptWorkerPool.class.getName());

    private final Path bundlePath;
    private final NodeEnvironment nodeEnv;
    private final long workerTimeoutMillis;

    /**
     * Creates a worker pool.  No workers are started at construction time.
     *
     * @param bundlePath          path to the verified bundle JS file
     * @param nodeEnv             Node.js environment information
     * @param poolSize            maximum number of concurrent workers; must be positive
     * @param workerTimeoutMillis per-request timeout in milliseconds
     * @param circuitBreaker      restart-limit tracker shared with this pool
     */
    /* default */ TypeScriptWorkerPool(Path bundlePath, NodeEnvironment nodeEnv,
            int poolSize, long workerTimeoutMillis, WorkerCircuitBreaker circuitBreaker) {
        super("TypeScript", "ts-worker-pool-shutdown", poolSize, circuitBreaker);
        this.bundlePath = bundlePath;
        this.nodeEnv = nodeEnv;
        this.workerTimeoutMillis = workerTimeoutMillis;
    }

    @Override
    protected TypeScriptWorker createWorker(int index, Path allowedRoot) throws IOException {
        TypeScriptWorker worker = new TypeScriptWorker(bundlePath, nodeEnv, workerTimeoutMillis, index);
        worker.start(allowedRoot);
        return worker;
    }

    @Override
    protected void kill(TypeScriptWorker worker, String reason) {
        worker.kill(reason);
    }

    /**
     * Locates the comments of one source text by delegating to a pooled
     * worker.
     *
     * <p>
     * Unlike {@link #scan}, every failure is reported to the caller: an open
     * circuit, a missing worker or a worker fault must stop redaction, never
     * yield an empty result that would look like "no comments".  A faulty
     * worker is killed and replaced.
     * </p>
     *
     * @param filePath    path whose extension selects the dialect; never read
     * @param source      source text to analyse
     * @param allowedRoot sandbox root forwarded to a newly started worker; may
     *                    be {@code null}
     * @return the comment ranges, or the worker's parse error
     * @throws IOException if no worker could be obtained or the worker failed
     */
    /* default */ TypeScriptWorker.RedactionRanges redact(Path filePath, String source, Path allowedRoot)
            throws IOException {
        if (isCircuitOpen()) {
            throw new IOException("TypeScript worker pool circuit breaker is open");
        }
        TypeScriptWorker worker = borrow(allowedRoot);
        if (worker == null) {
            throw new IOException("No TypeScript worker available within " + borrowTimeoutMillis() + " ms");
        }
        try {
            TypeScriptWorker.RedactionRanges result = worker.redact(filePath, source);
            returnWorker(worker);
            return result;
        } catch (TypeScriptWorker.WorkerException e) {
            worker.kill("redact error: " + e.getMessage());
            replaceWorker(allowedRoot);
            throw new IOException("TypeScript worker failed redacting " + filePath + ": " + e.getMessage(), e);
        } catch (IOException e) {
            worker.kill("redact I/O error: " + e.getMessage());
            replaceWorker(allowedRoot);
            throw e;
        }
    }

    /**
     * Scans one TypeScript file by delegating to a pooled worker.
     *
     * @param filePath      absolute path of the file to scan
     * @param functionNames test-function call names
     * @param allowedRoot   scan root for permission sandboxing; forwarded to a
     *                      newly started worker; may be {@code null}
     * @return list of discovered method descriptors; empty when the circuit is
     *         open or a non-recoverable error occurs
     * @throws IOException if borrowing a worker fails with a hard I/O error
     */
    /* default */ List<TypeScriptWorker.MethodDescriptor> scan(
            Path filePath, List<String> functionNames, Path allowedRoot) throws IOException {

        if (isCircuitOpen()) {
            if (LOG.isLoggable(Level.WARNING)) {
                LOG.warning("TypeScript worker pool circuit breaker is open — skipping " + filePath);
            }
            return List.of();
        }

        TypeScriptWorker worker = borrow(allowedRoot);
        if (worker == null) {
            if (LOG.isLoggable(Level.WARNING)) {
                LOG.warning("No TypeScript worker available within " + borrowTimeoutMillis()
                        + " ms — skipping " + filePath);
            }
            return List.of();
        }

        try {
            List<TypeScriptWorker.MethodDescriptor> result =
                    worker.scan(filePath, functionNames);
            returnWorker(worker);
            return result;
        } catch (TypeScriptWorker.WorkerException e) {
            if (LOG.isLoggable(Level.WARNING)) {
                LOG.log(Level.WARNING,
                        "TypeScript worker error scanning " + filePath + " — "
                        + "killing and replacing worker: " + e.getMessage(), e);
            }
            worker.kill("scan error: " + e.getMessage());
            replaceWorker(allowedRoot);
            return List.of();
        }
    }
}