fix(browser): recover abandoned install locks#2328
Conversation
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at a3bb558.
Clean fix for a real production bug (Windows two-lock deadlock reproducible on 0.7.55 per the PR body). Two orthogonal problems, two orthogonal fixes, both correct:
- Abandoned reclaim gate —
reclaimAbandonedReclaimLockat the top of each poll iteration ages out the reclaim gate itself when the process holding it died before releasing. Correctly narrower than the install-lock reclaim (singlermSync, no gate-of-gate needed because concurrent rmSync of a non-existent path is a no-op underforce: true). - Already-stale install lock short-circuit —
isDirLockStale(INSTALL_LOCK_DIR, timings.staleMs) || Date.now() > deadlineskips the full-timeout wait when the lock is already stale on entry. The reclaim-gate serialization inreclaimStaleInstallLockstill prevents concurrent reclaimers from racing, so the earlier trigger doesn't reintroduce the multi-waiter reclaim hazard.
Test coverage is on-point — the fake-timer regression test exercises the both-locks-present path with pre-aged mtime, and the existing progress-reporting test correctly pins initialMtimeMs: Date.now() to keep its lock non-stale so the new fast-path doesn't skip the wait it wants to observe.
What I didn't verify
- Windows-specific
mtimeMsbehavior on a directory (statSyncon a lock dir) — trusting Node's cross-platform surface. Miguel reproduced under Windows in the PR body, so this is empirically confirmed.
vanceingalls
left a comment
There was a problem hiding this comment.
R1 by Via — posted after Rames-D's parallel review; adds a distinct flake-mechanism diagnosis.
Peer-lens with Rames-D: convergent on the core fix (reclaim-gate + immediate-stale-lock reclaim, both correct, blast-radius contained, real production dispatch in tests). Unique from this review: a specific diagnosis of the Test lane flake on manager.test.ts:428 and a suggested tightening. Verdict downgraded to 🟡 LGTM-conditional on that basis.
PR #2328 — fix(browser): recover abandoned install locks
Verdict: 🟡 LGTM-conditional
Head: a3bb5583 on main (single-commit branch, miguel-heygen)
Status: CI green after retry (Test failed on attempt 1 at job/86790562063, passed on attempt 2 at job/86922281176); mergeable_state=blocked (awaiting review approval, not CI).
Ship-blocker to flag (flake, not deterministic)
Testlane failed on attempt 1 with an assertion inside a test THIS PR MODIFIED:
manager.test.ts:428 — expected false to be trueon the assertion
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Waiting for another hyperframes process"))
inwithInstallLock reports progress while waiting instead of staying silent.- The identical run passed on GitHub's rerun (attempt 2 at 20:10:23Z, all 27 tests green). So the failure is a flake, but the flake was introduced by this PR — see Finding 1. Retries hide the problem; the flake will re-emerge on every subsequent PR that pushes CLI test load.
Summary of change
Adds reclaimAbandonedReclaimLock + isDirLockStale helpers to packages/cli/src/browser/manager.ts so that (a) a crashed process holding the .chrome.install.reclaim.lock gate no longer wedges every future installer forever (the reported "both lock directories left behind" case), and (b) a waiter that walks into an already-stale .chrome.install.lock reclaims it immediately instead of waiting through a fresh staleMs=120s timeout. Adds one regression test using fake timers for the two-lock-dir scenario, and modifies one existing test's mtime seeding to keep it viable under the new mtime-check semantics.
Findings
-
🟡 Flake risk (nit — should tighten before merge) —
packages/cli/src/browser/manager.test.ts:413-429("withInstallLock reports progress while waiting instead of staying silent"). The test now seedsinitialMtimeMs: Date.now()at setup time, then doesawait import("./manager.js")(avi.resetModules()'d dynamic import per beforeEach) before callingwithInstallLock. Under the newisDirLockStale(INSTALL_LOCK_DIR, timings.staleMs)check on manager.ts:154 (short-circuited ahead of the wait-notice branch on lines 147-153), if the dynamic import takes more thanstaleMs=50ms— plausible on a busy shared-runner with vitest resetting modules between tests — iteration 1 firesreclaimStaleInstallLockimmediately, the loop breaks a few ms later, andwaitedMsnever reacheswaitNoticeMs=20. No warn ever emits, and the assertion at line 428 fails with exactly the observedexpected false to be true. The pre-PR test tolerated arbitrary import latency because the old code only checkedDate.now() > deadlinefromwaitStart(post-import), not mtime seeded pre-import. Suggested fix: mirror the pattern the PR already adopted for the new test at line 352-367 —vi.useFakeTimers()withnow: 1000and drive time withvi.advanceTimersByTimeAsync, or move theinitialMtimeMscapture inside a helper that runs after the import. Refute attempt: I looked for other reasons the warn wouldn't fire — spy setup, ordering with previoususeFakeTimersin the sibling test bleeding through (afterEachvi.useRealTimers()handles that), module-cache warmth reducing import cost (each test doesvi.resetModules(), so re-execution is always required). None hold up. The observed 123ms duration is consistent with iter-1 immediate-reclaim + break; a healthy run should be ~50-60ms (11-12 poll iters at ~5ms each). Not-refuted. -
🟢 Reclaim-gate abandonment fix is well-shaped —
packages/cli/src/browser/manager.ts:103-111 + 134-142.reclaimAbandonedReclaimLockreads mtime viaisDirLockStale(correctly non-strict>), rmSync's withforce:true(ENOENT-safe by itself, plus a redundant ENOENT catch — belt and suspenders), and swallows any other errno type via the catch. Called only inside theexistsSync(RECLAIM_LOCK)branch, so no cost when the gate is absent. Refute attempt: race withreclaimStaleInstallLock's legitimate holder — that holder justmkdirSync'd the gate, so its mtime is fresh (< staleMs old), andisDirLockStalereturns false. Two waiters both racing to clean an actually-abandoned gate — both call rmSync withforce:true, idempotent. Not-refuted. -
🟢 Immediate-reclaim of already-stale install lock is correct — manager.ts:154. Adding
isDirLockStale(INSTALL_LOCK_DIR, staleMs) || Date.now() > deadlinebefore the reclaim path means a fresh waiter walking into a lock whose mtime is already 3 hours old no longer waits its own 120s deadline before acting. The reclaim gate on line 89-101 still serializes reclaimers viatryAcquireDirLock(RECLAIM_LOCK)and re-checks the mtime after acquiring the gate, so waiter B can't delete waiter A's freshly-won lock. Refute attempt: what if the holder's event loop is blocked for >120s so heartbeat setInterval can't fire, and a waiter reads the resulting stale mtime and reclaims a still-live install?downloadBrowsercalls@puppeteer/browsers.install()which is async (extraction on a worker), andpurgeStaleInstallis a syncrmSyncof one version dir (seconds, not minutes). The comment at line 40-41 already acknowledges this concurrency contract. Not-refuted. -
🟢 Blast radius covered —
withInstallLockhas 3 call sites, all inmanager.ts(lines 474, 562, 577:findBrowser's stale-cache path,ensureBrowser's stale-cache path, andensureBrowser's main install path). All three go through the same function, so the reclaim-gate protection is uniform. No parallel install paths (grep across the repo confirmsINSTALL_RECLAIM_LOCK_DIRis defined and used only here). Sibling-of telemetry-gap-on-parallel-branch concern doesn't apply. -
🟢 New test is real, not aspirational —
manager.test.ts:352-367imports the productionwithInstallLockfrom./manager.js, exercises the actualreclaimAbandonedReclaimLockcode path (would loop forever without the fix;Promise.raceagainstPromise.resolve("still waiting")correctly asserts progress withinpollMs * 2 = 10msfake time). Under fake timers,Date.now()returns a real wall-clock timestamp frozen atuseFakeTimers()time — soDate.now() - 0 > 50msis trivially true, which is a mild characterization-test smell (any staleMs value would pass), but the reclaim-of-abandoned-gate semantics are still verified via the paths.has(HF_RECLAIM_LOCK) === false assertion. Signature markers checked: no localreclaimAbandonedReclaimLockshadow in the test file, no un-resolvable SHA references, direct dispatch through the production import. -
🟢 Rollout — no flag needed — Unconditional bug fix in the lock lifecycle. Correct choice: a feature-flagged rollout would leave broken installs behind for the disabled cohort, which is the opposite of the fix's purpose. No env-var scaffolding required.
Cross-checks
- Head SHA current: yes (
a3bb5583d67ab8a908f38f2853df438c525e70fb, single commit, no post-review author pushes). - Prior reviewers on record: none (0 reviews; 0 requested_reviewers; 0 issue comments).
- Mergeability:
mergeable=true, mergeable_state=blocked(awaiting approval; no CI block after retry). - Test lane state: currently GREEN via attempt 2 rerun; RED on attempt 1 with the flake described above.
- Failing-test root cause: PR-caused flake, not pre-existing/unrelated. The failure fired inside a test THIS PR MODIFIED, with a failure mode traceable to the new
isDirLockStaleshort-circuit interacting with mtime seeded before the dynamic import. Retry cleared it, but the flake will re-emerge under sufficient CI load.
R1 by Via
jrusso1020
left a comment
There was a problem hiding this comment.
Stamped — verified before approving: CI green (35 pass) at a3bb5583, author miguel-heygen, no standing CHANGES_REQUESTED. Confirmed at source: reclaimAbandonedReclaimLock ages out the reclaim gate itself (rmSync when stale) and the install-lock wait short-circuits via isDirLockStale(INSTALL_LOCK_DIR, …) — closing the Windows two-lock deadlock where a process dying after acquiring the reclaim gate stalled every future installer. ENOENT handled (treated as "not stale" / re-thrown otherwise).
— Rames Jusso
…mers
Adopt the fake-timer pattern the sibling "recovers when a crashed reclaimer
leaves both lock directories" test in the same file already uses. Without
fake timers, if the dynamic `import("./manager.js")` beat between
`installFsMocks({ initialMtimeMs: Date.now() })` and the `withInstallLock`
call exceeds `staleMs` (50 ms on a busy shared runner with `vi.resetModules()`
per beforeEach), the new immediate-stale short-circuit added in #2328 fires
on iteration 1, breaks out before `waitedMs` reaches `waitNoticeMs=20`, and
no "Waiting for another hyperframes process" warn ever emits — the assertion
at `manager.test.ts:428` (`expected false to be true`) then fails.
Under fake timers, `Date.now()` is frozen at the mtime seed, so the lock
stays non-stale across the dynamic import and the wait-notice branch
observes real polling; `vi.advanceTimersByTimeAsync(staleMs + pollMs * 5)`
then drives the loop past both the wait-notice threshold and the stale
deadline so the reclaim + acquisition still resolves.
Test-only change; no production-code diff. Verified 27/27 in
`packages/cli/src/browser/manager.test.ts` under `vitest run`.
What
Recover Chrome installation when a crashed process leaves a stale .chrome.install.reclaim.lock, including the reported case where both install lock directories remain.
Why
On Windows, a timed-out render could leave both browser lock directories behind. Every later render waited forever at browser setup because the reclaim gate was never aged out. HYPERFRAMES_BROWSER_PATH bypassed the stale cache, but the default managed-browser path remained blocked.
How
Test plan
Verification: