diff --git a/extension/src/classes/JobManager.ts b/extension/src/classes/JobManager.ts index b4517c0..bc0a162 100644 --- a/extension/src/classes/JobManager.ts +++ b/extension/src/classes/JobManager.ts @@ -15,6 +15,11 @@ import type { Job, Serializable, ErrorLike, DispatchArgs } from '../types'; // 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. @@ -77,6 +82,11 @@ export class JobManager { } 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, diff --git a/extension/src/commands/perf.ts b/extension/src/commands/perf.ts index ea86e84..ca4fe45 100644 --- a/extension/src/commands/perf.ts +++ b/extension/src/commands/perf.ts @@ -1,7 +1,7 @@ import { getLargeOperationThrottle, getPerformanceProfile, hasAudibleTabs, setPerformanceProfile } from '../core'; import { CommandGroup } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup'; -import type { PerfSetProfileArgs, JobIdArgs } from '../types'; +import type { Job, PerfSetProfileArgs, JobIdArgs } from '../types'; // PerfCommands also owns the jobs.* status/cancel queries: they read the same // JobManager (ctx.jobs) that perf.status reports, and there is no dedicated @@ -15,6 +15,19 @@ export class PerfCommands extends CommandGroup { "jobs.cancel": (a: JobIdArgs) => this.ctx.jobs.cancel(a), }; + private jobSummary(job: Job) { + return { + id: job.id, + command: job.command, + status: job.status, + phase: job.phase, + current: job.current, + total: job.total, + percent: job.percent, + cancelRequested: job.cancelRequested, + }; + } + private async perfStatus() { const profile = await getPerformanceProfile(); const audible = await hasAudibleTabs(); @@ -23,16 +36,7 @@ export class PerfCommands extends CommandGroup { performanceProfile: profile, audible, throttle, - jobs: this.ctx.jobs.list().map(job => ({ - id: job.id, - command: job.command, - status: job.status, - phase: job.phase, - current: job.current, - total: job.total, - percent: job.percent, - cancelRequested: job.cancelRequested, - })), + jobs: this.ctx.jobs.list().map(job => this.jobSummary(job)), }; } } diff --git a/extension/src/commands/windows.ts b/extension/src/commands/windows.ts index 761b66d..3c414e3 100644 --- a/extension/src/commands/windows.ts +++ b/extension/src/commands/windows.ts @@ -14,9 +14,31 @@ export class WindowsCommands extends CommandGroup { "windows.open": (a: WindowsOpenArgs) => this.windowsOpen(a), }; + private async activeWindowIds(): Promise> { + const windows = await api.windows.getAll({}); + return new Set(windows.map(w => w.id).filter(id => typeof id === "number")); + } + + private async pruneAliases(activeIds?: Set): Promise> { + const aliases = await getAliases(); + const liveIds = activeIds || await this.activeWindowIds(); + const pruned: Record = {}; + let changed = false; + for (const [id, alias] of Object.entries(aliases)) { + if (liveIds.has(Number(id))) { + pruned[id] = alias; + } else { + changed = true; + } + } + if (changed) await api.storage.local.set({ windowAliases: pruned }); + return pruned; + } + private async windowsList() { const windows = await api.windows.getAll({ populate: true }); - const aliases = await getAliases(); + const activeIds = new Set(windows.map(w => w.id).filter(id => typeof id === "number")); + const aliases = await this.pruneAliases(activeIds); return windows.map(w => ({ id: w.id, alias: aliases[w.id] || null, @@ -27,7 +49,7 @@ export class WindowsCommands extends CommandGroup { } private async windowsRename({ windowId, name }: WindowsRenameArgs) { - const aliases = await getAliases(); + const aliases = await this.pruneAliases(); aliases[windowId] = name; await api.storage.local.set({ windowAliases: aliases }); return { windowId, name }; @@ -35,6 +57,11 @@ export class WindowsCommands extends CommandGroup { private async windowsClose({ windowId }: WindowsCloseArgs) { await api.windows.remove(windowId); + const aliases = await this.pruneAliases(); + if (windowId in aliases) { + delete aliases[windowId]; + await api.storage.local.set({ windowAliases: aliases }); + } return { windowId }; } diff --git a/extension/test/jobs.test.ts b/extension/test/jobs.test.ts index f5b8dac..3bc0fa9 100644 --- a/extension/test/jobs.test.ts +++ b/extension/test/jobs.test.ts @@ -1,7 +1,7 @@ // @ts-nocheck import { test, mock } from 'node:test'; import assert from 'node:assert/strict'; -import { JobManager, JOB_TIMEOUT_MS, MAX_FINISHED_JOBS, pruneFinishedJobs } from '../src/classes/JobManager'; +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 @@ -129,6 +129,21 @@ test('JobManager: a runner that settles after the watchdog cannot resurrect the 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(); diff --git a/extension/test/windows.test.ts b/extension/test/windows.test.ts new file mode 100644 index 0000000..1ef41e6 --- /dev/null +++ b/extension/test/windows.test.ts @@ -0,0 +1,69 @@ +// @ts-nocheck +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { WindowsCommands } from '../src/commands/windows'; +import { makeChromeMock } from './chrome-mock'; + +function makeWindowsChromeMock(windows) { + const chrome = makeChromeMock(); + chrome.windows = { + getAll: async () => windows, + remove: async () => {}, + create: async () => ({ id: 99 }), + }; + return chrome; +} + +test('windows.list prunes aliases for closed windows', async () => { + globalThis.chrome = makeWindowsChromeMock([ + { id: 1, focused: true, state: 'normal', tabs: [{ id: 10 }] }, + { id: 2, focused: false, state: 'minimized', tabs: [] }, + ]); + globalThis.chrome.storage.local._store.windowAliases = { + 1: 'main', + 2: 'side', + 999: 'closed', + }; + + const commands = new WindowsCommands({ jobs: {} }); + const result = await commands.commands['windows.list']({}); + + assert.deepEqual(result.map(w => [w.id, w.alias]), [[1, 'main'], [2, 'side']]); + assert.deepEqual(globalThis.chrome.storage.local._store.windowAliases, { 1: 'main', 2: 'side' }); +}); + +test('windows.rename prunes stale aliases before saving the new name', async () => { + globalThis.chrome = makeWindowsChromeMock([ + { id: 1, focused: true, state: 'normal', tabs: [] }, + { id: 2, focused: false, state: 'normal', tabs: [] }, + ]); + globalThis.chrome.storage.local._store.windowAliases = { + 1: 'main', + 999: 'closed', + }; + + const commands = new WindowsCommands({ jobs: {} }); + await commands.commands['windows.rename']({ windowId: 2, name: 'work' }); + + assert.deepEqual(globalThis.chrome.storage.local._store.windowAliases, { 1: 'main', 2: 'work' }); +}); + +test('windows.close removes the closed window alias immediately', async () => { + let removed = null; + globalThis.chrome = makeWindowsChromeMock([ + { id: 1, focused: true, state: 'normal', tabs: [] }, + { id: 2, focused: false, state: 'normal', tabs: [] }, + ]); + globalThis.chrome.windows.remove = async id => { removed = id; }; + globalThis.chrome.storage.local._store.windowAliases = { + 1: 'main', + 2: 'side', + 999: 'closed', + }; + + const commands = new WindowsCommands({ jobs: {} }); + await commands.commands['windows.close']({ windowId: 2 }); + + assert.equal(removed, 2); + assert.deepEqual(globalThis.chrome.storage.local._store.windowAliases, { 1: 'main' }); +});