09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
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
194 lines
6.8 KiB
Python
194 lines
6.8 KiB
Python
"""Refresh state-machine regression tests.
|
|
|
|
Pins three behaviors of ``RpcExecutor.try_refresh_and_retry`` (the
|
|
canonical implementation; ``Session._try_refresh_and_retry`` was
|
|
inlined in PR #4b and callers now reach the executor through
|
|
``core._rpc_executor``):
|
|
|
|
1. Concurrent callers share the same in-flight refresh task (single-flight).
|
|
2. Refresh failures propagate to all waiters with chained ``__cause__``.
|
|
3. A second wave after the first task completes creates a *new* task
|
|
(the slot is not silently reused).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import importlib.util
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from notebooklm._auth_refresh_retry import RefreshBudget
|
|
from notebooklm.auth import AuthTokens
|
|
from notebooklm.rpc import AuthError, RPCMethod
|
|
|
|
_UNIT_CONFTEST_SPEC = importlib.util.spec_from_file_location(
|
|
"unit_conftest_make_core",
|
|
Path(__file__).resolve().parent / "conftest.py",
|
|
)
|
|
assert _UNIT_CONFTEST_SPEC is not None and _UNIT_CONFTEST_SPEC.loader is not None
|
|
_unit_conftest = importlib.util.module_from_spec(_UNIT_CONFTEST_SPEC)
|
|
_UNIT_CONFTEST_SPEC.loader.exec_module(_unit_conftest)
|
|
make_core = _unit_conftest.make_core
|
|
|
|
# Tight enough to fail fast if a regression hangs the suite, generous enough
|
|
# not to flake on a slow CI runner. Each event-wait should resolve in <100ms;
|
|
# 5s is two orders of magnitude of headroom.
|
|
EVENT_TIMEOUT_S = 5.0
|
|
|
|
|
|
async def _trigger_refresh(core):
|
|
"""Drive ``RpcExecutor.try_refresh_and_retry`` with throwaway args."""
|
|
return await core._rpc_executor.try_refresh_and_retry(
|
|
RPCMethod.LIST_NOTEBOOKS,
|
|
[],
|
|
"/",
|
|
False,
|
|
AuthError("simulated"),
|
|
_refresh_budget=RefreshBudget(),
|
|
)
|
|
|
|
|
|
async def _wait_for_inflight_refresh_task(core, ticks: int = 20) -> bool:
|
|
"""Yield up to ``ticks`` times for the shared refresh task to appear."""
|
|
for _ in range(ticks):
|
|
await asyncio.sleep(0)
|
|
if (
|
|
core._collaborators.auth_coord._refresh_task is not None
|
|
and not core._collaborators.auth_coord._refresh_task.done()
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_callers_share_single_refresh():
|
|
callback_entered = asyncio.Event()
|
|
release_refresh = asyncio.Event()
|
|
call_count = 0
|
|
core_box: list = []
|
|
|
|
async def cb():
|
|
nonlocal call_count
|
|
call_count += 1
|
|
callback_entered.set()
|
|
await release_refresh.wait()
|
|
tokens = AuthTokens(
|
|
csrf_token="CSRF_REFRESHED",
|
|
session_id="SID_REFRESHED",
|
|
cookies={"SID": "post_refresh"},
|
|
)
|
|
# Mirror real-world callback behavior: update core.auth in place.
|
|
core_box[0].auth.csrf_token = tokens.csrf_token
|
|
core_box[0].auth.session_id = tokens.session_id
|
|
return tokens
|
|
|
|
async with make_core(refresh_callback=cb) as core:
|
|
core_box.append(core)
|
|
|
|
async def fake_retry(*args, **kwargs):
|
|
return "ok"
|
|
|
|
core._rpc_executor.rpc_call = fake_retry # type: ignore[method-assign]
|
|
|
|
tasks = [asyncio.create_task(_trigger_refresh(core)) for _ in range(3)]
|
|
|
|
await asyncio.wait_for(callback_entered.wait(), EVENT_TIMEOUT_S)
|
|
assert call_count == 1, f"FIRST entry should have call_count=1, got {call_count}"
|
|
|
|
# Give tasks 2 and 3 a chance to reach `await refresh_task`. The real
|
|
# single-flight invariant is proven by the post-release assertion below;
|
|
# this loop just lets the scheduler tick.
|
|
if not await _wait_for_inflight_refresh_task(core):
|
|
pytest.fail("Refresh task did not appear in 20 ticks")
|
|
|
|
assert call_count == 1, f"Multiple refreshes fired before release: {call_count}"
|
|
|
|
release_refresh.set()
|
|
results = await asyncio.gather(*tasks)
|
|
|
|
assert all(r == "ok" for r in results)
|
|
assert call_count == 1, f"Post-release call_count drifted to {call_count}"
|
|
assert core.auth.csrf_token == "CSRF_REFRESHED"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_failure_propagates_to_all_waiters():
|
|
"""All waiters on the shared refresh task observe the same failure.
|
|
|
|
Uses a gated failing callback so all three triggers must join the in-flight
|
|
task before it raises — without the gate, the first task could complete
|
|
immediately and let the others spin up their own failed tasks, which would
|
|
pass the per-task assertions but not prove shared-task propagation.
|
|
"""
|
|
boom = RuntimeError("refresh boom")
|
|
enter = asyncio.Event()
|
|
release = asyncio.Event()
|
|
call_count = 0
|
|
|
|
async def cb():
|
|
nonlocal call_count
|
|
call_count += 1
|
|
enter.set()
|
|
await release.wait()
|
|
raise boom
|
|
|
|
async with make_core(refresh_callback=cb) as core:
|
|
tasks = [asyncio.create_task(_trigger_refresh(core)) for _ in range(3)]
|
|
|
|
await asyncio.wait_for(enter.wait(), EVENT_TIMEOUT_S)
|
|
if not await _wait_for_inflight_refresh_task(core):
|
|
pytest.fail("Refresh task did not appear in 20 ticks")
|
|
|
|
assert call_count == 1, (
|
|
f"Failure propagation test invalid: {call_count} callbacks fired "
|
|
"before release. Single-flight broken — each waiter spun its own."
|
|
)
|
|
|
|
release.set()
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
assert call_count == 1, f"Refresh re-fired after failure: {call_count}"
|
|
# Identity check: every waiter must observe the SAME RuntimeError as
|
|
# __cause__. This proves shared-task propagation — a per-waiter retry
|
|
# would produce distinct RuntimeError instances even with the same msg.
|
|
for r in results:
|
|
assert isinstance(r, AuthError)
|
|
assert r.__cause__ is boom, (
|
|
f"Expected shared-task propagation (cause is boom), got "
|
|
f"{r.__cause__!r} (id={id(r.__cause__)}, boom id={id(boom)})"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_second_wave_creates_distinct_refresh_task():
|
|
call_count = 0
|
|
|
|
async def cb():
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return AuthTokens(
|
|
csrf_token=f"R{call_count}",
|
|
session_id="S",
|
|
cookies={"SID": f"sid{call_count}"},
|
|
)
|
|
|
|
async with make_core(refresh_callback=cb) as core:
|
|
|
|
async def fake_retry(*args, **kwargs):
|
|
return "ok"
|
|
|
|
core._rpc_executor.rpc_call = fake_retry # type: ignore[method-assign]
|
|
|
|
await _trigger_refresh(core)
|
|
first_task = core._collaborators.auth_coord._refresh_task
|
|
assert first_task is not None and first_task.done()
|
|
|
|
await _trigger_refresh(core)
|
|
second_task = core._collaborators.auth_coord._refresh_task
|
|
assert second_task is not None and second_task.done()
|
|
|
|
assert first_task is not second_task, "Second wave reused completed task"
|
|
assert call_count == 2
|