From 1111e0f57d14eca332c45cf49160d065417c76e3 Mon Sep 17 00:00:00 2001 From: Rasul Abu Muhammad Amin Date: Sun, 13 Sep 2026 00:55:07 +0200 Subject: [PATCH 1/6] fix(content): start image inference watchdog when the task leaves the queue, fail closed on timeout On Firefox mobile the single-lane WASM queue takes well over 20 s to reach below-fold Google Images, so the send-time watchdog expired while tasks were still queued and finalized them as skipped, revealing a whole band unmasked. The background now broadcasts a 'started' result when a task is dequeued; the content watchdog arms from that moment. Exhausted timeouts keep the blur so the late prediction still applies; only explicit inference errors fail open. Claude-Session: https://claude.ai/code/session_01L7JHWoqQg8W4ExvRhfbj6g --- docs/MEDIA_PROCESSING.md | 15 ++++-- .../services/inferenceOrchestrationService.ts | 16 +++++++ entrypoints/content/core/ImageProcessor.ts | 47 +++++++++++++++---- utils/types/prediction.ts | 5 +- 4 files changed, 66 insertions(+), 17 deletions(-) diff --git a/docs/MEDIA_PROCESSING.md b/docs/MEDIA_PROCESSING.md index 64d61f5..3009216 100644 --- a/docs/MEDIA_PROCESSING.md +++ b/docs/MEDIA_PROCESSING.md @@ -682,11 +682,16 @@ owner is stalled (not loaded, e.g. a lazy copy in a hidden subtree) while the ne pixels. A duplicate send from a superseded owner is harmless — predictions are keyed by src and the second result is idempotent. The entry is deleted when a prediction for that src arrives. -Once a request is actually sent, a 20-second watchdog prevents that entry from living forever if the -background worker loses the task or inference fails without a prediction broadcast. It retries once -using a loaded, visible same-source copy when possible; a second timeout finalizes all pending -copies as `skipped`, matching the pipeline's existing inference-impossible behavior rather than -leaving Reddit images permanently under the initial blur. +Once a request is sent, two watchdogs guard the entry. A 120-second send-time guard covers a task +the background lost before it ever ran. The real 20-second inference watchdog starts only when the +background broadcasts `status: 'started'` for the src (the task left the queue), because queue wait +is unbounded on slow devices: single-lane WASM inference on Firefox mobile with a page of offscreen +Google Images ahead easily exceeded 20 s, and a queue-based timeout revealed the whole below-fold +band unmasked. Either watchdog retries once using a loaded, visible same-source copy when possible. +On the second timeout the images **fail closed**: the initial blur stays and the pending entry is +cleared, so the late prediction still applies when it arrives (`findImagesBySrc` matches blurred +images) and any later `process()` pass re-sends. Only an explicit `status: 'error'` result from the +background (inference genuinely impossible) finalizes the copies as `skipped` after two attempts. ### DOM Processing diff --git a/entrypoints/background/services/inferenceOrchestrationService.ts b/entrypoints/background/services/inferenceOrchestrationService.ts index 0b1ec9e..d9545ff 100644 --- a/entrypoints/background/services/inferenceOrchestrationService.ts +++ b/entrypoints/background/services/inferenceOrchestrationService.ts @@ -281,6 +281,7 @@ export class InferenceOrchestrationService { this.queueService.setTaskProcessingHandler(async (task: InferenceTask) => { this.markPlaybackFrameStarted(task); this.endQueueWait(task, 'started'); + this.notifyImageInferenceStarted(task); const attributes = taskAttributes(task); const runSpan = tracer.startSpan(SPAN.run, { attributes }, task.traceContext); const runStartedAt = Date.now(); @@ -462,6 +463,21 @@ export class InferenceOrchestrationService { } } + private notifyImageInferenceStarted(task: InferenceTask): void { + if (task.mediaMetadata.kind !== 'image') return; + this.sendImageResultsToContent( + [ + { + status: 'started', + src: task.imageSrc, + hostname: task.hostname, + traceparent: injectTraceparent(task.traceContext), + }, + ], + task.hostname, + ); + } + private sendImageResultsToContent(results: ImageInferenceResult[], hostname: string): void { try { if (this.onImagePredictionsCallback) { diff --git a/entrypoints/content/core/ImageProcessor.ts b/entrypoints/content/core/ImageProcessor.ts index ad81783..405b60e 100644 --- a/entrypoints/content/core/ImageProcessor.ts +++ b/entrypoints/content/core/ImageProcessor.ts @@ -69,6 +69,7 @@ const SVG_PATTERN = /\.svg(?:[?#]|$)|image\/svg\+xml/i; const MAX_CACHE_SIZE = 500; const SRC_STABILIZATION_DELAY = 150; const IMAGE_INFERENCE_TIMEOUT_MS = 20_000; +const IMAGE_QUEUE_WAIT_TIMEOUT_MS = 120_000; const MAX_IMAGE_INFERENCE_ATTEMPTS = 2; // Animated GIFs: cap tracked decode sessions and fail closed if frame verdicts @@ -308,8 +309,12 @@ export class ImageProcessor { */ handleInferenceResults(results: ImageInferenceResult[]): void { for (const result of results) { + if (result.status === 'started') { + this.handleInferenceStarted(result.src); + continue; + } if (result.status === 'error') { - this.handleInferenceFailure(result.src, result.reason, result.traceparent); + this.handleInferenceFailure(result.src, 'open', result.reason, result.traceparent); continue; } const pred = result.prediction; @@ -497,7 +502,7 @@ export class ImageProcessor { } try { - this.armInferenceWatchdog(src, img); + this.armInferenceWatchdog(src, img, IMAGE_QUEUE_WAIT_TIMEOUT_MS); const isVisible = this.visibilityMap.get(img) ?? false; const priority = isVisible ? INFERENCE_PRIORITY.visibleImage : INFERENCE_PRIORITY.offscreenImage; await requestImageInference(this.hostSettings.hostname, img, priority, getRoundtripContext(src)); @@ -559,7 +564,13 @@ export class ImageProcessor { } } - private armInferenceWatchdog(src: string, owner: HTMLImageElement): void { + /** + * Queue wait is unbounded on slow devices (single-lane WASM inference with + * a page of offscreen images ahead), so the send-time watchdog is only a + * lost-task guard; the real timeout starts when the background reports the + * task left the queue. + */ + private armInferenceWatchdog(src: string, owner: HTMLImageElement, timeoutMs: number): void { const existing = this.pendingInferenceTimers.get(src); if (existing) clearTimeout(existing); this.pendingInferenceTimers.set( @@ -567,17 +578,29 @@ export class ImageProcessor { setTimeout(() => { this.pendingInferenceTimers.delete(src); if (this.pendingInference.get(src) !== owner) return; - this.handleInferenceFailure(src); - }, IMAGE_INFERENCE_TIMEOUT_MS), + this.handleInferenceFailure(src, 'closed', 'timeout'); + }, timeoutMs), ); } + private handleInferenceStarted(src: string): void { + const owner = this.pendingInference.get(src); + if (!owner) return; + this.armInferenceWatchdog(src, owner, IMAGE_INFERENCE_TIMEOUT_MS); + } + /** - * A pending inference failed (errored result from background, or the - * watchdog fired with no reply). Retry with the best candidate element, - * failing open once attempts are exhausted. + * A pending inference failed. Retry with the best candidate element; once + * attempts are exhausted, an errored result fails open (inference is + * impossible for this image) while a timeout fails closed: the blur stays + * so a late prediction can still land, and the next process() pass retries. */ - private handleInferenceFailure(src: string, reason?: string, traceparent?: string): void { + private handleInferenceFailure( + src: string, + exhaustedMode: 'open' | 'closed', + reason?: string, + traceparent?: string, + ): void { if (!this.pendingInference.has(src)) return; const tracksRoundtrip = roundtripMatches(src, traceparent); this.clearPendingInference(src); @@ -591,9 +614,13 @@ export class ImageProcessor { endRoundtrip(src, { status: 'error', error: new Error(`Image inference failed after ${attempts} attempts${reason ? `: ${reason}` : ''}`), - attributes: { attempts }, + attributes: { attempts, exhaustedMode }, }); } + if (exhaustedMode === 'closed') { + log.warn('inference.exhausted.fail_closed', { [ATTR.src]: src, attempts, reason }); + return; + } this.finalizeAllImagesForSrc(src, 'skipped'); return; } diff --git a/utils/types/prediction.ts b/utils/types/prediction.ts index 324d22f..622c12f 100644 --- a/utils/types/prediction.ts +++ b/utils/types/prediction.ts @@ -66,11 +66,12 @@ export type ForcedVisibility = 'auto' | 'visible' | 'blocked'; * Outcome of one image inference request, broadcast to content scripts. * Only the 'ok' arm carries a cacheable prediction; 'error' feeds the content * script's retry counter instead of leaving the image stuck behind the - * inference watchdog. The tag is a literal union so it can grow (e.g. a - * future 'skipped'). + * inference watchdog. 'started' marks the moment the task left the queue so + * the content watchdog measures inference time, not queue backlog. */ export type ImageInferenceResult = | { status: 'ok'; prediction: IImagePrediction; traceparent?: string } + | { status: 'started'; src: string; hostname: string; traceparent?: string } | { status: 'error'; src: string; hostname: string; reason?: string; traceparent?: string }; export function shouldBlock(prediction: IImagePrediction): boolean { From 1835e63c3f1d904df5e73aa40d2782bbda533d8e Mon Sep 17 00:00:00 2001 From: Rasul Abu Muhammad Amin Date: Sun, 13 Sep 2026 01:11:16 +0200 Subject: [PATCH 2/6] perf(background): join in-flight image inference instead of enqueueing duplicates per src Content retries, repeated copies and sibling tabs used to enqueue one task each for the same hostname+src; the verdict is broadcast by src anyway. Duplicates now join the existing task (raising its queued priority when the newcomer is visible), so retries no longer inflate the backlog. Claude-Session: https://claude.ai/code/session_01L7JHWoqQg8W4ExvRhfbj6g --- docs/MEDIA_PROCESSING.md | 5 +++ .../services/inferenceOrchestrationService.ts | 44 ++++++++++++++++++- .../background/services/queueService.ts | 13 +++++- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/MEDIA_PROCESSING.md b/docs/MEDIA_PROCESSING.md index 3009216..ded7e3e 100644 --- a/docs/MEDIA_PROCESSING.md +++ b/docs/MEDIA_PROCESSING.md @@ -693,6 +693,11 @@ cleared, so the late prediction still applies when it arrives (`findImagesBySrc` images) and any later `process()` pass re-sends. Only an explicit `status: 'error'` result from the background (inference genuinely impossible) finalizes the copies as `skipped` after two attempts. +The background dedupes as well: a request for a `hostname + src` that is already queued or running +joins the in-flight task instead of enqueueing a second one (retries, extra `` copies, and +other tabs on the same host all receive the single broadcast verdict). A joining request with a +higher priority (a visible copy behind an offscreen one) raises the queued task's priority. + ### DOM Processing Blur class acts as marker - if image has blur class, don't re-apply: diff --git a/entrypoints/background/services/inferenceOrchestrationService.ts b/entrypoints/background/services/inferenceOrchestrationService.ts index d9545ff..b143dfe 100644 --- a/entrypoints/background/services/inferenceOrchestrationService.ts +++ b/entrypoints/background/services/inferenceOrchestrationService.ts @@ -1,4 +1,4 @@ -import { SpanStatusCode, type Counter, type Histogram, type Span } from '@opentelemetry/api'; +import { SpanStatusCode, type Context, type Counter, type Histogram, type Span } from '@opentelemetry/api'; import { getCurrentModelId } from '@inference-runtime'; @@ -69,6 +69,13 @@ export type ScheduleArgs = { traceparent?: string; }; +type TaskAttributes = ReturnType; + +function imageTaskKey(hostname: string, imageSrc: string): string { + return `${hostname} +${imageSrc}`; +} + const log = getLogger('inferenceOrchestrationService'); const tracer = getTracer('inference'); @@ -123,6 +130,7 @@ export class InferenceOrchestrationService { private onGifFramePredictionsCallback?: OnGifFramePredictionsCallback; /** At most one not-yet-started playback frame is retained per video session. */ private queuedPlaybackFrames = new Map(); + private readonly inFlightImages = new Map(); private queueWaitSpans = new WeakMap(); // Batches concurrent queue tasks into one session.run for dynamic-batch models. @@ -196,6 +204,8 @@ export class InferenceOrchestrationService { } } + if (mediaMetadata.kind === 'image' && this.joinInFlightImage(args, attributes, traceContext)) return; + const queueStartAt = Date.now(); const baseTask = { imageSrc, @@ -258,7 +268,10 @@ export class InferenceOrchestrationService { task, tracer.startSpan(SPAN.queueWait, { attributes: { ...attributes, [ATTR.priority]: args.priority } }, traceContext), ); - this.queueService.enqueue(task, controller?.signal).catch(error => { + const inFlightKey = mediaMetadata.kind === 'image' ? imageTaskKey(hostname, imageSrc) : undefined; + if (inFlightKey) this.inFlightImages.set(inFlightKey, { task, priority: args.priority }); + this.queueService.enqueue(task, controller?.signal, inFlightKey).catch(error => { + if (inFlightKey) this.inFlightImages.delete(inFlightKey); if (controller?.signal.aborted) { this.endQueueWait(task, 'aborted'); return; @@ -269,6 +282,31 @@ export class InferenceOrchestrationService { }); } + /** + * A second request for a src already queued or running (content retry, + * another copy, another tab on the same host) rides on the existing + * task: the verdict is broadcast by src, so it reaches every requester. + * A visible copy behind an offscreen one bumps the queued priority. + */ + private joinInFlightImage(args: ScheduleArgs, attributes: TaskAttributes, traceContext?: Context): boolean { + const key = imageTaskKey(args.hostname, args.input.imageSrc); + const inFlight = this.inFlightImages.get(key); + if (!inFlight) return false; + if (args.input.kind === 'bitmap') args.input.bitmap.close(); + if (args.priority > inFlight.priority) { + inFlight.priority = args.priority; + this.queueService.raisePriority(key, args.priority); + } + log.debug('inference.image.joined', { ...attributes, [ATTR.priority]: args.priority }, traceContext); + return true; + } + + private releaseInFlightImage(task: InferenceTask): void { + if (task.mediaMetadata.kind !== 'image') return; + const key = imageTaskKey(task.hostname, task.imageSrc); + if (this.inFlightImages.get(key)?.task === task) this.inFlightImages.delete(key); + } + private endQueueWait(task: InferenceTask, outcome: 'started' | 'aborted' | 'superseded' | 'error'): void { const span = this.queueWaitSpans.get(task); if (!span) return; @@ -306,6 +344,8 @@ export class InferenceOrchestrationService { }); log.error('inference.run.failed', { ...attributes, error }, task.traceContext); this.sendErrorToContent(task, error); + } finally { + this.releaseInFlightImage(task); } }); } diff --git a/entrypoints/background/services/queueService.ts b/entrypoints/background/services/queueService.ts index 30e7042..3120ff9 100644 --- a/entrypoints/background/services/queueService.ts +++ b/entrypoints/background/services/queueService.ts @@ -36,7 +36,7 @@ export class QueueService { return () => this.queue.off('idle', callback); } - enqueue(task: InferenceTask, signal?: AbortSignal): Promise { + enqueue(task: InferenceTask, signal?: AbortSignal, id?: string): Promise { // p-queue: higher priority number = runs first return this.queue.add( async () => { @@ -44,7 +44,16 @@ export class QueueService { await this.onTaskProcessing(task); } }, - { priority: task.priority, signal }, + { priority: task.priority, signal, id }, ); } + + /** No-op once the task has left the queue (p-queue throws for unknown ids). */ + raisePriority(id: string, priority: number): void { + try { + this.queue.setPriority(id, priority); + } catch { + return; + } + } } From 898f50a4d1635166afb74b4a442b539e82d842f3 Mon Sep 17 00:00:00 2001 From: Rasul Abu Muhammad Amin Date: Sun, 13 Sep 2026 02:21:04 +0200 Subject: [PATCH 3/6] fix(content): resolve image source from the reflected src while Firefox still reports the old currentSrc Firefox keeps currentSrc on the previous request until the new image's size is known, so a src swap looked like a no-op re-stamp in handleSrcChange. On Google Images the 10 px placeholder's safe verdict therefore stayed on the element and the full thumbnail rendered unmasked. Reads now go through resolveImageSource (reflected src unless srcset/picture select candidates), and every processed image re-checks its source on load. Claude-Session: https://claude.ai/code/session_01L7JHWoqQg8W4ExvRhfbj6g --- docs/MEDIA_PROCESSING.md | 12 ++++++ entrypoints/content/communication/sender.ts | 5 ++- entrypoints/content/core/ImageProcessor.ts | 39 +++++++++++++------ .../core/__tests__/imageSource.test.ts | 32 +++++++++++++++ entrypoints/content/core/imageSource.ts | 17 ++++++++ .../content/presentation/gifMaskPlayer.ts | 5 ++- .../content/presentation/imageMaskOverlay.ts | 7 ++-- .../content/presentation/predictionStyling.ts | 3 +- 8 files changed, 101 insertions(+), 19 deletions(-) create mode 100644 entrypoints/content/core/__tests__/imageSource.test.ts create mode 100644 entrypoints/content/core/imageSource.ts diff --git a/docs/MEDIA_PROCESSING.md b/docs/MEDIA_PROCESSING.md index ded7e3e..2bdedbd 100644 --- a/docs/MEDIA_PROCESSING.md +++ b/docs/MEDIA_PROCESSING.md @@ -554,6 +554,18 @@ t=8: Debounce fires, process(img) with src=C t=9: Inference sent for C, prediction applied ✓ ``` +### Resolving the Source Under Firefox's Pending Request + +Every "what is this image's URL" read goes through `resolveImageSource(img)` instead of a bare +`img.currentSrc || img.src`. Firefox keeps `currentSrc` on the **previous** request until the new +image's size is known, so right after `img.src = next` the mutation callback still reads the old +URL. On Google Images that made the placeholder→thumbnail swap (`…&s=10` → `…&s`) look like a no-op +re-stamp: the 10 px placeholder's `safe` verdict stayed on the element and the full thumbnail +rendered bare. For images without `srcset`/`` the reflected `src` is authoritative; +candidate selection keeps using `currentSrc`. As a second net, `process()` registers one `load` +listener per image that re-runs `handleSrcChange` whenever the resolved source no longer matches the +one last processed (also covers srcset re-selection on images that carry no overlay). + ### Robust Image Load Detection For images that aren't yet loaded, we use **both** `decode()` and `load` event - whichever fires diff --git a/entrypoints/content/communication/sender.ts b/entrypoints/content/communication/sender.ts index 4d7760a..f1e2c12 100644 --- a/entrypoints/content/communication/sender.ts +++ b/entrypoints/content/communication/sender.ts @@ -1,5 +1,6 @@ import { ROOT_CONTEXT, type Context, type Span } from '@opentelemetry/api'; +import { resolveImageSource } from '@/entrypoints/content/core/imageSource'; import { dvrRingBudget } from '@/entrypoints/content/video/dvr/ringBudget'; import { bitmapToCompressedBlob } from '@/entrypoints/content/video/sampling/compression'; import { @@ -127,7 +128,7 @@ async function buildPayload( parent: Context, ): Promise { const requestStartAt = Date.now(); - const src = image.currentSrc || image.src; + const src = resolveImageSource(image); const width = image.naturalWidth || image.width; const height = image.naturalHeight || image.height; const traceparent = injectTraceparent(parent); @@ -244,7 +245,7 @@ async function sendImageForInference( } } - log.error('inference.send.failed', { [ATTR.src]: image.currentSrc || image.src, error: lastError }, parent); + log.error('inference.send.failed', { [ATTR.src]: resolveImageSource(image), error: lastError }, parent); throw lastError; } diff --git a/entrypoints/content/core/ImageProcessor.ts b/entrypoints/content/core/ImageProcessor.ts index 405b60e..63c6ff9 100644 --- a/entrypoints/content/core/ImageProcessor.ts +++ b/entrypoints/content/core/ImageProcessor.ts @@ -3,6 +3,7 @@ import { requestImageInference, requestToggleUpdate, } from '@/entrypoints/content/communication/sender'; +import { resolveImageSource } from '@/entrypoints/content/core/imageSource'; import { PredictionCache } from '@/entrypoints/content/core/predictionCache'; import { decodeGifFrames, @@ -136,6 +137,7 @@ export class ImageProcessor { private readonly gifSessions = new Map(); // src → the element whose load listeners drive the request (see queueInference) private readonly pendingInference = new Map(); + private readonly loadDriftWatched = new WeakSet(); private readonly pendingInferenceTimers = new Map>(); private readonly inferenceAttempts = new Map(); /** Last resolved source seen for an element; filters Reddit's no-op attribute churn. */ @@ -180,9 +182,10 @@ export class ImageProcessor { * This is the main entry point for both new images and attribute changes. */ process(img: HTMLImageElement): void { - const src = img.currentSrc || img.src; + const src = resolveImageSource(img); if (!src) return; this.resolvedSrcByImage.set(img, src); + this.watchSourceDriftOnLoad(img); this.visibilityObserver.observe(img); this.trackShadowRoot(img); @@ -242,6 +245,20 @@ export class ImageProcessor { this.queueInference(img, src); } + /** + * A load can change the resolved source without any attribute mutation + * (srcset re-selection, or Firefox promoting a pending request), so every + * load re-checks it against the source that was last processed. + */ + private watchSourceDriftOnLoad(img: HTMLImageElement): void { + if (this.loadDriftWatched.has(img)) return; + this.loadDriftWatched.add(img); + img.addEventListener('load', () => { + const processedSrc = this.resolvedSrcByImage.get(img); + if (processedSrc && resolveImageSource(img) !== processedSrc) this.handleSrcChange(img); + }); + } + /** * Process multiple images. */ @@ -256,7 +273,7 @@ export class ImageProcessor { * Google Images rapidly changes src (quality upgrades), so we wait for it to settle. */ handleSrcChange(img: HTMLImageElement): void { - const resolvedSrc = img.currentSrc || img.src; + const resolvedSrc = resolveImageSource(img); const previousSrc = this.resolvedSrcByImage.get(img); if (resolvedSrc === previousSrc) { // Lit/React frequently re-stamp an unchanged src/srcset. Treating that @@ -284,7 +301,7 @@ export class ImageProcessor { // Debounce: wait for src to stabilize before processing const timeout = setTimeout(() => { this.srcChangeDebounce.delete(img); - const src = img.currentSrc || img.src; + const src = resolveImageSource(img); if (src) { this.process(img); } @@ -357,7 +374,7 @@ export class ImageProcessor { * Clean up when image removed from DOM. */ handleRemoved(img: HTMLImageElement): void { - const src = img.currentSrc || img.src; + const src = resolveImageSource(img); if (src) { cancelRoundtrip(src); } @@ -484,7 +501,7 @@ export class ImageProcessor { const sendRequest = async () => { // If src changed before load (common with srcset), reprocess with new src - const currentSrc = img.currentSrc || img.src; + const currentSrc = resolveImageSource(img); if (currentSrc !== src) { this.clearPendingInference(src, img); // Re-process with the actual loaded URL instead of just aborting @@ -1013,7 +1030,7 @@ export class ImageProcessor { // =========================================================================== private async applyPrediction(img: HTMLImageElement, prediction: IImagePrediction): Promise { - const currentSrc = img.currentSrc || img.src; + const currentSrc = resolveImageSource(img); // Verify src still matches (handles race where src changed) if (currentSrc !== prediction.src) { @@ -1036,7 +1053,7 @@ export class ImageProcessor { await waitForImageReady(img); // Double-check src after any async wait - const srcNow = img.currentSrc || img.src; + const srcNow = resolveImageSource(img); if (srcNow !== prediction.src) { // Responsive images can select a different srcset candidate while // decode() is pending without producing another observable attribute @@ -1099,7 +1116,7 @@ export class ImageProcessor { // Query light DOM for (const img of document.querySelectorAll(selector)) { - const imgSrc = img.currentSrc || img.src; + const imgSrc = resolveImageSource(img); if (imgSrc === src) { results.push(img); } @@ -1113,7 +1130,7 @@ export class ImageProcessor { continue; } for (const img of shadowRoot.querySelectorAll(selector)) { - const imgSrc = img.currentSrc || img.src; + const imgSrc = resolveImageSource(img); if (imgSrc === src) { results.push(img); } @@ -1130,7 +1147,7 @@ export class ImageProcessor { // Query light DOM for (const img of document.querySelectorAll('img')) { - const imgSrc = img.currentSrc || img.src; + const imgSrc = resolveImageSource(img); if (imgSrc === src) { results.push(img); } @@ -1143,7 +1160,7 @@ export class ImageProcessor { continue; } for (const img of shadowRoot.querySelectorAll('img')) { - const imgSrc = img.currentSrc || img.src; + const imgSrc = resolveImageSource(img); if (imgSrc === src) { results.push(img); } diff --git a/entrypoints/content/core/__tests__/imageSource.test.ts b/entrypoints/content/core/__tests__/imageSource.test.ts new file mode 100644 index 0000000..8923847 --- /dev/null +++ b/entrypoints/content/core/__tests__/imageSource.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveImageSource } from '@/entrypoints/content/core/imageSource'; + +const fakeImage = (fields: { src: string; currentSrc: string; srcset?: boolean; inPicture?: boolean }) => ({ + src: fields.src, + currentSrc: fields.currentSrc, + hasAttribute: (name: string) => name === 'srcset' && Boolean(fields.srcset), + parentElement: fields.inPicture ? ({ tagName: 'PICTURE' } as Element) : ({ tagName: 'DIV' } as Element), +}); + +describe('resolveImageSource', () => { + it('prefers the reflected src when currentSrc still points at the previous request', () => { + const img = fakeImage({ src: 'https://x/full', currentSrc: 'https://x/placeholder' }); + expect(resolveImageSource(img)).toBe('https://x/full'); + }); + + it('falls back to currentSrc when src is empty', () => { + const img = fakeImage({ src: '', currentSrc: 'https://x/from-picture' }); + expect(resolveImageSource(img)).toBe('https://x/from-picture'); + }); + + it('keeps currentSrc for srcset candidates', () => { + const img = fakeImage({ src: 'https://x/fallback', currentSrc: 'https://x/2x', srcset: true }); + expect(resolveImageSource(img)).toBe('https://x/2x'); + }); + + it('keeps currentSrc for picture sources', () => { + const img = fakeImage({ src: 'https://x/fallback', currentSrc: 'https://x/webp', inPicture: true }); + expect(resolveImageSource(img)).toBe('https://x/webp'); + }); +}); diff --git a/entrypoints/content/core/imageSource.ts b/entrypoints/content/core/imageSource.ts new file mode 100644 index 0000000..d50dd44 --- /dev/null +++ b/entrypoints/content/core/imageSource.ts @@ -0,0 +1,17 @@ +type ImageSourceLike = Pick & { + hasAttribute: (name: string) => boolean; + parentElement: Element | null; +}; + +const usesCandidateSelection = (img: ImageSourceLike): boolean => + img.hasAttribute('srcset') || img.parentElement?.tagName === 'PICTURE'; + +/** + * Firefox keeps `currentSrc` pointing at the previous request until the new + * image's size is known, so right after a `src` swap it still reports the old + * URL. Without candidate selection the reflected `src` is the truth. + */ +export const resolveImageSource = (img: ImageSourceLike): string => { + if (usesCandidateSelection(img)) return img.currentSrc || img.src; + return img.src || img.currentSrc; +}; diff --git a/entrypoints/content/presentation/gifMaskPlayer.ts b/entrypoints/content/presentation/gifMaskPlayer.ts index 1f123a3..9340335 100644 --- a/entrypoints/content/presentation/gifMaskPlayer.ts +++ b/entrypoints/content/presentation/gifMaskPlayer.ts @@ -1,3 +1,4 @@ +import { resolveImageSource } from '@/entrypoints/content/core/imageSource'; import { GIF_MASK_OVERLAY_ATTR } from '@/entrypoints/content/presentation/constants'; import { computeRenderedContentRect, maskGridSrcRect } from '@/entrypoints/content/presentation/imageLayout'; import { @@ -159,7 +160,7 @@ class GifMaskPlayer { viewportHandler: () => this.updateLayout(image), destroyed: false, originalOpacity: image.style.opacity, - trackedSrc: image.currentSrc || image.src, + trackedSrc: resolveImageSource(image), }; gifStates.set(image, state); @@ -220,7 +221,7 @@ class GifMaskPlayer { const state = gifStates.get(image); if (!state || state.destroyed) return; - const currentSrc = image.currentSrc || image.src; + const currentSrc = resolveImageSource(image); if (currentSrc !== state.trackedSrc) { this.clearPlayer(image); notifySrcDrift(image); diff --git a/entrypoints/content/presentation/imageMaskOverlay.ts b/entrypoints/content/presentation/imageMaskOverlay.ts index 61c8971..072973b 100644 --- a/entrypoints/content/presentation/imageMaskOverlay.ts +++ b/entrypoints/content/presentation/imageMaskOverlay.ts @@ -1,3 +1,4 @@ +import { resolveImageSource } from '@/entrypoints/content/core/imageSource'; import { IMAGE_MASK_OVERLAY_ATTR } from '@/entrypoints/content/presentation/constants'; import { computeRenderedContentRect, maskGridSrcRect } from '@/entrypoints/content/presentation/imageLayout'; import { hasInitialStyling } from '@/entrypoints/content/presentation/initialStyling'; @@ -275,7 +276,7 @@ class ImageMaskOverlay implements IMediaOverlay { rafId: null, destroyed: false, currentPrediction: undefined, - trackedSrc: image.currentSrc || image.src, + trackedSrc: resolveImageSource(image), masking, }; @@ -336,7 +337,7 @@ class ImageMaskOverlay implements IMediaOverlay { // ResizeObserver for image size changes + src change detection (self-cleaning) state.resizeObserver = new ResizeObserver(entries => { // Self-clean if src changed - const currentSrc = image.currentSrc || image.src; + const currentSrc = resolveImageSource(image); if (state.trackedSrc && currentSrc !== state.trackedSrc) { this.clearMaskOverlay(image); notifySrcDrift(image); @@ -390,7 +391,7 @@ class ImageMaskOverlay implements IMediaOverlay { private updateOverlayForImage(image: HTMLImageElement, state: IMediaOverlayState): void { // Self-clean if src changed - const currentSrc = image.currentSrc || image.src; + const currentSrc = resolveImageSource(image); if (state.trackedSrc && currentSrc !== state.trackedSrc) { this.clearMaskOverlay(image); notifySrcDrift(image); diff --git a/entrypoints/content/presentation/predictionStyling.ts b/entrypoints/content/presentation/predictionStyling.ts index 36a172d..4bece89 100644 --- a/entrypoints/content/presentation/predictionStyling.ts +++ b/entrypoints/content/presentation/predictionStyling.ts @@ -1,3 +1,4 @@ +import { resolveImageSource } from '@/entrypoints/content/core/imageSource'; import { imageMaskOverlay } from '@/entrypoints/content/presentation/imageMaskOverlay'; import { type IHostSettings, type IImagePrediction } from '@/utils/types'; @@ -11,7 +12,7 @@ export const applyPredictionsStyling = ( const predictionMap = new Map(predictions.map(p => [p.src, p])); for (const image of images) { - const imageSrc = image.currentSrc || image.src; + const imageSrc = resolveImageSource(image); const imagePrediction = predictionMap.get(imageSrc); if (imagePrediction) { From 72247ef6667ddfce577788ec6e47089e6fba8569 Mon Sep 17 00:00:00 2001 From: Rasul Abu Muhammad Amin Date: Sun, 13 Sep 2026 11:31:22 +0200 Subject: [PATCH 4/6] perf(content): skip inference for placeholder-resolution images Google Images decodes a 10 px thumbnail before the real one; those placeholders were most of the inference backlog on mobile while carrying nothing to detect. Finalize them as skipped once decoded; the src swap to the real thumbnail re-enters processing. Claude-Session: https://claude.ai/code/session_01L7JHWoqQg8W4ExvRhfbj6g --- docs/MEDIA_PROCESSING.md | 5 +++++ entrypoints/content/core/ImageProcessor.ts | 9 ++++++++- .../core/__tests__/imageSource.test.ts | 20 ++++++++++++++++++- entrypoints/content/core/imageSource.ts | 12 +++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/MEDIA_PROCESSING.md b/docs/MEDIA_PROCESSING.md index 2bdedbd..bc13e5a 100644 --- a/docs/MEDIA_PROCESSING.md +++ b/docs/MEDIA_PROCESSING.md @@ -566,6 +566,11 @@ candidate selection keeps using `currentSrc`. As a second net, `process()` regis listener per image that re-runs `handleSrcChange` whenever the resolved source no longer matches the one last processed (also covers srcset re-selection on images that carry no overlay). +Once such a placeholder has decoded, `isPlaceholderResolution` (either natural side under 32 px) +finalizes it as `skipped` instead of inferring it: a 10 px image carries nothing to detect, and on a +single-lane mobile queue those placeholders were the bulk of the backlog ahead of real thumbnails. +The swap to the real source re-enters processing as described above. + ### Robust Image Load Detection For images that aren't yet loaded, we use **both** `decode()` and `load` event - whichever fires diff --git a/entrypoints/content/core/ImageProcessor.ts b/entrypoints/content/core/ImageProcessor.ts index 63c6ff9..821d6a3 100644 --- a/entrypoints/content/core/ImageProcessor.ts +++ b/entrypoints/content/core/ImageProcessor.ts @@ -3,7 +3,7 @@ import { requestImageInference, requestToggleUpdate, } from '@/entrypoints/content/communication/sender'; -import { resolveImageSource } from '@/entrypoints/content/core/imageSource'; +import { isPlaceholderResolution, resolveImageSource } from '@/entrypoints/content/core/imageSource'; import { PredictionCache } from '@/entrypoints/content/core/predictionCache'; import { decodeGifFrames, @@ -518,6 +518,13 @@ export class ImageProcessor { return; } + if (isPlaceholderResolution(img)) { + this.clearPendingInference(src, img, true); + endRoundtrip(src, { status: 'skipped', attributes: { reason: 'placeholder resolution' } }); + this.finalizeAllImagesForSrc(src, 'skipped'); + return; + } + try { this.armInferenceWatchdog(src, img, IMAGE_QUEUE_WAIT_TIMEOUT_MS); const isVisible = this.visibilityMap.get(img) ?? false; diff --git a/entrypoints/content/core/__tests__/imageSource.test.ts b/entrypoints/content/core/__tests__/imageSource.test.ts index 8923847..cef7c92 100644 --- a/entrypoints/content/core/__tests__/imageSource.test.ts +++ b/entrypoints/content/core/__tests__/imageSource.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { resolveImageSource } from '@/entrypoints/content/core/imageSource'; +import { isPlaceholderResolution, resolveImageSource } from '@/entrypoints/content/core/imageSource'; const fakeImage = (fields: { src: string; currentSrc: string; srcset?: boolean; inPicture?: boolean }) => ({ src: fields.src, @@ -30,3 +30,21 @@ describe('resolveImageSource', () => { expect(resolveImageSource(img)).toBe('https://x/webp'); }); }); + +describe('isPlaceholderResolution', () => { + it('flags a 10 px Google placeholder', () => { + expect(isPlaceholderResolution({ naturalWidth: 10, naturalHeight: 13 })).toBe(true); + }); + + it('flags a strip whose smaller side is tiny', () => { + expect(isPlaceholderResolution({ naturalWidth: 400, naturalHeight: 16 })).toBe(true); + }); + + it('does not flag an undecoded image', () => { + expect(isPlaceholderResolution({ naturalWidth: 0, naturalHeight: 0 })).toBe(false); + }); + + it('does not flag a regular thumbnail', () => { + expect(isPlaceholderResolution({ naturalWidth: 160, naturalHeight: 200 })).toBe(false); + }); +}); diff --git a/entrypoints/content/core/imageSource.ts b/entrypoints/content/core/imageSource.ts index d50dd44..f171936 100644 --- a/entrypoints/content/core/imageSource.ts +++ b/entrypoints/content/core/imageSource.ts @@ -15,3 +15,15 @@ export const resolveImageSource = (img: ImageSourceLike): string => { if (usesCandidateSelection(img)) return img.currentSrc || img.src; return img.src || img.currentSrc; }; + +const PLACEHOLDER_MAX_NATURAL_PX = 32; + +/** + * Low-quality placeholders (Google Images serves a 10 px thumbnail before the + * real one) render at full layout size but carry no recognisable content; + * inferring them only delays the images behind them in the queue. + */ +export const isPlaceholderResolution = (img: Pick): boolean => + img.naturalWidth > 0 && + img.naturalHeight > 0 && + (img.naturalWidth < PLACEHOLDER_MAX_NATURAL_PX || img.naturalHeight < PLACEHOLDER_MAX_NATURAL_PX); From ef456ea7beb729cc6166618709e25ebf35039e86 Mon Sep 17 00:00:00 2001 From: Rasul Abu Muhammad Amin Date: Sun, 13 Sep 2026 12:57:55 +0200 Subject: [PATCH 5/6] refactor: address review on image watchdog and dedupe branch Drop the added comments, fold the four light/shadow DOM src loops into collectMatchingImages, brand the in-flight image key, keep priority on the task itself, track queued ids so raisePriority never swallows errors, and re-broadcast started to duplicates that join a running task. Placeholder skip now requires both sides tiny and never exceeds the host's min size. Add tests for the queue-wait guard, started arming, fail-open on errors, placeholder skip, in-flight joining and priority raising (happy-dom added as the DOM test environment). Claude-Session: https://claude.ai/code/session_01RvK31wdZERD2K4EmaytRTE --- docs/MEDIA_PROCESSING.md | 12 +- .../inferenceOrchestrationService.test.ts | 132 ++++++++++++++++++ .../services/__tests__/queueService.test.ts | 27 ++++ .../services/inferenceOrchestrationService.ts | 24 ++-- .../background/services/queueService.ts | 31 ++-- entrypoints/content/core/ImageProcessor.ts | 84 +++-------- .../__tests__/imageProcessorWatchdog.test.ts | 120 ++++++++++++++++ .../core/__tests__/imageSource.test.ts | 16 ++- entrypoints/content/core/imageSource.ts | 20 +-- package.json | 1 + pnpm-lock.yaml | 44 +++++- utils/types/prediction.ts | 4 +- 12 files changed, 399 insertions(+), 116 deletions(-) create mode 100644 entrypoints/background/services/__tests__/inferenceOrchestrationService.test.ts create mode 100644 entrypoints/content/core/__tests__/imageProcessorWatchdog.test.ts diff --git a/docs/MEDIA_PROCESSING.md b/docs/MEDIA_PROCESSING.md index bc13e5a..4c2f638 100644 --- a/docs/MEDIA_PROCESSING.md +++ b/docs/MEDIA_PROCESSING.md @@ -566,10 +566,11 @@ candidate selection keeps using `currentSrc`. As a second net, `process()` regis listener per image that re-runs `handleSrcChange` whenever the resolved source no longer matches the one last processed (also covers srcset re-selection on images that carry no overlay). -Once such a placeholder has decoded, `isPlaceholderResolution` (either natural side under 32 px) -finalizes it as `skipped` instead of inferring it: a 10 px image carries nothing to detect, and on a -single-lane mobile queue those placeholders were the bulk of the backlog ahead of real thumbnails. -The swap to the real source re-enters processing as described above. +Once such a placeholder has decoded, `isPlaceholderResolution` (both natural sides under 32 px, or +under the host's configured minimum size when that is smaller) finalizes it as `skipped` instead of +inferring it: a 10 px image carries nothing to detect, and on a single-lane mobile queue those +placeholders were the bulk of the backlog ahead of real thumbnails. The swap to the real source +re-enters processing as described above. ### Robust Image Load Detection @@ -713,7 +714,8 @@ background (inference genuinely impossible) finalizes the copies as `skipped` af The background dedupes as well: a request for a `hostname + src` that is already queued or running joins the in-flight task instead of enqueueing a second one (retries, extra `` copies, and other tabs on the same host all receive the single broadcast verdict). A joining request with a -higher priority (a visible copy behind an offscreen one) raises the queued task's priority. +higher priority (a visible copy behind an offscreen one) raises the queued task's priority, and one +that joins a task already running receives the `started` broadcast again so its watchdog arms. ### DOM Processing diff --git a/entrypoints/background/services/__tests__/inferenceOrchestrationService.test.ts b/entrypoints/background/services/__tests__/inferenceOrchestrationService.test.ts new file mode 100644 index 0000000..09a78e9 --- /dev/null +++ b/entrypoints/background/services/__tests__/inferenceOrchestrationService.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { InferenceOrchestrationService } from '@/entrypoints/background/services/inferenceOrchestrationService'; +import { QueueService } from '@/entrypoints/background/services/queueService'; +import { DEFAULT_HOST_SETTINGS } from '@/utils/constants/hostsettings'; + +import type { ImageCacheService } from '@/entrypoints/background/services/imageCacheService'; +import type { IImagePrediction, IMediaMetadata, ImageInferenceResult, InferenceTask } from '@/utils/types'; + +const { processInferenceTask, gates } = vi.hoisted(() => { + const gates = new Map void>(); + const processInferenceTask = vi.fn( + (task: { imageSrc: string }) => + new Promise(resolve => { + gates.set(task.imageSrc, () => + resolve({ + src: task.imageSrc, + hostname: 'example.com', + width: 100, + height: 100, + predictions: [], + timestamp: 0, + cacheMetadata: { createdAt: 0, accessedAt: 0 }, + maskTransform: { scaleX: 1, scaleY: 1, offsetX: 0, offsetY: 0 }, + processingTime: { + fetchTime: 0, + decodeTime: 0, + queueTime: 0, + inferenceTime: 0, + e2eTime: 0, + backend: 'wasm', + }, + forcedVisibility: 'auto', + }), + ); + }), + ); + return { processInferenceTask, gates }; +}); + +vi.mock('@inference-runtime', () => ({ getCurrentModelId: () => 'test-model' })); +vi.mock('@/utils/inference', () => ({ + getBatchCap: () => 1, + getInferenceBackend: () => 'wasm', + processInferenceBatch: vi.fn(), + processInferenceTask, +})); +vi.mock('@/entrypoints/background/services/batchCollector', () => ({ + BatchCollector: class { + submit = vi.fn(); + }, +})); + +const cacheService = { + getCachedPredictionsBySrc: vi.fn(() => Promise.resolve([])), + cachePredictions: vi.fn(() => Promise.resolve()), +} as unknown as ImageCacheService; + +function setup() { + const queue = new QueueService(); + const service = new InferenceOrchestrationService(queue, cacheService); + const results: ImageInferenceResult[] = []; + service.setOnImagePredictionsCallback(batch => results.push(...batch)); + const schedule = (imageSrc: string, priority = 0) => + service.scheduleInferenceTask({ + input: { kind: 'src', imageSrc }, + hostname: 'example.com', + hostSettings: DEFAULT_HOST_SETTINGS, + mediaMetadata: { kind: 'image' } as IMediaMetadata, + priority, + }); + return { service, queue, results, schedule }; +} + +const statuses = (results: ImageInferenceResult[], src: string) => + results + .filter(result => (result.status === 'ok' ? result.prediction.src : result.src) === src) + .map(result => result.status); + +const release = (src: string) => { + gates.get(src)?.(); + gates.delete(src); +}; + +describe('InferenceOrchestrationService image dedupe', () => { + it('broadcasts started when the task leaves the queue and ok when it completes', async () => { + const { results, schedule } = setup(); + await schedule('a'); + await vi.waitFor(() => expect(statuses(results, 'a')).toEqual(['started'])); + release('a'); + await vi.waitFor(() => expect(statuses(results, 'a')).toEqual(['started', 'ok'])); + expect(processInferenceTask).toHaveBeenCalledTimes(1); + }); + + it('joins a queued duplicate instead of running it twice and raises its priority', async () => { + const { results, schedule } = setup(); + await schedule('running'); + await vi.waitFor(() => expect(statuses(results, 'running')).toEqual(['started'])); + await schedule('queued', 0); + await schedule('queued', 30); + + release('running'); + await vi.waitFor(() => expect(statuses(results, 'queued')).toEqual(['started'])); + const queuedTask = processInferenceTask.mock.calls.at(-1)?.[0] as InferenceTask; + expect(queuedTask.priority).toBe(30); + release('queued'); + await vi.waitFor(() => expect(statuses(results, 'queued')).toEqual(['started', 'ok'])); + expect(processInferenceTask).toHaveBeenCalledTimes(2); + }); + + it('re-broadcasts started for a duplicate that joins an already running task', async () => { + const { results, schedule } = setup(); + await schedule('a'); + await vi.waitFor(() => expect(statuses(results, 'a')).toEqual(['started'])); + await schedule('a'); + expect(statuses(results, 'a')).toEqual(['started', 'started']); + release('a'); + await vi.waitFor(() => expect(statuses(results, 'a')).toEqual(['started', 'started', 'ok'])); + expect(processInferenceTask).toHaveBeenCalledTimes(1); + }); + + it('runs the same src again once the previous task has finished', async () => { + const { results, schedule } = setup(); + await schedule('a'); + await vi.waitFor(() => expect(statuses(results, 'a')).toEqual(['started'])); + release('a'); + await vi.waitFor(() => expect(statuses(results, 'a')).toEqual(['started', 'ok'])); + await schedule('a'); + await vi.waitFor(() => expect(processInferenceTask).toHaveBeenCalledTimes(2)); + release('a'); + }); +}); diff --git a/entrypoints/background/services/__tests__/queueService.test.ts b/entrypoints/background/services/__tests__/queueService.test.ts index b4e1dd4..537d5c3 100644 --- a/entrypoints/background/services/__tests__/queueService.test.ts +++ b/entrypoints/background/services/__tests__/queueService.test.ts @@ -40,3 +40,30 @@ describe('QueueService', () => { expect(started).toEqual(['running', 'latest-frame']); }); }); + +describe('QueueService.raisePriority', () => { + it('moves a queued task ahead of its siblings', async () => { + const queue = new QueueService(); + const gate = deferred(); + const started: string[] = []; + queue.setTaskProcessingHandler(async queued => { + started.push(queued.imageSrc); + if (queued.imageSrc === 'running') await gate.promise; + }); + + const running = queue.enqueue(task('running')); + await vi.waitFor(() => expect(started).toEqual(['running'])); + const first = queue.enqueue(task('first'), undefined, 'first'); + const second = queue.enqueue(task('second'), undefined, 'second'); + queue.raisePriority('second', 30); + + gate.resolve(); + await Promise.all([running, first, second]); + expect(started).toEqual(['running', 'second', 'first']); + }); + + it('ignores an id that is no longer queued', () => { + const queue = new QueueService(); + expect(() => queue.raisePriority('missing', 30)).not.toThrow(); + }); +}); diff --git a/entrypoints/background/services/inferenceOrchestrationService.ts b/entrypoints/background/services/inferenceOrchestrationService.ts index b143dfe..34b6112 100644 --- a/entrypoints/background/services/inferenceOrchestrationService.ts +++ b/entrypoints/background/services/inferenceOrchestrationService.ts @@ -71,9 +71,10 @@ export type ScheduleArgs = { type TaskAttributes = ReturnType; -function imageTaskKey(hostname: string, imageSrc: string): string { - return `${hostname} -${imageSrc}`; +type ImageTaskKey = string & { readonly __brand: 'ImageTaskKey' }; + +function imageTaskKey(hostname: string, imageSrc: string): ImageTaskKey { + return `${hostname}|${imageSrc}` as ImageTaskKey; } const log = getLogger('inferenceOrchestrationService'); @@ -130,7 +131,7 @@ export class InferenceOrchestrationService { private onGifFramePredictionsCallback?: OnGifFramePredictionsCallback; /** At most one not-yet-started playback frame is retained per video session. */ private queuedPlaybackFrames = new Map(); - private readonly inFlightImages = new Map(); + private readonly inFlightImages = new Map(); private queueWaitSpans = new WeakMap(); // Batches concurrent queue tasks into one session.run for dynamic-batch models. @@ -269,7 +270,7 @@ export class InferenceOrchestrationService { tracer.startSpan(SPAN.queueWait, { attributes: { ...attributes, [ATTR.priority]: args.priority } }, traceContext), ); const inFlightKey = mediaMetadata.kind === 'image' ? imageTaskKey(hostname, imageSrc) : undefined; - if (inFlightKey) this.inFlightImages.set(inFlightKey, { task, priority: args.priority }); + if (inFlightKey) this.inFlightImages.set(inFlightKey, { task, started: false }); this.queueService.enqueue(task, controller?.signal, inFlightKey).catch(error => { if (inFlightKey) this.inFlightImages.delete(inFlightKey); if (controller?.signal.aborted) { @@ -282,21 +283,16 @@ export class InferenceOrchestrationService { }); } - /** - * A second request for a src already queued or running (content retry, - * another copy, another tab on the same host) rides on the existing - * task: the verdict is broadcast by src, so it reaches every requester. - * A visible copy behind an offscreen one bumps the queued priority. - */ private joinInFlightImage(args: ScheduleArgs, attributes: TaskAttributes, traceContext?: Context): boolean { const key = imageTaskKey(args.hostname, args.input.imageSrc); const inFlight = this.inFlightImages.get(key); if (!inFlight) return false; if (args.input.kind === 'bitmap') args.input.bitmap.close(); - if (args.priority > inFlight.priority) { - inFlight.priority = args.priority; + if (args.priority > inFlight.task.priority) { + inFlight.task.priority = args.priority; this.queueService.raisePriority(key, args.priority); } + if (inFlight.started) this.notifyImageInferenceStarted(inFlight.task); log.debug('inference.image.joined', { ...attributes, [ATTR.priority]: args.priority }, traceContext); return true; } @@ -505,6 +501,8 @@ export class InferenceOrchestrationService { private notifyImageInferenceStarted(task: InferenceTask): void { if (task.mediaMetadata.kind !== 'image') return; + const inFlight = this.inFlightImages.get(imageTaskKey(task.hostname, task.imageSrc)); + if (inFlight?.task === task) inFlight.started = true; this.sendImageResultsToContent( [ { diff --git a/entrypoints/background/services/queueService.ts b/entrypoints/background/services/queueService.ts index 3120ff9..eca38a1 100644 --- a/entrypoints/background/services/queueService.ts +++ b/entrypoints/background/services/queueService.ts @@ -6,6 +6,7 @@ import { type InferenceTask } from '@/utils/types'; export class QueueService { private queue: PQueue; private onTaskProcessing?: (task: InferenceTask) => Promise; + private readonly queuedIds = new Set(); constructor() { this.queue = new PQueue({ @@ -37,23 +38,25 @@ export class QueueService { } enqueue(task: InferenceTask, signal?: AbortSignal, id?: string): Promise { + if (id) this.queuedIds.add(id); // p-queue: higher priority number = runs first - return this.queue.add( - async () => { - if (this.onTaskProcessing) { - await this.onTaskProcessing(task); - } - }, - { priority: task.priority, signal, id }, - ); + return this.queue + .add( + async () => { + if (id) this.queuedIds.delete(id); + if (this.onTaskProcessing) { + await this.onTaskProcessing(task); + } + }, + { priority: task.priority, signal, id }, + ) + .finally(() => { + if (id) this.queuedIds.delete(id); + }); } - /** No-op once the task has left the queue (p-queue throws for unknown ids). */ raisePriority(id: string, priority: number): void { - try { - this.queue.setPriority(id, priority); - } catch { - return; - } + if (!this.queuedIds.has(id)) return; + this.queue.setPriority(id, priority); } } diff --git a/entrypoints/content/core/ImageProcessor.ts b/entrypoints/content/core/ImageProcessor.ts index 821d6a3..90f7191 100644 --- a/entrypoints/content/core/ImageProcessor.ts +++ b/entrypoints/content/core/ImageProcessor.ts @@ -3,7 +3,7 @@ import { requestImageInference, requestToggleUpdate, } from '@/entrypoints/content/communication/sender'; -import { isPlaceholderResolution, resolveImageSource } from '@/entrypoints/content/core/imageSource'; +import { isPlaceholderResolution, resolveImageSource, type NaturalSize } from '@/entrypoints/content/core/imageSource'; import { PredictionCache } from '@/entrypoints/content/core/predictionCache'; import { decodeGifFrames, @@ -71,6 +71,7 @@ const MAX_CACHE_SIZE = 500; const SRC_STABILIZATION_DELAY = 150; const IMAGE_INFERENCE_TIMEOUT_MS = 20_000; const IMAGE_QUEUE_WAIT_TIMEOUT_MS = 120_000; +const PLACEHOLDER_MAX_NATURAL_PX = 32; const MAX_IMAGE_INFERENCE_ATTEMPTS = 2; // Animated GIFs: cap tracked decode sessions and fail closed if frame verdicts @@ -245,11 +246,6 @@ export class ImageProcessor { this.queueInference(img, src); } - /** - * A load can change the resolved source without any attribute mutation - * (srcset re-selection, or Firefox promoting a pending request), so every - * load re-checks it against the source that was last processed. - */ private watchSourceDriftOnLoad(img: HTMLImageElement): void { if (this.loadDriftWatched.has(img)) return; this.loadDriftWatched.add(img); @@ -518,7 +514,7 @@ export class ImageProcessor { return; } - if (isPlaceholderResolution(img)) { + if (isPlaceholderResolution(img, this.placeholderLimit())) { this.clearPendingInference(src, img, true); endRoundtrip(src, { status: 'skipped', attributes: { reason: 'placeholder resolution' } }); this.finalizeAllImagesForSrc(src, 'skipped'); @@ -588,12 +584,6 @@ export class ImageProcessor { } } - /** - * Queue wait is unbounded on slow devices (single-lane WASM inference with - * a page of offscreen images ahead), so the send-time watchdog is only a - * lost-task guard; the real timeout starts when the background reports the - * task left the queue. - */ private armInferenceWatchdog(src: string, owner: HTMLImageElement, timeoutMs: number): void { const existing = this.pendingInferenceTimers.get(src); if (existing) clearTimeout(existing); @@ -613,12 +603,6 @@ export class ImageProcessor { this.armInferenceWatchdog(src, owner, IMAGE_INFERENCE_TIMEOUT_MS); } - /** - * A pending inference failed. Retry with the best candidate element; once - * attempts are exhausted, an errored result fails open (inference is - * impossible for this image) while a timeout fails closed: the blur stays - * so a late prediction can still land, and the next process() pass retries. - */ private handleInferenceFailure( src: string, exhaustedMode: 'open' | 'closed', @@ -668,6 +652,14 @@ export class ImageProcessor { if (resetAttempts) this.inferenceAttempts.delete(src); } + private placeholderLimit(): NaturalSize { + const { minSize } = this.hostSettings; + return { + width: Math.min(PLACEHOLDER_MAX_NATURAL_PX, minSize.width), + height: Math.min(PLACEHOLDER_MAX_NATURAL_PX, minSize.height), + }; + } + private isBelowMinSize(img: HTMLImageElement): boolean { const w = img.clientWidth || img.naturalWidth; const h = img.clientHeight || img.naturalHeight; @@ -1118,62 +1110,30 @@ export class ImageProcessor { } private findImagesBySrc(src: string): HTMLImageElement[] { - const results: HTMLImageElement[] = []; - const selector = `img.${BLUR_CLASS}, img[${BLACKLIST_ATTR}]`; - - // Query light DOM - for (const img of document.querySelectorAll(selector)) { - const imgSrc = resolveImageSource(img); - if (imgSrc === src) { - results.push(img); - } - } - - // Query only tracked shadow roots (O(shadowRoots) instead of O(allElements)) - for (const shadowRoot of this.knownShadowRoots) { - // Skip disconnected shadow roots - if (!shadowRoot.host.isConnected) { - this.knownShadowRoots.delete(shadowRoot); - continue; - } - for (const img of shadowRoot.querySelectorAll(selector)) { - const imgSrc = resolveImageSource(img); - if (imgSrc === src) { - results.push(img); - } - } - } - - return results; + return this.collectMatchingImages(`img.${BLUR_CLASS}, img[${BLACKLIST_ATTR}]`, src); } // Queries all images on each call. Acceptable for user-initiated toggles (infrequent). // Maintaining a src→elements index would require complex cleanup for removed elements. private findAllImagesBySrc(src: string): HTMLImageElement[] { - const results: HTMLImageElement[] = []; - - // Query light DOM - for (const img of document.querySelectorAll('img')) { - const imgSrc = resolveImageSource(img); - if (imgSrc === src) { - results.push(img); - } - } + return this.collectMatchingImages('img', src); + } - // Query only tracked shadow roots + private collectMatchingImages(selector: string, src: string): HTMLImageElement[] { + const roots: ParentNode[] = [document]; for (const shadowRoot of this.knownShadowRoots) { if (!shadowRoot.host.isConnected) { this.knownShadowRoots.delete(shadowRoot); continue; } - for (const img of shadowRoot.querySelectorAll('img')) { - const imgSrc = resolveImageSource(img); - if (imgSrc === src) { - results.push(img); - } + roots.push(shadowRoot); + } + const results: HTMLImageElement[] = []; + for (const root of roots) { + for (const img of root.querySelectorAll(selector)) { + if (resolveImageSource(img) === src) results.push(img); } } - return results; } diff --git a/entrypoints/content/core/__tests__/imageProcessorWatchdog.test.ts b/entrypoints/content/core/__tests__/imageProcessorWatchdog.test.ts new file mode 100644 index 0000000..16176d5 --- /dev/null +++ b/entrypoints/content/core/__tests__/imageProcessorWatchdog.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ImageProcessor } from '@/entrypoints/content/core/ImageProcessor'; +import { BLUR_CLASS, PROCESSED_SKIPPED_ATTR } from '@/entrypoints/content/presentation/constants'; +import { DEFAULT_HOST_SETTINGS } from '@/utils/constants/hostsettings'; + +import type { BadgeCounter } from '@/entrypoints/content/core/BadgeCounter'; + +const { requestImageInference } = vi.hoisted(() => ({ + requestImageInference: vi.fn(() => Promise.resolve()), +})); + +vi.mock('@/entrypoints/content/communication/sender', () => ({ requestImageInference })); +vi.mock('@/entrypoints/content/presentation/quickToggle', () => ({ + initQuickToggle: vi.fn(), + ensureQuickToggleButton: vi.fn(), + removeQuickToggleButton: vi.fn(), + hideQuickToggleButton: vi.fn(), +})); +vi.mock('@/utils/messaging/content', () => ({ waitForMessageChannel: vi.fn(() => Promise.resolve()) })); + +const QUEUE_WAIT_MS = 120_000; +const INFERENCE_MS = 20_000; + +class FakeIntersectionObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} + +const loadedImage = (src: string): HTMLImageElement => { + const img = document.createElement('img'); + img.src = src; + Object.defineProperty(img, 'complete', { value: true }); + Object.defineProperty(img, 'naturalWidth', { value: 200 }); + Object.defineProperty(img, 'naturalHeight', { value: 200 }); + document.body.append(img); + return img; +}; + +const hostSettings = { ...DEFAULT_HOST_SETTINGS, hostname: 'example.com' }; +const badgeCounter = { trackDetections: vi.fn() } as unknown as BadgeCounter; + +describe('ImageProcessor inference watchdog', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); + document.body.innerHTML = ''; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('retries after the queue-wait guard, then keeps the blur instead of revealing', async () => { + const processor = new ImageProcessor(hostSettings, badgeCounter); + const img = loadedImage('https://cdn.test/a.jpg'); + processor.process(img); + await vi.advanceTimersByTimeAsync(0); + expect(requestImageInference).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(INFERENCE_MS); + expect(requestImageInference).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(QUEUE_WAIT_MS - INFERENCE_MS); + expect(requestImageInference).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(QUEUE_WAIT_MS); + expect(requestImageInference).toHaveBeenCalledTimes(2); + expect(img.classList.contains(BLUR_CLASS)).toBe(true); + expect(img.hasAttribute(PROCESSED_SKIPPED_ATTR)).toBe(false); + }); + + it('arms the short watchdog once the background reports the task started', async () => { + const processor = new ImageProcessor(hostSettings, badgeCounter); + const img = loadedImage('https://cdn.test/b.jpg'); + processor.process(img); + await vi.advanceTimersByTimeAsync(0); + expect(requestImageInference).toHaveBeenCalledTimes(1); + + processor.handleInferenceResults([{ status: 'started', src: img.src, hostname: 'example.com' }]); + await vi.advanceTimersByTimeAsync(INFERENCE_MS); + expect(requestImageInference).toHaveBeenCalledTimes(2); + }); + + it('fails open after repeated explicit inference errors', async () => { + const processor = new ImageProcessor(hostSettings, badgeCounter); + const img = loadedImage('https://cdn.test/c.jpg'); + processor.process(img); + await vi.advanceTimersByTimeAsync(0); + + const error = { status: 'error' as const, src: img.src, hostname: 'example.com', reason: 'decode failed' }; + processor.handleInferenceResults([error]); + await vi.advanceTimersByTimeAsync(0); + expect(requestImageInference).toHaveBeenCalledTimes(2); + + processor.handleInferenceResults([error]); + expect(img.classList.contains(BLUR_CLASS)).toBe(false); + expect(img.hasAttribute(PROCESSED_SKIPPED_ATTR)).toBe(true); + }); + + it('skips a decoded placeholder without sending it for inference', async () => { + const processor = new ImageProcessor(hostSettings, badgeCounter); + const img = document.createElement('img'); + img.src = 'https://cdn.test/placeholder.jpg'; + Object.defineProperty(img, 'complete', { value: true }); + Object.defineProperty(img, 'naturalWidth', { value: 10 }); + Object.defineProperty(img, 'naturalHeight', { value: 13 }); + Object.defineProperty(img, 'clientWidth', { value: 300 }); + Object.defineProperty(img, 'clientHeight', { value: 300 }); + document.body.append(img); + + processor.process(img); + await vi.advanceTimersByTimeAsync(0); + expect(requestImageInference).not.toHaveBeenCalled(); + expect(img.hasAttribute(PROCESSED_SKIPPED_ATTR)).toBe(true); + }); +}); diff --git a/entrypoints/content/core/__tests__/imageSource.test.ts b/entrypoints/content/core/__tests__/imageSource.test.ts index cef7c92..c694ea3 100644 --- a/entrypoints/content/core/__tests__/imageSource.test.ts +++ b/entrypoints/content/core/__tests__/imageSource.test.ts @@ -32,19 +32,25 @@ describe('resolveImageSource', () => { }); describe('isPlaceholderResolution', () => { + const limit = { width: 32, height: 32 }; + it('flags a 10 px Google placeholder', () => { - expect(isPlaceholderResolution({ naturalWidth: 10, naturalHeight: 13 })).toBe(true); + expect(isPlaceholderResolution({ naturalWidth: 10, naturalHeight: 13 }, limit)).toBe(true); }); - it('flags a strip whose smaller side is tiny', () => { - expect(isPlaceholderResolution({ naturalWidth: 400, naturalHeight: 16 })).toBe(true); + it('does not flag a strip that is tiny on one side only', () => { + expect(isPlaceholderResolution({ naturalWidth: 400, naturalHeight: 16 }, limit)).toBe(false); }); it('does not flag an undecoded image', () => { - expect(isPlaceholderResolution({ naturalWidth: 0, naturalHeight: 0 })).toBe(false); + expect(isPlaceholderResolution({ naturalWidth: 0, naturalHeight: 0 }, limit)).toBe(false); }); it('does not flag a regular thumbnail', () => { - expect(isPlaceholderResolution({ naturalWidth: 160, naturalHeight: 200 })).toBe(false); + expect(isPlaceholderResolution({ naturalWidth: 160, naturalHeight: 200 }, limit)).toBe(false); + }); + + it('respects a smaller configured limit', () => { + expect(isPlaceholderResolution({ naturalWidth: 10, naturalHeight: 13 }, { width: 8, height: 8 })).toBe(false); }); }); diff --git a/entrypoints/content/core/imageSource.ts b/entrypoints/content/core/imageSource.ts index f171936..26e026a 100644 --- a/entrypoints/content/core/imageSource.ts +++ b/entrypoints/content/core/imageSource.ts @@ -6,24 +6,18 @@ type ImageSourceLike = Pick & { const usesCandidateSelection = (img: ImageSourceLike): boolean => img.hasAttribute('srcset') || img.parentElement?.tagName === 'PICTURE'; -/** - * Firefox keeps `currentSrc` pointing at the previous request until the new - * image's size is known, so right after a `src` swap it still reports the old - * URL. Without candidate selection the reflected `src` is the truth. - */ export const resolveImageSource = (img: ImageSourceLike): string => { if (usesCandidateSelection(img)) return img.currentSrc || img.src; return img.src || img.currentSrc; }; -const PLACEHOLDER_MAX_NATURAL_PX = 32; +export type NaturalSize = { width: number; height: number }; -/** - * Low-quality placeholders (Google Images serves a 10 px thumbnail before the - * real one) render at full layout size but carry no recognisable content; - * inferring them only delays the images behind them in the queue. - */ -export const isPlaceholderResolution = (img: Pick): boolean => +export const isPlaceholderResolution = ( + img: Pick, + maxNatural: NaturalSize, +): boolean => img.naturalWidth > 0 && img.naturalHeight > 0 && - (img.naturalWidth < PLACEHOLDER_MAX_NATURAL_PX || img.naturalHeight < PLACEHOLDER_MAX_NATURAL_PX); + img.naturalWidth < maxNatural.width && + img.naturalHeight < maxNatural.height; diff --git a/package.json b/package.json index 3f87d58..ce82d7e 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "eslint-plugin-import": "^2.32.0", "eslint-plugin-react": "^7.37.5", "globals": "^17.9.0", + "happy-dom": "^20.14.5", "husky": "^9.1.7", "lint-staged": "^17.3.0", "playwright": "^1.62.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cdbcf1..ccc8c5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,6 +145,9 @@ importers: globals: specifier: ^17.9.0 version: 17.9.0 + happy-dom: + specifier: ^20.14.5 + version: 20.14.5 husky: specifier: ^9.1.7 version: 9.1.7 @@ -174,7 +177,7 @@ importers: version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(happy-dom@20.14.5)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)) web-ext: specifier: ^10.6.0 version: 10.6.0(jiti@2.7.0) @@ -1347,6 +1350,9 @@ packages: '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/which@2.0.2': resolution: {integrity: sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==} @@ -1956,6 +1962,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2956,6 +2966,10 @@ packages: guid-typescript@1.0.9: resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} + happy-dom@20.14.5: + resolution: {integrity: sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==} + engines: {node: '>=20.0.0'} + has-ansi@4.0.1: resolution: {integrity: sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==} engines: {node: '>=8'} @@ -5165,6 +5179,10 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -6449,6 +6467,8 @@ snapshots: '@types/uuid@9.0.8': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/which@2.0.2': {} '@types/ws@8.18.1': @@ -7215,6 +7235,10 @@ snapshots: buffer-from@1.1.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 26.1.2 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -8425,6 +8449,19 @@ snapshots: guid-typescript@1.0.9: {} + happy-dom@20.14.5: + dependencies: + '@types/node': 26.1.2 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-ansi@4.0.1: dependencies: ansi-regex: 4.1.1 @@ -10604,7 +10641,7 @@ snapshots: tsx: 4.23.4 yaml: 2.9.0 - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(happy-dom@20.14.5)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)) @@ -10629,6 +10666,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 26.1.2 + happy-dom: 20.14.5 transitivePeerDependencies: - msw @@ -10744,6 +10782,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} when-exit@2.1.5: {} diff --git a/utils/types/prediction.ts b/utils/types/prediction.ts index 622c12f..a797ee9 100644 --- a/utils/types/prediction.ts +++ b/utils/types/prediction.ts @@ -66,8 +66,8 @@ export type ForcedVisibility = 'auto' | 'visible' | 'blocked'; * Outcome of one image inference request, broadcast to content scripts. * Only the 'ok' arm carries a cacheable prediction; 'error' feeds the content * script's retry counter instead of leaving the image stuck behind the - * inference watchdog. 'started' marks the moment the task left the queue so - * the content watchdog measures inference time, not queue backlog. + * inference watchdog. The tag is a literal union so it can grow (e.g. a + * future 'skipped'). */ export type ImageInferenceResult = | { status: 'ok'; prediction: IImagePrediction; traceparent?: string } From c5832e7ed7632f4c4b64e2636be483a9156d0c1f Mon Sep 17 00:00:00 2001 From: Rasul Abu Muhammad Amin Date: Sun, 13 Sep 2026 13:12:47 +0200 Subject: [PATCH 6/6] fix(e2e): disable Fenix homepage-as-new-tab so Android teardown stops crashing GeckoView Firefox Nightly 158 (Bug 1977695) enables homepage-as-new-tab by default. On geckodriver's session delete Fenix then opens a fresh homepage tab while Gecko is already quitting, hitting MOZ_RELEASE_ASSERT(domWindow) in GeckoViewSupport::Open. The crash kills the Marionette reply, wdio's endSession throws, and the whole spec file is reported failed even though every scenario passed. Turn the feature off after launch by broadcasting a local Nimbus rollout to QANimbusToolingReceiver; seeding fenix_preferences.xml earlier does not survive the pm clear geckodriver runs while creating the session. Claude-Session: https://claude.ai/code/session_01JXYmCiDLk3QEbAwCTS5wS5 --- docs/TESTING.md | 7 +++ tests/e2e/config/wdio.android.conf.ts | 64 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/docs/TESTING.md b/docs/TESTING.md index 01f5ced..8cde9d9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -218,6 +218,13 @@ Android config expects that action-managed emulator to already be booted when `C workflow sets `ANDROID_SINGLE_SESSION=true` so all features run in one WebDriver session, since repeated geckodriver sessions can leave Fenix profile resources locked on CI. +Once the session is up, the `before` hook broadcasts a local Nimbus rollout to Fenix's +`QANimbusToolingReceiver` that turns the `homepage-as-new-tab` feature off. Fenix Nightly enables it +by default (Bug 1977695), and with it on Fenix opens a fresh homepage tab while Gecko is already +quitting, which trips `MOZ_RELEASE_ASSERT(domWindow)` in `GeckoViewSupport::Open` and crashes the +app during geckodriver's session delete. The override has to be sent after launch because +geckodriver runs `pm clear` on the package while creating the session, wiping any pre-seeded prefs. + **Setup:** ```bash diff --git a/tests/e2e/config/wdio.android.conf.ts b/tests/e2e/config/wdio.android.conf.ts index fab6ab1..eb9a953 100644 --- a/tests/e2e/config/wdio.android.conf.ts +++ b/tests/e2e/config/wdio.android.conf.ts @@ -50,6 +50,68 @@ const cleanupFirefoxRuntime = (reason: string): void => { runAdbCleanup(['shell', 'am', 'force-stop', FENIX_PACKAGE], 'force-stop Firefox Nightly'); }; +const FENIX_HOMEPAGE_AS_NEW_TAB_OPT_OUT_RECIPE = JSON.stringify({ + data: [ + { + schemaVersion: '1.12.0', + slug: 'haramblock-e2e-disable-homepage-as-new-tab', + appName: 'fenix', + appId: FENIX_PACKAGE, + channel: 'nightly', + userFacingName: 'HaramBlock E2E: disable homepage as new tab', + userFacingDescription: 'Opening a homepage tab while Gecko quits crashes GeckoView.', + isEnrollmentPaused: false, + isRollout: true, + bucketConfig: { + randomizationUnit: 'nimbus_id', + namespace: 'haramblock-e2e', + start: 0, + count: 10000, + total: 10000, + }, + probeSets: [], + outcomes: [], + startDate: null, + endDate: null, + proposedEnrollment: 7, + referenceBranch: 'control', + featureIds: ['homepage-as-new-tab'], + branches: [ + { + slug: 'control', + ratio: 1, + features: [{ featureId: 'homepage-as-new-tab', enabled: true, value: { enabled: false } }], + }, + ], + targeting: 'true', + }, + ], +}); + +const disableFenixHomepageAsNewTab = (): void => { + runAdbCleanup( + [ + 'shell', + 'am', + 'broadcast', + '-a', + `${FENIX_PACKAGE}.NIMBUS_TOOLING`, + '-p', + FENIX_PACKAGE, + '--ez', + 'nimbus-cli', + 'true', + '--ei', + 'version', + '1', + '--es', + 'experiments', + `'${FENIX_HOMEPAGE_AS_NEW_TAB_OPT_OUT_RECIPE}'`, + ], + 'disable Fenix homepage-as-new-tab via Nimbus tooling', + ); +}; + const cleanupFirefoxSessionState = (reason: string): void => { cleanupFirefoxRuntime(reason); runAdbCleanup(['shell', 'pm', 'clear', FENIX_PACKAGE], 'clear Firefox Nightly data'); @@ -364,6 +426,8 @@ export const config: WebdriverIO.Config = { throw new Error('Firefox extension path not set'); } + disableFenixHomepageAsNewTab(); + // Keep the session's initial tab open and create a fresh one: Android // geckodriver can return no remaining handles from closeWindow(), which // makes WebdriverIO terminate the entire session.