fix(browser): retire durable tab rows whose browser never returns#111307
Conversation
Durable cleanup defers whenever ownership cannot be proven, so a browser that never comes back at the same cdpUrl leaves its rows behind forever: each sweep re-claims them, fails the identity lookup, warns, and defers again. Nothing in the subsystem removes a row by age. The `browser.session-tabs` namespace is opened with a 5000-row cap and `reject-new`, so once those rows accumulate to the cap, tracking a new tab throws PLUGIN_STATE_LIMIT_EXCEEDED. That propagates into the compensation path in browser-tool-session-tabs.ts, which closes the tab the user just opened and rethrows -- every `browser open` on that profile then opens a tab, closes it again, and errors, with no self-healing path. Bound the retry: when a close attempt reports the target unavailable and the tab has been unused for longer than the retire window, drop the row instead of deferring again. A browser returning after that long almost always carries a fresh instance fingerprint, which retires the row through the ownership-mismatch path anyway. closeTrackedBrowserTabsForSessions now accepts `now` like the sweep does, so lifecycle cleanup can be exercised on a coherent clock.
|
Codex review: needs maintainer review before merge. Reviewed July 20, 2026, 3:38 PM ET / 19:38 UTC. Summary PR surface: Source +125, Tests -38, Docs +3. Total +90 across 6 files. Reproducibility: yes. at source level and through the contributor's supplied live run: a durable row for a browser that remains unavailable is retried rather than removed, while the proposed branch retires it at the explicit threshold. I did not independently execute the browser scenario in this read-only review. Review metrics: none identified. Stored data model Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review detailsBest possible solution: Adopt the bounded-retirement behavior only if maintainers explicitly prefer preventing durable-store exhaustion over preserving cleanup eligibility for a browser that returns after more than 24 hours; otherwise retain indefinite retry and pursue a separately approved capacity strategy. Do we have a high-confidence way to reproduce the issue? Yes, at source level and through the contributor's supplied live run: a durable row for a browser that remains unavailable is retried rather than removed, while the proposed branch retires it at the explicit threshold. I did not independently execute the browser scenario in this read-only review. Is this the best way to solve the issue? Unclear until maintainers choose the retention contract. The branch is a narrow implementation of bounded retirement, but indefinite retry versus 24-hour row removal is a compatibility decision rather than a correctness detail. AGENTS.md: unclear because the file could not be read completely. Codex review notes: model internal, reasoning high; reviewed against e2bb04328f5c. Label changesLabel justifications:
Evidence reviewedPR surface: Source +125, Tests -38, Docs +3. Total +90 across 6 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (5 earlier review cycles)
|
check-lint failed on max-lines: session-tab-registry.ts was at 699 of its 700-line budget and the durable registry test at 982 of 1000, so the retire branch and its regression test pushed both over. Extract the cleanup claim bookkeeping (claim, ownership match, delete) into session-tab-cleanup-claim.ts, and the durable registry test shapes into session-tab-registry.sqlite.test-helpers.ts, matching the existing *.test-helpers.ts convention in this directory. No behavior change.
|
Added the live-browser evidence requested in the review — see the Live-browser proof section in the PR body. Real Chrome, real CDP, real SQLite state dir, real registry code; the only injected input is The chain the review asked for:
Two limits stated in the body rather than papered over: the tab is created via raw CDP On the retention question for @FMLS: the 24h window is a policy choice and I have no attachment to the specific number — it is a single constant ( @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
…recoverable-tab-rows
…recoverable-tab-rows
|
Maintainer review accepts the fixed 24-hour unavailable-and-idle retirement window. The cleanup owner has the authoritative row, current clock, and real ownership outcome; a namespace TTL or reject-new eviction would remove rows without proving ownership, while a new retry counter would expand persistent state without avoiding the policy decision. I added one focused preparation regression: an over-age unavailable probe now races with Codex autoreview raised one false-positive finding claiming the lifecycle Provenance: the non-converging unavailable-row behavior and 5,000-entry reject-new store were introduced by #110797 ( |
|
ClawSweeper status: review started. I am starting a fresh review of this pull request: fix(browser): retire durable tab rows whose browser never returns This is item 1/1 in the current shard. Shard 0/1. This placeholder means the worker is alive and reading the current context. I will edit this same comment with the actual review when the claws are done clicking. Crustacean status: shell secured, claws on keyboard, evidence pebbles being sorted. |
|
Merged via squash.
|
…enclaw#111307) * fix(browser): retire durable tab rows whose browser never returns Durable cleanup defers whenever ownership cannot be proven, so a browser that never comes back at the same cdpUrl leaves its rows behind forever: each sweep re-claims them, fails the identity lookup, warns, and defers again. Nothing in the subsystem removes a row by age. The `browser.session-tabs` namespace is opened with a 5000-row cap and `reject-new`, so once those rows accumulate to the cap, tracking a new tab throws PLUGIN_STATE_LIMIT_EXCEEDED. That propagates into the compensation path in browser-tool-session-tabs.ts, which closes the tab the user just opened and rethrows -- every `browser open` on that profile then opens a tab, closes it again, and errors, with no self-healing path. Bound the retry: when a close attempt reports the target unavailable and the tab has been unused for longer than the retire window, drop the row instead of deferring again. A browser returning after that long almost always carries a fresh instance fingerprint, which retires the row through the ownership-mismatch path anyway. closeTrackedBrowserTabsForSessions now accepts `now` like the sweep does, so lifecycle cleanup can be exercised on a coherent clock. * refactor(browser): split session tab cleanup claim and test harness check-lint failed on max-lines: session-tab-registry.ts was at 699 of its 700-line budget and the durable registry test at 982 of 1000, so the retire branch and its regression test pushed both over. Extract the cleanup claim bookkeeping (claim, ownership match, delete) into session-tab-cleanup-claim.ts, and the durable registry test shapes into session-tab-registry.sqlite.test-helpers.ts, matching the existing *.test-helpers.ts convention in this directory. No behavior change. * test(browser): protect unreachable retirement races Co-authored-by: Yigtwxx <[email protected]> --------- Co-authored-by: Peter Steinberger <[email protected]>
What Problem This Solves
Resolves a problem where a user whose browser disappears — closed for good, moved to a different
cdpUrl, a laptop that never wakes on the same port — permanently loses the ability to open browser tabs on that profile.Durable session-tab cleanup defers whenever it cannot prove ownership of a tab. When
/json/versionfails because the browser is gone, the tab's tracking row is kept for a later retry, and nothing in the subsystem ever removes a row by age. The sweep runs by default every 5 minutes, so those rows are re-claimed, fail, warn, and defer again — forever.The tracking store has a hard 5,000-row cap with
reject-newand no eviction fallback. Once unreachable rows fill it, tracking a new tab throws, and the compensation path closes the tab the user just opened and rethrows. From then on everybrowser openon that profile opens a tab, closes it again, and errors, with no self-healing path.Why This Change Was Made
The retry is now bounded. When a close attempt reports the target unavailable and the tab has gone unused past a retire window, the row is dropped instead of deferred again:
Chosen over a namespace
defaultTtlMs— whichOpenKeyedStoreOptionsdoes support (plugin-state-store.types.ts:53) — because a TTL also expires rows for tabs that are alive and reachable, silently giving up cleanup on them. This branch only fires on a cleanup attempt that already failed to prove ownership, so healthy tabs are untouched.The window is 24h and deliberately generous: a laptop asleep overnight or a browser restart must not drop tracking. A browser returning after that long almost always carries a fresh
browserInstanceFingerprint, which retires the row through the existingownership-mismatchpath anyway, so the row's remaining value is close to nil.closeTrackedBrowserTabsForSessionsnow acceptsnowlikesweepTrackedBrowserTabsalready did, so lifecycle cleanup can be exercised on a coherent clock.Non-goal: this does not add a config key. The retire window is a constant, in keeping with the recent config-surface reduction.
The second commit is a pure refactor with no behavior change, needed to stay under the
max-linesbudget:session-tab-registry.tssat at 699 of 700 lines and the durable registry test at 982 of 1000, so the fix and its test pushed both over. Cleanup claim bookkeeping moved tosession-tab-cleanup-claim.tsand the test shapes tosession-tab-registry.sqlite.test-helpers.ts, matching the existing*.test-helpers.tsconvention in that directory.User Impact
A browser that never comes back no longer bricks tab tracking for its profile. Previously the only recovery was manually clearing plugin state; now the rows retire on their own and
browser openkeeps working. Users whose browser is merely restarting or asleep see no change — tracking survives, as before.Evidence
Live-browser proof
Real Chrome, real CDP, real SQLite state directory, real registry code. The only
injected input is
now— the parametersweepTrackedBrowserTabsalready accepts;the alternative is waiting 24 real hours. Nothing is mocked or stubbed.
It runs as two processes on purpose. A browser that stays away for a day outlives
the OpenClaw process, so the second phase starts fresh: the in-memory active-tab
set is empty and the row is read back from SQLite, exactly as after a restart.
Phase 1 — a real browser produces a durable row, then dies:
Phase 2 — a fresh process sweeps, using the same
idleMs/maxTabsPerSessionthe production sweep passes (
session-tab-cleanup.ts:44):browser-identity-lookup-failedis the genuine outcome of probing a killedbrowser — it is what
closeTrackedCdpTargetmaps to{status:"unavailable"}(
cdp.helpers.ts:406-410), so the row reaches the new branch the way it would inthe field, not because a status was handed to it.
Two limits I want to state plainly rather than imply otherwise:
PUT /json/newrather than through thebrowser opentool action, which would require standing up the browser controlserver. The tab, the ownership probe, the registry write and the sweep are all
production code; the entry point is one level below the tool boundary.
reject-newcap recovery is argued from the code, notdemonstrated. Showing it honestly would need 5000 durable rows each with a
distinct real browser fingerprint, and writing filler rows programmatically
would make the artifact fake. What is demonstrated above is the mechanism that
stops the cap filling: the unreachable row retires instead of being retried
forever.
Regression tests
New test pins both edges of the window — the row survives at
RETIRE_MS - 1and is gone atRETIRE_MS:(
session-tab-registry.sqlite.test.ts,session-tab-registry.test.ts,extensions/browser/index.test.ts,browser-tool.test.ts)The new test is load-bearing, not just passing: neutralising the retire condition to
if (false && ...)fails it on the terminal assertion, with["NATIVE-gone"]still in the store.Local gates:
run-tsgo.mjsclean ·run-oxlint.mjs extensions/browserclean ·oxfmt --checkclean ·check-max-lines-ratchet.mjsclean ·check-deadcode-exports.mjs0 unused exports.Four macOS-only failures elsewhere in
extensions/browser/src/browser/(Info.plist / LaunchServices /com.microsoft.edgemac) reproduce identically on a stashed tree, so they are pre-existing on Windows and unrelated to this change.One fixture change to call out: "retries pending lifecycle cleanup even when normal sweeps filter the session" tracked at
now: 1_000but called lifecycle cleanup on the wall clock, which made the row appear decades idle once age started mattering. Passingnow: 2_000keeps the test's original intent — a pending lifecycle cleanup survives a transient failure — on a single clock.Follow-up from #110797
This is the second of two findings I raised while reviewing #110797. The first — CDP's
No target with given id foundnot matching the ignorable-close classification — landed in that PR (cdp.helpers.ts:463). This one was flagged as a P1 there as well and is still open onmain.AI-assisted: investigation, patch, and tests were produced with AI assistance and verified locally as described above.