Files
teng-lin--notebooklm-py/tests/integration/concurrency/test_note_create_cancel.py
T
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

240 lines
9.6 KiB
Python

"""Regression test for the shield UPDATE_NOTE finalize, cleanup on cancel.
Audit item §28: ``_mind_map.create_note`` issued CREATE_NOTE then UPDATE_NOTE
back-to-back. A cancellation arriving after CREATE_NOTE returned but before
UPDATE_NOTE completed left an empty/orphan note on the server (CREATE_NOTE
persists; UPDATE_NOTE applies the title/content payload that callers expect).
Post-fix:
- UPDATE_NOTE call is wrapped in ``asyncio.shield`` so an outer cancel cannot
abort an in-flight finalize.
- On ``CancelledError`` raised by the shielded await, a best-effort
DELETE_NOTE fires via ``asyncio.create_task`` (NOT awaited — re-raise must
not block on cleanup), then the cancellation re-raises.
Acceptance invariant:
cancel mid-flight after CREATE_NOTE returns but before UPDATE_NOTE
completes; assert either
(a) both succeed (shield wins — UPDATE_NOTE finished within the shielded
await before the outer cancel arrived), OR
(b) both effectively undone (UPDATE_NOTE was cancelled and the best-effort
DELETE_NOTE cleanup ran on the mock transport).
"""
from __future__ import annotations
import asyncio
import json
import httpx
import pytest
from notebooklm import NotebookLMClient
from notebooklm.rpc import RPCMethod
from tests._fixtures.kernel_test_helpers import install_http_client_for_test
# mock-transport cancel-during-create tests; no HTTP, no cassette.
# Opt out of the tier-enforcement hook in tests/integration/conftest.py.
pytestmark = pytest.mark.allow_no_vcr
def _wrb_response(rpc_id: str, payload) -> str:
"""Single-RPC batchexecute response body in ``)]}}\\n<len>\\n<chunk>\\n`` form."""
inner = json.dumps(payload)
chunk = json.dumps([["wrb.fr", rpc_id, inner, None, None]])
return f")]}}'\n{len(chunk)}\n{chunk}\n"
def _rpc_id_in_request(request: httpx.Request) -> str | None:
"""Extract the ``rpcids=`` query param from a batchexecute request URL."""
for key, value in request.url.params.multi_items():
if key == "rpcids":
return value
return None
def _make_client_with_transport(
transport: httpx.AsyncBaseTransport, auth_tokens
) -> NotebookLMClient:
"""Wire a ``NotebookLMClient`` to a mock transport, bypassing full open()."""
client = NotebookLMClient(auth_tokens)
install_http_client_for_test(
client._collaborators.kernel,
httpx.AsyncClient(
transport=transport,
headers={
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
},
),
)
return client
class _NoteCancelTransport(httpx.AsyncBaseTransport):
"""Mock transport that lets the caller pace UPDATE_NOTE precisely.
``update_started`` fires on UPDATE_NOTE entry so the test can cancel the
outer task at exactly that point. ``release_update`` (an asyncio.Event)
is awaited inside UPDATE_NOTE so the test controls when the shielded
finalize actually completes.
"""
def __init__(self) -> None:
self.captured: list[tuple[str | None, httpx.Request]] = []
self.update_started = asyncio.Event()
self.release_update = asyncio.Event()
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
rpc_id = _rpc_id_in_request(request)
self.captured.append((rpc_id, request))
if rpc_id == RPCMethod.CREATE_NOTE.value:
# CREATE_NOTE returns the new note id; mirrors _mind_map.create_note
# parsing path that pulls ``result[0][0]`` when result is nested.
return httpx.Response(
200,
text=_wrb_response(RPCMethod.CREATE_NOTE.value, [["note_new_001"]]),
)
if rpc_id == RPCMethod.UPDATE_NOTE.value:
# Signal the test that UPDATE_NOTE has begun, then suspend until
# the test releases us. This is the precise window the
# cancellation shield must protect.
self.update_started.set()
await self.release_update.wait()
return httpx.Response(
200,
text=_wrb_response(RPCMethod.UPDATE_NOTE.value, None),
)
if rpc_id == RPCMethod.DELETE_NOTE.value:
return httpx.Response(
200,
text=_wrb_response(RPCMethod.DELETE_NOTE.value, None),
)
# Any other RPC: empty payload (decodes to None / []).
return httpx.Response(200, text=_wrb_response(rpc_id or "unknown", None))
def rpc_ids(self) -> list[str | None]:
return [rpc_id for rpc_id, _ in self.captured]
@pytest.mark.asyncio
async def test_cancel_during_update_note_shields_or_cleans_up(auth_tokens) -> None:
"""Cancel after CREATE_NOTE / before UPDATE_NOTE completes.
The acceptance invariant from the plan: assert EITHER both writes
succeed (shield wins) OR a DELETE_NOTE cleanup task fired on the
transport. Either branch proves the orphan-note bug is gone.
"""
transport = _NoteCancelTransport()
client = _make_client_with_transport(transport, auth_tokens)
try:
create_task = asyncio.create_task(
client.notes.create("nb_test", title="Hello", content="World")
)
# Let CREATE_NOTE run to completion AND let UPDATE_NOTE enter the
# transport. This is the window where the bug used to allow an
# orphan note to be left behind on cancel.
await asyncio.wait_for(transport.update_started.wait(), timeout=2.0)
# Cancel mid-UPDATE_NOTE. With the shield in place, UPDATE_NOTE
# keeps running until release_update is set; the outer awaiter
# receives CancelledError and schedules best-effort DELETE_NOTE.
create_task.cancel()
# Yield so the cancellation can be delivered and any best-effort
# cleanup task can be scheduled.
await asyncio.sleep(0)
# Release the in-flight UPDATE_NOTE so the inner shielded task can
# finish (it would otherwise hang the mock transport indefinitely).
transport.release_update.set()
# Drain the outer task. May raise CancelledError (cancel propagated
# because the await released after release_update fired) or return
# normally if the shielded UPDATE_NOTE happened to complete before
# the cancel landed. Both are acceptance-valid.
cancelled = False
try:
await create_task
except asyncio.CancelledError:
cancelled = True
# Yield enough times for the best-effort DELETE_NOTE create_task to
# be scheduled AND issue its request on the mock transport.
for _ in range(50): # up to ~0.5s
if RPCMethod.DELETE_NOTE.value in transport.rpc_ids():
break
await asyncio.sleep(0.01)
rpc_ids = transport.rpc_ids()
# CREATE_NOTE is always observed — that's the prerequisite for the
# cancellation window we're testing.
assert RPCMethod.CREATE_NOTE.value in rpc_ids, (
f"CREATE_NOTE never reached the transport: rpc_ids={rpc_ids!r}"
)
# UPDATE_NOTE is always observed: even when shield-protected, the
# inner task keeps running through the await point.
assert RPCMethod.UPDATE_NOTE.value in rpc_ids, (
f"UPDATE_NOTE never reached the transport: rpc_ids={rpc_ids!r}"
)
# Acceptance OR:
# (a) shield won — outer task returned normally, both writes
# succeeded, no cleanup required.
# (b) cancel landed — DELETE_NOTE cleanup task ran.
delete_ran = RPCMethod.DELETE_NOTE.value in rpc_ids
if cancelled:
assert delete_ran, (
"outer task was cancelled but no best-effort DELETE_NOTE "
f"cleanup reached the transport: rpc_ids={rpc_ids!r}"
)
else:
# Shield won: UPDATE_NOTE completed before cancel propagated.
# No cleanup expected.
assert not delete_ran, (
"shield won (no CancelledError) yet DELETE_NOTE still fired — "
f"cleanup should only run on cancel: rpc_ids={rpc_ids!r}"
)
finally:
# Defensive cleanup so a failing assertion doesn't leak the http
# client and warn at gc time.
if client._collaborators.kernel.http_client is not None:
await client._collaborators.kernel.get_http_client().aclose()
install_http_client_for_test(client._collaborators.kernel, None)
@pytest.mark.asyncio
async def test_no_cancel_no_cleanup(auth_tokens) -> None:
"""Sanity: when no cancel arrives, CREATE+UPDATE succeed and DELETE never fires.
Guards against an over-eager fix that fires DELETE_NOTE unconditionally
(which would silently nuke every newly-created note).
"""
transport = _NoteCancelTransport()
# Release UPDATE_NOTE immediately — no cancel will arrive.
transport.release_update.set()
client = _make_client_with_transport(transport, auth_tokens)
try:
note = await client.notes.create("nb_test", title="Hello", content="World")
assert note.id == "note_new_001"
rpc_ids = transport.rpc_ids()
assert RPCMethod.CREATE_NOTE.value in rpc_ids
assert RPCMethod.UPDATE_NOTE.value in rpc_ids
assert RPCMethod.DELETE_NOTE.value not in rpc_ids, (
"DELETE_NOTE fired on a non-cancelled create — cleanup is "
f"over-eager: rpc_ids={rpc_ids!r}"
)
finally:
if client._collaborators.kernel.http_client is not None:
await client._collaborators.kernel.get_http_client().aclose()
install_http_client_for_test(client._collaborators.kernel, None)