Skip to content

fix: read strictly before a merge-on-write so a failed read cannot wipe the file - #456

Merged
Vortiago merged 4 commits into
mainfrom
exp/446-qwen
Sep 23, 2026
Merged

Vortiago merged 4 commits into
mainfrom
exp/446-qwen

Conversation

@Vortiago

@Vortiago Vortiago commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

A merge-on-write read a JSON file, merged a partial update into it and wrote the whole file back. Its read treated every OSError as an empty file. One transient read failure (EACCES, EMFILE, EIO) then replaced the whole file with the partial update.

Each merge path now reads through a strict loader: a missing or torn file still reads as empty, and every other OSError raises, so the write does not happen. The public readers stay lenient, so a bad file never crashes a poll tick. The pattern is the one voices.py uses since #438.

Sites

  • roster.record_occurrence reads through the new load_roster. read_roster stays lenient.
  • sessions.write_session_meta reads through the new load_session_meta, over a strict _read_json_strict that holds the containment check. read_session_meta and _read_json_or_none stay lenient.
  • The voice-mapping route (routes/people.py) reads the voices map strictly, so a failed read cannot write back a map holding only the new key. The OSError propagates as a 500 before either the meta or people.json is written.
  • session_maintenance.absorb_session does every strict read of both sessions' roster and meta ahead of the WAV moves, so a refusal leaves nothing half-done and the source folder is not deleted.
  • Found in review and fixed here:
    • absorb's strip-meta and voices merges (strip_meta.load_strip_meta, voices.load_voices) read strictly with the other fold inputs, so they no longer overwrite a target file that failed to read;
    • config_store.read_json_strict is the one strict JSON read the loaders share, and it treats a path that is not a regular file as absent (opening a FIFO would block);
    • prune's session_is_empty treats a meta it cannot read as not empty, because that meta may hold a label and prune deletes on "empty".
  • Unrelated e2e fix: tests/e2e/test_dashboard_ui.py reads textContent, not innerText, because Chromium reports an empty innerText under content-visibility: auto.

Behaviour change to know about

A /tap open on a session whose roster cannot be read now fails, instead of silently resetting the roster. A bridge's reconnect retries into it until the file is readable again. That is the intended trade: the roster is the only record of a recording's full identity and its multi-person mode.

Tests

tests/test_merge_on_write_strict_read.py pins every site: a failed read raises and leaves the file byte-identical, an absent or torn file still reads as empty, and the lenient readers never raise. pytest tests --ignore=tests/e2e, ruff check and ruff format --check pass, and so does CI with CodeQL.

Closes #446

Implemented by the local model in the delegation experiment, then verified and reviewed by Claude.

… 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.
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'
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'
…pe 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.
@Vortiago
Vortiago merged commit 2c9722b into main Sep 23, 2026
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Merge-on-write over a lenient read: one transient OSError wipes a roster or a session's meta

1 participant