diff --git a/audit/stage2/plan_RELEASE_FIX_E.md b/audit/stage2/plan_RELEASE_FIX_E.md new file mode 100644 index 0000000..cc3c5f9 --- /dev/null +++ b/audit/stage2/plan_RELEASE_FIX_E.md @@ -0,0 +1,193 @@ +# plan_RELEASE_FIX_E — F-771: `list_tabs` raises a bare `TypeError` after any tab close + +**Status: AUTHORIZED, not yet executed.** Human authorized the FIX-plan route +2026-07-25. Branch stacked on `audit/release-fix-c` (PR #46). Merge gate: human — +**the executor never merges.** + +**Found by:** plan_RELEASE W2's three-OS transport gate, in +`tests/test_e2e_interaction.py::test_tabs_lifecycle`; pinned there as a +characterization xfail with the mechanism documented inline. +**Severity:** user-facing, ships today in 1.2.0, and breaks a core tool on a completely +ordinary sequence. + +--- + +## 1. The finding (F-771) + +`spawn_browser` → `new_tab` → `close_tab` → `list_tabs` raises: + +``` +TypeError: object Connection can't be used in 'await' expression +``` + +It is **not** a race and **not** transient. Once a target is discovered rather than +created in-process, the failure is permanent for the life of the browser. + +### Mechanism — established from nodriver's source, not inferred + +Three facts in nodriver 0.47 compose into the bug: + +1. **`Browser.update_targets()` appends raw `Connection` objects.** + `nodriver/core/browser.py:561-583` — for every target it did not already know about, + it appends a `Connection(...)`, **not** a `Tab`. + +2. **`Browser.tabs` returns them anyway.** `browser.py:137-142`: + ```python + @property + def tabs(self) -> List[tab.Tab]: + tabs = filter(lambda item: item.type_ == "page", self.targets) + return list(tabs) + ``` + It filters on `type_ == "page"` and returns whatever objects matched. The + `List[tab.Tab]` annotation is **wrong** — a `Connection` with `type_ == "page"` passes + straight through. + +3. **Only `Tab` defines `__await__`.** `nodriver/core/tab.py:1262`. `Connection` does not. + +The product then does, in `browser_manager.py::list_tabs`: + +```python +await browser.update_targets() + +tabs = [] +for tab in browser.tabs: + await tab # <-- TypeError for any discovered target + tabs.append({...}) +``` + +`close_tab` causes the next `update_targets()` to rediscover the surviving targets and +append them as `Connection`s. From that moment `await tab` raises, permanently. + +### Blast radius + +- Ships in 1.2.0 on every platform. Any user who closes a tab loses `list_tabs`. +- The error is a **bare `TypeError`**, not a `ToolError` — it violates the project's one + error convention (`DESIGN.md` §9) and reaches the client as an unhandled internal fault + with no actionable message. +- Invisible to the pre-existing in-process suite; only W2's real-transport journey hit it. + +--- + +## 2. The fix + +**Principle: `list_tabs` reads target metadata that `update_targets()` has already +refreshed. It has no reason to await anything.** + +`await tab` resolves to `Tab.wait()` (`tab.py:1222`), which blocks on page lifecycle +events (`FrameStoppedLoading`, `FrameNavigated`, `LoadEventFired`, …) with a timeout. +In a metadata-listing loop that is three separate defects at once: + +1. **wrong** — it raises for `Connection` objects (the finding); +2. **pointless** — every field the loop reads (`tab.target.target_id`, + `tab.target.title`, `tab.target.type_`, `tab.url`) was already refreshed by the + `update_targets()` call immediately above it; +3. **slow** — it serially blocks on lifecycle events *per tab*, so listing N tabs pays N + waits for data that is already in hand. + +So the fix is a **deletion**, not an `isinstance` guard bolted on top. Removing the +`await tab` closes the crash, removes the latency, and leaves one code path rather than +two. Prefer that over any branch that keeps `await` alive for one object type — a type +switch here would be a second way to do one thing (`CLAUDE.md` convention 4). + +If, and only if, E0 (below) proves a genuine settle is required for correct data, +the settle belongs **once, outside the loop**, not once per tab — and the plan's LOC and +convention rules still apply. + +### The trap: do not trade a loud crash for a silent wrong answer + +This is the part to get right. The loop already reads defensively: + +```python +"url": getattr(tab, "url", "") or "", +``` + +On a `Tab`, `url` is a property backed by `target.url`. On a raw `Connection` it may not +exist — in which case `getattr(..., "")` returns **empty string** and `list_tabs` starts +returning tabs with blank URLs instead of raising. + +That is strictly worse than the current bug: a `TypeError` is loud and gets fixed; a +silently empty `url` is a **lying success** that a caller acts on. It is exactly the +Tier-A "silent correctness" class this campaign exists to eliminate. + +Therefore acceptance requires asserting the **actual URL value**, not just that the call +returns without raising. Same for `title` and `type`. If a discovered target genuinely +cannot supply a real `url`, that is a finding to report — not something to paper over +with a default. + +--- + +## 3. E0 — RED-first pins (tests) + +Land these **before** the src edit and demonstrate each is RED for the right reason. +Read the failure text: a pin that fails on a harness `TypeError` rather than the product +`TypeError` is **not** a valid RED. + +1. **`test_list_tabs_after_close_tab`** (integration, real Chrome). + `spawn_browser` → `new_tab` → `close_tab` → `list_tabs`. Asserts the call succeeds, + the closed tab is absent, the surviving tab is present, **and its `url` equals the + expected fixture URL** (the anti-silent-regression assertion from §2). RED today with + `TypeError: object Connection can't be used in 'await' expression`. + +2. **`test_list_tabs_metadata_survives_rediscovery`** — after a close, every returned + record has a non-empty `tab_id`, a `url` matching what was navigated, a real `title`, + and `type == "page"`. This is the pin that would catch the empty-`url` regression. + +3. **A hermetic pin** in `tests/test_browser_manager*.py` using `tests/fakes.py` + (that file is THE hermetic harness home — do not start a second one): a fake browser + whose `tabs` yields one awaitable `Tab`-like and one non-awaitable `Connection`-like + object, proving `list_tabs` handles both. This keeps the guarantee enforced on the + fast unit lane, not only in the ~15-minute integration lane. + Note `call_tool(server_mod, name, /, **kwargs)` in `fakes.py` is positional-only by + design so a tool's own `name` parameter cannot collide. + +## 4. E1 — flip the existing characterization pin + +`tests/test_e2e_interaction.py::test_tabs_lifecycle` currently contains a bounded poll +that ends in `pytest.xfail("F-771: ...")`. In the same commit as the fix: + +- delete the tolerance loop and the `pytest.xfail` branch; +- restore the direct assertion (`new_id not in remaining`); +- keep or trim the explanatory comment block to reflect that F-771 is **closed**, citing + this plan. + +A characterization pin that survives its own fix is dead weight that teaches the next +reader the wrong thing. + +--- + +## 5. Acceptance + +1. Both real-Chrome pins green on **all three** W2 cells (Linux/X64, Windows/X64 through + the gate; macOS/ARM64 remains subject to the declared F-773 navigation gap — if the + macOS cell cannot run this journey, say so, and do not claim macOS coverage). +2. The hermetic pin green on the unit lane. +3. `test_tabs_lifecycle` passes with **no** xfail. +4. `url`/`title`/`type` asserted by value, not by presence. +5. Prove the fix is load-bearing: restore the `await tab` line alone and show pin #1 goes + red with the exact `TypeError`. +6. No new error shape. If you touch an error path, it **raises** `ToolError` / + `InstanceNotFoundError` — never a `{"success": False}` dict (`DESIGN.md` §9). +7. Full local gate green: ruff format+check; `ty check --exit-zero-on-warning + src/stealth_chrome_devtools_mcp/` at the **76-diagnostic baseline** (a bare `ty check` + reports 172 — wrong scope, not a regression); vulture; file budgets with **no cap + padded**; suppression owners; unit suite (~703-705 on `-m "not integration"`). +8. `--no-verify` never used. PR opened, **never merged**. + +### Scope limits + +- Out of scope: F-773 (macOS navigation), F-770 (headless UA — RELEASE-FIX-D, running in + parallel), and any other `list_tabs` behavior not named above. +- Do **not** attempt to fix nodriver's `update_targets`/`tabs` annotation upstream or + vendor a patch. The product must be correct against the pinned dependency as it is. +- If you find sibling call sites that `await` an element of `browser.tabs` and would fail + the same way, **report them with file:line** — fixing them may be in scope if the + change stays small and the LOC budget holds, but do not sprawl. Say what you found + either way. + +--- + +## 6. Gates + +Branch `audit/release-fix-e` stacked on `audit/release-fix-c`; PR opened against that +base and held at the human merge gate. Commit messages end with +`Co-Authored-By: Claude Opus 4.8 `. diff --git a/src/stealth_chrome_devtools_mcp/embedded/browser_manager.py b/src/stealth_chrome_devtools_mcp/embedded/browser_manager.py index b0c5775..900dfa0 100644 --- a/src/stealth_chrome_devtools_mcp/embedded/browser_manager.py +++ b/src/stealth_chrome_devtools_mcp/embedded/browser_manager.py @@ -1300,21 +1300,20 @@ async def list_tabs(self, instance_id: str) -> list[dict[str, str]]: if not browser: return [] + # No per-tab `await` (F-771): update_targets() just refreshed every field + # below; a rediscovered target is a raw Connection with no __await__ (its + # __getattr__ still answers .url); and Tab.wait() costs 0.5s per tab. await browser.update_targets() - tabs = [] - for tab in browser.tabs: - await tab - tabs.append( - { - "tab_id": str(tab.target.target_id), - "url": getattr(tab, "url", "") or "", - "title": getattr(tab.target, "title", "") or "Untitled", - "type": getattr(tab.target, "type_", "page"), - } - ) - - return tabs + return [ + { + "tab_id": str(tab.target.target_id), + "url": getattr(tab, "url", "") or "", + "title": getattr(tab.target, "title", "") or "Untitled", + "type": getattr(tab.target, "type_", "page"), + } + for tab in browser.tabs + ] async def switch_to_tab(self, instance_id: str, tab_id: str) -> bool: """ diff --git a/tests/fakes.py b/tests/fakes.py index c2563dc..d16a131 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -146,6 +146,70 @@ async def send(self, cdp_obj: Any, *args: Any, **kwargs: Any) -> Any: return resp(name) if callable(resp) else resp +# --------------------------------------------------------------------------- +# Fake target-listing seam (nodriver ``Browser.tabs`` entries) +# --------------------------------------------------------------------------- + + +def fake_target( + target_id: str = "T1", + url: str = "https://fake.test/page", + title: str = "Fake Page", + type_: str = "page", +) -> SimpleNamespace: + """A nodriver ``cdp.target.TargetInfo`` double. + + This is the metadata ``list_tabs`` reads off every entry of ``Browser.tabs``, + and the metadata ``Browser.update_targets()`` refreshes in place. + """ + return SimpleNamespace(target_id=target_id, url=url, title=title, type_=type_) + + +class FakeDiscoveredTarget: + """nodriver's raw ``Connection``, exactly as ``Browser.tabs`` yields it after + a rediscovery (the F-771 shape). + + ``Browser.update_targets()`` appends a ``Connection`` — **not** a ``Tab`` — + for every target it did not already know about, and ``Browser.tabs`` returns + it anyway (it filters on ``type_ == "page"`` despite the ``List[Tab]`` + annotation). Two behaviours matter and both are modelled here: + + * **not awaitable** — only ``Tab`` defines ``__await__``, so awaiting this + raises ``TypeError: object ... can't be used in 'await' expression``; + * **attribute fall-through** — ``Connection.__getattr__`` delegates to + ``self.target``, so ``.url``/``.title`` still resolve to REAL values. A + listing that returns blank urls is therefore a product defect, not an + unavoidable consequence of the object type. + """ + + def __init__(self, target: Any) -> None: + self.target = target + + def __getattr__(self, item: str) -> Any: + # ``self.__dict__`` (not ``self.target``) — attribute access inside a + # ``__getattr__`` would recurse for anything not yet set. + return getattr(self.__dict__["target"], item) + + +class FakeAttachedTab(FakeDiscoveredTarget): + """nodriver's ``Tab``: a ``Connection`` that additionally defines + ``__await__`` (which resolves to ``Tab.wait()``). + + Counts awaits in ``awaited`` so a test can pin that a metadata-only read + pays no per-tab lifecycle wait. + """ + + def __init__(self, target: Any) -> None: + super().__init__(target) + self.awaited = 0 + + def __await__(self) -> Any: + async def _wait() -> None: + self.awaited += 1 + + return _wait().__await__() + + # --------------------------------------------------------------------------- # Fake browser + browser manager # --------------------------------------------------------------------------- @@ -161,9 +225,18 @@ class FakeBrowser: * ``FakeBrowser(alive=False)`` → ``_process.poll()`` returns ``0`` (exited) * ``FakeBrowser(alive=None, pid=)`` → no ``_process``; psutil pid path * ``FakeBrowser(alive=None)`` → no ``_process``, no pid → defaults to alive + + ``tabs`` seeds the target-listing seam (``list_tabs``/``switch_to_tab``/ + ``close_tab`` read it after ``update_targets()``); seed it with + :class:`FakeAttachedTab` / :class:`FakeDiscoveredTarget`. """ - def __init__(self, alive: bool | None = True, pid: int | None = None) -> None: + def __init__( + self, + alive: bool | None = True, + pid: int | None = None, + tabs: list[Any] | None = None, + ) -> None: if alive is None: self._process = None else: @@ -171,10 +244,18 @@ def __init__(self, alive: bool | None = True, pid: int | None = None) -> None: self._process = SimpleNamespace(poll=lambda: code, returncode=code) self._process_pid = pid self.target = SimpleNamespace(url="https://fake.test/page") + self.tabs = list(tabs or []) + self.update_targets_calls = 0 async def get(self, url: str, new_tab: bool = False) -> FakeTab: return FakeTab(url=url) + async def update_targets(self) -> None: + """nodriver's target refresh. The real one rewrites every known + ``target``'s metadata in place from a fresh ``Target.getTargets``, which + is precisely why a metadata-listing loop has nothing left to await.""" + self.update_targets_calls += 1 + class FakeBrowserManager: """Seedable stand-in for the module-global ``browser_manager`` singleton. diff --git a/tests/test_browser_manager_list_tabs.py b/tests/test_browser_manager_list_tabs.py new file mode 100644 index 0000000..b8451af --- /dev/null +++ b/tests/test_browser_manager_list_tabs.py @@ -0,0 +1,112 @@ +"""RELEASE-FIX-E (F-771) — ``list_tabs`` survives tab rediscovery. + +The real-Chrome journey lives in ``test_e2e_interaction.py`` (the ~15-minute +integration lane). This module keeps the same guarantee on the FAST unit lane by +driving the real :class:`BrowserManager` against the one thing that breaks it: a +``Browser.tabs`` list holding both an attached ``Tab`` and a rediscovered raw +``Connection``. + +Two failure modes are pinned, and the second one matters more: + +1. **the crash** — ``await tab`` raises ``TypeError: object ... can't be used in + 'await' expression`` for a ``Connection`` (only ``Tab`` defines ``__await__``); +2. **the silent lie** — a listing that survives the crash but reports blank + ``url``/``title`` is strictly worse than the crash, because a caller acts on + it. ``Connection.__getattr__`` delegates to ``self.target``, so real values + ARE available; every field here is therefore asserted BY VALUE, never by + presence. + +See ``audit/stage2/plan_RELEASE_FIX_E.md``. +""" + +from __future__ import annotations + +import pytest + +from fakes import FakeAttachedTab, FakeBrowser, FakeDiscoveredTarget, fake_target +from stealth_chrome_devtools_mcp.embedded.browser_manager import BrowserManager + +INSTANCE_ID = "i1" + +# The tab the browser created in-process and still holds as a ``Tab``. +ATTACHED = { + "target_id": "T-attached", + "url": "https://fake.test/index.html", + "title": "fixture-index-page", +} +# The tab ``update_targets()`` re-appended as a raw ``Connection`` after a close. +REDISCOVERED = { + "target_id": "T-rediscovered", + "url": "https://fake.test/interact.html", + "title": "fixture-interact-page", +} + + +@pytest.fixture() +def manager_and_browser(monkeypatch): + """A real ``BrowserManager`` whose one instance holds a post-close browser.""" + attached = FakeAttachedTab(fake_target(**ATTACHED)) + rediscovered = FakeDiscoveredTarget(fake_target(**REDISCOVERED)) + browser = FakeBrowser(tabs=[attached, rediscovered]) + manager = BrowserManager() + + async def _get_browser(instance_id: str, touch_activity: bool = False): + return browser if instance_id == INSTANCE_ID else None + + monkeypatch.setattr(manager, "get_browser", _get_browser) + return manager, browser, attached + + +async def test_list_tabs_lists_a_rediscovered_connection(manager_and_browser): + """The crash pin: a ``Connection`` in ``browser.tabs`` must not blow up.""" + manager, _browser, _attached = manager_and_browser + + tabs = await manager.list_tabs(INSTANCE_ID) + + assert [t["tab_id"] for t in tabs] == [ + ATTACHED["target_id"], + REDISCOVERED["target_id"], + ] + + +async def test_list_tabs_metadata_is_real_for_a_rediscovered_connection( + manager_and_browser, +): + """The silent-lie pin: every field asserted BY VALUE, for BOTH object types. + + ``getattr(tab, "url", "") or ""`` would happily return ``""`` if the fix ever + regressed into papering over a missing attribute with a default. It must + return the target's real url instead. + """ + manager, _browser, _attached = manager_and_browser + + tabs = {t["tab_id"]: t for t in await manager.list_tabs(INSTANCE_ID)} + + for expected in (ATTACHED, REDISCOVERED): + record = tabs[expected["target_id"]] + assert record["url"] == expected["url"] + assert record["title"] == expected["title"] + assert record["type"] == "page" + + +async def test_list_tabs_refreshes_targets_without_awaiting_each_tab( + manager_and_browser, +): + """``update_targets()`` already refreshed every field the loop reads, so the + loop awaits nothing. Awaiting a ``Tab`` costs up to 0.5s EACH (``Tab.wait()`` + races the lifecycle event against an ``asyncio.sleep(0.5)``), so a per-tab + await would make listing N tabs pay N waits for data already in hand. + """ + manager, browser, attached = manager_and_browser + + await manager.list_tabs(INSTANCE_ID) + + assert browser.update_targets_calls == 1 + assert attached.awaited == 0 + + +async def test_list_tabs_returns_empty_for_unknown_instance(manager_and_browser): + """Unchanged contract: no browser → empty list (not an error shape).""" + manager, _browser, _attached = manager_and_browser + + assert await manager.list_tabs("nope") == [] diff --git a/tests/test_e2e_interaction.py b/tests/test_e2e_interaction.py index 8500a46..534443d 100644 --- a/tests/test_e2e_interaction.py +++ b/tests/test_e2e_interaction.py @@ -379,6 +379,134 @@ async def test_cookies_lifecycle(fixture_app_server): # --------------------------------------------------------------------------- +async def _tabs_until(iid, predicate, timeout: float = 10.0) -> dict[str, dict]: + """Poll ``list_tabs`` (keyed by ``tab_id``) until ``predicate`` holds. + + Bounded deadline + fixed interval, per plan §2.6 — never sleep-then-assert. + Deliberately does NOT catch anything: a tab disappears via Chrome's + asynchronous ``Target.targetDestroyed``, so *absence* is worth polling for, + but an exception out of ``list_tabs`` is a hard failure that must surface on + the first call rather than be polled away (F-771, see below). + + Returns the last observed listing so the caller's assert shows the real + mismatch when the deadline passes. + """ + list_tabs = get_fn("list_tabs") + deadline = time.monotonic() + timeout + tabs = {t["tab_id"]: t for t in await list_tabs(instance_id=iid)} + while not predicate(tabs) and time.monotonic() < deadline: + await asyncio.sleep(0.25) + tabs = {t["tab_id"]: t for t in await list_tabs(instance_id=iid)} + return tabs + + +async def test_list_tabs_after_close_tab(fixture_app_server): + """F-771: ``list_tabs`` must survive the rediscovery a ``close_tab`` forces. + + ``close_tab`` makes the next ``update_targets()`` re-append surviving targets + as raw ``Connection`` objects, which ``Browser.tabs`` returns despite its + ``List[Tab]`` annotation. Only ``Tab`` defines ``__await__``, so a per-tab + ``await`` in the listing loop raised + ``TypeError: object Connection can't be used in 'await' expression`` — + permanently, for the life of the browser. + + The surviving tab's ``url`` is asserted BY VALUE, not by presence: a listing + that comes back with blank urls would be a silent lie, strictly worse than + the crash it replaced. See ``audit/stage2/plan_RELEASE_FIX_E.md``. + """ + base = fixture_app_server + spawn = get_fn("spawn_browser") + list_tabs = get_fn("list_tabs") + new_tab = get_fn("new_tab") + close_tab = get_fn("close_tab") + close = get_fn("close_instance") + + index_url = f"{base}/index.html" + result = await spawn(headless=True, **sandbox_kwargs()) + iid = result["instance_id"] + try: + await navigate_and_settle(iid, index_url) + + before = {t["tab_id"]: t for t in await list_tabs(instance_id=iid)} + main_ids = [tid for tid, t in before.items() if t["url"] == index_url] + assert main_ids, before + main_id = main_ids[0] + + opened = await new_tab(instance_id=iid, url=f"{base}/interact.html") + new_id = opened["tab_id"] + assert await close_tab(instance_id=iid, tab_id=new_id) is True + + after = await _tabs_until(iid, lambda tabs: new_id not in tabs) + assert new_id not in after + assert main_id in after + assert after[main_id]["url"] == index_url + finally: + await close(instance_id=iid) + + +async def test_list_tabs_metadata_survives_rediscovery(fixture_app_server): + """F-771 anti-silent-regression pin: real metadata after a rediscovery. + + Removing the ``await`` must not be paid for with empty fields. + ``Connection.__getattr__`` delegates to ``self.target``, so a rediscovered + target CAN supply its real url/title — every field below is therefore + asserted by value, and the shape check covers whatever extra page targets + Chrome happens to hold. + """ + base = fixture_app_server + spawn = get_fn("spawn_browser") + list_tabs = get_fn("list_tabs") + new_tab = get_fn("new_tab") + close_tab = get_fn("close_tab") + close = get_fn("close_instance") + + index_url = f"{base}/index.html" + extract_url = f"{base}/extract.html" + result = await spawn(headless=True, **sandbox_kwargs()) + iid = result["instance_id"] + try: + await navigate_and_settle(iid, index_url) + + before = {t["tab_id"]: t for t in await list_tabs(instance_id=iid)} + main_id = next(tid for tid, t in before.items() if t["url"] == index_url) + + survivor_id = (await new_tab(instance_id=iid, url=extract_url))["tab_id"] + doomed_id = (await new_tab(instance_id=iid, url=f"{base}/interact.html"))[ + "tab_id" + ] + assert await close_tab(instance_id=iid, tab_id=doomed_id) is True + + expected = {main_id: index_url, survivor_id: extract_url} + titles = { + index_url: "fixture-index-page", + extract_url: "fixture-extract-page", + } + after = await _tabs_until( + iid, + lambda tabs: ( + doomed_id not in tabs + and all( + tabs.get(tid, {}).get("title") == titles[url] + for tid, url in expected.items() + ) + ), + ) + + assert doomed_id not in after + for tid, url in expected.items(): + assert after[tid]["url"] == url + assert after[tid]["title"] == titles[url] + assert after[tid]["type"] == "page" + + # No record may be a blank placeholder — that is the regression shape. + for tid, record in after.items(): + assert tid, record + assert record["url"], record + assert record["type"] == "page", record + finally: + await close(instance_id=iid) + + async def test_tabs_lifecycle(fixture_app_server): base = fixture_app_server spawn = get_fn("spawn_browser") @@ -417,42 +545,15 @@ async def test_tabs_lifecycle(fixture_app_server): assert await switch_tab(instance_id=iid, tab_id=an_original) is True assert await close_tab(instance_id=iid, tab_id=new_id) is True - # ── PINNED KNOWN DEFECT F-771 (characterization; do NOT "fix" here) ── - # After a close_tab, `list_tabs` can raise + # F-771 is CLOSED (RELEASE-FIX-E; audit/stage2/plan_RELEASE_FIX_E.md). + # The xfail branch that used to stand here tolerated # TypeError: object Connection can't be used in 'await' expression - # from browser_manager.list_tabs's `await tab` (browser_manager.py:1307). - # Mechanism (read from nodriver 0.47 source, not inferred): - # `Browser.update_targets()` APPENDS raw `Connection` objects for newly - # discovered targets (browser.py:574-584), and `Browser.tabs` returns - # them even though it is annotated `List[Tab]`. Only `Tab` defines - # `__await__` (tab.py:1262) — `Connection` does not. So any target the - # browser DISCOVERED (rather than created as a Tab) makes `await tab` - # raise. - # - # This is NOT the transient detach race the earlier comment here - # assumed: the bad entry persists, so no bounded wait can clear it - # (proved on CI — a 10s poll expired with the TypeError still firing). - # plan_RELEASE forbids src edits, so per §2.8 this is the other allowed - # branch: a characterization pin + route, flagged as a KNOWN GAP so a - # green run never reads as "close_tab's post-state is verified". - # - # Teeth kept deliberately: close_tab's own contract is asserted hard - # above; when list_tabs DOES work the closed tab must be gone; and the - # ONLY tolerated deviation is this one pinned TypeError — any other - # exception, or a stale tab in a successful listing, still fails. - deadline = time.monotonic() + 10.0 - remaining = None - pinned_defect: TypeError | None = None - while time.monotonic() < deadline: - try: - remaining = {t["tab_id"] for t in await list_tabs(instance_id=iid)} - except TypeError as exc: # F-771 only — never a blanket except - pinned_defect, remaining = exc, None - if remaining is not None and new_id not in remaining: - break - await asyncio.sleep(0.25) - if remaining is None: - pytest.xfail(f"F-771: list_tabs broken after close_tab: {pinned_defect}") + # out of list_tabs' per-tab `await tab`; that await is gone, so the + # assertion is direct again. The bounded poll below is NOT for F-771 — + # it is for Chrome's asynchronous `Target.targetDestroyed`, which is what + # actually removes the closed tab from the listing. An exception here + # fails the test on the first call; it is never polled away. + remaining = await _tabs_until(iid, lambda tabs: new_id not in tabs) assert new_id not in remaining finally: await close(instance_id=iid)