Spaces:
Sleeping
Sleeping
| /** | |
| * 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(); | |