/** * 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 | null; idleTimer: ReturnType | null; pendingPing: { resolve: () => void; reject: (e: Error) => void; sentAt: number; } | null; } // ─── WorkerManager ──────────────────────────────────────────────────────────── export class WorkerManager { private handles = new Map(); 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 { if (handle.pendingPing) return; const sentAt = Date.now(); const result = await new Promise((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();