AUDIT / src /lib /runtime /WorkerManager.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 4)
641b62c verified
Raw
History Blame Contribute Delete
12.5 kB
/**
* WorkerManager.ts — Worker lifecycle management (FASE 4.4)
*
* Gestisce le istanze Worker (js + py runners):
* - Ping/pong health check ogni 60s per verificare Worker alive
* - Terminate + respawn automatico se Worker non risponde a 3 ping consecutivi
* - Status API per RuntimeDiagnosticsPanel
* - Lazy spawn: Worker creati solo alla prima run (non al boot — evita init iOS lento)
*
* P6-1: gestisce il cap a 2 Worker su iOS — su iOS termina il Worker di tipo
* opposto prima di spawnare (js↔py swap). Stream Worker è fuori da questo scope.
* JS+Py sono serializzati da executionQueue → nessuna race condition.
*
* Non rimpiazza WorkerPool in RuntimeManager — wrappa il suo lifecycle.
*
* S37 — WorkerManager Safari-safe:
* + Idle auto-terminate: nessuna attività per IDLE_TIMEOUT_MS → auto-terminate
* + Idle timer reset: ogni ensureSpawned() resetta il timer
* + DI: _setWorkerFactory / _resetWorkerFactory — test senza browser Worker
* + isWorkerSupported(): check statico per ambiente che non supporta Worker
* + idleTimeoutMs configurable (default 60s)
*
* S246-Fix: Worker URL devono usare import.meta.url statico per ogni worker
* Vite analizza staticamente new URL("...", import.meta.url) — se il path
* è una variabile runtime, Vite NON bundla il worker → 404 in produzione
* → browser riceve HTML → TypeError: text/html is not a valid JavaScript MIME type
*/
export type WorkerType = "js" | "py";
export type WorkerHealth = "unknown" | "alive" | "degraded" | "dead";
export interface WorkerStatus {
type: WorkerType;
health: WorkerHealth;
spawned: boolean;
lastPingMs: number | null;
failedPings: number;
spawnCount: number;
lastUsedAt: number | null;
}
// ─── Worker interface (S37: DI-friendly) ─────────────────────────────────────
export interface WorkerLike {
onmessage: ((this: Worker, ev: MessageEvent) => void) | null;
onerror: ((this: AbstractWorker, ev: ErrorEvent) => void) | null;
postMessage(message: unknown): void;
terminate(): void;
addEventListener(type: "message", listener: (ev: MessageEvent) => void, opts?: boolean | AddEventListenerOptions): void;
addEventListener(type: "error", listener: (ev: ErrorEvent) => void, opts?: boolean | AddEventListenerOptions): void;
removeEventListener(type: "message", listener: (ev: MessageEvent) => void, opts?: boolean | EventListenerOptions): void;
removeEventListener(type: "error", listener: (ev: ErrorEvent) => void, opts?: boolean | EventListenerOptions): void;
}
// ─── DI: Worker factory ───────────────────────────────────────────────────────
export type WorkerFactory = (type: WorkerType) => WorkerLike;
// S246-Fix: factory usa URL statici per tipo — Vite può analizzarli staticamente
// e bundlare correttamente ogni worker nel build di produzione.
// NON passare URL come stringa runtime — Vite non li può analizzare.
const _defaultFactory: WorkerFactory = (type: WorkerType): WorkerLike => {
if (type === "js") {
return new Worker(
new URL("../../workers/jsRunner.worker.ts", import.meta.url),
{ type: "module" },
) as WorkerLike;
}
return new Worker(
new URL("../../workers/pyRunner.worker.ts", import.meta.url),
{ type: "module" },
) as WorkerLike;
};
let _workerFactory: WorkerFactory = _defaultFactory;
/** Solo per test — inietta una factory che crea mock Worker. */
export function _setWorkerFactory(factory: WorkerFactory): void {
_workerFactory = factory;
}
/** Solo per test — ripristina la factory reale. */
export function _resetWorkerFactory(): void {
_workerFactory = _defaultFactory;
}
// ─── P6-1: iOS Worker cap ─────────────────────────────────────────────────────
// iOS Safari: max 2 Worker concorrenti. lib/workerManager.ts occupa slot 1 (streamWorker).
// → WorkerManager può usare al massimo 1 code Worker alla volta su iOS.
// executionQueue serializza js e py → mai due run in parallelo → swap sicuro.
function _isIOS(): boolean {
if (typeof navigator === "undefined") return false;
return /iPhone|iPad|iPod/.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
}
/** @internal — testabile via _setIOSOverride */
let _iosOverride: boolean | null = null;
export function _setIOSOverride(v: boolean | null): void { _iosOverride = v; }
function _isIOSEffective(): boolean { return _iosOverride !== null ? _iosOverride : _isIOS(); }
// ─── Constants ────────────────────────────────────────────────────────────────
const PING_INTERVAL_MS = 60_000;
const PING_TIMEOUT_MS = 5_000;
const MAX_FAILED_PINGS = 3;
const IDLE_TIMEOUT_MS = 60_000;
// ─── Internal handle ──────────────────────────────────────────────────────────
interface WorkerHandle {
worker: WorkerLike;
type: WorkerType;
status: WorkerStatus;
pingTimer: ReturnType<typeof setInterval> | null;
idleTimer: ReturnType<typeof setTimeout> | null;
pendingPing: {
resolve: () => void;
reject: (e: Error) => void;
sentAt: number;
} | null;
}
// ─── WorkerManager ────────────────────────────────────────────────────────────
export class WorkerManager {
private handles = new Map<WorkerType, WorkerHandle>();
private listeners = new Set<(status: WorkerStatus) => void>();
private _idleTimeoutMs: number;
constructor(idleTimeoutMs = IDLE_TIMEOUT_MS) {
this._idleTimeoutMs = idleTimeoutMs;
}
// ─── Public API ────────────────────────────────────────────────────────────
/** Ritorna lo status corrente di un Worker (senza spawnarlo). */
getStatus(type: WorkerType): WorkerStatus {
return this.handles.get(type)?.status ?? {
type, health: "unknown", spawned: false,
lastPingMs: null, failedPings: 0, spawnCount: 0, lastUsedAt: null,
};
}
/** Ritorna lo status di tutti i Worker gestiti. */
getAllStatuses(): WorkerStatus[] {
return (["js", "py"] as WorkerType[]).map(t => this.getStatus(t));
}
/** Abbona a cambi di status (per diagnostics panel). */
onStatusChange(fn: (s: WorkerStatus) => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
/**
* Spawn esplicito (chiamato da RuntimeManager prima della prima run).
* S37: resetta l'idle timer ad ogni chiamata — il worker è "in uso".
*/
ensureSpawned(type: WorkerType): WorkerLike {
const existing = this.handles.get(type);
if (existing) {
this._resetIdleTimer(existing);
existing.status.lastUsedAt = Date.now();
return existing.worker;
}
return this.spawn(type);
}
/** Termina un Worker e lo rimuove. */
terminate(type: WorkerType): void {
const handle = this.handles.get(type);
if (!handle) return;
if (handle.pingTimer) clearInterval(handle.pingTimer);
if (handle.idleTimer) clearTimeout(handle.idleTimer);
handle.worker.terminate();
this.handles.delete(type);
this.notify({ ...handle.status, health: "dead", spawned: false });
}
/** Termina tutti i Worker (cleanup globale, es. prima di unmount). */
terminateAll(): void {
for (const type of Array.from(this.handles.keys())) {
this.terminate(type);
}
}
/**
* S37: Controlla se i Web Worker sono supportati nell'ambiente corrente.
* Ritorna false in Node.js (test), SSR, o browser molto datati.
*/
static isWorkerSupported(): boolean {
return typeof Worker !== "undefined";
}
// ─── Internal ──────────────────────────────────────────────────────────────
private spawn(type: WorkerType): WorkerLike {
// P6-1: iOS Worker cap — termina il Worker di tipo opposto prima di spawnare.
// Garantisce che js+py non coesistano mai su iOS (cap=2, slot1=streamWorker).
if (_isIOSEffective()) {
const other: WorkerType = type === "js" ? "py" : "js";
if (this.handles.has(other)) {
this.terminate(other); // graceful: postMessage(shutdown) + terminate()
}
}
// S246-Fix: factory riceve il tipo, non l'URL — URL statici dentro la factory
let worker: WorkerLike;
try {
worker = _workerFactory(type);
} catch {
// Worker non supportato o errore init → ritorna stub no-op
const stub: WorkerLike = {
onmessage: null,
onerror: null,
postMessage: () => {},
terminate: () => {},
addEventListener: () => {},
removeEventListener: () => {},
};
return stub;
}
const status: WorkerStatus = {
type, health: "unknown", spawned: true,
lastPingMs: null, failedPings: 0,
spawnCount: (this.handles.get(type)?.status.spawnCount ?? 0) + 1,
lastUsedAt: Date.now(),
};
const handle: WorkerHandle = {
worker, type, status,
pingTimer: null, idleTimer: null, pendingPing: null,
};
worker.onmessage = (e: MessageEvent<{ type: string }>) => {
if (e.data.type === "ready") {
handle.status.health = "alive";
handle.status.failedPings = 0;
this.notify({ ...handle.status });
this.startHealthCheck(handle);
this._resetIdleTimer(handle);
}
if (e.data.type === "pong") {
this.resolvePing(handle);
}
};
worker.onerror = () => {
handle.status.health = "dead";
handle.status.failedPings++;
this.notify({ ...handle.status });
if (handle.status.spawnCount < 3) {
this.handles.delete(type);
this.spawn(type);
}
};
this.handles.set(type, handle);
worker.postMessage({ type: "ping" });
return worker;
}
private _resetIdleTimer(handle: WorkerHandle): void {
if (handle.idleTimer) clearTimeout(handle.idleTimer);
handle.idleTimer = setTimeout(() => {
this.terminate(handle.type);
}, this._idleTimeoutMs);
}
private startHealthCheck(handle: WorkerHandle): void {
if (handle.pingTimer) return;
handle.pingTimer = setInterval(() => {
this.ping(handle);
}, PING_INTERVAL_MS);
}
private async ping(handle: WorkerHandle): Promise<void> {
if (handle.pendingPing) return;
const sentAt = Date.now();
const result = await new Promise<void>((resolve, reject) => {
handle.pendingPing = { resolve, reject, sentAt };
handle.worker.postMessage({ type: "ping" });
setTimeout(() => {
if (handle.pendingPing) {
handle.pendingPing.reject(new Error("ping timeout"));
handle.pendingPing = null;
}
}, PING_TIMEOUT_MS);
}).catch(() => null);
if (result === null) {
handle.status.failedPings++;
handle.status.health = handle.status.failedPings >= MAX_FAILED_PINGS ? "dead" : "degraded";
this.notify({ ...handle.status });
if (handle.status.failedPings >= MAX_FAILED_PINGS) {
const type = handle.type;
this.terminate(type);
if (handle.status.spawnCount < 3) this.spawn(type);
}
}
}
private resolvePing(handle: WorkerHandle): void {
if (!handle.pendingPing) return;
const ms = Date.now() - handle.pendingPing.sentAt;
handle.pendingPing.resolve();
handle.pendingPing = null;
handle.status.lastPingMs = ms;
handle.status.failedPings = 0;
handle.status.health = "alive";
this.notify({ ...handle.status });
}
private notify(status: WorkerStatus): void {
for (const fn of this.listeners) {
try { fn(status); } catch { /* ignore */ }
}
}
}
/** Singleton globale — importa questo, non costruire istanze. */
export const workerManager = new WorkerManager();