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.
162 lines
6.3 KiB
TypeScript
162 lines
6.3 KiB
TypeScript
// @ts-nocheck
|
|
import { test, mock } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { JobManager, JOB_TIMEOUT_MS, MAX_FINISHED_JOBS, MAX_RUNNING_JOBS, pruneFinishedJobs } from '../src/classes/JobManager';
|
|
import { makeChromeMock } from './chrome-mock';
|
|
|
|
// Drain pending microtasks (finalize() chains several awaits). setImmediate is
|
|
// never mocked here, so it fires after the current microtask queue is empty.
|
|
const flush = () => new Promise(r => setImmediate(r));
|
|
|
|
function makeJobs(specs) {
|
|
const map = new Map();
|
|
for (const s of specs) map.set(s.id, { __timer: null, ...s });
|
|
return map;
|
|
}
|
|
|
|
test('pruneFinishedJobs: keeps everything when under the cap', () => {
|
|
const jobs = makeJobs([
|
|
{ id: 'a', status: 'done', finishedAt: 1 },
|
|
{ id: 'b', status: 'done', finishedAt: 2 },
|
|
]);
|
|
const evicted = pruneFinishedJobs(jobs, 5);
|
|
assert.deepEqual(evicted, []);
|
|
assert.equal(jobs.size, 2);
|
|
});
|
|
|
|
test('pruneFinishedJobs: evicts the oldest finished jobs beyond the cap', () => {
|
|
const jobs = makeJobs([
|
|
{ id: 'old', status: 'done', finishedAt: 10 },
|
|
{ id: 'mid', status: 'error', finishedAt: 20 },
|
|
{ id: 'new', status: 'done', finishedAt: 30 },
|
|
]);
|
|
const evicted = pruneFinishedJobs(jobs, 2);
|
|
assert.deepEqual(evicted, ['old']);
|
|
assert.deepEqual([...jobs.keys()], ['mid', 'new']);
|
|
});
|
|
|
|
test('pruneFinishedJobs: never evicts running jobs, even past the cap', () => {
|
|
const jobs = makeJobs([
|
|
{ id: 'run1', status: 'running' },
|
|
{ id: 'run2', status: 'running' },
|
|
{ id: 'f1', status: 'done', finishedAt: 1 },
|
|
{ id: 'f2', status: 'done', finishedAt: 2 },
|
|
{ id: 'f3', status: 'done', finishedAt: 3 },
|
|
]);
|
|
pruneFinishedJobs(jobs, 1);
|
|
// both running kept; only newest finished (f3) kept
|
|
assert.deepEqual([...jobs.keys()].sort(), ['f3', 'run1', 'run2']);
|
|
});
|
|
|
|
test('pruneFinishedJobs: clears the timer of every evicted job (no interval leak)', () => {
|
|
const clear = mock.fn();
|
|
const jobs = makeJobs([
|
|
{ id: 'a', status: 'done', finishedAt: 1, __timer: 101 },
|
|
{ id: 'b', status: 'done', finishedAt: 2, __timer: 102 },
|
|
{ id: 'c', status: 'done', finishedAt: 3, __timer: 103 },
|
|
]);
|
|
pruneFinishedJobs(jobs, 1, clear);
|
|
const clearedArgs = clear.mock.calls.map(c => c.arguments[0]);
|
|
assert.ok(clearedArgs.includes(101));
|
|
assert.ok(clearedArgs.includes(102));
|
|
assert.ok(!clearedArgs.includes(103)); // surviving job's timer untouched
|
|
});
|
|
|
|
test('pruneFinishedJobs: treats missing finishedAt as oldest (evicted first)', () => {
|
|
const jobs = makeJobs([
|
|
{ id: 'nofin', status: 'done' },
|
|
{ id: 'has', status: 'done', finishedAt: 5 },
|
|
]);
|
|
pruneFinishedJobs(jobs, 1);
|
|
assert.deepEqual([...jobs.keys()], ['has']);
|
|
});
|
|
|
|
test('pruneFinishedJobs: defaults to MAX_FINISHED_JOBS and bounds the retained set', () => {
|
|
const specs = Array.from({ length: MAX_FINISHED_JOBS + 25 }, (_, i) => ({
|
|
id: `j${i}`, status: 'done', finishedAt: i,
|
|
}));
|
|
const jobs = makeJobs(specs);
|
|
pruneFinishedJobs(jobs);
|
|
assert.equal(jobs.size, MAX_FINISHED_JOBS);
|
|
// the 25 oldest (j0..j24) are gone; newest survive
|
|
assert.equal(jobs.has('j0'), false);
|
|
assert.equal(jobs.has('j24'), false);
|
|
assert.equal(jobs.has('j25'), true);
|
|
});
|
|
|
|
test('JobManager: a resolving runner finishes the job and clears its timers', async () => {
|
|
globalThis.chrome = makeChromeMock();
|
|
const mgr = new JobManager();
|
|
const { jobId } = await mgr.start('demo', {}, async () => ({ ok: 1 }));
|
|
await flush();
|
|
const job = await mgr.status({ jobId });
|
|
assert.equal(job.status, 'done');
|
|
assert.deepEqual(job.result, { ok: 1 });
|
|
assert.equal(job.percent, 100);
|
|
assert.equal(job.__timer ?? null, null);
|
|
assert.equal(job.__watchdog ?? null, null);
|
|
});
|
|
|
|
test('JobManager: watchdog finalizes a hung runner and stops the persist timer', async () => {
|
|
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
|
globalThis.chrome = makeChromeMock();
|
|
const mgr = new JobManager();
|
|
// Runner never settles — only the watchdog can finish this job.
|
|
const { jobId } = await mgr.start('hang', {}, () => new Promise(() => {}));
|
|
mock.timers.tick(JOB_TIMEOUT_MS);
|
|
await flush();
|
|
const job = await mgr.status({ jobId });
|
|
assert.equal(job.status, 'error');
|
|
assert.match(job.error, /timed out/);
|
|
assert.equal(job.cancelRequested, true);
|
|
assert.equal(job.__timer ?? null, null);
|
|
mock.timers.reset();
|
|
});
|
|
|
|
test('JobManager: a runner that settles after the watchdog cannot resurrect the job', async () => {
|
|
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
|
globalThis.chrome = makeChromeMock();
|
|
const mgr = new JobManager();
|
|
let settle;
|
|
const { jobId } = await mgr.start('late', {}, () => new Promise(r => { settle = r; }));
|
|
mock.timers.tick(JOB_TIMEOUT_MS);
|
|
await flush();
|
|
settle({ tooLate: true }); // runner resolves only after the watchdog fired
|
|
await flush();
|
|
const job = await mgr.status({ jobId });
|
|
assert.equal(job.status, 'error'); // stayed error — finalize() ran exactly once
|
|
assert.equal(job.result, null);
|
|
mock.timers.reset();
|
|
});
|
|
|
|
test('JobManager: rejects new background jobs above the running-job cap', async () => {
|
|
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
|
globalThis.chrome = makeChromeMock();
|
|
const mgr = new JobManager();
|
|
for (let i = 0; i < MAX_RUNNING_JOBS; i++) {
|
|
await mgr.start(`running${i}`, {}, () => new Promise(() => {}));
|
|
}
|
|
await assert.rejects(
|
|
() => mgr.start('overflow', {}, async () => 'nope'),
|
|
/too many background jobs running/,
|
|
);
|
|
assert.equal(mgr.list().filter(job => job.status === 'running').length, MAX_RUNNING_JOBS);
|
|
mock.timers.reset();
|
|
});
|
|
|
|
test('JobManager: persisted set keeps running jobs even past the finished cap', async () => {
|
|
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
|
globalThis.chrome = makeChromeMock();
|
|
const mgr = new JobManager();
|
|
await mgr.start('runner', {}, () => new Promise(() => {})); // stays running
|
|
for (let i = 0; i < MAX_FINISHED_JOBS + 5; i++) {
|
|
await mgr.start(`done${i}`, {}, async () => i);
|
|
}
|
|
await flush();
|
|
const stored = globalThis.chrome.storage.local._store.recentJobs;
|
|
const running = stored.filter(j => j.status === 'running');
|
|
assert.equal(running.length, 1, 'the running job must never be evicted from storage');
|
|
assert.ok(stored.length <= MAX_FINISHED_JOBS + 1, 'finished jobs stay capped');
|
|
mock.timers.reset();
|
|
});
|