| """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: |
| self.calls += 1 |
| raise AssertionError("generator should not run while the lock is held") |
|
|
|
|
| |
|
|
|
|
| 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) |
| |
| |
| 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) |
| |
| 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() |
| |
| 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) |
|
|
|
|
| |
|
|
|
|
| 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 |
|
|
| |
| |
| _evict_stale_sessions() |
|
|
| |
| |
| ctx = get_turn_context() |
| self.read_session_id = ctx.session_id |
| _record = session_store[ctx.session_id] |
|
|
| 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 |
|
|
| |
| |
| |
| |
| |
| |
| |
| monkeypatch.setattr("loosecanvas.turn_logic._SESSION_TTL_SECONDS", 1.0) |
| sid, _ = await load_fixture_session(fixture_path=_SMALL_GRAPH) |
| |
| 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 |
| |
| assert sid in session_store |
|
|
|
|
| |
|
|
|
|
| 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) |
| |
| 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"] |
|
|
|
|
| |
|
|
|
|
| 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) |
|
|
|
|
| |
|
|
|
|
| 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.""" |
| |
| outside = (ALLOWED_BASE_DIR.parent / "definitely-not-allowed-xyz").resolve() |
| if outside.is_relative_to(ALLOWED_BASE_DIR): |
| 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" |
| |
| assert str(outside) not in message |
| assert "ALLOWED_BASE_DIR" not in message |
| |
| assert any(str(outside) in rec.getMessage() for rec in caplog.records) |
|
|