477a00db1a
Testing / remote-protocol-compat (0.9.5) (push) Successful in 48s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 47s
Build & Publish Package / publish (push) Successful in 46s
Package Extension / package-extension (push) Successful in 59s
Testing / test (push) Failing after 50s
- Add a neutral WebExtension API adapter that uses Firefox browser.* or Chromium chrome.* without mutating globals. - Switch extension runtime code to the adapter and add Firefox-specific typings for tabs, windows, tab groups, storage, scripting, and native messaging ports. - Fix Firefox temporary add-on instructions to load the packaged manifest with background.scripts instead of the Chromium service worker manifest. - Detect Firefox in clients.list via runtime.getBrowserInfo and keep Chromium user-agent fallback support. - Make navigate.open wait briefly for Firefox to replace initial about:blank with the requested URL. - Add JS coverage for API selection, clients.list browser detection, and Firefox navigate.open URL polling. - Bump package and extension version to 0.15.2.
162 lines
5.9 KiB
TypeScript
162 lines
5.9 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;
|
|
|
|
// 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 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 };
|
|
}
|
|
}
|