loosecanvas / tests /test_session_reliability.py
Joshua Sundance Bailey
feat(find-connection): AI-articulated surprising link + chat insight
67292db
Raw
History Blame Contribute Delete
14.3 kB
"""WS-04 M1 β€” session-lifecycle reliability tests.
Covers:
- SL-1: the configured session-lock timeout is enforced on lock ACQUISITION in
``turn_logic.run_turn`` / ``run_turn_streaming`` and
``agent_harness.run_agent_turn_streaming`` (held lock β†’ graceful failure within
the timeout; happy path unaffected). main.py's advertised
``limits.session_lock_timeout_ms`` derives from the backend constant.
- SL-2: an in-flight agent turn is protected from TTL eviction (last_seen is
refreshed at turn entry so a mid-turn eviction sweep cannot drop the record a
tool is about to read).
- SL-3: the three alternate loaders (fixture/text/repo) enforce the session cap,
and /api/load_graph maps the resulting OverflowError to 503 for each input_kind.
- SL-4: save_session logs a warning (instead of silently swallowing) when the
checkpoint read fails.
- SEC-05: load_repo_session rejects an out-of-base path with a GENERIC message
(no absolute path leaked) while logging the resolved path server-side.
Every session-mutating test cleans up both ``session_store`` and ``session_locks``
via the autouse fixture below β€” no session leaks.
"""
from __future__ import annotations
import logging
import time
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from loosecanvas.config import ALLOWED_BASE_DIR
from loosecanvas.contracts import SceneState, SourceSnapshot
from loosecanvas.graph_repository import LooseGraphRepository
from loosecanvas.turn_logic import (
SessionRecord,
TurnResult,
UndoHistory,
_get_lock,
load_fixture_session,
load_repo_session,
load_text_session,
run_turn,
run_turn_streaming,
save_session,
session_locks,
session_store,
)
_REPO_ROOT = Path(__file__).resolve().parents[1]
_SMALL_GRAPH = _REPO_ROOT / "fixtures" / "small_graph.json"
_FIXTURE_PATH = str(_SMALL_GRAPH.resolve())
_NO_SELECTION: dict[str, Any] = {"kind": "none", "id": ""}
@pytest.fixture(autouse=True)
def _isolate_sessions() -> Iterator[None]:
session_store.clear()
session_locks.clear()
yield
session_store.clear()
session_locks.clear()
def _fresh_record(session_id: str) -> SessionRecord:
"""A minimal, non-stale SessionRecord (last_seen=now) for cap-fill tests."""
repo = LooseGraphRepository.from_extraction([], [], [], SourceSnapshot())
now = time.time()
return SessionRecord(
session_id=session_id,
graph=repo,
scene=SceneState(),
history=[],
graph_version=1,
created_at=now,
last_seen=now,
patch_id=0,
render_scene_id="",
undo=UndoHistory(),
)
def _fill_store_to_cap(max_sessions: int) -> None:
"""Fill session_store with ``max_sessions`` fresh (non-stale) sessions."""
for i in range(max_sessions):
sid = f"cap-fill-{i}"
session_store[sid] = _fresh_record(sid)
class _StubGenerator:
"""Stand-in for the ScenePlanGenerator; never reached when the lock blocks."""
def __init__(self) -> None:
self.calls = 0
async def generate(self, context: Any) -> Any: # pragma: no cover - not reached
self.calls += 1
raise AssertionError("generator should not run while the lock is held")
# ── SL-1: session-lock timeout enforced on acquisition ────────────────────────
async def test_run_turn_lock_timeout_returns_failed(monkeypatch: Any) -> None:
"""A held session lock makes the next ``run_turn`` fail gracefully within the timeout."""
monkeypatch.setattr("loosecanvas.turn_logic._SESSION_LOCK_TIMEOUT_SECONDS", 0.05)
sid, _ = await load_fixture_session(fixture_path=_SMALL_GRAPH)
# The per-session lock is created lazily inside the turn path; pre-create it so
# the test can hold it while the turn tries (and times out) to acquire it.
lock = _get_lock(sid)
await lock.acquire()
try:
started = time.monotonic()
result = await run_turn(sid, _NO_SELECTION, "highlight x", _StubGenerator())
elapsed = time.monotonic() - started
finally:
lock.release()
assert isinstance(result, TurnResult)
assert result.status == "failed"
assert any("lock timeout" in w for w in result.warnings)
# Returned within a small multiple of the (tiny) timeout, not blocked forever.
assert elapsed < 2.0
async def test_run_turn_streaming_lock_timeout_yields_failed(monkeypatch: Any) -> None:
"""A held lock makes ``run_turn_streaming`` yield a single failed TurnResult."""
monkeypatch.setattr("loosecanvas.turn_logic._SESSION_LOCK_TIMEOUT_SECONDS", 0.05)
sid, _ = await load_fixture_session(fixture_path=_SMALL_GRAPH)
lock = _get_lock(sid)
await lock.acquire()
try:
events = [
ev
async for ev in run_turn_streaming(
sid, _NO_SELECTION, "highlight x", _StubGenerator()
)
]
finally:
lock.release()
results = [ev for ev in events if isinstance(ev, TurnResult)]
assert len(results) == 1
assert results[0].status == "failed"
assert any("lock timeout" in w for w in results[0].warnings)
async def test_lock_released_after_timeout(monkeypatch: Any) -> None:
"""After a timeout the lock acquisition is NOT leaked (lock stays free)."""
monkeypatch.setattr("loosecanvas.turn_logic._SESSION_LOCK_TIMEOUT_SECONDS", 0.05)
sid, _ = await load_fixture_session(fixture_path=_SMALL_GRAPH)
lock = _get_lock(sid)
await lock.acquire()
try:
result = await run_turn(sid, _NO_SELECTION, "highlight x", _StubGenerator())
assert result.status == "failed"
finally:
lock.release()
# The failed turn must not have acquired the lock for itself.
assert not lock.locked()
def test_config_lock_timeout_derives_from_constant() -> None:
"""main.py advertises the lock timeout derived from the backend constant."""
import loosecanvas.main as main_module
from loosecanvas.turn_logic import _SESSION_LOCK_TIMEOUT_SECONDS
advertised = main_module._CONFIG_JSON["limits"]["session_lock_timeout_ms"]
assert advertised == int(_SESSION_LOCK_TIMEOUT_SECONDS * 1000)
# ── SL-2: in-flight turn protected from TTL eviction ──────────────────────────
class _EvictMidTurnAgent:
"""Fake agent whose ``astream`` fires an eviction sweep mid-turn, then reads
the session_store via a tool β€” crashing with KeyError if the record was
evicted (i.e. if last_seen was not refreshed at turn entry)."""
def __init__(self, ttl_seconds: float) -> None:
self._ttl_seconds = ttl_seconds
self.read_session_id: str | None = None
async def astream(
self, _inputs: Any, *, stream_mode: Any = None, config: Any = None
) -> AsyncIterator[tuple[Any, dict[str, Any]]]:
from loosecanvas.agent_tools import get_turn_context
from loosecanvas.turn_logic import _evict_stale_sessions
# Mid-turn: a TTL sweep runs. With a low TTL, any record whose last_seen
# predates the turn would be evicted here.
_evict_stale_sessions()
# A tool now reads the session_store keyed by the live context's session
# id β€” KeyError if the sweep dropped it.
ctx = get_turn_context()
self.read_session_id = ctx.session_id
_record = session_store[ctx.session_id] # noqa: F841 - the read is the point
yield (
SimpleNamespace(content="ok", tool_call_chunks=None),
{"langgraph_node": "model"},
)
async def test_inflight_turn_survives_eviction_sweep(monkeypatch: Any) -> None:
"""A mid-turn eviction sweep must NOT drop the in-flight session (SL-2)."""
from loosecanvas.agent_harness import FinalEvent, run_agent_turn_streaming
# Low TTL so the aged record below would be evicted UNLESS turn entry refreshes
# it β€” but not so low (was 0.0001s = 100us) that the gap between the refresh and
# the mid-turn sweep itself trips eviction (a slow full-suite run made that a
# flake: KeyError on the in-flight session). 1.0s gives the refreshed session
# (last_seen ~= now) ample margin over the sub-ms sweep, while the 10s-aged
# record below is still far past it β€” so the refresh remains the only thing that
# saves the session, preserving the SL-2 contract under test.
monkeypatch.setattr("loosecanvas.turn_logic._SESSION_TTL_SECONDS", 1.0)
sid, _ = await load_fixture_session(fixture_path=_SMALL_GRAPH)
# Age the record so its last_seen predates "now" by more than the TTL.
session_store[sid].last_seen = time.time() - 10.0
agent = _EvictMidTurnAgent(ttl_seconds=0.0001)
events = [
ev
async for ev in run_agent_turn_streaming(sid, _NO_SELECTION, "hi", agent=agent)
]
finals = [ev for ev in events if isinstance(ev, FinalEvent)]
assert len(finals) == 1
assert agent.read_session_id == sid
# Survived: the session is still present after the turn.
assert sid in session_store
# ── SL-3: capacity enforcement across alternate loaders ───────────────────────
async def test_load_fixture_session_enforces_cap(monkeypatch: Any) -> None:
monkeypatch.setattr("loosecanvas.turn_logic._MAX_SESSIONS", 3)
_fill_store_to_cap(3)
with pytest.raises(OverflowError, match="session_cap"):
await load_fixture_session(fixture_path=_SMALL_GRAPH)
async def test_load_text_session_enforces_cap(monkeypatch: Any) -> None:
monkeypatch.setattr("loosecanvas.turn_logic._MAX_SESSIONS", 3)
_fill_store_to_cap(3)
# The cap guard fires before extraction, so the client is never called.
fake_client = MagicMock()
with pytest.raises(OverflowError, match="session_cap"):
await load_text_session("some prose", "Title", fake_client)
async def test_load_repo_session_enforces_cap(monkeypatch: Any) -> None:
monkeypatch.setattr("loosecanvas.turn_logic._MAX_SESSIONS", 3)
_fill_store_to_cap(3)
repo = LooseGraphRepository.from_extraction([], [], [], SourceSnapshot())
from loosecanvas.extraction_pipeline import ExtractionResult
fake_result = ExtractionResult(
graph_id="cap-test-graph-id",
scene=SceneState(),
pathways=[],
import_report={},
)
with patch("loosecanvas.turn_logic.ExtractionOrchestrator") as mock_cls:
mock_instance = MagicMock()
mock_instance.run = AsyncMock(return_value=fake_result)
mock_cls.return_value = mock_instance
with (
patch("loosecanvas.turn_logic.get_repository", return_value=repo),
pytest.raises(OverflowError, match="session_cap"),
):
await load_repo_session(ALLOWED_BASE_DIR)
@pytest.mark.parametrize(
("input_kind", "loader_name", "body"),
[
(
"fixture",
"load_fixture_session",
{"input_kind": "fixture", "fixture_path": _FIXTURE_PATH},
),
(
"text",
"load_text_session",
{"input_kind": "text", "text": "some prose", "title": "T"},
),
(
"repo",
"load_repo_session",
{"input_kind": "repo", "repo_path": str(ALLOWED_BASE_DIR)},
),
],
)
async def test_api_load_graph_returns_503_at_cap(
input_kind: str, loader_name: str, body: dict[str, Any]
) -> None:
"""/api/load_graph maps a loader OverflowError to 503 for every input_kind."""
import loosecanvas.main as main_module
async def _raise_cap(*_args: Any, **_kwargs: Any) -> Any:
raise OverflowError("session_cap")
with patch.object(main_module, loader_name, new=_raise_cap):
app = main_module.create_api()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
resp = await client.post("/api/load_graph", json=body)
assert resp.status_code == 503
assert "session cap" in resp.json()["detail"]
# ── SL-4: save_session logs (not swallows) a checkpoint-read failure ──────────
async def test_save_session_logs_checkpoint_read_failure(
monkeypatch: Any, caplog: pytest.LogCaptureFixture
) -> None:
"""A failing checkpoint read yields an empty-memory snapshot AND a warning."""
sid, _ = await load_fixture_session(fixture_path=_SMALL_GRAPH)
class _BoomApp:
async def aget_state(self, _config: Any) -> Any:
raise RuntimeError("checkpoint read boom")
monkeypatch.setattr("loosecanvas.turn_graph.get_turn_app", lambda: _BoomApp())
with caplog.at_level(logging.WARNING, logger="loosecanvas.turn_logic"):
snap = await save_session(sid)
assert snap.messages == []
assert snap.recent_events == []
assert any("failed to read checkpoint" in rec.message for rec in caplog.records)
# ── SEC-05: generic path-rejection error (no absolute path leaked) ────────────
async def test_load_repo_session_rejects_path_generically(
monkeypatch: Any, caplog: pytest.LogCaptureFixture
) -> None:
"""An out-of-base repo_path β†’ ValueError with NO absolute path; warning logs it."""
# A path guaranteed outside ALLOWED_BASE_DIR on every host.
outside = (ALLOWED_BASE_DIR.parent / "definitely-not-allowed-xyz").resolve()
if outside.is_relative_to(ALLOWED_BASE_DIR): # pragma: no cover - host-dependent
pytest.skip("parent of ALLOWED_BASE_DIR is inside it on this host")
with (
caplog.at_level(logging.WARNING, logger="loosecanvas.turn_logic"),
pytest.raises(ValueError) as excinfo,
):
await load_repo_session(outside)
message = str(excinfo.value)
assert message == "repo_path not allowed"
# The leaked resolved path must NOT appear in the client-facing message.
assert str(outside) not in message
assert "ALLOWED_BASE_DIR" not in message
# The server-side warning carries the resolved path for diagnostics.
assert any(str(outside) in rec.getMessage() for rec in caplog.records)