From ed434dafc470c85d0aad774ef0e02e880f16d896 Mon Sep 17 00:00:00 2001 From: Vortiago Date: Wed, 23 Sep 2026 08:17:18 +0200 Subject: [PATCH 1/4] test: pin that a merge-on-write never writes over a file it could not read (#446) RED contract for #446. A failed read (any OSError but the file being absent) at the four merge-on-write sites must raise and leave the file byte-identical: roster.record_occurrence, sessions.write_session_meta, the voice-mapping route's own read, and absorb_session on both sides. The lenient read paths stay lenient, and a torn file still reads as empty. --- tests/test_merge_on_write_strict_read.py | 306 +++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 tests/test_merge_on_write_strict_read.py diff --git a/tests/test_merge_on_write_strict_read.py b/tests/test_merge_on_write_strict_read.py new file mode 100644 index 00000000..0cb21f98 --- /dev/null +++ b/tests/test_merge_on_write_strict_read.py @@ -0,0 +1,306 @@ +"""A merge-on-write never writes over a file it could not read. + +Every site here reads a JSON file, merges a partial update into what it read, +and writes the whole file back. When the read swallows every `OSError`, "I +could not read this file" looks the same as "there is nothing here": the merge +starts from `{}` and the write replaces the file with the partial update. One +transient read failure (EACCES, EIO, EMFILE: anything short of the file being +absent) then silently discards the rest of the file. + +The rule each site keeps: + + * A read that FAILS (any `OSError` except the file being absent) makes the + write path RAISE, and the file on disk stays byte-identical. + * A file that is absent, or torn (not valid JSON), still reads as empty, so + the write proceeds. That is recovery, not loss. + * The READ paths that feed the poll stay lenient: an unreadable file reads as + empty there and never raises, so a bad file cannot crash a tick. + +The fault is injected at `open` itself (`builtins.open` and `io.open`, which +`Path.read_text` uses), keyed on the one file's real path, so the rule holds +whatever read primitive a site uses. One rung also uses a real `chmod 000`. +""" + +from __future__ import annotations + +import builtins +import errno +import io +import json +import os +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from wav_builders import seed_session # type: ignore[import-not-found] + +from tapscribe import roster, session_maintenance, voices +from tapscribe.app import app, get_recorder +from tapscribe.session_paths import FILENAME_META_JSON, FILENAME_ROSTER_JSON +from tapscribe.sessions import read_session_meta, write_session_meta +from tapscribe.tap_mode import TAP_MODE_MULTI + +# One of each read failure the lenient reads used to swallow. FileNotFoundError +# is deliberately absent: an absent file IS "nothing here". +READ_FAILURES = [ + pytest.param(PermissionError(errno.EACCES, "Permission denied"), id="EACCES"), + pytest.param(OSError(errno.EIO, "Input/output error"), id="EIO"), + pytest.param(OSError(errno.EMFILE, "Too many open files"), id="EMFILE"), +] + +ALICE_WAV = "2026-01-01T00-00-00Z_Alice_Andersen_sc-alice-_aaaaaaaa.wav" +BOB_WAV = "2026-01-01T01-00-00Z_Bob_Bergman_sc-bob-us_bbbbbbbb.wav" +ALICE_IDENTITY = "sc-alice-9f8e7d6c5b4a3210" +BOB_IDENTITY = "sc-bob-user-0011223344556677" +T0 = datetime(2026, 1, 1, 1, 0, 0, tzinfo=UTC) + + +@contextmanager +def failing_reads(target: Path, exc: OSError, times: int | None = None) -> Iterator[None]: + """Make every read-mode `open` of `target` raise `exc` (only the first + `times` of them, when given). Writes, and every other file, pass through.""" + real_open = builtins.open + target_real = os.path.realpath(target) + left = [times] + + def fake_open(file, mode="r", *args, **kwargs): # type: ignore[no-untyped-def] + is_read = not any(flag in mode for flag in "wax+") + is_target = isinstance(file, (str, bytes, os.PathLike)) and os.path.realpath(file) == target_real + if is_read and is_target and (left[0] is None or left[0] > 0): + if left[0] is not None: + left[0] -= 1 + raise exc + return real_open(file, mode, *args, **kwargs) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(builtins, "open", fake_open) + mp.setattr(io, "open", fake_open) + yield + + +@pytest.fixture +def rec_root(recorder_under_test) -> Path: + """`recorder_under_test` (tests/conftest.py) points `config.RECORDINGS_DIR` + at a tmpdir, which the session resolvers read.""" + return Path(recorder_under_test.recordings_dir) + + +# --------------------------------------------------------------------------- +# roster.record_occurrence +# --------------------------------------------------------------------------- + + +def _seed_roster(rec_root: Path) -> Path: + session_dir = seed_session(rec_root, "s", [ALICE_WAV]) + roster.record_occurrence( + session_dir, + identity=ALICE_IDENTITY, + name="Alice Andersen", + recorded=True, + wav=ALICE_WAV, + mode=TAP_MODE_MULTI, + ) + return session_dir + + +@pytest.mark.parametrize("exc", READ_FAILURES) +def test_record_occurrence_refuses_to_write_over_an_unreadable_roster(rec_root: Path, exc: OSError) -> None: + session_dir = _seed_roster(rec_root) + path = session_dir / FILENAME_ROSTER_JSON + before = path.read_bytes() + + with failing_reads(path, exc), pytest.raises(OSError): + roster.record_occurrence( + session_dir, identity=BOB_IDENTITY, name="Bob Bergman", recorded=True, wav=BOB_WAV + ) + + assert path.read_bytes() == before, "a failed read must not become an overwrite of every other identity" + + +@pytest.mark.skipif( + os.name != "posix" or os.geteuid() == 0, reason="needs a non-root POSIX user for chmod to bite" +) +def test_record_occurrence_refuses_a_roster_it_has_no_permission_to_read(rec_root: Path) -> None: + session_dir = _seed_roster(rec_root) + path = session_dir / FILENAME_ROSTER_JSON + before = path.read_bytes() + path.chmod(0o000) + try: + with pytest.raises(OSError): + roster.record_occurrence( + session_dir, identity=BOB_IDENTITY, name="Bob Bergman", recorded=True, wav=BOB_WAV + ) + finally: + path.chmod(0o644) + assert path.read_bytes() == before + + +@pytest.mark.parametrize("exc", READ_FAILURES) +def test_read_roster_stays_lenient_on_an_unreadable_roster(rec_root: Path, exc: OSError) -> None: + session_dir = _seed_roster(rec_root) + + with failing_reads(session_dir / FILENAME_ROSTER_JSON, exc): + assert roster.read_roster(session_dir) == {} + + +def test_record_occurrence_over_a_torn_roster_still_records(rec_root: Path) -> None: + session_dir = seed_session(rec_root, "s", [BOB_WAV]) + (session_dir / FILENAME_ROSTER_JSON).write_text("{not json", encoding="utf-8") + + roster.record_occurrence( + session_dir, identity=BOB_IDENTITY, name="Bob Bergman", recorded=True, wav=BOB_WAV + ) + + assert set(roster.read_roster(session_dir)) == {BOB_IDENTITY} + + +# --------------------------------------------------------------------------- +# sessions.write_session_meta +# --------------------------------------------------------------------------- + + +def _seed_meta(rec_root: Path) -> Path: + session_dir = seed_session(rec_root, "s", [ALICE_WAV]) + write_session_meta( + "s", {"label": "Weekly sync", "aliases": {"Alice_Andersen": "Alice"}, "hotwords": "TapScribe"} + ) + return session_dir / FILENAME_META_JSON + + +@pytest.mark.parametrize("exc", READ_FAILURES) +def test_write_session_meta_refuses_to_write_over_an_unreadable_meta(rec_root: Path, exc: OSError) -> None: + path = _seed_meta(rec_root) + before = path.read_bytes() + + with failing_reads(path, exc), pytest.raises(OSError): + write_session_meta("s", {"prompt": "Discuss the roadmap"}) + + assert path.read_bytes() == before, "a failed read must not become an overwrite of the label and aliases" + + +@pytest.mark.parametrize("exc", READ_FAILURES) +def test_read_session_meta_stays_lenient_on_an_unreadable_meta(rec_root: Path, exc: OSError) -> None: + path = _seed_meta(rec_root) + + with failing_reads(path, exc): + assert read_session_meta("s") == {} + + +def test_write_session_meta_over_a_torn_meta_still_writes(rec_root: Path) -> None: + session_dir = seed_session(rec_root, "s", []) + (session_dir / FILENAME_META_JSON).write_text("{not json", encoding="utf-8") + + write_session_meta("s", {"label": "Recovered"}) + + assert read_session_meta("s")["label"] == "Recovered" + + +# --------------------------------------------------------------------------- +# PUT /api/sessions/{session}/voices: its OWN read of the voices map +# --------------------------------------------------------------------------- + + +@pytest.fixture +def client(recorder_under_test): + app.dependency_overrides[get_recorder] = lambda: recorder_under_test + app.state.recorder = recorder_under_test + with TestClient(app, raise_server_exceptions=False) as c: + yield c + app.dependency_overrides.clear() + + +@pytest.fixture +def diarized(recorder_under_test) -> Path: + """One multi-person tap with two Voices, as a finished diarization leaves it.""" + session_dir = seed_session( + recorder_under_test.recordings_dir, + "s", + [f"{T0.strftime('%Y-%m-%dT%H-%M-%SZ')}_them_sysaudio_0000abcd.wav"], + ) + (session_dir / FILENAME_ROSTER_JSON).write_text( + json.dumps( + { + "sysaudio": { + "name": "Them", + "source": "recorded", + "slug": "them", + "wavs": [], + "mode": TAP_MODE_MULTI, + } + } + ), + encoding="utf-8", + ) + voices.record_voices( + session_dir, + identity="sysaudio", + run_id="run-1", + spans={ + "A": [(T0, T0 + timedelta(seconds=30))], + "B": [(T0 + timedelta(seconds=30), T0 + timedelta(seconds=45))], + }, + ) + return session_dir + + +@pytest.mark.parametrize("exc", READ_FAILURES) +def test_mapping_a_voice_over_a_transiently_unreadable_meta_keeps_the_other_mappings( + client: TestClient, diarized: Path, exc: OSError +) -> None: + """The route reads the voices map, edits one key, and writes the map back. + A failure on ITS read alone (the meta is readable again a moment later) + must not write back a map holding only the new key.""" + assert client.put("/api/sessions/s/voices", json={"key": "sysaudio#A", "name": "Dana"}).status_code == 200 + path = diarized / FILENAME_META_JSON + before = path.read_bytes() + + with failing_reads(path, exc, times=1): + client.put("/api/sessions/s/voices", json={"key": "sysaudio#B", "name": "Robin"}) + + assert "sysaudio#A" in (read_session_meta("s").get("voices") or {}), ( + "Voice A's mapping to Dana was discarded" + ) + after = path.read_bytes() + assert after == before or "sysaudio#B" in json.loads(after).get("voices", {}), ( + "the meta must either stay as it was or carry BOTH mappings" + ) + + +# --------------------------------------------------------------------------- +# session_maintenance.absorb_session: the target's and the source's reads +# --------------------------------------------------------------------------- + + +def _seed_absorb(rec_root: Path) -> tuple[Path, Path]: + target = seed_session(rec_root, "tgt", [ALICE_WAV]) + source = seed_session(rec_root, "src", [BOB_WAV]) + roster.record_occurrence( + target, identity=ALICE_IDENTITY, name="Alice Andersen", recorded=True, wav=ALICE_WAV + ) + roster.record_occurrence(source, identity=BOB_IDENTITY, name="Bob Bergman", recorded=True, wav=BOB_WAV) + write_session_meta("tgt", {"label": "Weekly sync", "aliases": {"Alice_Andersen": "Alice"}}) + write_session_meta("src", {"aliases": {"Bob_Bergman": "Bob"}}) + return target, source + + +@pytest.mark.parametrize("exc", READ_FAILURES) +@pytest.mark.parametrize("side", ["tgt", "src"]) +@pytest.mark.parametrize("filename", [FILENAME_ROSTER_JSON, FILENAME_META_JSON]) +def test_absorb_refuses_to_fold_a_file_it_could_not_read( + rec_root: Path, exc: OSError, side: str, filename: str +) -> None: + """Absorb folds both sessions' roster and meta into the target and then + deletes the source folder. A read failure on either side must stop it + before the unread file is overwritten or the source is deleted.""" + target, source = _seed_absorb(rec_root) + path = (target if side == "tgt" else source) / filename + before = path.read_bytes() + + with failing_reads(path, exc), pytest.raises(OSError): + session_maintenance.absorb_session("tgt", "src") + + assert source.is_dir(), "the source folder was deleted over a file absorb could not read" + assert path.read_bytes() == before, f"the {side} {filename} was overwritten after a failed read" From a5a5d937c9cef22df909f235a66949e940d11533 Mon Sep 17 00:00:00 2001 From: Vortiago Date: Wed, 23 Sep 2026 13:32:04 +0200 Subject: [PATCH 2/4] chore: ralph(446-qwen) green progress at iteration 1 model=koishi/qwen3.8-flash-next-mtp gate='python3 -m pytest tests/test_merge_on_write_strict_read.py tests/test_roster.py tests/test_session_maintenance_absorb.py tests/test_routes_diarize.py tests/test_voices.py && ruff check tapscribe tests && ruff format --check tapscribe tests' --- tapscribe/roster.py | 28 +++++++---- tapscribe/routes/people.py | 6 ++- tapscribe/session_maintenance.py | 17 ++++--- tapscribe/sessions.py | 80 ++++++++++++++++++++++---------- 4 files changed, 88 insertions(+), 43 deletions(-) diff --git a/tapscribe/roster.py b/tapscribe/roster.py index 14ec21f5..e7f19fd3 100644 --- a/tapscribe/roster.py +++ b/tapscribe/roster.py @@ -107,8 +107,8 @@ def _coerce_entry(value: Any) -> dict[str, Any] | None: def coerce_roster(raw: Any) -> dict[str, dict[str, Any]]: """Coerce a raw parsed roster mapping into `{full identity: entry}`, dropping non-str identities and non-dict entries (and per-field junk, via - `_coerce_entry`). Non-dict top level → `{}`. Shared by `read_roster` (the - uncached write-path reader) and the cached poll path + `_coerce_entry`). Non-dict top level → `{}`. Shared by `load_roster` (the + strict write-path reader) and the cached poll path (`sessions._read_roster_cached`) so both produce the identical shape.""" if not isinstance(raw, dict): return {} @@ -138,20 +138,28 @@ def slug_owners(roster: Mapping[str, Any]) -> dict[str, set[str]]: return owners -def read_roster(session_dir: Path) -> dict[str, dict[str, Any]]: - """The session's roster as `{full identity: entry}`. Missing, torn, or - non-dict top level → `{}` so a single bad file never crashes the poll.""" +def load_roster(session_dir: Path) -> dict[str, dict[str, Any]]: + """The session's roster, parsed and coerced. Missing or torn → `{}`; every + OTHER `OSError` RAISES — the distinction a read-modify-write needs and a + display read does not (#446).""" path = session_dir / FILENAME_ROSTER_JSON try: data = json.loads(path.read_text(encoding="utf-8")) - except (OSError, ValueError): - # Missing file (OSError) or torn/garbage JSON (ValueError): the roster - # is best-effort durable state, recovered on the next occurrence — a - # read failure must degrade to "no roster", never propagate. + except (FileNotFoundError, ValueError): return {} return coerce_roster(data) +def read_roster(session_dir: Path) -> dict[str, dict[str, Any]]: + """Lenient display/poll reader: the session's roster as `{full identity: + entry}`, unreadable for ANY reason → `{}` so a bad file never crashes the + poll. Read-modify-write callers use `load_roster`.""" + try: + return load_roster(session_dir) + except OSError: + return {} + + def record_occurrence( session_dir: Path, *, @@ -171,7 +179,7 @@ def record_occurrence( `sanitise_name` HERE — the one seam where it becomes durable state.""" if not identity: return - roster = read_roster(session_dir) + roster = load_roster(session_dir) entry = roster.get(identity) or { "name": "", "source": "live", diff --git a/tapscribe/routes/people.py b/tapscribe/routes/people.py index 9241760c..b5f54aa1 100644 --- a/tapscribe/routes/people.py +++ b/tapscribe/routes/people.py @@ -41,7 +41,7 @@ from ..session_paths import resolve_session_dir from ..sessions import ( gather_sessions, - read_session_meta, + load_session_meta, repoint_voice_person, write_session_meta, ) @@ -166,7 +166,9 @@ async def api_session_voice_mapping(session: str, req: Request, recorder: Record else: person_id = "" - mapping = dict(read_session_meta(session).get("voices") or {}) + # The route's own read is the base of a whole-map write, so it must fail + # rather than start from `{}` (#446). + mapping = dict(load_session_meta(session).get("voices") or {}) if person_id: mapping[key] = {"person_id": person_id, "run_id": entry["run_id"]} else: diff --git a/tapscribe/session_maintenance.py b/tapscribe/session_maintenance.py index f1f8c132..fab6f6cb 100644 --- a/tapscribe/session_maintenance.py +++ b/tapscribe/session_maintenance.py @@ -32,7 +32,7 @@ import tapscribe.voices as voices from . import config, tap_registry -from .roster import read_roster +from .roster import load_roster from .session_paths import ( DIRNAME_STRIPPED, FILENAME_ROSTER_JSON, @@ -43,7 +43,7 @@ resolve_wav, stripped_dir, ) -from .sessions import read_session_meta, write_session_meta +from .sessions import load_session_meta, read_session_meta, write_session_meta from .tap_mode import TAP_MODE_MULTI # Re-exported from tap_registry (the canonical home, #405). These names must @@ -323,6 +323,15 @@ def absorb_session(target: str, source: str) -> dict[str, Any]: if collisions: raise AbsorbCollision(f"filename collision(s) between sessions: {', '.join(collisions[:5])}") + # Fold inputs are read strictly BEFORE any destructive step: a file absorb + # cannot read stops the whole operation, not just its leg (#446). Nothing + # between here and the fold below writes meta or roster, so the happy path + # reads the same bytes the in-place reads used to. + tgt_meta = load_session_meta(target) + src_meta = load_session_meta(source) + src_roster = load_roster(source_dir) + tgt_roster = load_roster(target_dir) if src_roster else {} + moved_wavs: list[str] = [] moved_stripped: list[str] = [] @@ -367,8 +376,6 @@ def absorb_session(target: str, source: str) -> dict[str, Any]: # Merge speaker aliases. Target wins on conflict; source fills in keys # the target doesn't already have. Target's label is preserved as-is. - tgt_meta = read_session_meta(target) - src_meta = read_session_meta(source) src_aliases = src_meta.get("aliases") or {} tgt_aliases = dict(tgt_meta.get("aliases") or {}) aliases_added: list[str] = [] @@ -414,10 +421,8 @@ def _lives(key: str) -> bool: # the scalar fields — EXCEPT `wavs`, which unions: the source's WAVs now # physically live in the target, so the target's entry has to account for # them. - src_roster = read_roster(source_dir) roster_merged = 0 if src_roster: - tgt_roster = read_roster(target_dir) for identity, src_entry in src_roster.items(): tgt_entry = tgt_roster.get(identity) if tgt_entry is None: diff --git a/tapscribe/sessions.py b/tapscribe/sessions.py index 6145f8e9..18879bce 100644 --- a/tapscribe/sessions.py +++ b/tapscribe/sessions.py @@ -25,6 +25,7 @@ import os import os.path import re +import stat from collections.abc import Iterable from datetime import UTC, datetime, timedelta from pathlib import Path @@ -148,17 +149,28 @@ def _coerce_session_meta(raw: Any) -> dict[str, Any]: def _read_roster_cached(sd: Path) -> dict[str, dict[str, Any]]: """session-roster.json through the stat-sig cache, coerced via the shared - `roster.coerce_roster` so the cached poll path and the uncached `read_roster` + `roster.coerce_roster` so the cached poll path and the uncached `load_roster` write path produce the identical shape. {} on None/non-dict.""" return coerce_roster(_read_session_json_cached(sd / FILENAME_ROSTER_JSON)) +def load_session_meta(session: str) -> dict[str, Any]: + """The per-session meta, read STRICTLY for merge-on-write callers: a failed + read RAISES rather than reading as `{}`, so a write never starts from empty + over a file it merely could not see (#446). Missing or torn → `{}`.""" + return _coerce_session_meta(_read_json_strict(session_meta_path(session))) + + def read_session_meta(session: str) -> dict[str, Any]: """Return the per-session metadata dict: operator-editable display label, speaker aliases, and per-session batch prompt/hotwords overrides. Missing or unreadable → {} (caller can treat as no - overrides). Non-string fields are dropped silently.""" - return _coerce_session_meta(_read_json_or_none(session_meta_path(session))) + overrides). Non-string fields are dropped silently. Merge-on-write + callers use `load_session_meta`, which raises on a failed read.""" + try: + return load_session_meta(session) + except OSError: + return {} def write_session_meta(session: str, meta: dict[str, Any]) -> None: @@ -173,9 +185,11 @@ def write_session_meta(session: str, meta: dict[str, Any]) -> None: Atomic via `atomic_write_text` so a crashed write never leaves a torn JSON file (which `_read_json_or_none` would silently swallow, - losing the operator's label + aliases + overrides all at once).""" + losing the operator's label + aliases + overrides all at once). The base + read is strict (`load_session_meta`), so a failed read RAISES instead of + merging onto `{}` and overwriting the fields it could not see (#446).""" session_dir = create_session_dir(session) - existing = read_session_meta(session) + existing = load_session_meta(session) allowed = {"aliases", "languages", "voices", *_META_STRING_FIELDS} merged = {**existing, **{k: v for k, v in meta.items() if k in allowed}} sanitized = {k: merged[k] if isinstance(merged.get(k), str) else "" for k in _META_STRING_FIELDS} @@ -401,36 +415,52 @@ def read_wav_strip_meta(session: str, name: str) -> dict[str, Any] | None: # --------------------------------------------------------------------------- -def _read_json_or_none(path: Path) -> Any: - """Parse `path` as JSON. Returns None when the file is missing, - unparseable, or sits outside RECORDINGS_DIR — `gather_sessions` - tolerates per-WAV transcripts going stale without breaking the - dashboard listing. - - The containment check is defense-in-depth: every caller already - passes a path derived from a validated session, but this second - layer makes the safety property local and visible to static - analysis, so a future refactor that bypasses the route-level - validation can't silently leak the function as an arbitrary - file-reader.""" - # Inline the realpath + startswith sanitiser (canonical CodeQL - # `py/path-injection` form) so taint analysis sees the check at the - # point of file access. Use the realpath string `real` directly in - # subsequent os.path.* and open() calls — CodeQL flows the sanitiser - # property through the `real` variable but not through a re-wrapped Path. +def _read_json_strict(path: Path) -> Any: + """Parse `path` as JSON, separating "nothing here" from "I could not read + it": missing or torn (not valid JSON) → None, but every OTHER `OSError` + (EACCES, EIO, EMFILE) RAISES, so a read-modify-write cannot merge onto `{}` + over a file it merely failed to see (#446). A path outside RECORDINGS_DIR → + None. + + The containment check is inlined at the point of file access (the canonical + CodeQL `py/path-injection` form): the realpath string `real` flows straight + into `os.stat` and `open`, so taint analysis sees the guard there. Every + caller already passes a `session_paths`-resolved dir, so it is defense-in- + depth, but keeping it local means a refactor that bypasses route-level + validation can't silently leak this as an arbitrary file-reader.""" root = os.path.realpath(config.RECORDINGS_DIR) try: real = os.path.realpath(path) - except (OSError, ValueError): + except ValueError: + # An embedded NUL makes the path malformed, not unreadable. return None if real != root and not real.startswith(root + os.sep): return None - if not os.path.isfile(real): + try: + st = os.stat(real) + except FileNotFoundError: + return None + if not stat.S_ISREG(st.st_mode): + # A directory or FIFO is not a JSON file; answer "nothing here" as the + # old `isfile` fast path did, so a FIFO can never block the poll. return None try: with open(real, encoding="utf-8") as fh: return json.load(fh) - except (OSError, ValueError): + except ValueError: + return None + + +def _read_json_or_none(path: Path) -> Any: + """`_read_json_strict` made lenient: any `OSError` → None. This is the + primitive the poll funnel (`_read_session_json_cached`) and + `repoint_voice_person`'s walk call use — a bad file must degrade to None, + never crash a tick. Returns None when the file is missing, unparseable, or + sits outside RECORDINGS_DIR. The containment guard lives at the strict sink + above.""" + try: + return _read_json_strict(path) + except OSError: return None From 15ee5d3acada59054405091a856dc8ad5bdd941f Mon Sep 17 00:00:00 2001 From: Vortiago Date: Wed, 23 Sep 2026 18:43:53 +0200 Subject: [PATCH 3/4] chore: ralph(446-qwen) green progress at iteration 2 model=koishi/qwen3.8-flash-next-mtp gate='python3 -m pytest tests/test_merge_on_write_strict_read.py tests/test_roster.py tests/test_session_maintenance_absorb.py tests/test_routes_diarize.py tests/test_voices.py && ruff check tapscribe tests && ruff format --check tapscribe tests' --- tapscribe/sessions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tapscribe/sessions.py b/tapscribe/sessions.py index 18879bce..5363638f 100644 --- a/tapscribe/sessions.py +++ b/tapscribe/sessions.py @@ -128,7 +128,7 @@ def _coerce_voices(value: Any) -> dict[str, dict[str, str]]: def _coerce_session_meta(raw: Any) -> dict[str, Any]: """Coerce a raw session-meta dict into the standard shape: string-field projection, alias coercion, language normalisation. Shared by - `read_session_meta` (the uncached write-path caller) and the cached + `load_session_meta` (the strict write-path caller) and the cached path in `_describe_session` so both produce the identical result.""" if not isinstance(raw, dict): return {} From dfdbad28021f0b4d69bc8c7a68e81e4c48105090 Mon Sep 17 00:00:00 2001 From: Vortiago Date: Wed, 23 Sep 2026 19:17:18 +0200 Subject: [PATCH 4/4] fix: read strictly before a merge-on-write so a failed read cannot wipe the file (#446) A merge-on-write over a lenient read treated any OSError as an empty file. One transient read failure then replaced a roster or a session's meta with the caller's partial update. The merge paths now use a strict loader that raises on every OSError other than a missing file, and the public readers stay lenient. Applies the code-review and simplify fixes on top of the ralph iterations. --- tapscribe/config_store.py | 21 ++++ tapscribe/roster.py | 15 ++- tapscribe/routes/people.py | 2 - tapscribe/session_maintenance.py | 27 +++--- tapscribe/sessions.py | 61 +++++------- tapscribe/strip_meta.py | 34 ++++--- tapscribe/voices.py | 22 ++--- tests/e2e/test_dashboard_ui.py | 4 +- tests/test_merge_on_write_strict_read.py | 117 +++++++++++++++++++++-- 9 files changed, 206 insertions(+), 97 deletions(-) diff --git a/tapscribe/config_store.py b/tapscribe/config_store.py index 8d862404..0b6a9e8e 100644 --- a/tapscribe/config_store.py +++ b/tapscribe/config_store.py @@ -15,6 +15,7 @@ import json import os import re +import stat import tempfile from collections.abc import Callable from dataclasses import dataclass @@ -322,6 +323,26 @@ def validate_config_text(content: str) -> str: return content +def read_json_strict(path: str | os.PathLike[str]) -> Any: + """Parse `path` as JSON, or None when nothing readable is there: the file + is absent, is not a regular file, or is torn. Every other `OSError` + (EACCES, EIO, EMFILE) raises, so a read-modify-write can tell "nothing + here" from "I could not read it" (#446).""" + try: + st = os.stat(path) + except (FileNotFoundError, NotADirectoryError): + return None + if not stat.S_ISREG(st.st_mode): + # A directory is not a JSON file, and opening a FIFO blocks the caller. + return None + try: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + except (FileNotFoundError, NotADirectoryError, ValueError): + # Removed between the stat and the open, or torn: absent, not unreadable. + return None + + def atomic_write_text(path: Path, content: str) -> None: """Write `content` to `path` via tempfile + os.replace so a crashed write never leaves a half-written file on disk. Caller is responsible diff --git a/tapscribe/roster.py b/tapscribe/roster.py index e7f19fd3..68ba3aad 100644 --- a/tapscribe/roster.py +++ b/tapscribe/roster.py @@ -36,6 +36,7 @@ from pathlib import Path from typing import Any +from .config_store import read_json_strict from .session_paths import FILENAME_ROSTER_JSON from .tap_mode import TAP_MODE_MULTI, TAP_MODE_SINGLE, is_mode from .text import atomic_write_text, parse_wav_speaker_slug @@ -139,15 +140,11 @@ def slug_owners(roster: Mapping[str, Any]) -> dict[str, set[str]]: def load_roster(session_dir: Path) -> dict[str, dict[str, Any]]: - """The session's roster, parsed and coerced. Missing or torn → `{}`; every - OTHER `OSError` RAISES — the distinction a read-modify-write needs and a - display read does not (#446).""" - path = session_dir / FILENAME_ROSTER_JSON - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, ValueError): - return {} - return coerce_roster(data) + """The session's roster, parsed and coerced, for a read-modify-write. An + absent or torn file reads as `{}`, because the next occurrence rebuilds it. + Any other `OSError` raises, so the write never replaces a roster it could + not read (#446).""" + return coerce_roster(read_json_strict(session_dir / FILENAME_ROSTER_JSON)) def read_roster(session_dir: Path) -> dict[str, dict[str, Any]]: diff --git a/tapscribe/routes/people.py b/tapscribe/routes/people.py index b5f54aa1..d5d785ed 100644 --- a/tapscribe/routes/people.py +++ b/tapscribe/routes/people.py @@ -166,8 +166,6 @@ async def api_session_voice_mapping(session: str, req: Request, recorder: Record else: person_id = "" - # The route's own read is the base of a whole-map write, so it must fail - # rather than start from `{}` (#446). mapping = dict(load_session_meta(session).get("voices") or {}) if person_id: mapping[key] = {"person_id": person_id, "run_id": entry["run_id"]} diff --git a/tapscribe/session_maintenance.py b/tapscribe/session_maintenance.py index fab6f6cb..b7ccc684 100644 --- a/tapscribe/session_maintenance.py +++ b/tapscribe/session_maintenance.py @@ -43,7 +43,7 @@ resolve_wav, stripped_dir, ) -from .sessions import load_session_meta, read_session_meta, write_session_meta +from .sessions import load_session_meta, write_session_meta from .tap_mode import TAP_MODE_MULTI # Re-exported from tap_registry (the canonical home, #405). These names must @@ -124,9 +124,12 @@ def session_is_empty(session_dir: Path) -> bool: return False if (session_dir / FILENAME_TRANSCRIPT_JSON).exists(): return False - if read_session_meta(session_dir.name).get("label"): + try: + meta = load_session_meta(session_dir.name) + except OSError: + # A meta this cannot read may hold a label, and prune deletes on True. return False - return True + return not meta.get("label") def _iter_candidate_session_dirs(current_session: str) -> Iterator[Path]: @@ -323,14 +326,17 @@ def absorb_session(target: str, source: str) -> dict[str, Any]: if collisions: raise AbsorbCollision(f"filename collision(s) between sessions: {', '.join(collisions[:5])}") - # Fold inputs are read strictly BEFORE any destructive step: a file absorb - # cannot read stops the whole operation, not just its leg (#446). Nothing - # between here and the fold below writes meta or roster, so the happy path - # reads the same bytes the in-place reads used to. + # Every fold input is read strictly, and only here, before the first move: + # a file absorb cannot read stops it before anything moves, and no read + # after the moves can leave the merge half-applied (#446). tgt_meta = load_session_meta(target) src_meta = load_session_meta(source) src_roster = load_roster(source_dir) - tgt_roster = load_roster(target_dir) if src_roster else {} + tgt_roster = load_roster(target_dir) + src_voices = voices.load_voices(source_dir) + tgt_voices = voices.load_voices(target_dir) + src_strip_meta = strip_meta.load_strip_meta(src_stripped_dir) + tgt_strip_meta = strip_meta.load_strip_meta(tgt_stripped_dir) moved_wavs: list[str] = [] moved_stripped: list[str] = [] @@ -358,9 +364,7 @@ def absorb_session(target: str, source: str) -> dict[str, Any]: # clash; target wins anyway). Knobs/stripped_at keep the TARGET's # values when both sides have a meta — they describe the target's # own last run; a target without a meta adopts the source's wholesale. - src_strip_meta = strip_meta.read_strip_meta(src_stripped_dir) if src_strip_meta is not None: - tgt_strip_meta = strip_meta.read_strip_meta(tgt_stripped_dir) if tgt_strip_meta is not None: tgt_strip_meta["files"] = {**src_strip_meta["files"], **tgt_strip_meta["files"]} strip_meta.write_strip_meta(tgt_stripped_dir, tgt_strip_meta or src_strip_meta) @@ -368,8 +372,6 @@ def absorb_session(target: str, source: str) -> dict[str, Any]: # Carry the source's Voices across, or the rmtree below destroys them while # the WAVs they describe live on in the target. An identity on both sides is # dropped from both: each session's Voice `A` is a different human (ADR-0021). - src_voices = voices.read_voices(source_dir) - tgt_voices = voices.read_voices(target_dir) voices_merged, collided = voices.fold_voices(tgt_voices, src_voices) if src_voices: voices.write_voices(target_dir, voices_merged) @@ -406,6 +408,7 @@ def _lives(key: str) -> bool: "aliases": tgt_aliases, "voices": tgt_voice_map, }, + base=tgt_meta, ) # Carry the source's Roster into the target's. The Roster is the ONLY diff --git a/tapscribe/sessions.py b/tapscribe/sessions.py index 5363638f..e01e3286 100644 --- a/tapscribe/sessions.py +++ b/tapscribe/sessions.py @@ -25,7 +25,6 @@ import os import os.path import re -import stat from collections.abc import Iterable from datetime import UTC, datetime, timedelta from pathlib import Path @@ -36,6 +35,7 @@ from . import config from .audio import wav_duration_s +from .config_store import read_json_strict from .name_resolution import DEFAULT_KNOWN_NAMES_LIMIT, known_names_from, resolve_session_names from .people import PeopleRegistry from .roster import coerce_roster, read_roster @@ -173,7 +173,7 @@ def read_session_meta(session: str) -> dict[str, Any]: return {} -def write_session_meta(session: str, meta: dict[str, Any]) -> None: +def write_session_meta(session: str, meta: dict[str, Any], *, base: dict[str, Any] | None = None) -> None: """Persist the per-session meta. Partial updates (e.g. only `{"prompt": "..."}`) preserve existing fields the caller didn't mention — otherwise editing one field would clear the others. @@ -187,9 +187,13 @@ def write_session_meta(session: str, meta: dict[str, Any]) -> None: torn JSON file (which `_read_json_or_none` would silently swallow, losing the operator's label + aliases + overrides all at once). The base read is strict (`load_session_meta`), so a failed read RAISES instead of - merging onto `{}` and overwriting the fields it could not see (#446).""" + merging onto `{}` and overwriting the fields it could not see (#446). + + `base` is a meta the caller already read with `load_session_meta`. Absorb + passes it, so no read runs after its WAV moves and a read fault cannot + leave the merge half-applied.""" session_dir = create_session_dir(session) - existing = load_session_meta(session) + existing = load_session_meta(session) if base is None else base allowed = {"aliases", "languages", "voices", *_META_STRING_FIELDS} merged = {**existing, **{k: v for k, v in meta.items() if k in allowed}} sanitized = {k: merged[k] if isinstance(merged.get(k), str) else "" for k in _META_STRING_FIELDS} @@ -243,7 +247,9 @@ def repoint_voice_person(old_person_id: str, new_person_id: str) -> list[str]: for sd in sorted(config.RECORDINGS_DIR.glob("*")): if not sd.is_dir(): continue - raw = _read_json_or_none(sd / FILENAME_META_JSON) + # Strict: a meta this cannot read may name the old id, and the caller + # deletes that Person next, so an unreadable meta aborts the merge. + raw = _read_json_strict(sd / FILENAME_META_JSON) mapping = _coerce_voices(raw.get("voices") if isinstance(raw, dict) else None) if not any(entry["person_id"] == old_person_id for entry in mapping.values()): continue @@ -344,8 +350,8 @@ def read_session_transcript(session: str) -> dict[str, Any] | None: session has no merged transcript. Backs `GET /api/sessions/{session}/ transcript`. `session` is validated against path traversal by `resolve_session_dir` (the canonical CodeQL realpath sanitiser); the file - is read through `_read_json_or_none`, which re-checks containment so static - analysis sees the guard at the point of file access.""" + is read through `_read_json_or_none`, whose strict core re-checks containment + so static analysis sees the guard at the point of file access.""" session_dir = resolve_session_dir(session) data = _read_json_or_none(session_dir / FILENAME_TRANSCRIPT_JSON) return data if isinstance(data, dict) else None @@ -355,8 +361,8 @@ def read_session_summary(session: str) -> dict[str, Any] | None: """The FULL persisted session-summary.json for `session`, or None when the session has never been summarized. Backs `GET /api/sessions/{session}/ summary`. Same path-safety shape as `read_session_transcript`: - `resolve_session_dir` validates traversal, `_read_json_or_none` re-checks - containment at the point of file access.""" + `resolve_session_dir` validates traversal, and the strict core of + `_read_json_or_none` re-checks containment at the point of file access.""" session_dir = resolve_session_dir(session) data = _read_json_or_none(session_dir / FILENAME_SUMMARY_JSON) return data if isinstance(data, dict) else None @@ -417,17 +423,15 @@ def read_wav_strip_meta(session: str, name: str) -> dict[str, Any] | None: def _read_json_strict(path: Path) -> Any: """Parse `path` as JSON, separating "nothing here" from "I could not read - it": missing or torn (not valid JSON) → None, but every OTHER `OSError` - (EACCES, EIO, EMFILE) RAISES, so a read-modify-write cannot merge onto `{}` - over a file it merely failed to see (#446). A path outside RECORDINGS_DIR → - None. + it". Missing, not a regular file, torn, or outside RECORDINGS_DIR → None. + Every other `OSError` (EACCES, EIO, EMFILE) raises (#446). - The containment check is inlined at the point of file access (the canonical + The containment check sits at the point of file access (the canonical CodeQL `py/path-injection` form): the realpath string `real` flows straight - into `os.stat` and `open`, so taint analysis sees the guard there. Every - caller already passes a `session_paths`-resolved dir, so it is defense-in- - depth, but keeping it local means a refactor that bypasses route-level - validation can't silently leak this as an arbitrary file-reader.""" + into `read_json_strict`, so taint analysis sees the guard there. Every + caller already passes a `session_paths`-resolved dir, so it is defence in + depth, and keeping it local stops a refactor that bypasses route-level + validation from turning this into an arbitrary file reader.""" root = os.path.realpath(config.RECORDINGS_DIR) try: real = os.path.realpath(path) @@ -436,28 +440,11 @@ def _read_json_strict(path: Path) -> Any: return None if real != root and not real.startswith(root + os.sep): return None - try: - st = os.stat(real) - except FileNotFoundError: - return None - if not stat.S_ISREG(st.st_mode): - # A directory or FIFO is not a JSON file; answer "nothing here" as the - # old `isfile` fast path did, so a FIFO can never block the poll. - return None - try: - with open(real, encoding="utf-8") as fh: - return json.load(fh) - except ValueError: - return None + return read_json_strict(real) def _read_json_or_none(path: Path) -> Any: - """`_read_json_strict` made lenient: any `OSError` → None. This is the - primitive the poll funnel (`_read_session_json_cached`) and - `repoint_voice_person`'s walk call use — a bad file must degrade to None, - never crash a tick. Returns None when the file is missing, unparseable, or - sits outside RECORDINGS_DIR. The containment guard lives at the strict sink - above.""" + """`_read_json_strict` for the poll and display reads: any `OSError` → None.""" try: return _read_json_strict(path) except OSError: diff --git a/tapscribe/strip_meta.py b/tapscribe/strip_meta.py index 5a844381..4b8a339d 100644 --- a/tapscribe/strip_meta.py +++ b/tapscribe/strip_meta.py @@ -17,6 +17,7 @@ from typing import Any from . import config +from .config_store import read_json_strict from .session_paths import FILENAME_STRIP_META_JSON from .text import atomic_write_text @@ -46,30 +47,35 @@ def read_strip_meta_file(path: Path) -> dict[str, Any] | None: return None -def read_strip_meta(stripped: Path) -> dict[str, Any] | None: - """RECORDINGS_DIR-contained read. None when the sidecar (after symlinks are - resolved) escapes RECORDINGS_DIR, is missing, or is legacy. +def load_strip_meta(stripped: Path) -> dict[str, Any] | None: + """RECORDINGS_DIR-contained strict read, for a read-modify-write. None when + the sidecar (after symlinks are resolved) escapes RECORDINGS_DIR, is + missing, is not a regular file, is torn, or is legacy. Any other `OSError` + raises, so a fold never writes over a sidecar it could not read (#446). Containment is a point-of-ACCESS property: the FILE is realpath'd and the - realpath string is opened directly (canonical CodeQL py/path-injection - form — the sanitiser sits at open(), and CodeQL flows it through `real`, not + realpath string is read directly (canonical CodeQL py/path-injection + form: the sanitiser sits at the read, and CodeQL flows it through `real`, not through a re-wrapped Path), so a symlinked strip-meta.json cannot turn this - into an arbitrary-file reader. Mirrors the sessions._read_json_or_none guard - this was extracted from. `read_strip_meta_file` is the UNguarded pure reader - for callers (session_merge) that own their containment separately.""" + into an arbitrary-file reader. Mirrors the sessions._read_json_strict guard. + `read_strip_meta_file` is the UNguarded pure reader for callers + (session_merge) that own their containment separately.""" root = os.path.realpath(config.RECORDINGS_DIR) try: real = os.path.realpath(stripped / FILENAME_STRIP_META_JSON) - except (OSError, ValueError): + except ValueError: + # An embedded NUL makes the path malformed, not unreadable. return None if real != root and not real.startswith(root + os.sep): return None - if not os.path.isfile(real): - return None + return valid_strip_meta(read_json_strict(real)) + + +def read_strip_meta(stripped: Path) -> dict[str, Any] | None: + """`load_strip_meta` for display and prune: any `OSError` reads as None.""" try: - with open(real, encoding="utf-8") as fh: - return valid_strip_meta(json.load(fh)) - except (OSError, ValueError): + return load_strip_meta(stripped) + except OSError: return None diff --git a/tapscribe/voices.py b/tapscribe/voices.py index 47fff52f..483ae699 100644 --- a/tapscribe/voices.py +++ b/tapscribe/voices.py @@ -24,6 +24,7 @@ from pathlib import Path from typing import Any +from .config_store import read_json_strict from .session_paths import FILENAME_VOICES_JSON from .text import atomic_write_text, parse_iso @@ -120,15 +121,12 @@ def voices_sig(runs: Mapping[str, str]) -> str: return ";".join(f"{identity}:{run}" for identity, run in sorted(runs.items())) -def _load(session_dir: Path) -> dict[str, dict[str, Any]]: - """The sidecar, parsed and coerced. Missing or torn → `{}` (nothing in a - torn file is recoverable); every OTHER `OSError` RAISES, so a caller can - tell "there are no Voices" from "I could not read them" — the distinction a - read-modify-write needs and a display read does not.""" - try: - return coerce_voices(json.loads((session_dir / FILENAME_VOICES_JSON).read_text(encoding="utf-8"))) - except (FileNotFoundError, ValueError): - return {} +def load_voices(session_dir: Path) -> dict[str, dict[str, Any]]: + """The sidecar, parsed and coerced, for a read-modify-write. Missing or torn + → `{}`, because nothing in a torn file is recoverable. Every other `OSError` + raises, so a caller can tell "there are no Voices" from "I could not read + them" (#446).""" + return coerce_voices(read_json_strict(session_dir / FILENAME_VOICES_JSON)) def read_voices(session_dir: Path) -> dict[str, dict[str, Any]]: @@ -136,7 +134,7 @@ def read_voices(session_dir: Path) -> dict[str, dict[str, Any]]: session is the normal case, and the poll must not crash on a bad file. The sidecar is regenerable by re-running diarize.""" try: - return _load(session_dir) + return load_voices(session_dir) except OSError: # A permission change or a concurrent delete mid-read. Degrade to "no # Voices", which leaves every segment on its plain identity key. @@ -180,13 +178,13 @@ def record_voices( Scoped to one identity so a sibling's `run_id` — and every mapping made against it — survives. - Through `_load`, not `read_voices`: the reader degrades an unreadable file to + Through `load_voices`, not `read_voices`: the reader degrades an unreadable file to `{}`, which is right for display and destructive as the base of a whole-file write — a transient `OSError` (a Windows sharing violation against the poll's concurrent read) would delete every sibling identity's Voices. Letting it raise fails the diarize instead, which is re-runnable. """ - current = _load(session_dir) + current = load_voices(session_dir) voices = { label: {"spans": [{"start": s.isoformat(), "end": e.isoformat()} for s, e in windows]} for label, windows in spans.items() diff --git a/tests/e2e/test_dashboard_ui.py b/tests/e2e/test_dashboard_ui.py index 1de634d5..f8ceb795 100644 --- a/tests/e2e/test_dashboard_ui.py +++ b/tests/e2e/test_dashboard_ui.py @@ -6976,7 +6976,9 @@ async def test_transcript_single_wav_transcribe_marks_row_done( await page.goto(rr.base_url + "/#transcript", wait_until="domcontentloaded") await page.locator(tx_tag).first.wait_for(state="visible", timeout=8000) - assert (await page.locator(tx_tag).first.inner_text()).strip() == "no tx" + # textContent, not innerText: `.wavrow` carries `content-visibility: + # auto`, and Chromium reports an empty innerText for a skipped subtree. + assert (await page.locator(tx_tag).first.text_content() or "").strip() == "no tx" # The default selection (files[0]) enables the single-transcribe button. await page.wait_for_function( f"""() => {{ const b = document.querySelector('{tx_one}'); diff --git a/tests/test_merge_on_write_strict_read.py b/tests/test_merge_on_write_strict_read.py index 0cb21f98..e76ee47e 100644 --- a/tests/test_merge_on_write_strict_read.py +++ b/tests/test_merge_on_write_strict_read.py @@ -28,6 +28,7 @@ import io import json import os +import threading from collections.abc import Iterator from contextlib import contextmanager from datetime import UTC, datetime, timedelta @@ -37,10 +38,16 @@ from fastapi.testclient import TestClient from wav_builders import seed_session # type: ignore[import-not-found] -from tapscribe import roster, session_maintenance, voices +from tapscribe import roster, session_maintenance, strip_meta, voices from tapscribe.app import app, get_recorder -from tapscribe.session_paths import FILENAME_META_JSON, FILENAME_ROSTER_JSON -from tapscribe.sessions import read_session_meta, write_session_meta +from tapscribe.session_paths import ( + DIRNAME_STRIPPED, + FILENAME_META_JSON, + FILENAME_ROSTER_JSON, + FILENAME_STRIP_META_JSON, + FILENAME_VOICES_JSON, +) +from tapscribe.sessions import load_session_meta, read_session_meta, write_session_meta from tapscribe.tap_mode import TAP_MODE_MULTI # One of each read failure the lenient reads used to swallow. FileNotFoundError @@ -59,17 +66,21 @@ @contextmanager -def failing_reads(target: Path, exc: OSError, times: int | None = None) -> Iterator[None]: +def failing_reads(target: Path, exc: OSError, times: int | None = None, skip: int = 0) -> Iterator[None]: """Make every read-mode `open` of `target` raise `exc` (only the first - `times` of them, when given). Writes, and every other file, pass through.""" + `times` of them, when given), after letting the first `skip` reads through. + Writes, and every other file, pass through.""" real_open = builtins.open target_real = os.path.realpath(target) left = [times] + passed = [0] def fake_open(file, mode="r", *args, **kwargs): # type: ignore[no-untyped-def] is_read = not any(flag in mode for flag in "wax+") is_target = isinstance(file, (str, bytes, os.PathLike)) and os.path.realpath(file) == target_real - if is_read and is_target and (left[0] is None or left[0] > 0): + if is_read and is_target and passed[0] < skip: + passed[0] += 1 + elif is_read and is_target and (left[0] is None or left[0] > 0): if left[0] is not None: left[0] -= 1 raise exc @@ -274,6 +285,18 @@ def test_mapping_a_voice_over_a_transiently_unreadable_meta_keeps_the_other_mapp # --------------------------------------------------------------------------- +STRIP_META_FILE = f"{DIRNAME_STRIPPED}/{FILENAME_STRIP_META_JSON}" + + +def _seed_side(session_dir: Path, *, identity: str, clip: str, original: str) -> None: + """One diarized Voice, and one committed-cut clip with its strip-meta.""" + voices.record_voices( + session_dir, identity=identity, run_id="run-1", spans={"A": [(T0, T0 + timedelta(seconds=5))]} + ) + stripped = seed_session(session_dir, DIRNAME_STRIPPED, [clip]) + strip_meta.write_strip_meta(stripped, {"files": {original: {"spans": [{"name": clip}]}}}) + + def _seed_absorb(rec_root: Path) -> tuple[Path, Path]: target = seed_session(rec_root, "tgt", [ALICE_WAV]) source = seed_session(rec_root, "src", [BOB_WAV]) @@ -283,18 +306,23 @@ def _seed_absorb(rec_root: Path) -> tuple[Path, Path]: roster.record_occurrence(source, identity=BOB_IDENTITY, name="Bob Bergman", recorded=True, wav=BOB_WAV) write_session_meta("tgt", {"label": "Weekly sync", "aliases": {"Alice_Andersen": "Alice"}}) write_session_meta("src", {"aliases": {"Bob_Bergman": "Bob"}}) + _seed_side(target, identity=ALICE_IDENTITY, clip="alice-clip-0.wav", original=ALICE_WAV) + _seed_side(source, identity=BOB_IDENTITY, clip="bob-clip-0.wav", original=BOB_WAV) return target, source @pytest.mark.parametrize("exc", READ_FAILURES) @pytest.mark.parametrize("side", ["tgt", "src"]) -@pytest.mark.parametrize("filename", [FILENAME_ROSTER_JSON, FILENAME_META_JSON]) +@pytest.mark.parametrize( + "filename", [FILENAME_ROSTER_JSON, FILENAME_META_JSON, FILENAME_VOICES_JSON, STRIP_META_FILE] +) def test_absorb_refuses_to_fold_a_file_it_could_not_read( rec_root: Path, exc: OSError, side: str, filename: str ) -> None: - """Absorb folds both sessions' roster and meta into the target and then - deletes the source folder. A read failure on either side must stop it - before the unread file is overwritten or the source is deleted.""" + """Absorb folds both sessions' roster, meta, Voices and strip-meta into the + target and then deletes the source folder. A read failure on either side + must stop it before anything moves, the unread file is overwritten, or the + source is deleted.""" target, source = _seed_absorb(rec_root) path = (target if side == "tgt" else source) / filename before = path.read_bytes() @@ -303,4 +331,73 @@ def test_absorb_refuses_to_fold_a_file_it_could_not_read( session_maintenance.absorb_session("tgt", "src") assert source.is_dir(), "the source folder was deleted over a file absorb could not read" + assert (source / BOB_WAV).is_file(), "a WAV moved before absorb read every fold input" + assert not (target / BOB_WAV).exists(), "absorb was left half-applied" assert path.read_bytes() == before, f"the {side} {filename} was overwritten after a failed read" + + +@pytest.mark.parametrize("exc", READ_FAILURES) +def test_absorb_never_rereads_the_target_meta_after_its_moves(rec_root: Path, exc: OSError) -> None: + """Absorb reads the target meta once, before the WAV moves. A read that + would fail after that first one must not leave the merge half-applied: the + source's WAVs in the target and the source folder still on disk.""" + target, source = _seed_absorb(rec_root) + + with failing_reads(target / FILENAME_META_JSON, exc, skip=1): + session_maintenance.absorb_session("tgt", "src") + + assert not source.exists() + assert (target / BOB_WAV).is_file() + assert read_session_meta("tgt")["aliases"] == {"Alice_Andersen": "Alice", "Bob_Bergman": "Bob"} + + +# --------------------------------------------------------------------------- +# The strict read's input taxonomy: what reads as "nothing here" +# --------------------------------------------------------------------------- + + +def test_a_file_removed_between_stat_and_open_reads_as_absent(rec_root: Path) -> None: + """The stat sees the file and the open does not: that is a concurrent + delete, so the file is absent, not unreadable.""" + session_dir = _seed_roster(rec_root) + write_session_meta("s", {"label": "Weekly sync"}) + gone = FileNotFoundError(errno.ENOENT, "No such file or directory") + + with failing_reads(session_dir / FILENAME_META_JSON, gone): + assert load_session_meta("s") == {} + with failing_reads(session_dir / FILENAME_ROSTER_JSON, gone): + assert roster.load_roster(session_dir) == {} + + +def test_a_directory_at_the_roster_path_reads_as_absent(rec_root: Path) -> None: + session_dir = seed_session(rec_root, "s", []) + (session_dir / FILENAME_ROSTER_JSON).mkdir() + + assert roster.load_roster(session_dir) == {} + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="needs a POSIX FIFO") +def test_a_fifo_at_the_roster_path_reads_as_absent_without_blocking(rec_root: Path) -> None: + session_dir = seed_session(rec_root, "s", []) + os.mkfifo(session_dir / FILENAME_ROSTER_JSON) + result: list[object] = [] + reader = threading.Thread(target=lambda: result.append(roster.load_roster(session_dir)), daemon=True) + + reader.start() + reader.join(timeout=5) + + assert result == [{}], "the read blocked on the FIFO or did not read it as absent" + + +# --------------------------------------------------------------------------- +# session_is_empty: prune deletes what it calls empty +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("exc", READ_FAILURES) +def test_a_session_whose_meta_cannot_be_read_is_not_empty(rec_root: Path, exc: OSError) -> None: + session_dir = seed_session(rec_root, "s", []) + write_session_meta("s", {"label": "Board meeting"}) + + with failing_reads(session_dir / FILENAME_META_JSON, exc): + assert session_maintenance.session_is_empty(session_dir) is False