Skip to content

[#4758 P1] perf-runner driver crash: setInterval race condition closes Playwright context mid-scrape #4759

Description

@OneStepAt4time

Summary

The perf-runner drivers (driver.mjs, driver-final.mjs) crash with page.evaluate: Target page, context or browser has been closed due to a race condition between concurrent setInterval callbacks sharing the same Playwright page object. This is the root cause of #4758 (watch=not-yet-launched for 48h+).

Fix is already in place at /home/buntu/.aegis-perf-run/driver-final.mjs and driver.mjs (in-place edit, no PR — drivers live outside the aegis repo). Regression test at /home/buntu/.aegis-perf-run/driver-race-test.mjs (2/2 passing).

Symptom

  • Driver starts, runs cycles 1-5 (or 1-11 in phase 1) successfully.
  • After ~30-50 minutes, a cycle tick fires the route-navigation block, which takes longer than SCRAPE_MS (30s).
  • The next setInterval fires while the cycle is still in progress.
  • Both callbacks touch the same page object: one is doing page.goto, the other is doing page.evaluate.
  • Playwright throws page.evaluate: Target page, context or browser has been closed.
  • 5 consecutive failures, then the process dies with no clean shutdown.

Evidence

{"t":"2026-06-15T14:28:20.613Z","level":"error","msg":"scrape failed","label":"scrape:94","message":"page.evaluate: Target page, context or browser has been closed"}   <- driver.mjs (Phase 1)
{"t":"2026-06-15T15:21:10.341Z","level":"info","msg":"cycle tick","cycleCount":6,"elapsed":1501723}                                        <- driver-final.mjs (Phase 2)
{"t":"2026-06-15T15:22:10.359Z","level":"error","msg":"scrape failed","label":"scrape:52","message":"page.evaluate: Target page, context or browser has been closed"}
{"t":"2026-06-15T15:22:40.380Z","level":"error","msg":"scrape failed","label":"scrape:53","message":"page.evaluate: Target page, context or browser has been closed"}
{"t":"2026-06-15T15:23:10.408Z","level":"error","msg":"scrape failed","label":"scrape:54","message":"page.evaluate: Target page, context or browser has been closed"}
{"t":"2026-06-15T15:23:40.437Z","level":"error","msg":"scrape failed","label":"scrape:55","message":"page.evaluate: Target page, context or browser has been closed"}
{"t":"2026-06-15T15:24:10.444Z","level":"error","msg":"scrape failed","label":"scrape:56","message":"page.evaluate: Target page, context or browser has been closed"}
[log ends — no clean shutdown, process dead at 15:24]

The 30s spacing between failures matches SCRAPE_MS=30000. The cycle tick at 15:21:10 ran the 9-route block; the next setInterval at 15:21:40 (30s later) raced against the still-in-progress cycle, and the page was being torn down by page.goto when the new page.evaluate fired.

Root cause

driver-final.mjs:73-87 (pre-fix):

const timer = setInterval(async () => {
  const elapsed = Date.now() - startedAt;
  if (elapsed >= totalMs) { ...; process.exit(0); }
  scrapeCount++;
  await scrape(`scrape:${scrapeCount}`);
  if (elapsed % cycleMs < scrapeMs + 1000) {
    cycleCount++;
    log('info', 'cycle tick', { cycleCount, elapsed });
    for (const route of ROUTES) {
      try { await page.goto(`${AEGIS_BASE}/dashboard${route}?perf=1`, ...); await scrape(`cycle:${route}`); }
      catch (e) { ... }
    }
    try { await page.goto(DASH_URL, ...); } catch (e) { ... }
  }
}, scrapeMs);

The cycle block does 9 route navigations (each with its own page.goto + scrape) + 1 return navigation. With ~3-5s per route, the cycle takes 30-50s — longer than scrapeMs (30s). setInterval does not wait for the previous callback to complete, so the next callback fires while the cycle is still in progress, racing on the shared page object.

driver.mjs (Phase 1) has a different but related form: two separate setIntervals (scrapeTimer 30s, cycleTimer 5min) — quickRouteCycle can run 30s+ for 9 routes, racing against the next scrapeTimer fire.

Fix

In-place edit to both driver-final.mjs and driver.mjs. Add an inFlight flag that guards both timers' callbacks. Second concurrent tick is skipped with a warn log.

let inFlight = false;
const timer = setInterval(async () => {
  if (inFlight) { log('warn', 'tick skipped: previous tick still in flight'); return; }
  inFlight = true;
  try {
    // ... existing tick logic ...
  } catch (e) {
    log('error', 'tick failed', { message: e.message });
  } finally {
    inFlight = false;
  }
}, scrapeMs);

The try/catch/finally ensures inFlight is always reset, even on thrown errors. The warn log on skipped ticks provides observability into how often the race is being prevented (useful telemetry for sizing SCRAPE_MS vs route-load time).

Regression test

/home/buntu/.aegis-perf-run/driver-race-test.mjs (Node --test):

$ node --test /home/buntu/.aegis-perf-run/driver-race-test.mjs
ok 1 - buggy setInterval pattern: concurrent ticks produce page errors
ok 2 - fixed inFlight pattern: concurrent ticks are skipped, no page errors
# tests 2 / pass 2 / fail 0

Test 1 reproduces the bug (5 concurrent ticks at 30ms intervals → page errors). Test 2 verifies the fix (5 concurrent ticks with inFlight guard → 0 errors, 2 skipped).

Verification

  • node --test on the regression test: 2/2 pass.
  • Code review of the fix: inFlight is set before any work, reset in finally, and the second concurrent tick is skipped with a warn log. The try/catch/finally block ensures the flag is never stuck true.
  • The drivers themselves are not run end-to-end here (would require a running aegis dashboard + 30+ minute endurance run); the unit test exercises the same race-condition pattern with a mock page.

Out of scope (deferred)

Lane

Refs

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions