Skip to content

RELEASE-FIX-E: list_tabs survives tab rediscovery (F-771) - #49

Open
AminDhouib wants to merge 2 commits into
audit/release-fix-cfrom
audit/release-fix-e
Open

RELEASE-FIX-E: list_tabs survives tab rediscovery (F-771)#49
AminDhouib wants to merge 2 commits into
audit/release-fix-cfrom
audit/release-fix-e

Conversation

@AminDhouib

Copy link
Copy Markdown
Member

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_browsernew_tabclose_tablist_tabs raised

TypeError: object Connection can't be used in 'await' expression

Not 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:

# fact source
1 Browser.update_targets() appends raw Connection objects for targets it did not already know browser.py:561-583
2 Browser.tabs returns them anyway — it filters on type_ == "page" despite its List[Tab] annotation browser.py:137-142
3 Only Tab defines __await__ tab.py:1262

The fix — a deletion

await tab is removed from the listing loop. It was wrong three ways at once:

  1. wrong — it raised for Connection objects;
  2. pointlessupdate_targets() one line above had already refreshed every
    field the loop reads;
  3. slow — it resolves to Tab.wait(), which races a lifecycle event against
    asyncio.sleep(0.5), so listing N tabs paid up to N x 0.5 s for data already
    in hand.

No isinstance branch: a type switch here would be a second way to do one thing
(CLAUDE.md convention 4). The loop is now a pure transform, so ruff's PERF401
applies and it became a comprehension — browser_manager.py lands at
1531/1532 LOC, cap untouched and not padded.

The trap, checked

Removing the await must not turn a loud crash into a silent lie: the loop reads
getattr(tab, "url", "") or "", so a Connection without url would have
started returning blank URLs.

It does not. Connection.__getattr__ delegates to self.target. Forcing
nodriver's own rediscovery path against real Chrome — drop a target, let
update_targets() re-append it — produces a genuine
nodriver.core.connection.Connection, and list_tabs returns:

{'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/type
by 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 BrowserManager against a browser.tabs holding one awaitable
    Tab-like and one non-awaitable Connection-like object. Also pins that the
    listing awaits nothing (the latency defect).
  • tests/test_e2e_interaction.py::test_list_tabs_after_close_tab and
    ::test_list_tabs_metadata_survives_rediscovery — real Chrome, the ordinary
    journey.
  • Doubles live in tests/fakes.py (THE hermetic harness home): fake_target,
    FakeDiscoveredTarget, FakeAttachedTab, plus a tabs/update_targets seam
    on the existing FakeBrowser rather than a parallel browser double.

E1: test_tabs_lifecycle loses its pytest.xfail branch and asserts
directly again. Its remaining bounded poll is for Chrome's asynchronous
Target.targetDestroyednot for F-771; an exception out of list_tabs now
fails on the first call and is never polled away.

RED to GREEN

pin before after
hermetic (3 of 4) TypeError: object FakeDiscoveredTarget can't be used in 'await' expression @ browser_manager.py:1307 4 passed
real-Chrome Connection probe TypeError: object Connection can't be used in 'await' expression returns the record above
test_e2e_interaction.py 9 passed (F-771 does not reproduce on the author's Chrome — see caveat) 9 passed, no xfail

Load-bearing check: restoring the await tab line 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.tabs entry staying a Tab, because nodriver
never missed a TargetCreated event there. The two new real-Chrome pins
therefore passed locally both before and after the fix; their RED evidence comes
from the hermetic tier and from the real-Connection probe above, and their
value 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.py 9 passed.
--no-verify never 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:

file:line call symptom on a rediscovered Connection
browser_manager.py:1095 / :1100 await candidate_tab / await fallback_tab in get_navigation_tab TypeError caught by the except at :1105, falls through to _replace_main_tab — so every navigation after a close_tab silently abandons the tracked tab and creates a new one
browser_manager.py:1393 await target_tab.close() in close_tab Connection has no close() (only Tab does) so this is AttributeError: 'TargetInfo' object has no attribute 'close', caught — close_tab returns False for a tab that could have been closed
browser_manager.py:1346 await target_tab.bring_to_front() in switch_to_tab bring_to_front is Tab-only, AttributeError, caught, returns False; and on success it would store a Connection as the instance's main tab
browser_manager.py:865 await tab.close() in close_instance teardown same missing close(); caught, browser is killed anyway — cosmetic

For reference, the full Tab-only public surface a rediscovered Connection
cannot answer includes select, find, evaluate, get, reload, back,
forward, close, bring_to_front, wait, save_screenshot, and ~40 more.

Generated with Claude Code

AminDhouib and others added 2 commits July 25, 2026 10:25
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>
@AminDhouib

Copy link
Copy Markdown
Member Author

CI result — green on all three cells, with one flake worth recording

Run 30161964937. All 23 checks pass, including release-gate / release-gate.

Both new real-Chrome pins and the de-xfailed test_tabs_lifecycle ran and passed on every cell:

cell integration result
Linux/X64 57 passed
Windows/X64 57 passed
macOS/ARM64 56 passed

test_list_tabs_after_close_tab, test_list_tabs_metadata_survives_rediscovery, test_tabs_lifecycle — PASSED on all three, no xfail anywhere.

The flake, stated rather than buried

The first macOS/ARM64 integration attempt failed, and it did not fail on F-771 — list_tabs returned cleanly with real metadata for both tabs. It failed because the tab close_tab had just reported closing was still in the listing after the full 10 s poll:

FAILED test_list_tabs_after_close_tab - AssertionError:
assert '24282EF7DAF9086B2C402994F7789EA4' not in
  {'24282EF7...': {'title': 'fixture-interact-page', 'type': 'page',
                   'url': 'http://127.0.0.1:49746/interact.html'}, ...}

test_tabs_lifecycle failed the same way in that attempt; test_list_tabs_metadata_survives_rediscovery passed in the same run. A plain re-run of the failed job was fully green, so it is a flake, not a regression — and it is not masked by anything this PR changed: the base branch's macOS integration cell passes outright (it does not xfail), so this is not an F-771 xfail being unmasked.

What it does suggest is a separate, macOS-only intermittent defect: close_tab can return True while Chrome keeps the target alive past 10 seconds. That is close_tab's contract, not list_tabs', so it is out of scope for RELEASE-FIX-E and is reported here rather than patched. Worth a finding of its own if it recurs.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant