Skip to content

fix: AppendSurface survives a torn write, and counts what it skips - #898

Merged
laynepenney merged 2 commits into
devfrom
fix/append-surface-torn-line
Aug 20, 2026
Merged

fix: AppendSurface survives a torn write, and counts what it skips#898
laynepenney merged 2 commits into
devfrom
fix/append-surface-torn-line

Conversation

@laynepenney

@laynepenney laynepenney commented Aug 20, 2026

Copy link
Copy Markdown
Member

What

AppendSurface (Prototype 2, gr2/prototypes/contribution_protocol.py) is the declared append-only surface: one guarded append point, exclusive lock across write + flush + fsync, arrival-ordered sequence numbers. A writer killed between its record and the terminator leaves a remnant line, and three things followed. All three were measured, not inferred:

  1. append() raised on the remnant and kept raising — the surface was BRICKED permanently. Every later writer re-reads the whole file to compute the next sequence number, so every later writer died on the same line. The failure sits on the WRITE path, behind the lock, and is not survivable by retrying.
  2. The next record GLUED onto the remnant. With no terminator to separate them, a record that had itself completed and fsynced became part of one unparseable line and was lost.
  3. records() raised, so every reader died too.

Both scans now skip a line they cannot read and count it (malformed_lines, reflecting the most recent full scan). The count is the load-bearing half: skipping alone is silent, and the remnant otherwise sits in the file while a caller sees a healthy-looking surface with a contiguous sequence — a loss invisible by construction. append() also repairs a missing terminator before writing, so a completed record is never swallowed by an incomplete one.

Two review blocks, and both times the guard was the wrong SHAPE, not missing a case

The range is two commits and is worth reading as one: the original fix, and one commit carrying both corrections. Each block moved the guard down a layer, and the second one is where it belongs.

Block 1 — the guard was a denylist. Valid JSON seq of 1e999 parses to inf; int(inf) raises OverflowError, which was not in the except tuple, so both scans still bricked permanently. Asking what else escaped the same guard found a sibling the review had not named: deeply nested JSON raises RecursionError from json.loads itself. Two escapes from one guard is not two bugs. An except tuple over untrusted content enumerates the failures imaginable from the parse side, while the coercion after the parse invents new ones — int() applied to a value json can legitimately produce raises a type no parse-side list predicts. So the coercion was removed: everything after the parse validates types rather than converting them, and inf is refused because a float is not a sequence number, with no exception existing to catch.

Block 2 — the guard was in the wrong place. A raw invalid UTF-8 byte raised UnicodeDecodeError in both scans before any JSON handling. The type was never the problem: UnicodeDecodeError subclasses ValueError, which was already caught. The operation that raises it sat outside the guarded region — in text mode the decode happens while the iterator manufactures the line, so the bytes are converted before any try can see them. The fix guarded the transformation and left the acquisition unguarded. A line does not arrive as text; it is manufactured as text, and manufacturing it is content-dependent.

Operationally that was worse than a brick: a whole-file decode means one bad byte anywhere destroys every record in the surface, including the thousands written correctly around it. The file is now read as bytes and decoded one line at a time, which is what a JSONL file actually is, so a bad byte costs exactly its own line.

What the two blocks have in common is the useful part: both times the fix was sound about the case in front of it and wrong about the boundary it was defending. A line passes through four layers — read, decode, parse, shape-check — and each of the last three is now guarded where it happens.

Evidence (RAN)

  • 23 witnesses on this surface, up from 0. Eleven hostile-content rows (float infinity, NaN, deep nesting, an integer literal past the conversion limit, seq as an object, seq as a bool — a bool is an int in Python — a non-string writer, a non-object payload, a non-string timestamp, a bare array, a bare string); six undecodable-byte rows (invalid 0xff, lone surrogate, truncated multi-byte sequence, overlong encoding, orphan continuation byte, pure binary); and structural witnesses for the remnant surviving twice, the following record being a separate whole line, a fresh handle reporting the same finding, the numbering semantic below, and confinement — ten good records surviving a bad byte written between them.
  • Ten mutation rows, each asserting its own anchor applied (occurrence count == 1 and the file hash changed) and restoring from a hash-verified copy. Removing the decode guard kills seven by real UnicodeDecodeError, each naming its own fixture's byte; reverting the read to a whole-file text decode kills the same seven; errors="replace" instead of refusing turns a bad line into a fake record and kills six; reverting to the denylist form kills exactly the two escapes by real OverflowError and RecursionError; dropping RecursionError kills only the nesting row; restoring the coercion kills five; accepting a bool kills only the bool row; dropping the writer type check kills only the writer row; silencing either counter kills only that path's count assertions.
  • Two instrument defects found in my own harness and disclosed, because both touched decisive rows. (a) The first version extracted failure types by grepping pytest output for exception names — and parametrized test IDs contain exception names, so it reported OverflowError and RecursionError for runs where neither was raised. It agreed with what I expected, which is when a harness is least questioned. The corrected extractor reads only E-prefixed traceback lines and is proven against a green control that must yield zero. (b) One mutation row was discarded as contaminated — 15 of its 22 kills were an AttributeError from a sloppy edit rather than from the defect — and re-run clean, where it kills 7 for the right reason.
  • One fixture of mine was wrong and the code was right, found by the new witness and pinned rather than quietly edited away: a line can be unreadable as a record while its sequence number is perfectly readable, and numbering honours it anyway. No writer is ever issued a number a reader can already see on disk.
  • Suites: test_contribution_protocol.py, test_propagation_contribution.py, test_propagation_state_machine.py, test_propagation_daemon.py127 passed, 1 xfailed, against a measured baseline of 104 passed, 1 xfailed on clean dev with these files stashed. Delta is exactly the 23 new witnesses. Import resolution pinned from inside pytest (the gr2 package is synthesized by conftest.py, so an out-of-pytest import proves nothing).
  • ruff check and ruff format --check clean on both files.

Not in scope

  • Unbounded line length is deliberately NOT defended. A file with no terminator for a very long span is a resource limit on the READ layer, below the three that are guarded here. It is stated in the class docstring as well as here rather than left to be discovered, because the two completeness claims this branch made earlier were both broken on first contact with a reviewer, and naming the enumeration is more honest than asserting the set is closed.
  • The same defect class on the other three surfaces. This is fix 1 of 4 in a sweep; the remaining three land as their own PRs with their own witnesses, and the validate-don't-coerce, guard-the-decode shape established here is what they follow.
  • Any change to protocol semantics: locking, arrival order, and the commute property are untouched, and their existing witnesses are unchanged.

Premium boundary: grip is OSS; this is local file mechanics over opaque identifiers and carries no identity, org, or policy content.

Ref #897

A writer killed between its record and the terminator leaves a remnant line.
Three consequences, all measured before this change:

  1. append() raised on the remnant and kept raising: the guarded append point
     was BRICKED permanently, because every later writer re-reads the whole file
     to compute the next sequence number and dies on the same line.
  2. The next record GLUED onto the remnant, producing one unparseable line and
     losing a record that had itself completed and fsynced.
  3. records() raised, so every reader of the surface died too.

Both scans now skip a line they cannot read AND count it (malformed_lines,
reflecting the most recent full scan). Skipping alone is silent, and silence is
the defect: the remnant sits in the file while a caller sees a healthy-looking
surface with a contiguous sequence. append() also repairs a missing terminator
before writing, so a completed record is never swallowed by an incomplete one.

MalformedLine is reused from the state machine rather than re-declared, so the
two append-only surfaces in this tree report a torn line in one vocabulary.

Four witnesses, five mutations, each mutation killing witnesses whose failure
TYPE matches it: removing the terminator repair kills by AssertionError (glue is
wrong content, not an exception); removing either scan's guard kills by
JSONDecodeError and KeyError, exactly the types the guard catches; silencing
either count kills only that path's count assertions. Tear-fixture count for
this surface: 4, previously 0.

Ref #897 - closes at promotion

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

Copy link
Copy Markdown
Member Author

r2 (Sentinel) — BLOCK, bound to head f051ab8.

RAN: at the exact head, a valid JSON row with "seq": 1e999 reaches int(inf) and raises OverflowError in both append() and records(). That exception is outside the four caught types, so a file-content remnant can still brick the append path.

Scope: AppendSurface torn-line handling at the bound head.

Two review blocks on this PR, both correct, and both about the SHAPE of the
guard rather than a case it was missing.

FIRST: an except tuple over untrusted file content is a DENYLIST, and a denylist
leaks by construction. Valid JSON seq 1e999 parses to inf, int(inf) raises
OverflowError, and no list written from the parse side would have predicted it,
because the COERCION AFTER the parse is what invents the new failure. Asking what
else escaped the same guard found a sibling: deeply nested JSON raises
RecursionError from json.loads itself and bricks identically. So the coercion is
gone. Everything after the parse validates types rather than converting them, and
inf is refused because a float is not a sequence number, with no exception
existing to catch.

SECOND: the decode is a raise site BELOW the parse. The exception type was never
the problem, since UnicodeDecodeError subclasses ValueError which was already
caught; the OPERATION that raises it sat outside the guarded region. In text mode
the decode happens while the iterator MANUFACTURES the line, so bytes are
converted before any try block can see them. The fix guarded the transformation
and left the ACQUISITION unguarded.

Operationally that was worse than a brick: a whole-file decode means ONE bad byte
anywhere destroys every record in the surface, including the thousands written
correctly around it. The file is now read as bytes and decoded one line at a
time, which is what a JSONL file actually is, so a bad byte costs its own line.

A line passes through four layers - read, decode, parse, shape-check - and the
last three are each guarded where they happen. Naming the enumeration rather than
claiming completeness, because two earlier claims that the raise-site set was
closed were each broken by the next reviewer: unbounded line length is a resource
limit on the READ layer and is deliberately not defended, stated in the class
docstring and the PR body rather than left to be discovered.

Also pinned, found when a new witness exposed a wrong expectation in a fixture of
mine rather than a defect in the code: a line can be unreadable as a RECORD while
its sequence number is perfectly readable, and numbering honours it anyway. No
writer is ever issued a number a reader can already see on disk.

Seventeen hostile-content and undecodable-byte rows plus a confinement witness
proving ten good records survive a bad byte written between them. Ten mutation
rows, measured with an extractor that reads only E-prefixed traceback lines and
is proven against a green control - an earlier extractor read exception names out
of parametrized TEST IDS and reported types that were never raised. One row was
discarded as contaminated, its kills coming from a sloppy edit rather than the
defect, and re-run clean.

Ref #897

Co-Authored-By: Claude <noreply@anthropic.com>
@laynepenney
laynepenney merged commit 879927c into dev Aug 20, 2026
1 check passed
@laynepenney
laynepenney deleted the fix/append-surface-torn-line branch August 20, 2026 12:18
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