fix(corekit): keep SeekableReader's buffer content when truncating it (#622) - #633
Conversation
The two mechanisms, isolated by measurementThe description asserts that fixing
The two halves are orthogonal, which is the point: the The half-fixed trees were derived from the two endpoint snapshots by text substitution rather than by hand-editing, with an assertion that exactly one occurrence was replaced. Worth recording why that assertion mattered: |
489ab20 to
79730f7
Compare
Amended: a second crash route, found by fuzzing after the first pushThe commit was amended ( Fuzzing 3000 random operation sequences against the buffer's own invariants showed the r = SeekableReader(io.BytesIO(b'abcde'), buffer_size=5)
r.truncate(0)
r.read(1) # ValueError, before and after the first push
This belongs in this PR rather than a separate one because
One thing deliberately not changed while in there: the A seventh test covers it, The fuzz also turned up a pre-existing defect that is not fixed here — a refused |
79730f7 to
50e8c1d
Compare
Cross-review verdict: NEEDS CHANGES (now addressed)Per the standing rule that an agent-raised change gets reviewed by a different model, this PR was cross-reviewed by a Sonnet subagent (the PR was authored by Opus), briefed to falsify rather than bless — a verdict per claim, with evidence it obtained itself, and told that disagreements were the valuable output. It ran read-only against its own immutable snapshots under It returned NEEDS CHANGES on two counts. Both are now fixed, in 1. The reduction desynced the window base from the stream — silent wrong octets
Upheld, and worse than reported. I reproduced it, then measured the same sequence on the base commit, which the review had not: there it raises Root cause, as the review diagnosed it: Fixed by treating the buffer as what it is — a sliding window whose octet 0 sits at absolute Two things came with the fix: a regression test, 2. A stale number in the commit message
Upheld. 165 was correct when written and went stale when the 7th test landed. Re-measured directly rather than adopted: What the review confirmedClaims 1–6 and 8–11 CONFIRMED, each with independently obtained evidence: the reproduction both ways; that On the amendment it also proved the On what stays unfixedIt confirmed the separately disclosed Both want their own issue. I have not filed one, not having been asked to create issues here. |
…#622) * `SeekableReader.truncate` kept padding in preference to content. The buffer is a sliding window whose content occupies `[0:_buffer_cur]`, with never-read padding behind it, so `temp[-size:]` -- slicing the buffer rather than the content -- kept the padding and discarded the octets actually read, and `temp.rjust(size)` prefixed the new zeros, displacing the content past where `_buffer_set` and `_buffer_cur` address it. An extension now appends at the tail, which is what `io.IOBase.truncate` means by "the contents of the new file area", and a reduction keeps the most recent octets, which is the lookback a window holds. * A reduction advances `_buffer_set` past the octets it drops, so that `_buffer_set + _buffer_cur` still equals how far the stream has been consumed -- the quantity `seek` reads as its licence to fetch more. Clamping `_buffer_cur` alone left that sum short of the stream, and the next forward `seek` then spliced in octets from the wrong absolute offset and said nothing. * `read` capped a buffered read at `min(size, self._buffer_cur - 1)`, a count less one measured from the start of the buffer rather than the run remaining from the position being read from. That is one octet short at the start of the buffer -- and the shortfall was made up from the stream past the octet it had skipped, dropping it -- and reaches into the padding anywhere further in. * Three more of the method's contract: the position was reset to the start of the buffer rather than left where it was, an omitted `size` resized to `0` rather than to the current position, and `_buffer_cur` was left addressing octets a reduced buffer no longer had. * `_write_buffer`'s `buf[-self._buffer_size:]` is `buf[-0:]` for a buffer of no size -- the whole of the octets just read rather than none of them -- which raised `ValueError` after a `truncate(0)`. Now counted from the front. Only `truncate` can reach a zero-length buffer; the constructor refuses one. * Eight new tests in `tests/corekit/test_io.py`, all over `io.BytesIO` with no fixture. The existing `truncate(None)` assertion expected `0` and now expects the current position. `tests/corekit/test_io.py` goes 13 failed / 14 passed / 5 subtests failed / exit 1 to 22 passed / 6 subtests passed / exit 0. `tests/corekit` 167 passed / 378 subtests / exit 0, `tests/foundation/test_extraction.py` -- the one place that wraps a non-seekable stream in this class -- 12 passed / 28 subtests / exit 0. Fuzzing 3000 random operation sequences against the buffer's invariants, including that `_buffer_set + _buffer_cur` tracks the stream's real consumption, goes 2372 inconsistent states and 683 undocumented exceptions to none of either. Coverage of `pcapkit/corekit/io.py` holds at 100% statement and 100% branch. `util/changelog_md.py --check` exits 0. Fixes #622
50e8c1d to
478296a
Compare
|
One follow-up amend,
Re-measured after the rename: 13 failed / 14 passed / 5 subtests failed / exit 1 on the base, 22 passed / 6 subtests passed / exit 0 with the fix, Repository CI is queued rather than running — every workflow run in the repo is |
Fixes #622.
SeekableReader.truncatekept padding in preference to content, andreadthen returned one octet fewer than the buffer held. Two mechanisms, not one — which is why this is not the blindrjust→ljustswap #604 was.Reproduction, re-verified on current
mainpcapkit/corekit/io.py:328as the issue says, unmoved. Measured against an immutablegit archivesnapshot, with the editable install's_EditableFinderstripped fromsys.meta_path(a meta-path finder runs beforesys.path, so neitherPYTHONPATHnorPYTHONSAFEPATH=1can displace it) andpcapkit.__file__asserted:After:
b'abcde', 5 octets. Five is the whole of what a five-octet stream can answer an eight-octet request with, so five is correct rather than still-short.The two mechanisms, isolated by measurement
Four trees, each an immutable
chmod -R a-wsnapshot:sha256(pcapkit/corekit/io.py)919e4923…b'\x00\x00\x00e'— 4 octets, content losttruncatechange9e1353d8…b'abce'— 4 octets, content restored bar the droppeddreadchange46bb8fd8…b'\x00\x00\x00\x00e'— 5 octets, content still all paddingb'abcde'— 5 octets, correctOrthogonal, which is the point: the
readcap governs how many octets come back (4 → 5),truncategoverns which octets they are (padding → data). Neither alone fixes the reported output.truncate—io.py:331for the reported symptom,io.py:328for its twin.truncate(8)on a default 8192-octet buffer is a reduction, so it took line 331,temp[-size:]. Note what that slices: the buffer, not the content. The buffer is_buffer_sizelong but only[0:_buffer_cur]of it was ever read, so taking its tail keeps eight octets of padding and throwsabcdaway. The line the issue names, 328'stemp.rjust(size, b'\x00'), is the extension path and is not what produces the reported output — it needs a buffer smaller than the requested size to reach:read(4),truncate(8)read(4)buffer_size=4b'\x00\x00\x00\x00abcd'b'\x00\x00\x00e'b'abcd\x00\x00\x00\x00'b'abcd'read—io.py:375.min(size, self._buffer_cur - 1)is a count less one, measured from the start of the buffer rather than from the position being read from. At the start of the buffer it is one octet short, and the shortfall was then made up from the stream past the octet it had skipped — dropping it and leaving the return short — while anywhere further in it reaches beyond the content and hands the padding back as data. Independent oftruncateentirely:What
io.IOBase.truncate's contract says, and where it stops— https://docs.python.org/3/library/io.html#io.IOBase.truncate
The extension is settled by that on its own terms rather than by analogy with #604: the new file area is the region past the old end, so its zeros belong at the tail. Three further clauses were also being broken:
io.BytesIOresets the position to 0 and nothing put it back. Measured: with the buffer position at 2,truncate(8)(to the size it already had, so the content is byte-identical either way and only the position can account for the difference) left it at 0, and the nextread(2)returnedb'ab'where it should returnb'cd'. Now restored, shifted by however far the window's base moved so that it still denotes the same octet. A position past the new end is left where it is, asio.BytesIOleaves it (measured on 3.14.7:truncate(2)withtell()at 4 leaves 4).size is Nonewas hardcoded to0, discarding the whole buffer. Now the current position, relative to_buffer_set.What a reduction keeps
This is where the io contract stops being decisive, and getting it wrong is how the first revision of this PR introduced a silent-corruption regression. The contract describes a stream whose octet 0 is at absolute offset 0. This buffer is a sliding window over a stream that cannot be seeked: its octet 0 sits at absolute
_buffer_set, and_write_bufferalready slides it forward on overflow, keeping the most recent octets and advancing_buffer_setto match.So a reduction keeps the most recent
sizeoctets of the content and advances_buffer_setpast the ones it drops. What this holds is lookback, and the octets it can still answer for are the ones just read — the stream is already beyond them either way.That is not cosmetic.
_buffer_set + _buffer_curis whatseekreads as "how far the stream has been consumed", and therefore as its licence to read ahead and fill a gap. Advancing the base keeps that sum exact — the base gains precisely what the content pointer loses, so it is unchanged by construction for everysize. The first revision of this PR clamped_buffer_curwithout moving the base, which left the sum short of the stream, and the next forwardseekthen spliced in octets from the wrong absolute offset and said nothing:Worth stating plainly because it is the more instructive half of this PR: on
mainthat sequence crashes, and the first revision turned the crash into silently wrong data, which is worse. Caught by a cross-review, then fixed here. Three positions, one of which is right:_buffer_cur ≤ _buffer_size_buffer_set + _buffer_cur= stream consumptionValueErrorTwo crashes, both fixed
truncatecould leave the reader in a state where the next read raised rather than returning anything —ValueError: memoryview assignment: lvalue and rvalue have different structures, from_write_buffer. Two independent routes:_buffer_curwas not brought down with the buffer it indexes, so it addressed octets a reduced buffer no longer had.truncate(0)produces a zero-length buffer, and_write_buffer'sbuf[-self._buffer_size:]isbuf[-0:]for one — the whole of the octets just read rather than none of them. Now counted from the front,buf[buf_len - self._buffer_size:]. That state is reachable only throughtruncate: the constructor refuses it,io.BufferedReaderrejecting a non-positivebuffer_sizewithValueError: buffer size must be strictly positive. So it belongs to this fix rather than to a separate one.BytesIO.seekwithSEEK_ENDwas checked before relying on it rather than guarded speculatively: it clamps a negative result to 0 rather than raising (io.BytesIO(b'').seek(-1, io.SEEK_END)→0), so theseek(-buf_len, SEEK_END)beside that slice needs no guard and did not get one.Fuzzing, which is what found all three
None of the three came out of reading the code. 3000 random sequences of
read/read1/readline/peek/seek/truncateover nine buffer sizes, checked after every operation against three invariants — the buffer's physical length equals_buffer_size;_buffer_curlies within it; and_buffer_set + _buffer_curequals the octets the underlying stream has actually handed out, counted by aBytesIOsubclass that tallies everyread/read1/readline— plus any exception the class does not document:One caveat on that harness, since it bit me: the counter subclass initially overrode
readandreadlineonly, andSeekableReaderalso callsstream.read1, whichBytesIOprovides natively. That undercounted consumption and produced 9887 phantom invariant breaks untilread1was counted too. The numbers above are from the corrected harness.Why the inputs discriminate
b'abcde',b'abcdefghijkl'andb'abcdefghijklmnop'are non-zero and non-uniform, so head and tail handling cannot produce the same bytes: growingabcdto eight givesabcd\x00\x00\x00\x00one way round and\x00\x00\x00\x00abcdthe other, and reducingabcdefghto three givesfgh,abcordefdepending on which end and which accounting — all distinguishable. The table-driven test uses sizes 3, 5, 7 and 8 rather than all multiples of one number, so an implementation off by a constant cannot pass it; the 7 case distinguishesb'abcde\x00\x00'fromb'bcde\x00\x00\x00'by a single octet's shift. It carries one control (buffer_size=8, size=8) whose expected value is identical either way, which passes on both sides, and it asserts_buffer_set + _buffer_curagainst the octets each case took off the stream.The position test asserts the return value,
tell(), the buffer's own position, and the bytes read, since the contract covers all of them.Why nothing caught any of it
truncateassertions checked the return value and never the content, exactly as the issue says.test_buffered_readline_read_read1_and_peek_pathsseeks to 1, where_buffer_cur - 1numerically equals the octets actually available — measured,2and2— so the wrong cap and the right one agree there. Seeking to 0 separates them.Evidence
Both runs from immutable
chmod -R a-wgit archivesnapshots, the same test file on each side, exit codes read from a file rather than a pipeline.Unfixed
io.py(919e4923), new tests — 13 failed, 14 passed, 5 subtests failed, exit 1:Fixed
io.py(f1141f91), same tests:Wider, in the worktree itself:
tests/corekit167 passed / 378 subtests / exit 0, andtests/foundation/test_extraction.py12 passed / 28 subtests / exit 0 — that file is the one place in the suite that wraps a non-seekable stream inSeekableReader, so it is the regression surface for thereadchange.util/changelog_md.py --checkexits 0.Coverage cannot be the evidence axis here, because
pcapkit/corekit/io.pywas already at 100% statement and 100% branch before this change — the edited lines already executed. It holds at 100%/100% after (211 statements / 80 branches → 215 / 78). The axis is the test count: 14 tests → 22, 0 subtests → 6, with 13 of the 22 failing without the fix.No fixture is involved. Every case runs against
io.BytesIO, so this suite needs nothing fromexamples/captures.Reachability
Latent rather than live:
grep -rn '\.truncate(' pcapkit/returns nothing, so no caller in the library reaches it — my own grep, confirming what #593's review recorded. It is public on a public class, and wrong when a consumer calls it.Found and deliberately not fixed
A refused
seekleaves_tellalready moved, and the next read then returns the wrong octet silently.seekassignsself._tell = offsetbefore the check that rejects the offset, soSeekErrorescapes with the position already changed. Measured identically on the base and on this branch:Not reachable from
truncateand not what SeekableReader.truncate pads on the wrong side and returns fewer octets than requested #622 reports, so it is left for its own issue. It is the largest remaining source of divergence when the fuzz above is also compared against aBytesIOoracle: read/seek-only sequences that disagree fall from 27 to 14 of 3000 with this PR, and the residue is this. A related one alongside it: theSEEK_CURandSEEK_ENDbranches never check that the resulting_tellis non-negative at all, whereSEEK_SETat least validates its raw offset.The same
min(size, self._buffer_cur - 1)cap appears three more times — inreadline,read1andpeek. Onlyread's copy is fixed here, because it is the one the reported symptom runs through;readline's interacts with its newline semantics and each needs its own discriminating test. None is reachable fromtruncateorseek:seek's internalread1call sets_tellto the end of the buffered content first, which takes the stream branch. Worth flagging for whoever picks them up thatreadandpeekcarry byte-identical copies of the line, so a search-and-replace will hit two sites that need different treatment._buffer's position can drift out of step with_tellwithout aseekto resync it, becausepeekwrites into the buffer without advancing_tell. A read from that state reads from the stale position. Not fixed, and this change cannot make it worse: the new cap is never larger than the old one whenever the position is past the buffer start, so it leaks less padding, not more.writeable()returnsFalse, andio's own contract says a non-writable stream'struncateraises —io.BufferedReader.truncateover a read-only file raisesio.UnsupportedOperation('truncate'), measured on 3.14.7. This class deliberately reinterpretstruncateas resizing the buffer instead. Making it raise is an API break and a call for the owner, so it keeps its existing semantics; the docstring now says plainly that what it resizes is the buffer and not the stream behind it.Octets dropped by a reduction cannot be recovered, the stream not being seekable. A position left among them is then before the window, which
seekrefuses as it refuses any other. Documented in the method'sNote:rather than worked around.