Skip to content

fix(corekit): keep SeekableReader's position bookkeeping in step (#643, #644) - #663

Merged
JarryShaw merged 1 commit into
mainfrom
fix/io-position-bookkeeping
Sep 22, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/io-position-bookkeeping

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 22, 2026

Copy link
Copy Markdown
Owner

Fixes #643
Fixes #644

One PR, not two. The two issues are independently diagnosable but not independently patchable: peek's buffered read is one line, and both fixes rewrite it — #643 because reading through self._buffer advances a cursor a preview must not move, #644 because the cap on that read is wrong. Splitting them would put two PRs on the same line. The joint reproductions each issue records also only become correct once both halves land, so the tests for them could not be written in the first PR of a pair. The two defects are kept separable inside the change instead: distinct commit-message bullets, distinct test methods, and Fixes for each.

What was wrong

#643 — position bookkeeping is not maintained across operations. Four faces, one root cause: nothing kept the three position variables in step across an operation that did not complete normally, and seek's resync was the only thing that ever restored them.

  1. A refused seek left _tell already moved. Each whence branch assigned _tell before anything validated the result, so a refusal left the position at the rejected target with the resync skipped. A caller that catches SeekError and reasonably believes the position unchanged read from the rejected offset, silently.
  2. SEEK_CUR/SEEK_END never checked the resulting position was non-negative. Only SEEK_SET looked at its offset, so tell() of -100 and -96 were reachable — and the message conflated a position that cannot exist with one that has merely slid out of the window, which is ordinary and recoverable with buffer_save=True.
  3. peek desynced the buffer's cursor from _tell. It correctly never touched _tell, but its buffered branch read through self._buffer, advancing that BytesIO's cursor with nothing to put it back. tell() then reported the same number in both a peeked and an unpeeked run while the bytes differed, so a caller had no way to detect it.
  4. With buffer_save=True the refusal was skipped entirely. The window refusal is conditional on there being no buffer file, and a negative position is not that case: seek(-5, io.SEEK_CUR) returned -5, and the failure surfaced later as a bare OSError: [Errno 22] from inside read.

#644 — the - 1 off-by-one. min(size, self._buffer_cur - 1) is wrong twice: _buffer_cur is measured from the window's base rather than from the position being read from, and the - 1 is short of even that. The wrong-origin half is the worse one — the buffer is allocated full of NUL padding, so over-asking returns real octets followed by padding at the correct length, which makes size_rem zero and skips the top-up from the stream, so the real data is never fetched at all.

Two corrections to the issues, both measured on 0c7f2b7c9:

  • The issues name four sites. Only three remain: read's cap was already corrected by da381f259 (fix(corekit): keep SeekableReader's buffer content when truncating it (#622) #633's landing). readline, read1 and peek are the three that were still wrong. read's cases are kept in the tests as regression guards and are the subtests that pass on base.
  • The issues state that this corrupts the PCAP magic number through Extractor. It does not reproduce on current main — see below.

The invariant, and how it is preserved by construction

The buffer is a sliding window whose octet 0 sits at absolute _buffer_set, and _buffer_set + _buffer_cur is what seek reads as stream consumption. #633's first revision broke exactly that by clamping _buffer_cur alone, turning a loud ValueError into silent corruption.

The fix does not adjust either member to make a read fit. It adds _seek_buffer(), which treats the buffer's own cursor as a derived quantity — _tell - _buffer_set, recomputed at each point of use — and returns the count genuinely available from the position, _buffer_cur - (_tell - _buffer_set). So:

  • Nothing clamps _buffer_set or _buffer_cur; the pair is only ever written by _write_buffer and truncate, both unchanged here. It therefore cannot drift from what was actually consumed.
  • The cursor drift of face 3 becomes unrepresentable rather than repaired-afterwards: there is no longer a stale cursor to read from, because no read path trusts the cursor it finds.
  • seek validates its target into a local and commits _tell only after both refusals, so a refused seek mutates nothing at all — face 1 and 2 are fixed by not writing, which is the strongest form of the guarantee.

Demonstrated, not asserted. test_the_window_base_and_content_pointer_track_the_stream_consumption drives eight operations against a stream that counts what has actually been taken off it, and checks _buffer_set + _buffer_cur == stream.consumed after every one — plus the bounds — rather than only at the end. An output assertion can pass while the bookkeeping behind it is already wrong, which is how #633's first revision reached review.

Evidence

Every face has a test that fails on base and passes here. Base run, with the library file swapped to origin/main's and the new tests kept:

FAILED test_a_refused_seek_leaves_the_position_untouched
FAILED test_seek_refuses_a_negative_position_with_a_saved_buffer_too
FAILED test_peek_does_not_move_what_the_next_read_returns
FAILED test_an_unbounded_buffered_readline_still_finishes_the_line
FAILED test_a_position_the_window_has_dropped_is_refused_not_guessed
SUBFAILED(whence=1, offset=-100)  test_seek_refuses_a_negative_absolute_position_under_every_whence
SUBFAILED(whence=2, offset=-100)  test_seek_refuses_a_negative_absolute_position_under_every_whence
SUBFAILED(half='count-less-one', method='readline'|'read1'|'peek')  test_buffered_reads_...
SUBFAILED(half='wrong-origin',   method='readline'|'read1'|'peek')  test_buffered_reads_...
SUBFAILED(operation='read', expected=b'fgh')  test_the_window_base_and_content_pointer_...
14 failed, 3 passed, 22 deselected, 10 subtests passed
exit code 1

The three passing are the deliberate regression guards: read for both halves of #644, and SEEK_SET for the negative check, which base already got right. A fourth test added later, test_a_zero_length_read_is_answered_without_consulting_the_window, also passes on base by design -- it guards behaviour base already had and this PR's first revision broke, so it is a guard against the fix rather than a demonstration of a defect. The assertion diffs, verbatim:

AssertionError: 8 != 13                                   # refused seek moved tell()
AssertionError: 5 != 2                                     # peek left the cursor adrift
AssertionError: b'abc' != b'fgh'                           # and the next read paid for it
AssertionError: b'abce\n' != b'abcde\n'                    # readline, the -1 alone
AssertionError: b'abc' != b'abcd'                          # read1 / peek, the -1 alone
AssertionError: b'56789\x00\x00\x00' != b'56789'           # wrong origin: NUL for real data
AssertionError: b'56789\x00\x00\x00' != b'56789\n'
AssertionError: b'abcd\x00\x00\x00\x00' != b'abcdefghij\n' # unbounded readline ran into padding
AssertionError: 'cannot seek before the beginning of the buffer: -100 < 0' != 'negative seek value -100'
AssertionError: 'cannot seek before the beginning of the buffer: -96 < 0'  != 'negative seek value -96'
AssertionError: SeekError not raised                       # buffer_save skipped the refusal

This branch:

tests/corekit/test_io.py          31 passed, 35 subtests passed        exit code 0
tests/corekit tests/foundation    401 passed, 11 skipped, 781 subtests  exit code 0
tests/test_docstring_contract.py
  tests/foundation/test_extraction.py    19 passed, 40 subtests         exit code 0
integration: runtime_extract,
  engine_parity, frame_iteration         21 passed, 34 subtests         exit code 0

Coverage of the module, coverage run -m pytest (no pytest-cov), goes from 100% of 215 statements to 100% of 235 (88 branches, 0 missed, 0 partial) — the one line that would have been left uncovered was the old refusal in seek, now dead because the new guard turns that position away before _tell is committed, so it is removed rather than left unreachable.

mypy and pylint on the file report exactly what they reported on base: one [override] note on the raw property at :85 and one consider-using-with at :115, both in untouched code.

Inputs discriminate. b'\x00' is exactly the buffer's NUL padding, so bytes(range(50)) — which the issues use — cannot tell a padded answer from a real one; the tests use bytes(range(1, 51)) and b'abcde'/b'0123456789XXX…' instead. A head-padded and a tail-padded implementation differ observably on these: growing b'abcd' to 8 gives b'abcd\x00\x00\x00\x00' one way and b'\x00\x00\x00\x00abcd' the other, and the wrong-origin cases additionally assert b'\x00' not in the result, which no padded answer can satisfy.

Fuzzing

3000 pseudo-random operation sequences × 10 operations, over read/read1/readline/peek/seek(all three whence)/truncate, across ten buffer sizes with buffer_save and stream-peek availability both varied, checking the consumption invariant, the bounds, data correctness against the absolute offset read from, and position-stability under refusal:

BASELINE: inconsistent states: 6729     undocumented exceptions: 446
  first inconsistent:  seek_cur(off=-8) buffer_size=7 save=True -> _tell = -5
  first undocumented:  read1(size=8) buffer_size=7 save=True -> OSError: [Errno 22] Invalid argument

FIXED:    inconsistent states: 0        undocumented exceptions: 0

Zero on four further seeds as well (150,000 operations total).

The Extractor magic-number claim: it does not reproduce

Both issues assert that peek(4) at pcapkit/foundation/extraction.py:991 corrupts the PCAP magic number. Measured against the real Extractor, driving examples/captures/in.pcap through a genuine os.pipe() so the SeekableReader wrapper is actually constructed: it does not. _magic came back b'\xd4\xc3\xb2\xa1' in every configuration, on both trees, at every buffer size tried — and at io.DEFAULT_BUFFER_SIZE, the default, the extraction is byte-identical to the same file parsed through an ordinary seekable open().

The reason the issues saw it is that they were measured on 375e9d411, before da381f259 (#633) removed the - 1 from read. That - 1 was the mechanism that dropped the \xa1: on 375e9d411 the cap gave min(16, 3) = 3, so the buffered part returned three octets and the 13-octet top-up came from a stream already at offset 4. Running the issues' own synthetic reproduction against origin/main now returns the correct b'\xd4\xc3\xb2\xa1RESTOFHEADER'. The claim was true when written and is no longer reachable.

A narrower failure does exist nearby, and is worth recording rather than repeating as the same thing: with buffer_size < 4 and a raw stream offering neither peek nor read1, peek(4) reads more than the window can hold, so _write_buffer advances _buffer_set past a _tell that a preview must not move. Base fails that loudly with ProtocolError: invalid magic number from the shifted header; this branch fails it loudly with SeekError: cannot read before the beginning of the buffer: 0 < 2. Loud either way, and the new message names the actual problem — but the underlying question of whether peek should consume more than it can retain is a design question this PR does not answer.

Deliberately not changed

@JarryShaw

Copy link
Copy Markdown
Owner Author

Differential evidence for the thing #633's first revision got wrong

The PR body claims the fix does not turn a loud failure into a silent one. That is the specific way #633's first revision failed review, so it is worth measuring rather than asserting. Every (state, operation) cell below was run against base's pcapkit/corekit/io.py and against this branch, and classified by direction — with the truth recomputed from the underlying data, not eyeballed.

2090 cells: 11 setup states × 19 operations × 5 buffer sizes × buffer_save both ways. 417 differ.

base RAISED   -> branch RETURNS  (loud to quiet):     0
base RETURNED -> branch RAISES   (quiet to loud):   175
both return, value differs:                         115
both raise, error differs:                          127

Zero loud-to-quiet. No input on which base raised now silently returns. That is the property that matters, and it is a measurement rather than a reading of the diff.

The 175 quiet-to-loud cells, each judged against the data

A new refusal is only defensible if base's answer was wrong. Classified by recomputing DATA[pos:pos+len] for the position base claimed to read from:

NEG-POS       115   base's seek RETURNED a negative absolute position
WRONG-DATA     55   base returned octets the data does not hold there
EMPTY           5   base returned b'' from a state where nothing is readable
CORRECT         0   <- base was right and the branch now raises

CORRECT is zero: there is no cell where base returned the right answer and this branch raises. The WRONG-DATA ones are stark — base answering a read at absolute 8 with the octet at 11, or a read at 1 with the octet at 2:

read(1)  @ read8_peek4         bs=1  pos=8   base b'['        data b'F'
read(8)  @ read8_peek4         bs=1  pos=8   base b'[bipw~\x85\x8c'  data b'FMT[bipw'
read(1)  @ read4_seek1_trunc2  bs=4  pos=1   base b'\x1c'     data b'\x15'
read(8)  @ read4_seek1_trunc2  bs=4  pos=1   base b'\x1c#*18?FM'    data b'\x15\x1c#*18?F'

And the 115 cells where both return a value

Same judgement applied:

BRANCH-RIGHT               70   base was wrong, branch is right
BOTH-RIGHT                 45   both correct, differing lengths (legal for read1/peek/readline)
BASE-RIGHT-BRANCH-WRONG     0
BOTH-WRONG                  0

Examples of the 70, showing the NUL substitution #644 describes and the cursor drift #643 describes:

peek(8) @ read10_seek5   bs=16  pos=5   base b'18?FM\x00\x00\x00'   data b'18?FMT[b'
peek(8) @ read4_seek2    bs=8   pos=2   base b'\x1c#\x00'           data b'\x1c#*'
peek(8) @ read8_peek4    bs=16  pos=8   base b'\x0e\x15\x1c#*18?'   data b'FMT[bipw'

The one thing to disclose

The 5 EMPTY cells are peek(0) from the two wedged states — read(4); seek(1); truncate(2) and, at buffer_size=1, read(8); peek(4). In both, _tell sits before _buffer_set with no buffer file, so no octet at _tell exists to return. Base answered peek(0) with b'', which is vacuously not wrong; this branch raises SeekError as it does for any other buffered access from that position. Every non-zero-size read from those same states returned wrong octets on base, so refusing uniformly is the consistent answer — but it is a behaviour change on a technicality, and worth a maintainer's glance rather than being buried. peek(0) on a healthy reader is unchanged and still returns b'' on both trees; it does not appear among the differing cells.

Scripts are scratch files under /tmp, not part of the change. pcapkit.__file__ asserted as this worktree's on every run, since the editable-install finder otherwise resolves to a stale checkout.

@JarryShaw
JarryShaw force-pushed the fix/io-position-bookkeeping branch from 30adbc5 to 66a3c1b Compare September 22, 2026 17:55
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review verdict

NEEDS CHANGES

Reviewed by an independent subagent on a different model (Sonnet) from the one that wrote the change (Opus), briefed to falsify each load-bearing claim rather than confirm it, and to attack the _buffer_set + _buffer_cur invariant and hunt specifically for a loud failure turned silent — the way #633's first revision failed review.

It found one real defect. Recorded here rather than folded away, then fixed in 66a3c1bc8.

The finding

read(0) / read1(0) / readline(0) / peek(0) now raise SeekError where base returned b'' successfully — a correct-on-base answer became an exception. _seek_buffer() validates the window unconditionally, before the requested size is even inspected, so a zero-byte request pays for a window check it doesn't need.

Reproduced independently before acting on it:

reader = SeekableReader(io.BytesIO(b'abcdefghijkl'), buffer_size=8)
reader.read(4); reader.seek(1); reader.truncate(2)   # tell()=1, _buffer_set=2

BASE    read(0)=b''  read1(0)=b''  readline(0)=b''  peek(0)=b''  peek()=b''
rev 1   read(0)=SeekError  read1(0)=SeekError  readline(0)=SeekError  peek(0)=SeekError  peek()=SeekError

It is correct and it is my error. Neither issue asked for a zero-length read to start failing, and a request for no octets genuinely needs none, so the window has nothing to say about it. My own differential grid missed it because it included peek(0) but not read(0), read1(0) or readline(0) — the reviewer's grid varied size at zero across all four.

The fix, in 66a3c1bc8

A zero-octet request is answered without consulting the window, in the buffered branch only of all four methods. peek's stream branch is deliberately untouched: it hands a bare peek() to the raw stream, whose own peek(0) may legitimately return a whole buffer's worth, and changing that would have been a second unasked-for behaviour change.

The non-zero case from the same stranded position still raises, which is the point of that guard:

BASE    read(4); seek(1); truncate(2); read(1) -> b'c'        (offset 1 holds b'b')
BRANCH  read(4); seek(1); truncate(2); read(1) -> SeekError

test_a_zero_length_read_is_answered_without_consulting_the_window covers it, asserting the same position both ways round so the test is a statement about the size rather than the state. Disclosed honestly: that test passes on base — measured, 1 passed, 10 subtests, exit 0 — because it guards behaviour base already had and my first revision broke. It is a regression guard against me, not a demonstration of a base defect, and it is the only new test of which that is true besides the three the PR body already declares.

Re-verified after the fix

tests/corekit/test_io.py                 31 passed, 35 subtests      exit 0
+ docstring_contract + test_extraction   50 passed, 75 subtests      exit 0
coverage of pcapkit/corekit/io.py        100%  (235 stmts, 88 branch, 0 missed, 0 partial)
fuzz, 3000 sequences                     base 6729/446  ->  branch 0/0
31 issue reproductions                   31/31 correct
pylint / mypy                            identical to base (R1732 at :115, [override] at :85, both untouched)

Differential grid re-run and widened to 2530 cells with the size-zero operations added:

base RAISED   -> branch RETURNS (loud to quiet):     0
base RETURNED -> branch RAISES  (quiet to loud):   170     was 175; the 5 peek(0) cells are gone
  of which  NEG-POS         115   base's seek RETURNED a negative position
            WRONGDATA       55    base returned octets the data does not hold
            EMPTY           0     <- the category this finding lived in, now empty
            CORRECT         0
both return, value differs:                        115
  of which  BRANCH-RIGHT    70    base wrong, branch right
            BOTH-RIGHT      45    both correct, differing lengths (legal for read1/peek/readline)
            BASE-RIGHT-BRANCH-WRONG  0
            BOTH-WRONG      0
both raise, exception TYPE differs:                 15

All 15 type changes are the same cell shape — a closed reader plus a negative seek under buffer_save=True — where base's ValueError becomes SeekError. SeekError subclasses ValueError (pcapkit/utilities/exceptions.py:381), so no except ValueError caller is affected and the message is now accurate rather than incidental.

What the reviewer confirmed, with its own evidence

Claims 1, 2, 4, 5, 6, 7 and 9 CONFIRMED, each independently reproduced rather than by re-running my scripts — including driving the real Extractor over examples/captures/in.pcap through a genuine os.pipe() with base's module substituted via sys.modules + importlib.reload, which agrees that the magic number is correct on both trees at every buffer size including 4. Claim 8 partially re-derived (branch side only, 227 0 80 0 100% at the time, matching).

Two audit findings worth keeping even though neither is a defect:

  • My fuzz harness has a structural blind spot. Its data check runs only if raised is None, so it can detect a missing raise but never a wrongful one — precisely the class of bug above, which is why 3000 sequences did not catch it. The reviewer wrote its own gated differential checker to cover the gap. The harness is scratch, not part of the change, but the limitation is worth stating next to the 0/0 figure it produces: that zero means "no corruption and no under-validation", not "no over-strictness". The widened differential grid above is what covers over-strictness.
  • _seek_buffer()'s negative-count return is provably unreachable from its four callers, each already guarded by _tell < _buffer_set + _buffer_cur. Independently agreed.

Reviewer's own summary of the two remaining # pragma: no cover top-up blocks and truncate: untouched by this diff, and truncate's _buffer_view release/reacquire has no yield point, so no method can observe a released view mid-truncation.

#644)

- seek assigned _tell before validating it, so a refused seek left the position
  at the rejected offset and the next read returned the wrong octet. The target
  is now validated before it is committed.
- SEEK_CUR and SEEK_END never checked the resulting position, so a negative
  absolute _tell was reachable. The check now applies under every whence, and
  with buffer_save=True, where the old refusal was skipped entirely and seek
  returned the negative position as though it had worked.
- peek read through self._buffer and advanced its cursor while leaving _tell
  alone, so the next buffered read started from the wrong octet. It now reads
  through the view and leaves the cursor where it found it.
- readline, read1 and peek capped size with min(size, _buffer_cur - 1): a count
  less one, measured from the window's base rather than the position read from,
  which handed back NUL padding at a plausible length and so suppressed the
  top-up from the stream. All three now derive the count from the position, as
  read already did.
- A position the window has dropped raises rather than being answered from the
  buffer's stale cursor -- except for a zero-length read, which wants no octets
  and so is answered without consulting the window at all.

tests/corekit/test_io.py: 31 passed, 35 subtests, 100% coverage of the module.
3000 random operation sequences: 6729 inconsistent states and 446 undocumented
exceptions before, zero of each after.
@JarryShaw
JarryShaw force-pushed the fix/io-position-bookkeeping branch from 66a3c1b to a38fd2c Compare September 22, 2026 18:06
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review, second pass

GOOD TO GO

Same reviewer, same model (Sonnet, different from the authoring Opus), resumed with its own context rather than restarted, and asked to verify the fix for the finding it raised — not to re-bless the change. Its verdict, verbatim at the top of its report: GOOD TO GO. Its first-pass NEEDS CHANGES and the defect behind it stand in the comment above; this supersedes it.

What it checked, with its own measurements:

  1. The repro now matches base. From the stranded state, read(0) / read1(0) / readline(0) / peek(0) and a bare peek() all return b'' with tell() unmoved on both trees — and likewise from a healthy in-window position.
  2. The guard it bypasses is not weakened. Size 1 from the same stranded state still raises SeekError: cannot read before the beginning of the buffer: 1 < 2 on all four methods.
  3. not size traced per method. read normalises None and negatives to -1 before the check, so truthiness there is provably size == 0. read1/readline leave other negatives alone, but not (-5) is False, so every non-zero size still reaches the data path. It reached the same conclusion I did about peek(None) independently, and judged base's TypeError there "an accidental crash, not a considered contract" — outside peek's documented int signature, with no production caller doing it.
  4. Its own gated differential fuzzer, 8000 sequences, re-run. Its over-strictness count dropped from 363 to 255 and it enumerated the entries that disappeared (seq=71/111/160/170/177/258/286/306/422/443/444/590, all size-zero cases). Its loud-to-quiet count stayed at 0. It inspected every remaining entry and confirmed each is the pre-existing family where base under-reads and the branch returns the correct answer. No new over-strictness and no new loud-to-quiet cell.
  5. The new arm does not shadow the saved-buffer branch. With buffer_save=True, read(6) then seek(0) — position before the window, saved file covers it — read(1) still returns the correct b'a', which the new arm cannot produce, and read(0) returns b'' through that same first branch. Confirmed structurally too: the elif is chained after the temp-file if and nested inside the outer else, so it is unreachable from peek's raw-stream path.

Two notes on provenance, so the verdict is not overstated

The reviewer verified 66a3c1bc8; the head is now a38fd2cb1. The difference is eight added comment lines and no code, which I checked rather than asserted:

$ git diff 66a3c1bc8 a38fd2cb1 --stat
 pcapkit/corekit/io.py | 8 ++++++++
$ git diff 66a3c1bc8 a38fd2cb1 | grep -E '^[+-]' | grep -v '^[+-][+-]' | grep -vE '^[+-]\s*(#|$)' | wc -l
0

The comment documents the very trade-off its item 3 endorses. I had tried size == 0 and backed it out: the two spellings are byte-identical across {0, None, False, -1, -5, 1} × four methods × two states except peek(None) from a stranded position, where size == 0 would report a wrongly-typed argument as SeekError while not size keeps base's TypeError. Matching base won.

One further probe of mine, after its report, on the path it suggested and I had not tested: readinto and readinto1 pass len(b), so an empty target supplies a zero size. readinto(bytearray(0)) and readinto1(bytearray(0)) return 0 on both trees, healthy and stranded — the fix covers that route. Exotic non-int sizes (an object that is truthy with __index__ returning 0) differ loud-to-loud: base TypeError, branch SeekError for peek/read1/readline. That is the same class as the documented peek(None) case — arguments the signature does not permit, undefined either way, and loud in both — so it is recorded here rather than changed. read(True) from the stranded state goes base b'c' (the wrong octet; offset 1 holds b'b') to branch SeekError, which is the intended refusal since True == 1.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review, third pass — GOOD TO GO confirmed at the actual final head

Same reviewer (Sonnet, vs authoring Opus), asked to re-verify because the head moved again after its second pass (66a3c1bc8a38fd2cb1) and to chase three exotic inputs I hadn't tried myself: a bool subclass, a numpy-style integer, and an object with contradictory __bool__/__eq__.

GOOD TO GO, independently re-derived rather than assumed from the prior pass:

  • Confirmed via git diff 66a3c1bc8 a38fd2cb1 -- pcapkit/corekit/io.py tests/corekit/test_io.py that the only change is a comment block, so every dynamic result from its second pass (full scoped suite, coverage, 8000-sequence differential fuzzer, Extractor probe) carries over unchanged. Reran the scoped suite anyway as a sanity check: 31 passed, 35 subtests, exit 0.
  • A correction to my own mechanism description, which I accept: I'd said base's peek(None) TypeError comes from the later len(buf) < size check. It actually comes earlier, from the old cap formula itself — min(size, self._buffer_cur - 1) evaluating min(None, 3). Traced with real tracebacks on both trees:
    BASE:   baseline_io.py:543, peek(): min(size, self._buffer_cur - 1)   <- raises here
    BRANCH: io.py:646, peek(): if not buf and len(buf) < size:            <- raises here
    
    Both produce the identical message text ('<' not supported between instances of 'int' and 'NoneType'), which is why the two sites were indistinguishable from the caught exception alone. The outcome I was protecting — TypeError, not a mistaken position error, on both trees — is unaffected; only which line raises it changed.
  • bool subclass: impossible in CPython. class MyBool(bool): pass raises TypeError: type 'bool' is not an acceptable base type. Nothing to test.
  • numpy-style integer, redone honestly. Its first mock (hand-rolled, missing __gt__/__le__/__ge__) produced spurious TypeErrors on base that were an artifact of the incomplete mock, not a finding — flagged and discarded rather than reported. Redone as class RealIntSubclass(int): pass, which is what numpy.int64 actually is with respect to these protocols: zero divergence from plain 0/1 on branch, and matches base in every state.
  • Adversarial __bool__/__eq__ object: not realistic (both signatures are typed int/Optional[int], and the one production caller never passes anything else) but tested anyway. Where it diverged from base, the cause was again an incomplete comparison protocol on the mock preventing base's min()-based cap from even running — not a pcapkit defect.
  • readinto(bytearray(0)) / readinto1(bytearray(0)): len(bytearray(0)) is a genuine Python int by the data model, so no exotic type reaches read/read1 through this path at all. Confirmed both methods, both trees, healthy and stranded: all eight cells OK n=0, tell() unmoved, base and branch identical.

No divergence found beyond the peek(None) case already known and deliberately preserved. Nothing further pending from the cross-review; the PR remains open, unpublished, and awaiting the user.

@JarryShaw JarryShaw added the fix Pull requests that fix a defect (fix: subject prefix) label Sep 22, 2026
@JarryShaw
JarryShaw merged commit b34f132 into main Sep 22, 2026
26 checks passed
@JarryShaw
JarryShaw deleted the fix/io-position-bookkeeping branch September 22, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Pull requests that fix a defect (fix: subject prefix)

Projects

None yet

1 participant