Skip to content

[Feature][High] Fix ImageBitmap leak, selection timeout race, singleton init race, and watermark format mismatch #68

@numbers-official

Description

@numbers-official

Overview

Deep audit (2026-03-27) identified 5 new high-priority feature/reliability findings not covered by existing issues.


Finding 1: Watermark Always Outputs PNG, Ignoring User JPEG Setting

File: src/offscreen/offscreen.ts, line 166

addWatermark() always calls canvas.toDataURL('image/png') regardless of user's screenshotFormat setting. If user chose JPEG, they still get PNG output (3-10x larger for photos). cropImage() at line 117 has the same issue.

return { dataUrl: canvas.toDataURL('image/png') }; // Always PNG

Impact: Silently inflates file sizes, increases storage consumption and upload times.

Fix: Pass screenshotFormat and screenshotQuality through to the offscreen document message payload. Use canvas.toDataURL(mimeType, quality).


Finding 2: ImageBitmap Resource Leak on Every Capture

File: src/background/service-worker.ts, line 434

createImageBitmap() creates a GPU-backed bitmap to measure dimensions but .close() is never called. Each capture leaks ~8MB of uncompressed bitmap memory (1920x1080 @ 32bpp).

const img = await createImageBitmap(await (await fetch(dataUrl)).blob());
let width = img.width;
let height = img.height;
// img.close() never called

Fix: Call img.close() after reading dimensions. Or have the offscreen document return dimensions alongside the watermarked data URL.


Finding 3: Selection Timeout setTimeout Never Cleared

File: src/background/service-worker.ts, lines 187-199

The 60-second selection timeout return value is never stored and never cleared on completion. If user completes selection quickly, the old timeout fires 55s later. If user starts a second selection within 60s, the old timeout may interfere.

setTimeout(() => {  // Return value not stored
  if (pendingSelectionReject) {
    pendingSelectionReject(new Error('Selection timed out'));
  }
}, 60000);

Fix: Store the timeout ID; call clearTimeout() in handleSelectionComplete() and the catch block.


Finding 4: Race Condition in getNumbersApi() Singleton Init

File: src/services/NumbersApiManager.ts, lines 150-159

After instance = new NumbersApiManager() on line 155, instance is non-null but initialize() is still running. A concurrent caller sees instance !== null and returns a partially-initialized instance with no restored token.

export async function getNumbersApi(): Promise<NumbersApiManager> {
  if (!instance) {
    instance = new NumbersApiManager();
    await instance.initialize(); // Yields here; concurrent callers skip
  }
  return instance;
}

Fix: Use a promise-based lock:

let initPromise: Promise<NumbersApiManager> | null = null;
export function getNumbersApi(): Promise<NumbersApiManager> {
  if (!initPromise) {
    initPromise = (async () => {
      const inst = new NumbersApiManager();
      await inst.initialize();
      return inst;
    })();
  }
  return initPromise;
}

Finding 5: ScreenshotService.ts is 278 Lines of Dead Code

File: src/services/ScreenshotService.ts (entire file)

ScreenshotService and its exported singleton are never imported by any other file. All actual capture logic lives in service-worker.ts lines 404-581. 278 lines of dead code increasing bundle size and cognitive load.

Fix: Remove ScreenshotService.ts entirely, or refactor service-worker capture logic to actually use it.


Generated by Heart Beat with Omni

Metadata

Metadata

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions