/* eslint-disable @typescript-eslint/no-explicit-any */ /** * SandboxRunner — main-thread controller of the sandbox Web Worker. * * Responsibilities: * - worker lifecycle (create / terminate+recreate on watchdog timeout) * - watchdogs: compile >3s, full run >5s, true-incremental update >200ms * - incremental strategy: feature-detected from the engine's capabilities * reported at compile time; engines without incremental support fall back * to FULL recomputes throttled to at most one per second (trailing-edge * coalescing — the newest bars always win) * - transparent re-compile after a worker was killed (source is remembered) * * No eval / new Function on the main thread — all script execution happens * inside the worker. CSP: the worker is same-origin (`worker-src 'self'`). */ import type { Bar, CompileMeta, RunResult } from "../engine/types"; import type { FromWorker, ToWorker } from "./protocol"; export const COMPILE_TIMEOUT_MS = 3_000; export const FULL_RUN_TIMEOUT_MS = 5_000; export const INCREMENTAL_TIMEOUT_MS = 200; export const FALLBACK_MIN_INTERVAL_MS = 1_000; export const MSG_TOO_SLOW = "اسکریپت بیش از حد کند است"; export type SandboxError = Error & { kind: "compile" | "timeout" | "run"; line?: number }; function sandboxError(kind: SandboxError["kind"], message: string, line?: number): SandboxError { const e = new Error(message) as SandboxError; e.kind = kind; e.line = line; return e; } type Pending = { resolve: (msg: FromWorker) => void; timer: ReturnType; }; export class SandboxRunner { private worker: Worker | null = null; private reqSeq = 0; private pending = new Map(); private source: string | null = null; private meta: CompileMeta | null = null; private incrementalCapable = false; private compiledInWorker = false; /** fallback throttle state */ private lastFullRunAt = 0; private trailing: { bars: Bar[]; inputs: Record } | null = null; private trailingTimer: ReturnType | null = null; private trailingWaiters: { resolve: (r: RunResult) => void; reject: (e: unknown) => void }[] = []; constructor(private readonly onSlow?: () => void) {} /* ─────────────────────────── worker lifecycle ─────────────────────────── */ private ensureWorker(): Worker { if (this.worker) return this.worker; this.worker = new Worker(new URL("./sandbox.worker.ts", import.meta.url), { type: "module" }); this.worker.onmessage = (ev: MessageEvent) => { const p = this.pending.get(ev.data.reqId); if (!p) return; clearTimeout(p.timer); this.pending.delete(ev.data.reqId); p.resolve(ev.data); }; this.compiledInWorker = false; return this.worker; } /** Kill and recreate the worker (watchdog fired or hard error). */ private recycle() { try { this.worker?.terminate(); } catch { /* already dead */ } this.worker = null; this.compiledInWorker = false; for (const [, p] of this.pending) clearTimeout(p.timer); this.pending.clear(); } dispose() { this.recycle(); if (this.trailingTimer) clearTimeout(this.trailingTimer); this.trailingWaiters.forEach((w) => w.reject(sandboxError("run", "سندباکس بسته شد"))); this.trailingWaiters = []; } private send(msg: ToWorker, timeoutMs: number): Promise { const w = this.ensureWorker(); return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(msg.reqId); this.recycle(); this.onSlow?.(); reject(sandboxError("timeout", MSG_TOO_SLOW)); }, timeoutMs); this.pending.set(msg.reqId, { resolve, timer }); w.postMessage(msg); }); } /* ────────────────────────────── public API ────────────────────────────── */ async compile(source: string): Promise { const reqId = ++this.reqSeq; const res = await this.send({ type: "compile", reqId, source }, COMPILE_TIMEOUT_MS); if (res.type === "compileError") throw sandboxError("compile", res.message, res.line); if (res.type !== "compiled") throw sandboxError("run", "پاسخ نامعتبر از سندباکس"); this.source = source; this.meta = res.meta; this.incrementalCapable = res.capabilities.incremental; this.compiledInWorker = true; return res.meta; } get compileMeta(): CompileMeta | null { return this.meta; } /** Re-send compile after a worker recycle (source already validated). */ private async ensureCompiled(): Promise { if (this.compiledInWorker) return; if (!this.source) throw sandboxError("run", "اسکریپت هنوز کامپایل نشده است"); const reqId = ++this.reqSeq; const res = await this.send({ type: "compile", reqId, source: this.source }, COMPILE_TIMEOUT_MS); if (res.type !== "compiled") throw sandboxError("run", "بازسازی سندباکس ناموفق بود"); this.compiledInWorker = true; } async runFull(bars: Bar[], inputs: Record): Promise { await this.ensureCompiled(); const reqId = ++this.reqSeq; this.lastFullRunAt = Date.now(); const res = await this.send({ type: "run", reqId, bars, inputs }, FULL_RUN_TIMEOUT_MS); return unwrapResult(res); } /** * New/updated tail bars. True-incremental engines get the tight 200ms * watchdog; fallback engines get a throttled (≤1/s) full recompute. */ async runIncremental(bars: Bar[], inputs: Record): Promise { await this.ensureCompiled(); if (this.incrementalCapable) { const reqId = ++this.reqSeq; const res = await this.send({ type: "runIncremental", reqId, bars, inputs }, INCREMENTAL_TIMEOUT_MS); return unwrapResult(res); } // Fallback path — coalesce updates, at most one full recompute per second. return new Promise((resolve, reject) => { this.trailing = { bars: mergeTail(this.trailing?.bars ?? [], bars), inputs }; this.trailingWaiters.push({ resolve, reject }); if (this.trailingTimer) return; // already scheduled — coalesced const delay = Math.max(0, FALLBACK_MIN_INTERVAL_MS - (Date.now() - this.lastFullRunAt)); this.trailingTimer = setTimeout(async () => { this.trailingTimer = null; const batch = this.trailing!; const waiters = this.trailingWaiters; this.trailing = null; this.trailingWaiters = []; try { await this.ensureCompiled(); const reqId = ++this.reqSeq; this.lastFullRunAt = Date.now(); const res = await this.send( { type: "runIncremental", reqId, bars: batch.bars, inputs: batch.inputs }, FULL_RUN_TIMEOUT_MS, ); const r = unwrapResult(res); waiters.forEach((w) => w.resolve(r)); } catch (e) { waiters.forEach((w) => w.reject(e)); } }, delay); }); } } function unwrapResult(res: FromWorker): RunResult { if (res.type === "result") return { plots: res.plots, alerts: res.alerts, warnings: res.warnings }; const message = res.type === "runError" || res.type === "compileError" ? res.message : "پاسخ نامعتبر از سندباکس"; throw sandboxError("run", message); } /** Coalesce trailing tail-bar batches (same-time bars: newest wins). */ function mergeTail(a: Bar[], b: Bar[]): Bar[] { if (a.length === 0) return [...b]; const out = [...a]; for (const bar of b) { const last = out[out.length - 1]; if (bar.time > last.time) out.push(bar); else if (bar.time === last.time) out[out.length - 1] = bar; } return out; }