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
379 lines
16 KiB
Python
379 lines
16 KiB
Python
"""Tests for the per-loop / per-resolved-storage-path refresh lock registry.
|
||
|
||
The module-global ``_REFRESH_LOCK = asyncio.Lock()`` was removed because
|
||
``asyncio.Lock`` binds to the first event loop that uses it, breaking on
|
||
cross-loop / cross-thread usage. The replacement mirrors the keepalive
|
||
``_get_poke_lock`` pattern: a ``WeakKeyDictionary`` keyed on the running
|
||
loop, with per-resolved-storage-path inner locks. Cross-loop atomicity
|
||
for ``_REFRESH_GENERATIONS`` is provided by the sync ``_REFRESH_STATE_LOCK``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import gc
|
||
import threading
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from notebooklm import auth as auth_mod
|
||
from notebooklm._auth import refresh as _auth_refresh
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clear_refresh_state():
|
||
"""Reset module state between tests."""
|
||
auth_mod._REFRESH_GENERATIONS.clear()
|
||
# The WeakKeyDictionary is mutated indirectly; we don't reset it here so
|
||
# the cleanup test can observe natural GC behavior.
|
||
yield
|
||
auth_mod._REFRESH_GENERATIONS.clear()
|
||
|
||
|
||
class TestPerLoopLockIdentity:
|
||
def test_different_loops_get_different_locks(self):
|
||
"""A lock created in loop X must NOT be returned to loop Y.
|
||
|
||
Both loops must be kept alive simultaneously so the WeakKeyDictionary
|
||
entries survive long enough to compare the locks by identity. Using
|
||
``asyncio.run`` for each loop in turn allows Python 3.13's GC to
|
||
reclaim the first loop's entry before the second is created, after
|
||
which ``id()`` of the second lock can collide with the recycled
|
||
memory address of the first.
|
||
"""
|
||
path = Path("/tmp/notebooklm-test/storage_state.json")
|
||
|
||
loop_a = asyncio.new_event_loop()
|
||
loop_b = asyncio.new_event_loop()
|
||
try:
|
||
|
||
async def _grab():
|
||
return auth_mod._get_refresh_lock(path)
|
||
|
||
lock_a = loop_a.run_until_complete(_grab())
|
||
lock_b = loop_b.run_until_complete(_grab())
|
||
# Hold strong references to both locks AND both loops until
|
||
# after the assertion so neither can be GC'd / address-recycled.
|
||
assert lock_a is not lock_b, (
|
||
"Different event loops must produce distinct asyncio.Lock instances"
|
||
)
|
||
finally:
|
||
loop_a.close()
|
||
loop_b.close()
|
||
|
||
def test_same_loop_same_path_returns_same_lock(self):
|
||
"""Repeated calls within one loop for the same path return the same lock."""
|
||
path = Path("/tmp/notebooklm-test/storage_state.json")
|
||
|
||
async def capture_two_locks():
|
||
return id(auth_mod._get_refresh_lock(path)), id(auth_mod._get_refresh_lock(path))
|
||
|
||
a, b = asyncio.run(capture_two_locks())
|
||
assert a == b
|
||
|
||
def test_same_loop_different_paths_get_different_locks(self):
|
||
"""Distinct storage paths within one loop get distinct locks."""
|
||
path_a = Path("/tmp/notebooklm-test/profile_a/storage_state.json")
|
||
path_b = Path("/tmp/notebooklm-test/profile_b/storage_state.json")
|
||
|
||
async def capture():
|
||
return (
|
||
id(auth_mod._get_refresh_lock(path_a)),
|
||
id(auth_mod._get_refresh_lock(path_b)),
|
||
)
|
||
|
||
a, b = asyncio.run(capture())
|
||
assert a != b
|
||
|
||
|
||
class TestResolvedPathEquivalence:
|
||
def test_none_with_profile_and_explicit_path_share_lock(self, monkeypatch, tmp_path):
|
||
"""``(None, profile="foo")`` and the explicit path resolve to the same key.
|
||
|
||
``_fetch_tokens_with_refresh`` computes
|
||
``refresh_storage_path = storage_path or get_storage_path(profile=profile)``
|
||
and keys the lock on that resolved Path, so two callers using either
|
||
form share the same lock.
|
||
"""
|
||
resolved = tmp_path / "profile_foo" / "storage_state.json"
|
||
|
||
async def capture():
|
||
# Both calls pass the SAME resolved Path object the production code
|
||
# would compute. The registry keys on Path equality, not identity.
|
||
lock_via_none = auth_mod._get_refresh_lock(resolved)
|
||
# Build an equivalent Path object (different instance, same value).
|
||
equivalent = Path(str(resolved))
|
||
lock_via_explicit = auth_mod._get_refresh_lock(equivalent)
|
||
return id(lock_via_none), id(lock_via_explicit)
|
||
|
||
a, b = asyncio.run(capture())
|
||
assert a == b, "Equal Path values must hash to the same registry slot"
|
||
|
||
def test_symlink_and_real_path_share_lock_via_refresh(self, monkeypatch, tmp_path):
|
||
"""Two different surface representations of the same physical file
|
||
(symlink vs canonical absolute path, and relative vs absolute) must
|
||
flow through ``_fetch_tokens_with_refresh``'s path canonicalization
|
||
and end up sharing a single lock / generation-key.
|
||
|
||
The production fix is ``.expanduser().resolve()`` applied to
|
||
``refresh_storage_path`` before it is used as a key. Without that
|
||
normalization, two callers referring to the same on-disk file by
|
||
different paths would each get their own lock — defeating the
|
||
cross-loop / cross-thread refresh coalescing.
|
||
"""
|
||
# Set up a real file plus a symlink pointing at it.
|
||
real_dir = tmp_path / "real"
|
||
real_dir.mkdir()
|
||
real_path = real_dir / "storage_state.json"
|
||
real_path.write_text('{"cookies": [], "origins": []}')
|
||
|
||
link_dir = tmp_path / "via_link"
|
||
link_dir.symlink_to(real_dir, target_is_directory=True)
|
||
symlinked_path = link_dir / "storage_state.json"
|
||
|
||
# Sanity: surface paths differ, resolved targets match.
|
||
assert symlinked_path != real_path
|
||
assert symlinked_path.resolve() == real_path.resolve()
|
||
|
||
captured_keys: list[str] = []
|
||
original_get_refresh_lock = auth_mod._get_refresh_lock
|
||
|
||
def spy_get_refresh_lock(p):
|
||
captured_keys.append(str(p))
|
||
return original_get_refresh_lock(p)
|
||
|
||
# Force a refresh attempt and short-circuit downstream side effects.
|
||
monkeypatch.setenv(auth_mod.NOTEBOOKLM_REFRESH_CMD_ENV, "dummy")
|
||
monkeypatch.setattr(_auth_refresh, "_get_refresh_lock", spy_get_refresh_lock)
|
||
|
||
call_phase = {"first": True}
|
||
|
||
async def fake_fetch_tokens_with_jar(jar, path, **kwargs):
|
||
if call_phase["first"]:
|
||
call_phase["first"] = False
|
||
raise ValueError("Authentication expired. Run 'notebooklm login'.")
|
||
return "csrf-token", "session-id"
|
||
|
||
async def fake_run_refresh_cmd(storage_path, profile):
|
||
return None
|
||
|
||
import httpx
|
||
|
||
def fake_build(_p):
|
||
return httpx.Cookies()
|
||
|
||
def fake_snapshot(_j):
|
||
return None
|
||
|
||
monkeypatch.setattr(_auth_refresh, "_fetch_tokens_with_jar", fake_fetch_tokens_with_jar)
|
||
monkeypatch.setattr(_auth_refresh, "_run_refresh_cmd", fake_run_refresh_cmd)
|
||
monkeypatch.setattr(_auth_refresh, "build_httpx_cookies_from_storage", fake_build)
|
||
monkeypatch.setattr(_auth_refresh, "snapshot_cookie_jar", fake_snapshot)
|
||
|
||
async def drive(path: Path):
|
||
jar = httpx.Cookies()
|
||
return await auth_mod._fetch_tokens_with_refresh(jar, storage_path=path)
|
||
|
||
# First caller uses the symlinked path.
|
||
asyncio.run(drive(symlinked_path))
|
||
# Reset phase so the second caller also triggers the refresh branch.
|
||
call_phase["first"] = True
|
||
# Second caller uses the canonical real path.
|
||
asyncio.run(drive(real_path))
|
||
|
||
# Both calls must have canonicalized to the same key.
|
||
assert len(captured_keys) == 2
|
||
assert captured_keys[0] == captured_keys[1], (
|
||
f"Symlinked and direct paths produced distinct keys: {captured_keys!r}"
|
||
)
|
||
# And the canonical key must equal the resolved real path.
|
||
assert captured_keys[0] == str(real_path.resolve())
|
||
|
||
|
||
class TestWeakKeyDictionaryCleanup:
|
||
def test_loops_are_garbage_collected(self):
|
||
"""When loops go out of scope, their inner dict is reclaimed."""
|
||
path = Path("/tmp/notebooklm-test/storage_state.json")
|
||
|
||
def spawn_and_drop():
|
||
"""Run a short-lived loop, return; do not retain a reference."""
|
||
|
||
async def inner():
|
||
auth_mod._get_refresh_lock(path)
|
||
|
||
asyncio.run(inner())
|
||
|
||
baseline_len = len(auth_mod._REFRESH_LOCKS_BY_LOOP)
|
||
for _ in range(5):
|
||
spawn_and_drop()
|
||
|
||
# Force collection of the closed loops.
|
||
gc.collect()
|
||
|
||
post_len = len(auth_mod._REFRESH_LOCKS_BY_LOOP)
|
||
# The WeakKeyDictionary should reclaim entries for collected loops.
|
||
# We assert the registry didn't grow unboundedly across iterations.
|
||
assert post_len <= baseline_len + 1, (
|
||
f"WeakKeyDictionary did not reclaim closed-loop entries "
|
||
f"(baseline={baseline_len}, post={post_len})"
|
||
)
|
||
|
||
|
||
class TestCrossLoopGenerationGuard:
|
||
def test_two_loops_at_most_two_refreshes(self, monkeypatch, tmp_path):
|
||
"""Two concurrent event loops both call ``_fetch_tokens_with_refresh``;
|
||
AT MOST 2 subprocess invocations occur (each loop runs once at the
|
||
worst), and crucially BOTH calls succeed without raising.
|
||
|
||
Race model: both loops observe an auth-expiry failure, each
|
||
capture the same pre-refresh generation, each acquire their OWN
|
||
per-loop asyncio lock (the registry hands out distinct locks per
|
||
loop), then race the check-and-claim under ``_REFRESH_STATE_LOCK``.
|
||
|
||
Legacy contract (before the gated-generation fix): this test
|
||
asserted ``run_count == 1`` because the old code bumped
|
||
``_REFRESH_GENERATIONS`` EAGERLY pre-subprocess; the cross-loop
|
||
loser saw the bump and skipped. That eager-bump behavior was the
|
||
root cause of the phantom-bump failure — when the subprocess
|
||
failed, the bump fooled concurrent waiters into skipping with
|
||
stale storage.
|
||
|
||
Current contract (gated-generation fix): generation is bumped
|
||
ONLY after the subprocess succeeds. Cross-loop callers cannot
|
||
signal "in flight" to each other (``asyncio.Future`` is
|
||
loop-bound). In the rare
|
||
cross-loop-concurrent-refresh case both loops may run their own
|
||
subprocess — equivalent to two ``RotateCookies`` POSTs against
|
||
the same storage. The end-state is correct (fresh cookies on
|
||
disk; last writer wins, but both write the same fresh data).
|
||
|
||
For SAME-LOOP coalescing (the dominant real-world case), the
|
||
per-loop in-flight future ensures exactly-once subprocess
|
||
execution; see ``tests/integration/concurrency/test_refresh_cmd_race.py``.
|
||
|
||
To force a deterministic race in a unit test, we use a barrier at
|
||
the point AFTER both threads have captured the generation but
|
||
BEFORE either has entered the inner sync mutex.
|
||
"""
|
||
import httpx
|
||
|
||
storage = tmp_path / "storage_state.json"
|
||
storage.write_text('{"cookies": [], "origins": []}')
|
||
|
||
# Force ``_should_try_refresh`` to return True.
|
||
monkeypatch.setenv(auth_mod.NOTEBOOKLM_REFRESH_CMD_ENV, "dummy")
|
||
|
||
run_count = 0
|
||
run_count_lock = threading.Lock()
|
||
|
||
# Barrier #1: align both fakes at the failure point so neither
|
||
# captures the generation before the other has had a chance to
|
||
# observe gen 0.
|
||
fail_barrier = threading.Barrier(2, timeout=5)
|
||
# Barrier #2: align both threads INSIDE their per-loop asyncio lock
|
||
# but BEFORE the inner sync-mutex check-and-claim. This guarantees
|
||
# both have already captured gen=0 pre-lock.
|
||
post_lock_barrier = threading.Barrier(2, timeout=5)
|
||
|
||
async def fake_run_refresh_cmd(storage_path, profile):
|
||
nonlocal run_count
|
||
with run_count_lock:
|
||
run_count += 1
|
||
await asyncio.sleep(0.01)
|
||
|
||
async def fake_fetch_tokens_with_jar(cookie_jar, storage_path, **kwargs):
|
||
if not getattr(cookie_jar, "_refresh_done", False):
|
||
cookie_jar._refresh_done = True
|
||
try:
|
||
fail_barrier.wait()
|
||
except threading.BrokenBarrierError:
|
||
pass
|
||
raise ValueError("Authentication expired. Run 'notebooklm login'.")
|
||
return "csrf-token", "session-id"
|
||
|
||
def fake_build_httpx_cookies(path):
|
||
return httpx.Cookies()
|
||
|
||
def fake_snapshot(jar):
|
||
return None
|
||
|
||
# Wrap ``_get_refresh_lock`` so we can interpose a barrier between
|
||
# the (pre-lock) generation capture and the (post-lock) inner
|
||
# sync-mutex check-and-claim.
|
||
original_get_refresh_lock = auth_mod._get_refresh_lock
|
||
|
||
class _BarrierLock:
|
||
def __init__(self, inner):
|
||
self._inner = inner
|
||
|
||
async def __aenter__(self):
|
||
await self._inner.acquire()
|
||
# Yield to a thread so the OTHER loop can also acquire its
|
||
# (distinct) per-loop lock before either touches the
|
||
# generation dict.
|
||
await asyncio.to_thread(post_lock_barrier.wait)
|
||
return self
|
||
|
||
async def __aexit__(self, exc_type, exc, tb):
|
||
self._inner.release()
|
||
return False
|
||
|
||
def wrapped_get_refresh_lock(p):
|
||
return _BarrierLock(original_get_refresh_lock(p))
|
||
|
||
monkeypatch.setattr(_auth_refresh, "_run_refresh_cmd", fake_run_refresh_cmd)
|
||
monkeypatch.setattr(_auth_refresh, "_fetch_tokens_with_jar", fake_fetch_tokens_with_jar)
|
||
monkeypatch.setattr(
|
||
_auth_refresh, "build_httpx_cookies_from_storage", fake_build_httpx_cookies
|
||
)
|
||
monkeypatch.setattr(_auth_refresh, "snapshot_cookie_jar", fake_snapshot)
|
||
monkeypatch.setattr(_auth_refresh, "_get_refresh_lock", wrapped_get_refresh_lock)
|
||
|
||
results: list[BaseException | tuple] = []
|
||
results_lock = threading.Lock()
|
||
|
||
def run_in_own_loop():
|
||
async def _work():
|
||
jar = httpx.Cookies()
|
||
return await auth_mod._fetch_tokens_with_refresh(jar, storage_path=storage)
|
||
|
||
try:
|
||
res = asyncio.run(_work())
|
||
with results_lock:
|
||
results.append(res)
|
||
except BaseException as exc: # noqa: BLE001
|
||
with results_lock:
|
||
results.append(exc)
|
||
|
||
thread_a = threading.Thread(target=run_in_own_loop)
|
||
thread_b = threading.Thread(target=run_in_own_loop)
|
||
thread_a.start()
|
||
thread_b.start()
|
||
thread_a.join(timeout=15)
|
||
thread_b.join(timeout=15)
|
||
|
||
assert not thread_a.is_alive() and not thread_b.is_alive(), (
|
||
"Threads failed to terminate; likely a deadlock"
|
||
)
|
||
# Both calls must have completed successfully.
|
||
assert len(results) == 2
|
||
for r in results:
|
||
assert not isinstance(r, BaseException), f"Refresh raised: {r!r}"
|
||
|
||
# The critical assertion under the gated-generation contract:
|
||
# AT MOST one subprocess invocation per loop (== 2 across two
|
||
# loops). Under the legacy eager-bump contract this asserted
|
||
# ``run_count == 1`` (cross-loop coalescing); the fix dropped
|
||
# eager-bump to close the phantom-bump failure mode, accepting
|
||
# that two cross-loop callers may both run their subprocess in
|
||
# the rare concurrent-refresh case (correct end-state: fresh
|
||
# cookies on disk).
|
||
assert 1 <= run_count <= 2, (
|
||
f"Expected 1–2 refresh invocations across loops, observed "
|
||
f"{run_count}. ``run_count == 0`` would mean the cross-loop "
|
||
"generation guard SKIPPED both refreshes (phantom-bump regression). "
|
||
"``run_count > 2`` means each loop ran refresh more than once "
|
||
"(per-loop coalescing broken)."
|
||
)
|