fix(foundation): close the input stream pcapkit opened, not the caller's (#610) - #636
Conversation
|
Note on landing order — these two PRs touch the same file and will conflict textually, trivially. #639 (
One substantive interaction worth knowing, in this direction only: #639 makes a |
Cross-review: GOOD TO GOIndependent cross-review, as this PR was raised by an agent and AutoSDE-style review alone is not enough. Run on Sonnet 5 — a different model from the one that wrote the change (Opus 5) — briefed to falsify rather than confirm, read-only, with every claim required to come with evidence the reviewer obtained itself. Provenance of the numbers it worked from: base Verdicts
On C5, which is the riskiest part of this PR because it is new API rather than an inversion, it verified four separate hazards with its own probes: a genuinely half-constructed instance ( Three things it found that this PR does not mention
Disagreement it resolved internally, worth recordingIts own sub-agents split on C7(c), the Baseline/head figures it re-measured for itselfclassified as 7 behavioural failures (handle counts Not verified by this review, and stated as such: nothing was left unverified. The reviewer confirmed it left no runaway processes and removed its scratch snapshots. |
ecd22ab to
7d615f3
Compare
|
Amended to Comment-only, no behaviour change. Re-verified after the amend: The cross-review's other two findings need no code change here:
|
7d615f3 to
a4cf4ce
Compare
a4cf4ce to
6d1570f
Compare
Rewritten: changelog entry moved out, rebased onto current
|
The bullet #636 originally carried, moved here verbatim so that #636 touches only `pcapkit/foundation/extraction.py` and its two test files. Covers: `Extractor` closing the caller's input stream and leaking the one it opened itself, both handlers now reading a single `_owns_input` predicate. 23 lines added to the entry file; `CHANGELOG.md` regenerated, not edited.
…r's (#610) * `Extractor._cleanup` closed the input under `not self._flag_s`, the negation of the flag that gated the `open()`, so it leaked the handle it owned and closed the one it did not. The path-given case left 1 descriptor open per extraction; the stream-given case closed a caller's file it still needed. * The `SeekableReader` wrapping a non-seekable input carried the same inverted polarity as `stream_closing=not self._flag_s`, which closed the caller's stream along with the wrapper pcapkit had built around it. * Both now read one predicate, `Extractor._owns_input`, so they cannot drift. * Added `Extractor.__del__` as a backstop for an extraction abandoned before EOF -- `auto=False`, iterated part way, dropped -- which reaches `_cleanup` by no route at all. It answers `False` for a half-built instance, since `_flag_s` is assigned long before `_ifile`. * `tests/foundation/test_extraction.py` asserted the leak was correct (`_flag_s` set, `assertFalse(closed_by_test)`); inverted, with the caller-supplied direction added beside it. * New `tests/foundation/test_extraction_ownership.py` counts handles two ways, via a `builtins.open` recorder and via `/proc/self/fd`, rather than asserting the absence of a warning. Measured on 6c3d1b0: 14 failed / 5 passed before, 10 passed after. The two files #610 names emit 0 unclosed-`in.pcap` ResourceWarnings, down from 2.
383f1b8 to
fc6c93b
Compare
Fixes #610
The defect
Extractorrecorded how its input arrived and then acted on the opposite of it:but
_cleanup()closed undernot self._flag_s. So it did both halves of thewrong thing at once — it leaked the handle it owned, and closed the handle it did
not.
The same inverted polarity appeared a second time, one screen up, at the
SeekableReaderthat wraps a non-seekable input:stream_closing=not self._flag_s.Because a handle pcapkit opens itself is always seekable and so never wrapped, that
argument was effectively a constant
True— every non-seekable caller-suppliedstream was closed along with the wrapper pcapkit had built around it.
Re-verified on current
main6c3d1b0d9, CPython 3.14.7, measured from an immutablegit archivesnapshot in/tmp(extraction.pysha256ebe7967079ca…, re-hashed afterwards and unchanged):The line numbers in the issue have shifted:
_cleanup's condition is atextraction.py:1095-1098, and thestream_closingargument atextraction.py:969.Closing the caller's stream is the more dangerous half. There is no warning and
no error — a caller that passes an open file it still intends to read simply finds
it closed, which is silent data loss.
The fix
Both sites now consult one predicate,
Extractor._owns_input, so they cannot driftapart on the rule again:
_cleanup?finis a pathfinis a seekable streamfinis a non-seekable streamWhy this needed more than the one-line inversion
The issue asks that the fix make the cross-test contamination class impossible
rather than balance the counts, and inverting the condition alone does not. Running
the two files #610 names still left one warning:
test_runtime_extract.py:35builds anauto=Falseextractor, reads two frames ofsix, and walks away.
_cleanupis reached on EOF, on interrupt, and fromrun()— an extraction abandoned before EOF reaches it by no route at all. So there is an
Extractor.__del__backstop here as well. It is documented as a backstop and notthe recommended route: collection is not deterministic, and
Extractorsits in areference cycle through its engine, so the context manager remains the way to
control when the handle goes.
With both parts, the count from #610's own confirmation command goes to zero:
in.pcapResourceWarnings6c3d1b0d9Evidence
tests/foundation/test_extraction.pyasserted the leak was correct —_flag_s = True, thenassertFalse(named_file._ifile.closed_by_test). That isinverted here, with the caller-supplied direction added next to it.
The new
tests/foundation/test_extraction_ownership.pycounts handles rather thanasserting the absence of a warning, because a leak test that cannot fail is worse
than none. Two independent measurements: a
builtins.openrecorder that keeps thefile objects
Extractoropened, and a/proc/self/fddescriptor count (skippedwhere
/procis absent).Both trees measured with
pcapkit.__file__printed into the pytest header, sincethis venv's editable install appends a finder that maps
pcapkitto the maincheckout:
with the counts named outright:
and on this branch:
Coverage cannot show this: every line the fix touches already executed, and it is
the branch that changed. The evidence axis is therefore the subtest count — 7
behavioural subtests and assertions that previously could not fail, all of which
now can.
Deliberately not fixed
__exit__still closes unconditionally. A caller who scopes anExtractorwith
withhas asked for exactly that, and an existing test pins it. Changing itwould be a contract change rather than a defect fix.
SeekableReaderoff-by-one,pcapkit/corekit/io.py:375.read()returnsmin(size, self._buffer_cur - 1)from the buffer, one byte short, so anon-seekable stream without
peek— a bareio.BytesIOsubclass — mis-readsthe first record and fails with
invalid magic number. It is why the existingsuite only ever drives one with
Extractor.runpatched out, and why the testhere uses a
BufferedReaderover a non-seekable raw, which issys.stdin.buffer'sreal shape. Not this PR's file.
SeekableReader.close()leaves a finaliser to fail. After closing, CPython'sBufferedReader.__del__callsflush(), which touches the already-closed_streamand printsValueError: I/O operation on closed fileas an ignoredexception. Pre-existing, in
io.py, and cosmetic.