RELEASE-FIX-E: list_tabs survives tab rediscovery (F-771) - #49
RELEASE-FIX-E: list_tabs survives tab rediscovery (F-771)#49AminDhouib wants to merge 2 commits into
Conversation
Pins only -- no src edit in this commit, so CI records the RED. F-771: after any close_tab, Browser.update_targets() re-appends surviving targets as raw Connection objects (browser.py:561-583) and Browser.tabs returns them anyway despite its List[Tab] annotation (browser.py:137-142). Only Tab defines __await__ (tab.py:1262), so list_tabs' per-tab `await tab` raises `TypeError: object Connection can't be used in 'await' expression` -- permanently, for the life of the browser. Three pins, per plan_RELEASE_FIX_E sections 3 and 2: - tests/test_browser_manager_list_tabs.py (hermetic, fast unit lane): drives the real BrowserManager against a browser.tabs holding one awaitable Tab-like and one non-awaitable Connection-like object. RED today with the product TypeError at browser_manager.py:1307. - tests/test_e2e_interaction.py::test_list_tabs_after_close_tab and ::test_list_tabs_metadata_survives_rediscovery (real Chrome): the ordinary spawn -> new_tab -> close_tab -> list_tabs journey. Both tiers assert url/title/type BY VALUE, never by presence. That is the point: Connection.__getattr__ delegates to self.target, so a rediscovered target CAN supply its real url -- a listing that came back with blank urls would be a silent lie, strictly worse than the crash it replaced. The doubles live in tests/fakes.py (THE hermetic harness home, never a second one): fake_target, FakeDiscoveredTarget, FakeAttachedTab, and a tabs/update_targets seam on the existing FakeBrowser rather than a parallel browser double. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fix is the DELETION of `await tab` from list_tabs' listing loop, not an
isinstance branch -- a type switch here would be a second way to do one thing
(CLAUDE.md convention 4).
That await was wrong three ways at once:
1. WRONG. After any close_tab, Browser.update_targets() re-appends surviving
targets as raw Connection objects and Browser.tabs returns them despite its
List[Tab] annotation. Only Tab defines __await__, so the await raised
`TypeError: object Connection can't be used in 'await' expression` --
permanently, for the life of the browser, on every platform in 1.2.0. A bare
TypeError also violates the one error convention (DESIGN.md section 9).
2. POINTLESS. update_targets(), one line above, had already refreshed every
field the loop reads.
3. SLOW. `await tab` resolves to Tab.wait(), which races a page lifecycle event
against asyncio.sleep(0.5) -- so listing N tabs paid up to N x 0.5s for data
already in hand.
Verified against real Chrome, not just the fake: forcing nodriver's own
rediscovery path (drop a target, let update_targets() re-append it) yields a
genuine nodriver.core.connection.Connection, and list_tabs then returns
{'tab_id': '5240F31A...', 'url': 'http://127.0.0.1:50953/index.html',
'title': 'fixture-index-page', 'type': 'page'}
-- real values, not blanks. Connection.__getattr__ delegates to self.target, so
the deletion does NOT trade a loud crash for a silent wrong answer, which was
the one thing that would have made this change worse than the bug.
The loop is now a pure transform, so ruff's PERF401 applies; the comprehension
keeps browser_manager.py at 1531/1532 LOC. The grandfathered cap is untouched
(not padded, and deliberately not ratcheted -- a parallel FIX branch is live in
this file's neighbourhood).
E1: tests/test_e2e_interaction.py::test_tabs_lifecycle loses its F-771 xfail
branch and asserts directly again. Its remaining bounded poll is for Chrome's
asynchronous Target.targetDestroyed, NOT for F-771: an exception out of
list_tabs now fails on the first call and is never polled away.
Local evidence:
- hermetic pins RED before this commit with the product TypeError at
browser_manager.py:1307, GREEN after (4 passed);
- restoring the `await tab` line ALONE turns pin #1 red again with the exact
original error, in both the fake and the real-Chrome probe;
- unit lane 764 passed / 1 skipped; tests/test_e2e_interaction.py 9 passed with
no xfail; ruff, ty (76 = baseline), vulture, budgets, suppression owners green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI result — green on all three cells, with one flake worth recordingRun 30161964937. All 23 checks pass, including Both new real-Chrome pins and the de-xfailed
The flake, stated rather than buriedThe first macOS/ARM64 integration attempt failed, and it did not fail on F-771 —
What it does suggest is a separate, macOS-only intermittent defect: |
Closes F-771. Plan:
audit/stage2/plan_RELEASE_FIX_E.md(added in this PR).Stacked on
audit/release-fix-c; do not merge — human holds the gate.The bug
spawn_browser→new_tab→close_tab→list_tabsraisedNot a race. Once a target is discovered rather than created in-process, the
failure is permanent for the life of the browser, and it ships in 1.2.0 on every
platform.
Three facts in nodriver 0.47 compose into it:
Browser.update_targets()appends rawConnectionobjects for targets it did not already knowbrowser.py:561-583Browser.tabsreturns them anyway — it filters ontype_ == "page"despite itsList[Tab]annotationbrowser.py:137-142Tabdefines__await__tab.py:1262The fix — a deletion
await tabis removed from the listing loop. It was wrong three ways at once:Connectionobjects;update_targets()one line above had already refreshed everyfield the loop reads;
Tab.wait(), which races a lifecycle event againstasyncio.sleep(0.5), so listing N tabs paid up to N x 0.5 s for data alreadyin hand.
No
isinstancebranch: a type switch here would be a second way to do one thing(
CLAUDE.mdconvention 4). The loop is now a pure transform, so ruff's PERF401applies and it became a comprehension —
browser_manager.pylands at1531/1532 LOC, cap untouched and not padded.
The trap, checked
Removing the
awaitmust not turn a loud crash into a silent lie: the loop readsgetattr(tab, "url", "") or "", so aConnectionwithouturlwould havestarted returning blank URLs.
It does not.
Connection.__getattr__delegates toself.target. Forcingnodriver's own rediscovery path against real Chrome — drop a target, let
update_targets()re-append it — produces a genuinenodriver.core.connection.Connection, andlist_tabsreturns:{'tab_id': '5240F31A1D516E5AFE46A639797AF704', 'url': 'http://127.0.0.1:50953/index.html', 'title': 'fixture-index-page', 'type': 'page'}Real values, not defaults. Every pin therefore asserts
url/title/typeby value, never by presence.
Pins (E0, landed RED-first in
d57eac7, before the fix)tests/test_browser_manager_list_tabs.py— hermetic, on the fast unit lane.Drives the real
BrowserManageragainst abrowser.tabsholding one awaitableTab-like and one non-awaitableConnection-like object. Also pins that thelisting awaits nothing (the latency defect).
tests/test_e2e_interaction.py::test_list_tabs_after_close_taband::test_list_tabs_metadata_survives_rediscovery— real Chrome, the ordinaryjourney.
tests/fakes.py(THE hermetic harness home):fake_target,FakeDiscoveredTarget,FakeAttachedTab, plus atabs/update_targetsseamon the existing
FakeBrowserrather than a parallel browser double.E1:
test_tabs_lifecycleloses itspytest.xfailbranch and assertsdirectly again. Its remaining bounded poll is for Chrome's asynchronous
Target.targetDestroyed— not for F-771; an exception out oflist_tabsnowfails on the first call and is never polled away.
RED to GREEN
TypeError: object FakeDiscoveredTarget can't be used in 'await' expression@browser_manager.py:1307ConnectionprobeTypeError: object Connection can't be used in 'await' expressiontest_e2e_interaction.pyLoad-bearing check: restoring the
await tabline alone turns hermetic pin#1 red again with the exact original error, in both the fake and the real-Chrome
probe.
Caveat on local integration coverage
F-771 does not reproduce on the author's Windows Chrome: instrumenting the
whole journey showed every
browser.tabsentry staying aTab, because nodrivernever missed a
TargetCreatedevent there. The two new real-Chrome pinstherefore passed locally both before and after the fix; their RED evidence comes
from the hermetic tier and from the real-
Connectionprobe above, and theirvalue is the CI cells where the condition does occur. This is stated rather than
papered over.
Gates
ruff format + check;
ty --exit-zero-on-warning src/...76 = baseline;vulture; file budgets (no cap padded); suppression owners;
unit lane 764 passed / 1 skipped;
tests/test_e2e_interaction.py9 passed.--no-verifynever used.Sibling call sites found (NOT fixed here — routing to the human)
Same mechanism, different symptom. None is a pure deletion, all are outside the
plan's declared scope, so they are reported rather than folded in:
Connectionbrowser_manager.py:1095/:1100await candidate_tab/await fallback_tabinget_navigation_tabTypeErrorcaught by theexceptat:1105, falls through to_replace_main_tab— so every navigation after aclose_tabsilently abandons the tracked tab and creates a new onebrowser_manager.py:1393await target_tab.close()inclose_tabConnectionhas noclose()(onlyTabdoes) so this isAttributeError: 'TargetInfo' object has no attribute 'close', caught —close_tabreturnsFalsefor a tab that could have been closedbrowser_manager.py:1346await target_tab.bring_to_front()inswitch_to_tabbring_to_frontis Tab-only,AttributeError, caught, returnsFalse; and on success it would store aConnectionas the instance's main tabbrowser_manager.py:865await tab.close()inclose_instanceteardownclose(); caught, browser is killed anyway — cosmeticFor reference, the full Tab-only public surface a rediscovered
Connectioncannot answer includes
select,find,evaluate,get,reload,back,forward,close,bring_to_front,wait,save_screenshot, and ~40 more.Generated with Claude Code