Two service-worker leaks. Running jobs were only bounded by their watchdog, so a command flood could hold many timers and results for up to JOB_TIMEOUT_MS; cap concurrent jobs at 32 and reject beyond that, since a clear error beats an unresponsive worker. Window aliases were never removed, so storage kept an entry for every window the user had ever renamed. Prune aliases against the live window set on list, rename, and close rather than only on close, because windows also disappear without going through windows.close. Also extract the repeated job summary in perf.status into a helper.
172 lines
6.4 KiB
TypeScript
172 lines
6.4 KiB
TypeScript
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;
|
|
|
|
// Cap simultaneously running background jobs. A hung job has a watchdog, but a
|
|
// command flood could still pin many timers/results for up to JOB_TIMEOUT_MS.
|
|
// Rejecting above this bound keeps service-worker memory predictable.
|
|
export const MAX_RUNNING_JOBS = 32;
|
|
|
|
// 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<string, Job>,
|
|
max = MAX_FINISHED_JOBS,
|
|
clearTimer: (handle: NonNullable<Job["__timer"]>) => 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<Serializable>;
|
|
|
|
/** Owns the live job map plus its persistence + retention lifecycle. */
|
|
export class JobManager {
|
|
private readonly jobs = new Map<string, Job>();
|
|
|
|
/** 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 runningCount = [...this.jobs.values()].filter(job => job.status === "running").length;
|
|
if (runningCount >= MAX_RUNNING_JOBS) {
|
|
throw new Error(`too many background jobs running (${runningCount}); wait for jobs to finish or cancel one`);
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|