Skip to content

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

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
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions