import { ErrorFactory } from "../utils/errorHandling.js";
const DEFAULT_READY_TIMEOUT_MS = 30_000;
const DEFAULT_SOCKET_QUEUE_LIMIT = 1_024;
const DEFAULT_SOCKET_QUEUE_TIMEOUT_MS = 30_000;
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
/**
 * Owns worker generations while the caller owns the public listening socket.
 * Sockets are transferred once to the active worker, so response bytes never
 * traverse a supervisor-side proxy hop.
 */
export class RollingWorkerSupervisor {
    options;
    generation = 0;
    active = null;
    candidate = null;
    draining = new Map();
    queuedSockets = [];
    replacement = null;
    rejectedSockets = 0;
    failedTransfers = 0;
    lastFailure = null;
    closed = false;
    shutdownPromise = null;
    shutdownWaiters = new Set();
    constructor(options) {
        this.options = {
            ...options,
            readyTimeoutMs: options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
            socketQueueLimit: options.socketQueueLimit ?? DEFAULT_SOCKET_QUEUE_LIMIT,
            socketQueueTimeoutMs: options.socketQueueTimeoutMs ?? DEFAULT_SOCKET_QUEUE_TIMEOUT_MS,
            shutdownTimeoutMs: options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,
        };
    }
    snapshot() {
        return {
            generation: this.generation,
            active: this.active
                ? {
                    pid: this.active.handle.pid,
                    version: this.active.version,
                    generation: this.active.generation,
                }
                : null,
            candidate: this.candidate
                ? {
                    pid: this.candidate.handle.pid,
                    expectedVersion: this.candidate.expectedVersion,
                    generation: this.candidate.generation,
                }
                : null,
            draining: [...this.draining.values()].map((worker) => ({
                pid: worker.handle.pid,
                version: worker.version,
                generation: worker.generation,
            })),
            queuedSockets: this.queuedSockets.length,
            rejectedSockets: this.rejectedSockets,
            failedTransfers: this.failedTransfers,
            lastFailure: this.lastFailure,
        };
    }
    start(expectedVersion) {
        if (this.active) {
            return this.active.version === expectedVersion
                ? Promise.resolve(this.snapshot())
                : this.replace(expectedVersion);
        }
        return this.replace(expectedVersion);
    }
    replace(expectedVersion) {
        if (this.closed) {
            return Promise.reject(ErrorFactory.proxyWorkerLifecycle("rolling worker supervisor is closed"));
        }
        if (!/^\d+\.\d+\.\d+$/.test(expectedVersion)) {
            return Promise.reject(ErrorFactory.proxyWorkerLifecycle(`invalid expected worker version: ${expectedVersion}`, { expectedVersion }));
        }
        if (this.replacement) {
            return this.candidate?.expectedVersion === expectedVersion
                ? this.replacement
                : Promise.reject(ErrorFactory.proxyWorkerLifecycle(`worker replacement for v${this.candidate?.expectedVersion ?? "unknown"} is already in progress`, { requestedVersion: expectedVersion }));
        }
        this.lastFailure = null;
        this.replacement = this.spawnCandidate(expectedVersion).finally(() => {
            this.replacement = null;
        });
        return this.replacement;
    }
    acceptSocket(socket) {
        socket.pause();
        if (this.closed) {
            this.rejectSocket(socket);
            return;
        }
        if (this.active) {
            this.transferSocket(this.active, socket);
            return;
        }
        this.queueSocket(socket);
    }
    queueSocket(socket) {
        if (this.queuedSockets.length >= this.options.socketQueueLimit) {
            this.rejectSocket(socket);
            return;
        }
        const queued = {
            socket,
            timeout: setTimeout(() => {
                const index = this.queuedSockets.indexOf(queued);
                if (index >= 0) {
                    this.queuedSockets.splice(index, 1);
                    this.rejectSocket(socket);
                }
            }, this.options.socketQueueTimeoutMs),
        };
        queued.timeout.unref?.();
        this.queuedSockets.push(queued);
        this.publishState();
    }
    close() {
        if (this.shutdownPromise) {
            return this.shutdownPromise;
        }
        this.closed = true;
        this.shutdownPromise = this.closeWorkers();
        return this.shutdownPromise;
    }
    async closeWorkers() {
        for (const queued of this.queuedSockets.splice(0)) {
            clearTimeout(queued.timeout);
            this.rejectSocket(queued.socket);
        }
        if (this.candidate) {
            this.candidate.settle(new Error("rolling worker supervisor closed during replacement"));
        }
        if (this.active) {
            this.requestWorkerShutdown(this.active);
        }
        for (const worker of this.draining.values()) {
            this.requestWorkerShutdown(worker);
        }
        this.publishState();
        if (!this.active && this.draining.size === 0) {
            return;
        }
        await new Promise((resolve) => {
            const finish = () => {
                clearTimeout(timeout);
                this.shutdownWaiters.delete(finish);
                resolve();
            };
            const timeout = setTimeout(() => {
                this.forceTerminateWorkers();
                finish();
            }, this.options.shutdownTimeoutMs);
            timeout.unref?.();
            this.shutdownWaiters.add(finish);
        });
    }
    requestWorkerShutdown(worker) {
        try {
            worker.handle.sendControl({
                type: "proxy-worker:shutdown",
                generation: worker.generation,
            });
        }
        catch {
            worker.handle.terminate("SIGTERM");
        }
    }
    forceTerminateWorkers() {
        if (this.active) {
            this.active.handle.terminate("SIGTERM");
            this.active.dispose();
            this.active = null;
        }
        for (const worker of this.draining.values()) {
            worker.handle.terminate("SIGTERM");
            worker.dispose();
        }
        this.draining.clear();
        this.publishState();
    }
    notifyShutdownWaiters() {
        if (!this.closed || this.active || this.draining.size > 0) {
            return;
        }
        for (const finish of [...this.shutdownWaiters]) {
            finish();
        }
    }
    spawnCandidate(expectedVersion) {
        const generation = ++this.generation;
        let handle;
        try {
            handle = this.options.spawnWorker(generation, expectedVersion);
        }
        catch (error) {
            this.recordFailure(generation, expectedVersion, "startup", error instanceof Error ? error.message : String(error));
            this.publishState();
            return Promise.reject(error instanceof Error ? error : new Error(String(error)));
        }
        return new Promise((resolve, reject) => {
            let settled = false;
            const finish = (error, phase = "startup") => {
                if (settled) {
                    return;
                }
                settled = true;
                clearTimeout(readyTimeout);
                if (error) {
                    if (!this.closed) {
                        this.recordFailure(generation, expectedVersion, phase, error.message);
                    }
                    if (this.candidate?.generation === generation) {
                        this.candidate = null;
                    }
                    handle.terminate("SIGTERM");
                    dispose();
                    this.publishState();
                    reject(error);
                    return;
                }
                resolve(this.snapshot());
            };
            const offMessage = handle.onMessage((message) => {
                if (message.generation !== generation || message.pid !== handle.pid) {
                    return;
                }
                if (message.type === "proxy-worker:fatal") {
                    const error = new Error(`worker ${handle.pid} failed: ${message.message}`);
                    if (this.candidate?.generation === generation) {
                        finish(error);
                    }
                    else if (this.active?.generation === generation) {
                        this.active.dispose();
                        this.active = null;
                        handle.terminate("SIGTERM");
                        this.recordFailure(generation, expectedVersion, "runtime", message.message);
                        this.options.log?.(`[proxy-supervisor] active worker reported fatal generation=${generation} pid=${handle.pid}: ${message.message}`);
                        this.publishState();
                        this.notifyShutdownWaiters();
                    }
                    return;
                }
                if (message.type === "proxy-worker:ready") {
                    if (message.version !== expectedVersion) {
                        finish(new Error(`worker ${handle.pid} reported v${message.version}; expected v${expectedVersion}`));
                        return;
                    }
                    const candidate = this.candidate;
                    if (!candidate) {
                        return;
                    }
                    if (!candidate.activationRequested) {
                        try {
                            handle.sendControl({
                                type: "proxy-worker:activate",
                                generation,
                            });
                            candidate.activationRequested = true;
                        }
                        catch (error) {
                            finish(error instanceof Error ? error : new Error(String(error)), "activation");
                        }
                    }
                    return;
                }
                if (message.type !== "proxy-worker:activated") {
                    return;
                }
                if (!this.candidate?.activationRequested) {
                    finish(new Error(`worker ${handle.pid} acknowledged activation before readiness`), "activation");
                    return;
                }
                const previous = this.active;
                const activated = {
                    handle,
                    generation,
                    version: expectedVersion,
                    dispose,
                };
                this.active = activated;
                this.candidate = null;
                this.flushQueuedSockets();
                if (previous) {
                    this.draining.set(previous.generation, previous);
                    try {
                        previous.handle.sendControl({
                            type: "proxy-worker:drain",
                            generation: previous.generation,
                        });
                    }
                    catch {
                        previous.handle.terminate("SIGTERM");
                    }
                }
                this.options.log?.(`[proxy-supervisor] activated generation=${generation} pid=${handle.pid} version=${expectedVersion}`);
                this.publishState();
                finish();
            });
            const offExit = handle.onExit((code, signal) => {
                if (this.candidate?.generation === generation) {
                    finish(new Error(`worker ${handle.pid} exited before readiness (code=${code ?? "none"}, signal=${signal ?? "none"})`));
                    return;
                }
                if (this.active?.generation === generation) {
                    this.active.dispose();
                    this.active = null;
                    if (!this.closed) {
                        this.recordFailure(generation, expectedVersion, "runtime", `worker exited (code=${code ?? "none"}, signal=${signal ?? "none"})`);
                    }
                    this.options.log?.(`[proxy-supervisor] active worker exited generation=${generation} pid=${handle.pid}`);
                }
                const drained = this.draining.get(generation);
                if (drained) {
                    drained.dispose();
                    this.draining.delete(generation);
                }
                this.publishState();
                this.notifyShutdownWaiters();
            });
            const dispose = () => {
                offMessage();
                offExit();
            };
            const readyTimeout = setTimeout(() => {
                finish(new Error(`worker ${handle.pid} did not become ready within ${this.options.readyTimeoutMs}ms`));
            }, this.options.readyTimeoutMs);
            readyTimeout.unref?.();
            this.candidate = {
                handle,
                generation,
                version: expectedVersion,
                expectedVersion,
                activationRequested: false,
                dispose,
                settle: finish,
            };
            this.publishState();
        });
    }
    flushQueuedSockets() {
        // Capture the active worker once: a synchronous sendSocket failure inside
        // transferSocket can clear this.active mid-loop, and re-reading it would
        // pass null into transferSocket and strand the queued socket.
        const worker = this.active;
        if (!worker) {
            return;
        }
        for (const queued of this.queuedSockets.splice(0)) {
            clearTimeout(queued.timeout);
            this.transferSocket(worker, queued.socket);
        }
    }
    transferSocket(worker, socket) {
        try {
            worker.handle.sendSocket(worker.generation, socket, (error) => {
                if (error) {
                    this.handleTransferFailure(worker, socket);
                }
            });
        }
        catch {
            this.handleTransferFailure(worker, socket);
        }
    }
    handleTransferFailure(worker, socket) {
        this.failedTransfers += 1;
        this.recordFailure(worker.generation, worker.version, "transfer", `worker ${worker.handle.pid} failed to accept a transferred socket`);
        if (this.active?.generation === worker.generation && !this.closed) {
            // Do not let worker-side graceful shutdown call shutdown(2) on an
            // offered-but-uncommitted duplicate descriptor.
            worker.dispose();
            worker.handle.terminate("SIGKILL");
            this.active = null;
        }
        this.rejectSocket(socket);
    }
    rejectSocket(socket) {
        this.rejectedSockets += 1;
        socket.destroy();
        this.publishState();
    }
    recordFailure(generation, version, phase, message) {
        this.lastFailure = {
            at: new Date().toISOString(),
            generation,
            version,
            phase,
            message: message.slice(0, 1_000),
        };
    }
    publishState() {
        try {
            this.options.onStateChange?.(this.snapshot());
        }
        catch (error) {
            this.options.log?.(`[proxy-supervisor] failed to persist supervisor state: ${error instanceof Error ? error.message : String(error)}`);
        }
    }
}
//# sourceMappingURL=rollingWorkerSupervisor.js.map