**Phase 187 — final verification pass: all criteria met, repo 100% green.**
Verified (all four tasks already in `complete/`)
- `app/core/memory.py`: `rss_mb` / `rss_peak_mb` / `cgroup_peak_mb` / `fmt_mb`, pure stdlib, never-raise, **100%** coverage, 24 branch tests.
- Lifecycle lines append-only: `sync: started … rss_mb=`, all three terminals `… rss_mb= rss_peak_mb=` (failed = sanitized one-liner beside the surviving `logger.exception`), `import: summary … rss_mb=`; prefixes byte-identical.
- `GET /api/sync/status`: additive `rss_mb`/`rss_peak_mb` (`int|null`), keyed off the ONE `_sample_rss` (run start, phase transitions, forced terminals, 5 s-throttled hook), `peak >= current` invariant, anonymous 403.
- `sources.js` `fmtSyncPeakClause`: ` · peak <N> MB` trailing the counts line / "Sync cancelled", nothing when null, no new control/live region.
Gates (exact commands)
- `uv run pytest --cov=app --cov-report=term-missing` → **4570 passed**, TOTAL **99%**, `app/api/sync.py` **100%**, `app/core/memory.py` **100%**
- `uv run ruff check .` → clean · `uv run pyright` → **0 errors**
- `uv run pytest tests/e2e/test_sync_peak_memory_visible.py -v --no-cov` → **5 passed in isolation** (ran twice)
- All 27 touched E2E families re-run **in isolation**, all green (progress_box 8, cancel 3, status_freshness 4, summary_visibility 3, sync_button 3, session_reset 4, git_freeze 2, git_log_stream_dates 5, PDF/image suites, …)
Completion criteria: every checkbox ✅ except the commit + phase-dir move, which belong to the harness.
Documentation
- README reviewed in full (pages, commands, config tables cross-checked against `app/config.py` — 85 env rows, all defaults match; phase-187 ops section, greps, process-vs-cgroup caveat): consistent, no changes needed.
- `AGENTS.md`: **fixed one pre-existing inconsistency** — rule 6 claimed the no-CDN test covers "the index page"; it actually parametrizes 10 pages (all but `/doc-edit.html`).
Notable
- Two failures early in the pass were self-inflicted by running the E2E module concurrently with the full suite on the shared Postgres (TRUNCATE deadlock + one order-dependent `test_overview` flake); both vanish under the documented serial isolation — no code defect, no change made.
- **PLAN §9 still needs the owner's reconciliation** (phase 187 adds the sync memory fields + two status keys; PLAN is never agent-edited — AGENTS rule 10 carries the spec). `TODO.md` holds an unrelated owner note, left untouched.
Next pending phase: `188_chunks_hnsw_index`.
286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""Unit tests: ``app.core.memory`` — process RSS helpers (phase 187, task 01).
|
|
|
|
Every branch of the four helpers, with the filesystem stubbed through the
|
|
module's path constants (``PROC_STATUS_PATH`` / ``CGROUP_PEAK_PATH`` →
|
|
``tmp_path`` files, so ``open`` is never mocked globally) and ``resource``
|
|
stubbed through ``memory._resource`` (the guarded-import binding):
|
|
|
|
* the REAL ``/proc/self/status`` read on a Linux runner — ``rss_mb()`` is a
|
|
positive int and provably comes from the ``VmRSS`` parse, not the
|
|
fallback (the fallback is pinned to a sentinel that never surfaces);
|
|
* the kB→MB floor arithmetic (1500 kB → 1 MB);
|
|
* missing file, unparseable value, value-less ``VmRSS:`` line, and a file
|
|
without any ``VmRSS`` line — each falls back to ``rss_peak_mb()``;
|
|
* the non-Linux path: ``/proc`` open raises AND ``getrusage`` is patched →
|
|
the proxy value; both unavailable → ``None``;
|
|
* ``rss_peak_mb`` arithmetic, the no-``resource`` ``None``, and the
|
|
never-raise wrapper when ``getrusage`` itself raises;
|
|
* ``cgroup_peak_mb`` present (bytes→MB), absent, garbage, empty → the
|
|
latter three are ``None``;
|
|
* ``fmt_mb(None) == "?"``, ``fmt_mb(0) == "0"`` (0 is a value, not
|
|
missing), plain ints as digits;
|
|
* the purity contract: stdlib imports only — no ``app.`` import — and the
|
|
docstring carries the "Pure stdlib" line the phase completion criteria
|
|
require.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import importlib
|
|
import inspect
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
import app.core.memory as memory
|
|
|
|
|
|
#: Stub whose ``getrusage`` answers with a fixed ``ru_maxrss`` (kB on
|
|
#: Linux) so fallback assertions are deterministic.
|
|
def _fake_resource(maxrss_kb: int) -> Any:
|
|
return SimpleNamespace(
|
|
RUSAGE_SELF=0,
|
|
getrusage=lambda who: SimpleNamespace(ru_maxrss=maxrss_kb),
|
|
)
|
|
|
|
|
|
#: Stub whose ``getrusage`` raises — the never-raise contract probe.
|
|
def _raising_resource(exc: Exception) -> Any:
|
|
def getrusage(who: int) -> Any:
|
|
raise exc
|
|
|
|
return SimpleNamespace(RUSAGE_SELF=0, getrusage=getrusage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# rss_mb — the real /proc read (Linux) and the stubbed failure modes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="needs /proc/self/status")
|
|
def test_rss_mb_reads_real_vm_rss_on_linux(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# Pin the FALLBACK to a negative sentinel: a positive int can therefore
|
|
# only come from the real VmRSS parse, not from getrusage.
|
|
monkeypatch.setattr(memory, "rss_peak_mb", lambda: -1)
|
|
|
|
value = memory.rss_mb()
|
|
|
|
assert value is not None
|
|
assert isinstance(value, int)
|
|
assert value > 0
|
|
|
|
|
|
def test_rss_mb_parses_vm_rss_kb_flooring_to_mb(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
|
) -> None:
|
|
status = tmp_path / "status"
|
|
status.write_text("Name:\tpython\nVmRSS:\t 1500 kB\nVmSize:\t999999 kB\n")
|
|
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(status))
|
|
monkeypatch.setattr(memory, "_resource", _fake_resource(999_999))
|
|
|
|
assert memory.rss_mb() == 1 # 1500 kB // 1024 — integer floor, not round
|
|
|
|
|
|
def test_rss_mb_missing_file_falls_back_to_getrusage(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
|
) -> None:
|
|
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(tmp_path / "nope"))
|
|
monkeypatch.setattr(memory, "_resource", _fake_resource(9216)) # 9 MB
|
|
|
|
assert memory.rss_mb() == 9
|
|
|
|
|
|
def test_rss_mb_unparseable_value_falls_back(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
|
) -> None:
|
|
status = tmp_path / "status"
|
|
status.write_text("VmRSS:\tnot-a-number kB\n")
|
|
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(status))
|
|
monkeypatch.setattr(memory, "_resource", _fake_resource(2048))
|
|
|
|
assert memory.rss_mb() == 2
|
|
|
|
|
|
def test_rss_mb_value_less_line_falls_back(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
|
) -> None:
|
|
# "VmRSS:" with no value: the split()[1] raises IndexError internally,
|
|
# the suppress turns it into the rss_peak_mb() proxy.
|
|
status = tmp_path / "status"
|
|
status.write_text("VmRSS:\n")
|
|
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(status))
|
|
monkeypatch.setattr(memory, "_resource", _fake_resource(3072))
|
|
|
|
assert memory.rss_mb() == 3
|
|
|
|
|
|
def test_rss_mb_file_without_vm_rss_line_falls_back(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
|
) -> None:
|
|
status = tmp_path / "status"
|
|
status.write_text("Name:\tpython\nVmSize:\t999999 kB\nVmHWM:\t888888 kB\n")
|
|
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(status))
|
|
monkeypatch.setattr(memory, "_resource", _fake_resource(1024))
|
|
|
|
assert memory.rss_mb() == 1
|
|
|
|
|
|
def test_rss_mb_returns_none_when_proc_and_resource_both_unavailable(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
|
) -> None:
|
|
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(tmp_path / "nope"))
|
|
monkeypatch.setattr(memory, "_resource", None)
|
|
|
|
assert memory.rss_mb() is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# rss_peak_mb — arithmetic, the guarded import, the never-raise wrapper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("maxrss_kb", "expected_mb"),
|
|
[(1024, 1), (1023, 0), (1500, 1), (4_294_967_296, 4_194_304)],
|
|
)
|
|
def test_rss_peak_mb_arithmetic(
|
|
monkeypatch: pytest.MonkeyPatch, maxrss_kb: int, expected_mb: int
|
|
) -> None:
|
|
monkeypatch.setattr(memory, "_resource", _fake_resource(maxrss_kb))
|
|
|
|
assert memory.rss_peak_mb() == expected_mb
|
|
|
|
|
|
def test_rss_peak_mb_none_without_resource_module(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(memory, "_resource", None)
|
|
|
|
assert memory.rss_peak_mb() is None
|
|
|
|
|
|
def test_guarded_import_degrades_to_none_when_resource_is_missing(
|
|
tmp_path: Any,
|
|
) -> None:
|
|
# The guarded-import branch itself: a platform WITHOUT the Unix-only
|
|
# `resource` module must still import this module, and every helper
|
|
# must answer None (no /proc, no proxy). `sys.modules[name] = None`
|
|
# makes `import resource` raise ImportError, so re-executing the
|
|
# module exercises the except arm for real (no pragma).
|
|
saved = sys.modules.get("resource")
|
|
sys.modules["resource"] = None # type: ignore[assignment]
|
|
try:
|
|
importlib.reload(memory)
|
|
|
|
assert memory._resource is None
|
|
assert memory.rss_peak_mb() is None
|
|
memory.PROC_STATUS_PATH = str(tmp_path / "nope") # reset by reload
|
|
assert memory.rss_mb() is None
|
|
finally:
|
|
if saved is not None:
|
|
sys.modules["resource"] = saved
|
|
else: # pragma: no cover - non-Unix runners never had it
|
|
sys.modules.pop("resource", None)
|
|
importlib.reload(memory) # restore the real bindings for later tests
|
|
|
|
|
|
def test_module_reloads_with_real_resource_after_the_missing_module_probe() -> None:
|
|
# Sibling pin: the finally-reload above restored the module, so the
|
|
# guarded import took the happy arm again (order-independent: this
|
|
# only passes if _resource is the live module on this platform).
|
|
if not sys.platform.startswith("linux"):
|
|
pytest.skip("resource exists on Linux")
|
|
|
|
assert memory._resource is not None
|
|
|
|
|
|
def test_rss_peak_mb_never_raises_when_getrusage_raises(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(memory, "_resource", _raising_resource(OSError("boom")))
|
|
|
|
assert memory.rss_peak_mb() is None
|
|
|
|
|
|
def test_rss_peak_mb_real_call_on_linux_yields_positive_int() -> None:
|
|
if not sys.platform.startswith("linux"):
|
|
pytest.skip("resource.getrusage is the Linux deploy path")
|
|
value = memory.rss_peak_mb()
|
|
|
|
assert value is not None
|
|
assert isinstance(value, int)
|
|
assert value > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cgroup_peak_mb — present / absent / garbage / empty
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_cgroup_peak_mb_present_converts_bytes_to_mb(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
|
) -> None:
|
|
# 2,306,867,200 bytes == 2200 MiB — the incident box's ~2.16 GB of
|
|
# cgroup page-cache-inflated "peak" (module docstring caveat).
|
|
peak = tmp_path / "memory.peak"
|
|
peak.write_text("2306867200\n")
|
|
monkeypatch.setattr(memory, "CGROUP_PEAK_PATH", str(peak))
|
|
|
|
assert memory.cgroup_peak_mb() == 2200
|
|
|
|
|
|
def test_cgroup_peak_mb_absent_is_none(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
|
) -> None:
|
|
monkeypatch.setattr(memory, "CGROUP_PEAK_PATH", str(tmp_path / "memory.peak"))
|
|
|
|
assert memory.cgroup_peak_mb() is None
|
|
|
|
|
|
@pytest.mark.parametrize("body", ["max", "", " \n", "1048576 kB"])
|
|
def test_cgroup_peak_mb_garbage_is_none(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Any, body: str
|
|
) -> None:
|
|
peak = tmp_path / "memory.peak"
|
|
peak.write_text(body)
|
|
monkeypatch.setattr(memory, "CGROUP_PEAK_PATH", str(peak))
|
|
|
|
assert memory.cgroup_peak_mb() is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fmt_mb — the "?" rendering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fmt_mb_renders_none_as_question_mark_and_values_as_digits() -> None:
|
|
assert memory.fmt_mb(None) == "?"
|
|
assert memory.fmt_mb(0) == "0" # 0 is a VALUE, not missing
|
|
assert memory.fmt_mb(512) == "512"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Purity contract (completion criterion: stdlib only, no app. imports)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_module_imports_stdlib_only_and_declares_pure_stdlib() -> None:
|
|
tree = ast.parse(inspect.getsource(memory))
|
|
imported: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
imported.update(alias.name.split(".")[0] for alias in node.names)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
if node.level: # relative import — still inside the app package
|
|
imported.add("app")
|
|
elif node.module:
|
|
imported.add(node.module.split(".")[0])
|
|
|
|
assert "app" not in imported
|
|
# __future__, contextlib, types are static; `resource` is the guarded
|
|
# Unix-only stdlib import — everything must stay standard library.
|
|
assert imported <= {"__future__", "contextlib", "resource", "types"}, imported
|
|
assert "Pure stdlib" in (memory.__doc__ or "")
|