Skip to content

fix(gr2): a torn journal line stops being fatal, and stops eating its neighbour - #896

Merged
laynepenney merged 1 commit into
devfrom
fix/gr2-journal-torn-line
Aug 20, 2026
Merged

fix(gr2): a torn journal line stops being fatal, and stops eating its neighbour#896
laynepenney merged 1 commit into
devfrom
fix/gr2-journal-torn-line

Conversation

@laynepenney

Copy link
Copy Markdown
Member

The journal writer appends and fsyncs, so a kill between the two leaves a partial line. Three consequences, each with its own witness.

A tear used to cost two rows, not one. Appending onto an unterminated line glues the new row to the remnant, so a row that was serialized correctly and fsynced becomes unreadable. _write now terminates a remnant first, confining the damage to the line actually interrupted. This is the half the issue did not contain, and it is the reason the issue's discriminator does not hold.

_rows raised a bare JSONDecodeError on any unparseable line. The daemon lets anything outside its named failure list propagate, so one bad moment exited the loop on its first tick and on every restart after it. Unparseable lines are now dropped and counted, and MalformedLine keeps the drop from being silent.

The drop is unconditional rather than trailing-only, because position does not discriminate cause. A tear is trailing only until the daemon restarts and appends again, after which the same orphan sits mid-file with intact rows on both sides — so a position rule would raise on the writer's own crash-then-restart signature. The question that does discriminate is whether the cursor can still be accounted for, and replay already asks it and already raises JournalInconsistent by name. Dropping here lets that check decide instead of being pre-empted by a parse error. Skipping is safe because the acknowledgement row is written before the cursor advances, so a torn acknowledgement leaves the operation unacknowledged and it is redone idempotently.

Idle ticks no longer re-parse the file. Prototype 1 asks the machine to account for an observation on every tick, which moved this parse from once-per-change to once-per-tick. Rows are cached against (size, mtime_ns); the Propagator is built once per loop and holds one Journal, so the cache spans ticks — verified rather than assumed, since a cache that never hits in production would be a green over a non-fix. The one state it cannot see, an in-place mutation changing neither size nor mtime_ns, is named in the code rather than assumed away.

Verification

86 passed, 1 xfailed — up from 83 by exactly the three witnesses added, so nothing was lost or silently skipped.

Each guard is mutation-proven, and each mutation was restored byte-exact:

Mutation Kills
remove the newline repair W1 only
restore the bare json.loads W1 and W2
disable the cache W3 only

The second killing two is a real dependency rather than a stray: W1 asserts on the rows that survive, so without the skip guard its torn orphan still raises. The guards compose, and that is reported rather than presented as three tidy isolations.

Ref #893 — closes at promotion.

Premium boundary: grip is OSS because this is local workspace state machinery — journal durability and read caching — with no identity, org, or entitlement semantics.

… neighbour

The journal writer appends and fsyncs, so a kill between the two leaves a
partial line. Three consequences, each with its own witness.

A tear used to cost TWO rows, not one. Appending onto an unterminated line
glues the new row to the remnant, so a row that was written correctly and
fsynced becomes unreadable. _write now terminates a remnant before
appending, confining the damage to the line actually interrupted.

_rows raised a bare JSONDecodeError on any unparseable line. The daemon
lets anything outside its named failure list propagate, so one bad moment
exited the loop on its first tick and on every restart after it. Lines that
cannot be parsed are now dropped and counted, and MalformedLine keeps the
drop from being silent.

Position does not discriminate cause, which is why the drop is unconditional
rather than trailing-only: a tear is trailing only until the daemon restarts
and appends again, after which the same orphan sits mid-file with intact
rows on both sides. The question that does discriminate is whether the
cursor can still be accounted for, and replay already asks it and already
raises JournalInconsistent by name. Dropping here lets that check decide
instead of being pre-empted by a parse error.

Prototype 1 asks the machine to account for an observation on every tick,
including idle ones, which moved this parse from once-per-change to
once-per-tick. Rows are now cached against (size, mtime_ns); the Propagator
is built once per loop and holds one Journal, so the cache spans ticks. The
one state it cannot see - an in-place mutation changing neither size nor
mtime_ns - is named in the code rather than assumed away.

86 passed, 1 xfailed, up from 83 by exactly the three witnesses added.
Each guard is mutation-proven: removing the newline repair reddens the
neighbour witness, restoring the bare parse reddens that one and the skip
witness both, and disabling the cache reddens the idle-tick witness.

Ref #893 - closes at promotion

Co-Authored-By: Claude <noreply@anthropic.com>
@laynepenney

Copy link
Copy Markdown
Member Author

r2 (Sentinel) — APPROVE, bound to head 37eced3.

RAN: 86 passed, 1 xfailed across the state-machine and daemon suites with the checkout pinned on PYTHONPATH. Disabling the unchanged-file cache made only its cache witness fail; the restored source hash matched exactly.

READ: journal parsing, append repair, cursor ordering, and the three new witnesses. Scope: this public journal torn-line and cache change at the bound head.

@laynepenney
laynepenney merged commit a5635a0 into dev Aug 20, 2026
1 check passed
@laynepenney
laynepenney deleted the fix/gr2-journal-torn-line branch August 20, 2026 11:03
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.

1 participant