fix(recapture): background renders save the page without a srcset-only image it fetched#1843

opened16d agobyBbirdbrain-agent

Defect: a background render can save a page without an image the page actually loaded

What: When a page shows an image offered only as a set of resolutions, a background render fetches the image but the saved page does not contain it, so a Capture can show a gap where the live page showed an image. The renderer spike found this on a local test page and left it out of its own change, which was a report only.

Where it was seen and how to reproduce it

Where: src/main/services/backgroundRenderer.ts:519 (wc.savePage(tmpPath, 'MHTML')), observed in the spike at docs/specs/2026-10-11-capture-engine-renderer-spike.md:306-308 (run q2-savepage; the server log at :279 shows /savepage/srcset-1x.png requested at +32 ms).

Reproduce: Serve the spike's q2-savepage fixture (docs/specs/2026-10-11-capture-engine-renderer-spike.md:1005-1022), whose page holds <img srcset="/savepage/srcset-1x.png 1x, /savepage/srcset-2x.png 2x"> with no src. Run a background render of it, then search the stored MHTML for a Content-Location line naming srcset-1x.png: there is none, although the fixture server logged the request.

Not yet known: whether Chromium's MHTML serializer skips srcset-only images in general or only in this run; one fixture run is the only evidence. The spike PR was #1840.

Note

Written by claude-opus-5-5 via Claude Code on behalf of Matt.

This was generated by AI during triage.

Triage: bug, marked evidence-affecting, and kept in needs-triage so it can be folded into the capture engine design work.

I reproduced the defect, and it is broader than the issue states. When the app saves a page, the browser's saver drops the list of alternative image sources from every image. An image offered only through that list is missing from the saved page even though the browser fetched and displayed it. An image that also has a plain source is worse: the saved page stores the bytes of the image the page displayed under the plain source's address, which the browser never requested, so the saved page names the wrong origin for those bytes. Images inside a picture element and plain images save correctly. This is how the browser saver behaves every time, not a one-off result.

The browser extension saves pages through the same browser feature, so it probably behaves the same way, but I did not test it.

The three fixes I can see all need a decision about how evidence is handled: rewrite the page before saving it, record the gap as a stated limitation of the saved page, or rely on the planned transaction record for the true bytes. I have not picked one.

The test page, the run, and what the saved file held

Run on 2026-10-14 against origin/main 0c5ee8fc's Electron 44.7.0 (Chromium 152.0.7977.130), under xvfb-run on Linux. A standalone Electron script served this page from 127.0.0.1, loaded it in a hidden BrowserWindow, waited 1.5 s, and called webContents.savePage(path, 'MHTML'), the same call backgroundRenderer.ts makes. Each image response was a 1x1 PNG with TAG:<request path> appended after IEND, so the bytes in each MHTML part name the request that served them.

html
1<img srcset="/srcset-only-1x.png 1x, /srcset-only-2x.png 2x" width="10" height="10">
2<img src="/src-fallback.png" srcset="/srcset-with-src-1x.png 1x, /srcset-with-src-2x.png 2x" width="10" height="10">
3<img srcset="/srcset-w-400.png 400w, /srcset-w-800.png 800w" sizes="10px" width="10" height="10">
4<picture><source srcset="/picture-source.png" type="image/png"><img src="/picture-fallback.png" width="10" height="10"></picture>
5<img src="/plain.png" width="10" height="10">
ImageRequested and shown (currentSrc)In the MHTMLSaved <img> markup
srcset only, x descriptors/srcset-only-1x.pngno part<img width height>
src + srcset/srcset-with-src-1x.pngpart Content-Location: /src-fallback.png, bytes tagged /srcset-with-src-1x.pngsrc="/src-fallback.png", no srcset
srcset only, w descriptors/srcset-w-400.pngno part<img width height>
<picture>/picture-source.pngpart /picture-source.png, bytes match<source srcset> kept
plain src/plain.pngpart /plain.png, bytes matchunchanged

The server log never shows a request for /src-fallback.png.

Call sites that use the Blink MHTML serializer: src/main/services/backgroundRenderer.ts (wc.savePage(tmpPath, 'MHTML')) and extension/src/background.ts (chrome.pageCapture.saveAsMHTML). The extension path was not run.

Related records: ADR-0004 already says MHTML is a browser representation and not a complete transaction record, and ADR-0035 puts a WARC transaction record beside the MHTML. Whether that record should be the answer for these bytes is part of the open decision.

Note

Written by claude-opus-5-5 via Claude Code on behalf of Matt.

This was generated by AI during triage.

Triage: bug, evidence-affecting, ready for an agent once two other issues merge. The open decision from the last triage is now made.

The maintainer chose to correct the page before it is saved, not to record the gap as a limitation or wait for the planned network record. After the screenshot, a background render points each image offered as a set of alternatives at the image it actually displayed, then saves the page. The saved page then holds every displayed image under the address it really came from.

Birdbrain is changing the page before saving it, so each Capture states that it did. Its signed record gains two counts: the images corrected, and the corrected images still loading when the save ran. The report shows the count. The browser extension is out of scope, because nobody has tested it and a change there would alter the investigator's own tab.

This work starts when the verifier knows the two new counts (#1904) and when Captures first write the inventory that holds them (#1928). The brief, the glossary entry and the decision record below are the contract.

Agent brief

Agent Brief

Category: bug Summary: A background Recapture pins each srcset image to the source it displayed before saving the MHTML, and records Image Source Pinning in the signed capture entry's inventory.

Current behavior: Reproduced on Electron 44.7.0 during the 2026-10-15 triage (method and table in the earlier triage comment). The MHTML serializer drops srcset from every <img>. An <img> with srcset and no src (either x or w descriptors) has no part in the MHTML, although the browser fetched and displayed it. An <img> with src and srcset is saved with the bytes it displayed (currentSrc), filed under Content-Location of its src, which the browser never requested. <picture> and plain <img src> save correctly.

Desired behavior:

  • After the screenshot is taken and before the page is saved, the render rewrites each <img> that is not inside <picture>, carries srcset, and has a non-empty currentSrc. Its src becomes its currentSrc, and its srcset and sizes attributes are removed. An <img> with an empty currentSrc is left untouched.
  • The save waits for each pinned image to finish loading, bounded by the render's existing time budget. It never extends the render past its deadline.
  • The capture entry's inventory carries, in its MHTML part, pinnedImages (the images rewritten) and pinnedImagesNotLoaded (the pinned images not loaded when the save ran). Both are always written, 0 when nothing applied, and both are whole numbers. The Capture row mirrors them the way it mirrors the rest of the inventory.
  • The report's per-Capture detail adds a row next to the consent overlay row, reading Image sources: N pinned to the displayed image, and , M not loaded when saved when M is nonzero. The row is absent when N is 0.
  • CONTEXT.md gains the Image Source Pinning entry below, and the decision record below lands as the next free ADR number.

Key interfaces:

  • RenderedPage carries the two counts from the background renderer to ingest, the way it carries consentSuppression.
  • The inventory in the capture entry (the first-slice plan's D4) holds them under inventory.mhtml. #1904 adds both fields to the schema and the verifier, at the schema number it takes. If #1904 merges without them, stop and take the give-up path rather than adding a schema step here.
  • The verifier refine #1904 adds rejects pinnedImagesNotLoaded greater than pinnedImages.
  • Consent Suppression is the pattern to follow end to end: render, RenderedPage, ingest, signed entry, Capture row, report row.

Acceptance criteria:

  • An end-to-end Recapture of a local page with five images (srcset only with x descriptors, src plus srcset, srcset only with w descriptors, <picture>, plain src) stores an MHTML that has a part for each image's displayed source, and no part under an address the server never served.
  • That Capture's signed entry has inventory.mhtml.pinnedImages 3 and pinnedImagesNotLoaded 0, and the report shows Image sources: 3 pinned to the displayed image.
  • A renderer test asserts the order: the screenshot is captured, then the rewrite runs, then the page is saved.
  • A page with no srcset image gets both counts 0 and no report row.
  • A test in which a pinned image cannot load before the budget ends counts it in pinnedImagesNotLoaded, and the render still finishes within its deadline.
  • The package from such a Capture verifies in the app and passes verify.sh.
  • Captures made by the extension are unchanged.

Verification path:

  • pnpm test tests/main/services/backgroundRenderer.test.ts and pnpm test tests/main/services/recapture.test.ts
  • pnpm test:e2e, with a new spec beside the existing Recapture specs that serves the five-image page
  • pnpm preflight, green at the PR head

Starting points (as of main 5509cff8, may move):

  • src/main/services/backgroundRenderer.ts: render(), between captureFullPageScreenshot and wc.savePage.
  • src/main/services/recapture.ts: RenderedPage and the ingest call.
  • src/main/services/reportHtml.ts: the Consent overlay row.
  • e2e/recapture-consent.spec.ts: the pattern for a served local page.
  • The inventory writer #1928 adds, and the schema fields #1904 adds.

Evidence-affecting: yes. The change alters the MHTML a Capture stores and adds signed fields.

Out of scope:

  • The browser extension's save path. If you test it and it drops images too, record it in the PR's findings.
  • Operator-witnessed Captures.
  • Rewriting stored Captures made before this change.
  • Any change to how <picture> or plain images are saved.

Blocked by: #1904 and #1928.

Glossary entry, to add beside Consent Suppression

Image Source Pinning: The record that a background Recapture pointed each image offered as a set of alternatives at the one it displayed before saving the MHTML, with how many images that covered and how many were still loading when the page was saved. Absent for operator-witnessed Captures and for Captures made before it existed. Avoid: srcset rewrite, image fix.

Decision record to add as the next free ADR

A local draft of this text is committed on drafts/2026-10-21-image-source-pinning-adr, which has not been pushed, so the text is reproduced here.

A background render pins image sources before it saves the MHTML

Status: Accepted. Date: 2026-10-21. Source: the maintainer's ruling in the triage of #1843.

Context. The MHTML serializer drops srcset from every <img>: an image offered only through srcset is missing from the saved page, and an image with both src and srcset is filed under an address the browser never requested. ADR-0004 treats MHTML as a browser-oriented representation and requires a Capture to record the limitations that applied. The planned Transaction Record (ADR-0035) holds the true bytes only when recording was on before the page loaded, and does not correct an address the MHTML states wrongly.

Considered options. Stating the gap as a limitation never changes captured content but leaves a wrong origin in the saved page. Relying on the Transaction Record builds nothing now and leaves the wrong address in every MHTML. Pinning the image sources before the save was chosen.

Decision. After the screenshot and before the save, each <img> outside <picture> that carries srcset and displayed an image has its src set to the source it displayed and its srcset and sizes removed. The Capture records Image Source Pinning as two always-written counts in the MHTML part of the signed inventory, following Consent Suppression. Background renders only: the extension is untested, and a change there would alter the Operator's own tab.

Consequences. The saved MHTML is no longer exactly what the serializer would have written from the page as loaded; the signed counts are what a reader relies on. The counts join the schema number the first-slice verifier takes (#1904), because a released verifier cannot be recalled. A verifier rejects a not-loaded count above the pinned count.