Skip to content

fix(beholder): guard #fetchImages against scrollHeight runaway and detached frame races#881

Merged
YusukeHirao merged 2 commits into
devfrom
fix/beholder-fetch-images-scroll-height-guard
Jun 17, 2026
Merged

fix(beholder): guard #fetchImages against scrollHeight runaway and detached frame races#881
YusukeHirao merged 2 commits into
devfrom
fix/beholder-fetch-images-scroll-height-guard

Conversation

@YusukeHirao

Copy link
Copy Markdown
Member

Summary

  • @d-zero/beholder#fetchImages が responsive レイアウトのページで 📷 mobile-small: skipped — Attempted to use detached Frame ... を発生させていた問題を修正
  • 真因: scrollAllOver が 320 px viewport で 5 分超走り、@retryable の 5 分 timeout が Promise.race で発火しても fn() を kill しないため、 background の page.evaluate と次の retry が同じ page で衝突 (Promise.race の loser-uncancelled 仕様)
  • 対策: @retryable timeout を 5 → 20 分、scrollHeight > 1,000,000 px の device 全体スキップ (maxScrollHeight ガード) を追加

Changes

@d-zero/puppeteer-page-scan

  • beforePageScanOptionsmaxScrollHeight?: number を追加
  • 戻り値型を Promise<void>Promise<{ scrolled: boolean; scrollHeight: number }> に拡張
    • 既存 4 caller (print / a11y-check-core / replicator / puppeteer-screenshot) は戻り値を破棄しているため非破壊
  • 設定時のみ scroll 前に document.body.scrollHeight を測定し、超過したら scrollAllOver をスキップして scrolled: false を返す

@d-zero/beholder

  • #fetchImages@retryable timeout を 5 分 → 20 分に拡張
  • MAX_SCROLL_HEIGHT = 1,000,000 を導入し、 beforePageScanmaxScrollHeight として渡す
  • scrolled: false を受け取ったら専用メッセージで emit + continue で device 全体をスキップ
  • 上記すべての WHY をクラス JSDoc / 定数 JSDoc に記録

Root cause analysis

実機調査 (Chrome DevTools MCP) で確認:

観測値
document.body.scrollHeight (mobile-small 320 px) 321,466 px
1 scroll step ~360 px
scroll interval ~350 ms
推定完走時間 ~5 分 12 秒
元の @retryable timeout 5 分

5 分 timeout が確実に先発火 → Promise.race は loser を kill しない → 旧 scroll の page.evaluate が background で走り続ける → 新 retry が同じ page で setViewport + reload を実行 → main frame navigation と衝突して Attempted to use detached Frame または Session closed

frame ID が毎回異なる事実、 Emulation.setTouchEmulationEnabled で session closed が出る事実、 mobile-small でだけ顕在化する事実すべてがこのメカニズムで一貫して説明できる (元々の「iframe detach race」仮説は実機で iframe 0 個を確認したため棄却)。

Trade-offs

  • 根本対応 (retry.tsPromise.raceAbortSignal ベース) は全 caller に波及するため別タスクに deferred
  • 1,000,000 px ガードは「20 分でも完走しない病的ページ」を切り捨てる側に倒した band-aid

Test plan

  • @d-zero/puppeteer-page-scanmaxScrollHeight ガードの単体テスト 6 件追加 (境界 / 超過 / 未指定 / 0 指定 / page.evaluate reject 伝播)
  • yarn test (775 tests pass)
  • yarn lint (cspell pass)
  • yarn build (27 projects pass)
  • 実機検証: nitpicker で tepco の問題 URL を再実行し Attempted to use detached Frame が出ないことを確認 (merge 後)

🤖 Generated with Claude Code

Return `{ scrolled, scrollHeight }` instead of `void` so callers can detect
pages whose post-load scrollHeight exceeds `maxScrollHeight` and abandon the
device preset. Existing callers ignore the return value, so the type widening
is non-breaking. Without `maxScrollHeight` the scroll runs unbounded as
before.

Motivated by a Puppeteer race surfaced in beholder's image extraction:
`scrollAllOver` on a pathological responsive page can run longer than the
@retryable timeout, and `Promise.race` does not cancel `fn()`, leaving
background `page.evaluate` calls colliding with the next retry attempt.
…tached frame races

Extend the @retryable timeout from 5 to 20 minutes and skip any device preset
whose post-load scrollHeight exceeds 1,000,000 px. The previous 5-minute
budget was shorter than the natural scroll time for responsive pages that
expand to ~300k px at 320 px viewport, so the retry's `Promise.race` would
fire its `RetryTimeoutError` while `scrollAllOver` kept running in the
background. The next retry then collided with those pending `page.evaluate`
calls on the same Puppeteer page, surfacing as "Attempted to use detached
Frame" or "Session closed".

The `maxScrollHeight` ceiling backs up the longer timeout: even 20 minutes
cannot absorb every pathological layout, and abandoning a device preset is
preferable to risking the same retry/background collision again.
@YusukeHirao YusukeHirao requested a review from yusasa16 as a code owner June 17, 2026 14:49
@YusukeHirao YusukeHirao merged commit 9ee2424 into dev Jun 17, 2026
2 checks passed
@YusukeHirao YusukeHirao deleted the fix/beholder-fetch-images-scroll-height-guard branch June 17, 2026 23:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant