import { webExtApi as api } from '../browser-api'; /** * Background-job retention helpers + the JobManager that owns the live job map. * * `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of api.* / * service-worker side effects so the retention logic (memory-leak guard) can be * unit-tested in isolation. */ import { getErrorMessage } from '../core'; import type { Job, Serializable, ErrorLike, DispatchArgs } from '../types'; // Cap retained finished jobs so the in-memory Map cannot grow unbounded while // the service worker is pinned alive by the native port. Kept small: finished // jobs only need to survive long enough for the CLI to poll their result. export const MAX_FINISHED_JOBS = 20; // Watchdog: if a runner never resolves/rejects (e.g. executeScript against a // dead tab), finalize the job as an error so its persist interval stops instead // of writing to api.storage.local every second forever. export const JOB_TIMEOUT_MS = 5 * 60 * 1000; /** * Evict the oldest *finished* jobs from `jobs` once their count exceeds `max`. * Running jobs are always kept. Each evicted job's persistence timer (if any) * is cleared via `clearTimer` so no interval is leaked. * * Returns the list of evicted job ids (useful for assertions/logging). */ export function pruneFinishedJobs( jobs: Map, max = MAX_FINISHED_JOBS, clearTimer: (handle: NonNullable) => void = clearInterval, ): string[] { const finished = [...jobs.values()].filter(job => job.status !== "running"); const excess = finished.length - max; if (excess <= 0) return []; const evicted = finished .sort((a, b) => (a.finishedAt || 0) - (b.finishedAt || 0)) .slice(0, excess); for (const job of evicted) { if (job.__timer) clearTimer(job.__timer); jobs.delete(job.id); } return evicted.map(job => job.id); } /** Runs a background command, threading the live `__job` handle for progress. */ export type JobRunner = (args: DispatchArgs) => Promise; /** Owns the live job map plus its persistence + retention lifecycle. */ export class JobManager { private readonly jobs = new Map(); /** Snapshot of live jobs (for perf.status etc.). */ list(): Job[] { return [...this.jobs.values()]; } private async persistJobs() { // Persist all running jobs plus the most recent finished ones. Running jobs // must never be dropped by the cap, otherwise a burst of finished jobs could // evict a still-active job from storage. Live timer handles are stripped — // they are not meaningful once stored. const all = [...this.jobs.values()]; const running = all.filter(job => job.status === "running"); const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS); const recentJobs = [...running, ...finished].map(({ __timer, __watchdog, ...rest }) => rest); await api.storage.local.set({ recentJobs }); } // Evict the oldest finished jobs once their count exceeds the retention cap. // Recent finished jobs remain queryable via api.storage.local (persistJobs) // even after eviction from the in-memory Map. private pruneJobs() { pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS); } async start(command: string, args: DispatchArgs, runner: JobRunner) { const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; const job: Job = { id: jobId, command, status: "running", phase: "queued", current: 0, total: null, percent: 0, cancelRequested: false, startedAt: Date.now(), updatedAt: Date.now(), finishedAt: null, result: null, error: null, }; this.jobs.set(jobId, job); job.__timer = setInterval(() => this.persistJobs(), 1000); job.__watchdog = setTimeout(() => { job.cancelRequested = true; void this.finalize(job, "error", { error: `Job timed out after ${JOB_TIMEOUT_MS}ms` }); }, JOB_TIMEOUT_MS); await this.persistJobs(); runner({ ...args, __job: job }) .then(result => this.finalize(job, "done", { result })) .catch((error: ErrorLike) => this.finalize(job, job.cancelRequested ? "cancelled" : "error", { error: getErrorMessage(error), }), ); return { jobId, command, status: job.status }; } /** * Move a running job to a terminal state exactly once. Late settlements (a * runner that resolves after the watchdog already fired) are ignored so a * timed-out job cannot be resurrected. Clears both the persist interval and * the watchdog so no timer outlives the job. */ private async finalize( job: Job, status: "done" | "error" | "cancelled", { result = null, error = null }: { result?: Serializable; error?: string | null }, ) { if (job.status !== "running") return; job.status = status; job.phase = status; if (status === "done") { job.result = result; job.current = job.total || job.current; job.percent = 100; } else { job.error = error; } job.finishedAt = Date.now(); job.updatedAt = Date.now(); if (job.__timer) clearInterval(job.__timer); if (job.__watchdog) clearTimeout(job.__watchdog); job.__timer = null; job.__watchdog = null; this.pruneJobs(); await this.persistJobs(); } async status({ jobId }: { jobId?: string }) { const job = this.jobs.get(jobId); if (job) return { ...job }; const { recentJobs } = await api.storage.local.get<{ recentJobs?: Job[] }>("recentJobs"); const stored = (recentJobs || []).find(entry => entry.id === jobId); if (!stored) throw new Error(`Job '${jobId}' not found`); return stored; } async cancel({ jobId }: { jobId?: string }) { const job = this.jobs.get(jobId); if (!job) throw new Error(`Job '${jobId}' not running`); job.cancelRequested = true; job.updatedAt = Date.now(); await this.persistJobs(); return { jobId, cancelled: true }; } }