chenglou/pretext · error · Error

Timed out waiting for report from ${browser} (last phase: ${

Error message

Timed out waiting for report from ${browser} (last phase: ${lastPhase})

What it means

Thrown by loadHashReport when the page-side harness is expected to write its result into location.hash (the #report=... transport) and no matching report arrives within timeoutMs (default 60s, overridable via --timeout). The host navigates the session to a URL, then polls session.readLocationUrl() every 100ms, parsing the hash for a navigation phase and a report JSON. The error includes the last observed phase (loading / measuring / posting) so the caller can tell how far the page got.

Source

Thrown at scripts/browser-automation.ts:664

      phase !== null &&
      (phase.requestId === undefined || phase.requestId === expectedRequestId)
    ) {
      lastPhase = phase.phase
    }
    const reportJson = readNavigationReportText(currentUrl)
    if (reportJson === '' || reportJson === 'null') continue

    const report = JSON.parse(reportJson) as T
    if (report.requestId === expectedRequestId) {
      return report
    }
  }

  if (lastPhase === null) {
    lastPhase = await readLastNavigationPhase(session, expectedRequestId)
  }
  const observedUrl = formatObservedLocation(await session.readLocationUrl())
  throw new Error(getTimeoutMessage(browser, 'report', lastPhase, observedUrl))
}

export async function loadPostedReport<T extends { requestId?: string }>(
  session: BrowserSession,
  url: string,
  waitForReport: () => Promise<T>,
  expectedRequestId: string,
  browser: BrowserKind,
  timeoutMs = 60_000,
): Promise<T> {
  await session.navigate(url)

  let resolvedReport: T | null = null
  let reportError: unknown = null

  void waitForReport().then(
    value => {
      resolvedReport = value

View on GitHub (pinned to ac49b09b7d)

Solutions

  1. Read lastPhase in the message: "loading" = page never finished setup (check page console for import/runtime errors); "measuring" = measurement loop stalled (font/canvas issue); "posting" = transport problem (hash overflow — switch to the POST path).
  2. Raise --timeout (corpus-check) / timeoutMs arg; a corpus sweep over many widths legitimately needs more than the 60s default.
  3. If the report is large, switch the calling script from loadHashReport to loadPostedReport (startPostedReportServer + POST sidechannel) to bypass hash length limits.
  4. Verify the requestId woven into the URL matches the report.requestId the page writes — a mismatched id makes every poll look like "no report".
  5. Open the same URL in a headed browser and watch the console; loadHashReport swallows page-side errors by design, so the page is the source of truth.

Example fix

// before
const report = await loadHashReport<CorpusReport>(session, url, requestId, browser, timeoutMs)
// after — for large reports, use the POST sidechannel instead of the hash
const reportServer = await startPostedReportServer<CorpusReport>(requestId)
try {
  const posted = await loadPostedReport(
    session,
  urlWithReportEndpoint(url, reportServer.endpoint),
    () => reportServer.waitForReport(null),
    requestId,
    browser,
    timeoutMs,
  )
} finally {
  reportServer.close()
}
Defensive patterns

Strategy: retry

Validate before calling

// Choose transport by payload shape BEFORE picking loadHashReport
function shouldUsePostTransport(corpusMeta: { rows?: number }, widths: number[]): boolean {
  const approxRows = (corpusMeta.rows ?? 1) * widths.length
  // Browsers cap URL/hash near 2k (Safari) to ~32k; anything big should POST
  return approxRows > 50
}

Try / catch

try {
  return await loadHashReport(session, url, requestId, browser, timeoutMs)
} catch (error) {
  if (error instanceof Error && error.message.includes('last phase: posting')) {
    // Hash overflow is the most likely posting-phase failure — switch to the POST sidechannel
    const server = await startPostedReportServer<T>(requestId)
    try {
      return await loadPostedReport(session, `${url}&reportEndpoint=${encodeURIComponent(server.endpoint)}`, () => server.waitForReport(null), requestId, browser, timeoutMs)
    } finally {
      server.close()
    }
  }
  throw error
}

Prevention

When it happens

Trigger: loadHashReport polls up to ceil(timeoutMs/100) times. Each tick: readLocationUrl, readNavigationPhaseState to track lastPhase, readNavigationReportText to extract report JSON. Throw when no report.requestId === expectedRequestId ever matched. Concrete causes: the page threw before reaching the hash-write step (lastPhase stays "loading" or "measuring"); the report payload exceeded the browser's URL/hash length cap so it was truncated/never written (lastPhase "posting"); the requestId encoded in the URL did not match what the page echoes back; the tab was navigated away or closed by the user.

Common situations: An accuracy/corpus page that produces a large report (many rows) overflows the hash transport — use loadPostedReport (the POST sidechannel) instead; the page hit a runtime error mid-measurement (font not loaded, DOM missing) and never reached the report step; a stale browser tab from a prior run intercepted the navigation; Safari's stricter URL-length handling truncates the hash earlier than Chromium; --timeout too low for a slow Corpus on a cold browser.

Understand the failure class

Related errors


AI-assisted analysis of chenglou/pretext@ac49b09b7d (2026-08-12). Data as JSON: /api/errors/9bd0c34e4e83963f. Report an issue: GitHub.