"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.BatchProcess = void 0;
const node_timers_1 = __importDefault(require("node:timers"));
const Deferred_1 = require("./Deferred");
const Error_1 = require("./Error");
const Object_1 = require("./Object");
const Parser_1 = require("./Parser");
const Pids_1 = require("./Pids");
const ProcessHealthMonitor_1 = require("./ProcessHealthMonitor");
const ProcessTerminator_1 = require("./ProcessTerminator");
const StreamHandler_1 = require("./StreamHandler");
const String_1 = require("./String");
const Task_1 = require("./Task");
const WhyNotHealthy_1 = require("./WhyNotHealthy");
/**
 * BatchProcess manages the care and feeding of a single child process.
 */
class BatchProcess {
    proc;
    opts;
    onIdle;
    name;
    pid;
    start = Date.now();
    #exitCode = null;
    #exitSignal = null;
    /**
     * Exit code from the child process, or null if not yet exited.
     * Populated for both expected terminations (idle, old, worn) and
     * unexpected exits (crashes, errors).
     * Use {@link unexpectedExit} to determine if the exit was unexpected.
     */
    get exitCode() {
        return this.#exitCode;
    }
    /**
     * Exit signal from the child process, or null if not terminated by signal.
     * Populated for both expected terminations and unexpected exits.
     * Use {@link unexpectedExit} to determine if the exit was unexpected.
     */
    get exitSignal() {
        return this.#exitSignal;
    }
    /**
     * Returns true if the process exited unexpectedly (crash, error, timeout, etc.)
     * rather than through proactive cluster management (worn, old, idle, etc.).
     * Useful for determining if a non-zero exit code indicates a problem.
     * @see {@link ExpectedTerminationReasons} for the list of expected termination reasons.
     */
    get unexpectedExit() {
        return (this.#whyNotHealthy != null &&
            !WhyNotHealthy_1.ExpectedTerminationReasons.includes(this.#whyNotHealthy));
    }
    startupTaskId;
    #logger;
    #terminator;
    #healthMonitor;
    #streamHandler;
    #lastJobFinishedAt = Date.now();
    // Only set to true when `proc.pid` is no longer in the process table.
    #starting = true;
    // Deferred that resolves when the process exits (via OS events)
    #processExitDeferred = new Deferred_1.Deferred();
    // override for .whyNotHealthy()
    #whyNotHealthy;
    failedTaskCount = 0;
    /**
     * Number of user tasks (not including the startup/version task) that have
     * been started on this process. Used for maxTasksPerProcess recycling.
     */
    #taskCount = 0;
    /**
     * Should be undefined if this instance is not currently processing a task.
     */
    #currentTask;
    /**
     * Getter for current task (required by StreamContext interface)
     */
    get currentTask() {
        return this.#currentTask;
    }
    /**
     * Create a StreamContext adapter for this BatchProcess
     */
    #createStreamContext = () => {
        return {
            name: this.name,
            isEnding: () => this.ending,
            getCurrentTask: () => this.#currentTask,
            onError: (reason, error) => this.#onError(reason, error),
            end: (gracefully, reason) => void this.end(gracefully, reason),
        };
    };
    #currentTaskTimeout;
    #endPromise;
    /**
     * @param onIdle to be called when internal state changes (like the current
     * task is resolved, or the process exits)
     */
    constructor(proc, opts, onIdle, healthMonitor) {
        this.proc = proc;
        this.opts = opts;
        this.onIdle = onIdle;
        this.name = "BatchProcess(" + proc.pid + ")";
        this.#logger = opts.logger;
        this.#terminator = new ProcessTerminator_1.ProcessTerminator(opts);
        this.#healthMonitor =
            healthMonitor ?? new ProcessHealthMonitor_1.ProcessHealthMonitor(opts, opts.observer);
        this.#streamHandler = new StreamHandler_1.StreamHandler({ logger: this.#logger }, opts.observer);
        // don't let node count the child processes as a reason to stay alive
        this.proc.unref();
        if (opts.unrefStreams) {
            // Also unref the streams so they don't prevent the parent from exiting.
            // The streams are net.Socket instances when using 'pipe' stdio, which have
            // unref(), but the TypeScript types don't expose it on Readable/Writable.
            /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
            this.proc.stdin?.unref?.();
            this.proc.stdout?.unref?.();
            this.proc.stderr?.unref?.();
        }
        if (proc.pid == null) {
            throw new Error("BatchProcess.constructor: child process pid is null");
        }
        this.pid = proc.pid;
        this.proc.on("error", (err) => this.#onError("proc.error", err));
        this.proc.on("close", () => {
            this.#processExitDeferred.resolve();
            void this.end(false, "proc.close");
        });
        this.proc.on("exit", (code, signal) => {
            // Always capture exit code/signal for diagnostic purposes.
            // Use unexpectedExit to determine if this was a managed shutdown vs unexpected exit.
            this.#exitCode = code;
            this.#exitSignal = signal;
            this.#processExitDeferred.resolve();
            void this.end(false, "proc.exit");
        });
        this.proc.on("disconnect", () => {
            this.#processExitDeferred.resolve();
            void this.end(false, "proc.disconnect");
        });
        // Set up stream handlers using StreamHandler
        this.#streamHandler.setupStreamListeners(this.proc, this.#createStreamContext());
        const startupTask = new Task_1.Task(opts.versionCommand, Parser_1.SimpleParser);
        this.startupTaskId = startupTask.taskId;
        if (!this.execTask(startupTask)) {
            this.opts.observer.emit("internalError", new Error(this.name + " startup task was not submitted"));
        }
        // Initialize health monitoring for this process
        this.#healthMonitor.initializeProcess(this.pid);
        // this needs to be at the end of the constructor, to ensure everything is
        // set up on `this`
        this.opts.observer.emit("childStart", this);
    }
    get taskCount() {
        return this.#taskCount;
    }
    get starting() {
        return this.#starting;
    }
    /**
     * @return true if `this.end()` has been requested (which may be due to the
     * child process exiting)
     */
    get ending() {
        return this.#endPromise != null;
    }
    /**
     * @return true if `this.end()` has completed running, which includes child
     * process cleanup. Note that this may return `true` and the process table may
     * still include the child pid. Call {@link BatchProcess#running()} for an authoritative
     * (but expensive!) answer.
     */
    get ended() {
        return true === this.#endPromise?.settled;
    }
    /**
     * @return true if the child process has exited (based on OS events).
     * This is now authoritative and inexpensive since it's driven by OS events
     * rather than polling.
     */
    get exited() {
        return this.#processExitDeferred.settled;
    }
    /**
     * @return a string describing why this process should be recycled, or null if
     * the process passes all health checks. Note that this doesn't include if
     * we're already busy: see {@link BatchProcess.whyNotReady} if you need to
     * know if a process can handle a new task.
     */
    get whyNotHealthy() {
        return this.#healthMonitor.assessHealth(this, this.#whyNotHealthy);
    }
    /**
     * @return true if the process doesn't need to be recycled.
     */
    get healthy() {
        return this.whyNotHealthy == null;
    }
    /**
     * @return true iff no current task. Does not take into consideration if the
     * process has ended or should be recycled: see {@link BatchProcess.ready}.
     */
    get idle() {
        return this.#currentTask == null;
    }
    /**
     * @return a string describing why this process cannot currently handle a new
     * task, or `undefined` if this process is idle and healthy.
     */
    get whyNotReady() {
        return !this.idle ? "busy" : this.whyNotHealthy;
    }
    /**
     * @return true iff this process is  both healthy and idle, and ready for a
     * new task.
     */
    get ready() {
        return this.whyNotReady == null;
    }
    get idleMs() {
        return this.idle ? Date.now() - this.#lastJobFinishedAt : -1;
    }
    /**
     * @return true if the child process is running.
     * Now event-driven first with polling fallback.
     */
    running() {
        // If we've been notified via OS events that process exited, trust that immediately
        if (this.exited)
            return false;
        // Only poll as fallback if we haven't been notified yet
        // This handles edge cases where events might not fire reliably
        const alive = (0, Pids_1.pidExists)(this.pid);
        if (!alive) {
            this.#processExitDeferred.resolve();
            // once a PID leaves the process table, it's gone for good.
            void this.end(false, "proc.exit");
        }
        return alive;
    }
    notRunning() {
        return !this.running();
    }
    maybeRunHealthCheck() {
        return this.#healthMonitor.maybeRunHealthCheck(this);
    }
    // This must not be async, or new instances aren't started as busy (until the
    // startup task is complete)
    execTask(task) {
        return this.ready ? this.#execTask(task) : false;
    }
    #execTask(task) {
        if (this.ending)
            return false;
        this.#currentTask = task;
        const cmd = (0, String_1.ensureSuffix)(task.command, "\n");
        const isStartupTask = task.taskId === this.startupTaskId;
        // Only count user tasks, not the startup task (for maxTasksPerProcess)
        if (!isStartupTask) {
            this.#taskCount++;
        }
        const taskTimeoutMs = isStartupTask
            ? this.opts.spawnTimeoutMillis
            : this.opts.taskTimeoutMillis;
        if (taskTimeoutMs > 0) {
            // add the stream flush millis to the taskTimeoutMs, because that time
            // should not be counted against the task.
            this.#currentTaskTimeout = node_timers_1.default.setTimeout(() => this.#onTimeout(task, taskTimeoutMs), taskTimeoutMs + this.opts.streamFlushMillis);
        }
        // CAREFUL! If you add a .catch or .finally, the pipeline can emit unhandled
        // rejections:
        void task.promise.then(() => {
            this.#clearCurrentTask(task);
            // this.#logger().trace("task completed", { task })
            if (isStartupTask) {
                // no need to emit taskResolved for startup tasks.
                this.#starting = false;
            }
            else {
                this.opts.observer.emit("taskResolved", task, this);
            }
            // Call _after_ we've cleared the current task:
            this.onIdle();
        }, (error) => {
            this.#clearCurrentTask(task);
            // this.#logger().trace("task failed", { task, err: error })
            if (isStartupTask) {
                this.opts.observer.emit("startError", error instanceof Error ? error : new Error(String(error)));
                void this.end(false, "startError");
            }
            else {
                this.opts.observer.emit("taskError", error instanceof Error ? error : new Error(String(error)), task, this);
            }
            // Call _after_ we've cleared the current task:
            this.onIdle();
        });
        try {
            task.onStart(this.opts);
            const stdin = this.proc?.stdin;
            if (stdin == null || stdin.destroyed) {
                task.reject(new Error("proc.stdin unexpectedly closed"));
                return false;
            }
            else {
                stdin.write(cmd, (err) => {
                    if (err != null) {
                        task.reject(err);
                        void this.end(false, "stdin.error");
                    }
                });
                return true;
            }
        }
        catch {
            // child process went away. We should too.
            void this.end(false, "stdin.error");
            return false;
        }
    }
    /**
     * End this child process.
     *
     * @param gracefully Wait for any current task to be resolved or rejected
     * before shutting down the child process.
     * @param reason who called end() (used for logging)
     * @return Promise that will be resolved when the process has completed.
     * Subsequent calls to end() will ignore the parameters and return the first
     * endPromise.
     */
    // NOT ASYNC! needs to change state immediately.
    end(gracefully = true, reason) {
        return (this.#endPromise ??= new Deferred_1.Deferred().observe(this.#end(gracefully, (this.#whyNotHealthy ??= reason)))).promise;
    }
    // NOTE: Must only be invoked by this.end(), and only expected to be invoked
    // once per instance.
    async #end(gracefully, reason) {
        const lastTask = this.#currentTask;
        this.#clearCurrentTask();
        await this.#terminator.terminate(this.proc, this.name, lastTask, this.startupTaskId, gracefully, this.exited, () => this.running());
        // Clean up health monitoring for this process
        this.#healthMonitor.cleanupProcess(this.pid);
        this.opts.observer.emit("childEnd", this, reason);
    }
    #onTimeout(task, timeoutMs) {
        if (task.pending) {
            this.opts.observer.emit("taskTimeout", timeoutMs, task, this);
            this.#onError("timeout", new Error("waited " + timeoutMs + "ms"), task);
        }
    }
    #onError(reason, error, task) {
        if (task == null) {
            task = this.#currentTask;
        }
        const cleanedError = new Error(reason + ": " + (0, Error_1.cleanError)(error.message));
        if (error.stack != null) {
            // Error stacks, if set, will not be redefined from a rethrow:
            cleanedError.stack = (0, Error_1.cleanError)(error.stack);
        }
        this.#logger().warn(this.name + ".onError()", {
            reason,
            task: (0, Object_1.map)(task, (t) => t.command),
            error: cleanedError,
        });
        if (this.ending) {
            // .#end is already disconnecting the error listeners, but in any event,
            // we don't really care about errors after we've been told to shut down.
            return;
        }
        // clear the task before ending so the onExit from end() doesn't retry the task:
        this.#clearCurrentTask();
        void this.end(false, reason);
        // Only emit startError for actual startup task (version command) failures,
        // not for regular task failures.
        // See: https://github.com/photostructure/exiftool-vendored.js/issues/312
        if (task != null && task.taskId === this.startupTaskId) {
            this.#logger().warn(this.name + ".onError(): startup task failed: " + String(cleanedError));
            this.opts.observer.emit("startError", cleanedError);
        }
        if (task != null) {
            if (task.pending) {
                task.reject(cleanedError);
            }
            else {
                this.opts.observer.emit("internalError", new Error(`${this.name}.onError(${cleanedError}) cannot reject already-fulfilled task.`));
            }
        }
    }
    #clearCurrentTask(task) {
        const taskFailed = task?.state === "rejected";
        if (taskFailed) {
            this.#healthMonitor.recordJobFailure(this.pid);
        }
        else if (task != null) {
            this.#healthMonitor.recordJobSuccess(this.pid);
        }
        // Skip if asked to clear a specific task that isn't the current one
        if (task != null && task.taskId !== this.#currentTask?.taskId)
            return;
        (0, Object_1.map)(this.#currentTaskTimeout, (ea) => clearTimeout(ea));
        this.#currentTaskTimeout = undefined;
        this.#currentTask = undefined;
        this.#lastJobFinishedAt = Date.now();
    }
}
exports.BatchProcess = BatchProcess;
//# sourceMappingURL=BatchProcess.js.map