fix(ingest): finish the non-regular-file guards left out of 3.7.1 (#2221) - #2244
Merged
igorls merged 1 commit intoAug 15, 2026
Merged
Conversation
…mPalace#2221) The MemPalace#2221 fix reached develop through MemPalace#2228 at an earlier revision of the branch, so three guards and their tests did not come with it. One of the three is a regression the gate that did land introduced. sweep_directory: the new gate probes the file type with f.stat() inside a try, and its except OSError printed SKIP and continued. A dangling symlink, a symlink loop and a file unlinked between rglob and the gate all raise there. Before the gate existed each of them reached sweep(), raised, and was appended to failures — so the gate turned "could not read this transcript" into a silent skip and a successful exit. A probe that FAILS is an error, not a benign file type: log it, print WARNING, book it in failures. A probe that succeeds and reports a non-regular file still skips silently. _parse_gradle: the is_file() gate sat in front of the try whose except OSError the parser already had, so a manifest under a directory with r but no x raised PermissionError out of a call that used to answer "no manifest name". The gate moves inside that try. _collect_manifest_names stats with os.path.isfile, which reports instead of raising, matching the parsers it guards. split_file: the type gate in main() covers the files the glob listed, but split_file builds its output names itself, so a pre-existing FIFO at one of them wedged write_text in the kernel waiting for a reader. Output names that are anything but a regular file are skipped. That gate asks os.path.lexists, not os.path.exists. exists() follows the link, so a DANGLING symlink at an output name reads as "nothing there" and the write goes through it, creating the target — a chunk landing wherever the link points rather than in the output directory. Measured: the two calls differ on that one case and agree on every other (regular file, symlink to a file, missing name, FIFO, symlink to FIFO, directory). test_gather_origin_samples_survives_an_unreadable_directory broke under root rather than passing vacuously: CAP_DAC_OVERRIDE walks into the 0o444 directory, the walled-off file stays readable, and the count assertion sees two samples instead of one. It now carries the same needs_unprivileged_posix gate as the three new permission tests. miner._read_text_no_follow: comment fix only. F_SETLEASE on a FIFO fails EINVAL, not ENXIO — measured on Linux 6.18 / glibc 2.39. The code branches on EAGAIN and is unaffected.
mvalentsev
force-pushed
the
fix/non-regular-guard-followup
branch
from
August 13, 2026 14:50
93c47bb to
ba5a539
Compare
0xdhx
added a commit
to 0xdhx/mempalace
that referenced
this pull request
Aug 16, 2026
`import_palace` opened every `*.jsonl` glob hit with a plain `open()`. That is safe for the cases the surrounding comment named — a directory, bad permissions, bad UTF-8 — because each raises, and the `except OSError` books it as malformed. A FIFO does neither: opening one for reading parks in the kernel until a writer appears, so a named pipe carrying a `.jsonl` name wedges the whole import and no handler can see it. Adopt the guard the ingest side already uses (`miner._read_text_no_follow`, MemPalace#2221/MemPalace#2244): `O_NONBLOCK` makes the `S_ISREG` check reachable, `O_NOFOLLOW` keeps a symlinked entry from reading through to a target outside the import tree — matching the `_reject_symlink` posture the exporter applies on the write side — and the EAGAIN arm preserves the blocking read for a regular file whose write lease we broke. A non-regular entry is booked exactly like an unreadable one, so a partially hostile export still imports what survives. The test bounds the regression with SIGALRM, since a regression does not raise — it hangs. The alarm handler deliberately raises a non-OSError: the first version used TimeoutError, which IS an OSError subclass, so the importer's own handler swallowed it and the test passed against unfixed code.
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
The #2221 fix reached
developthrough #2228 at an earlier revision of the branch, so three of its guards and their tests did not come with it. #2221 is still open. This PR carries the remainder — 6 files, +179/−10 — on top of the 3.7.1 code now ondevelop.All three are covered: copy this branch's
tests/test_non_regular_file_guards.pyonto a pristine906b918aand its five new tests fail 5/5 while the file's other 38 pass. Reverting each fix individually in place also kills exactly its own test, one for one.sweepbooks a failedstatas a failure again. The gate that landed probes the type withf.stat()inside atry, and itsexcept OSErrorprintedSKIPand continued. A dangling symlink, a symlink loop and a file unlinked betweenrgloband the gate all raise there. Before the gate existed every one of them reachedsweep(), raised, and was appended tofailures; afterwards "could not read this transcript" became a silent skip and a successful exit. A probe that fails is an error, not a benign file type — it is logged, printed asWARNING, and booked infailures. A probe that succeeds and reports a non-regular file still skips silently, unchanged.mempalace initno longer tracebacks on a directory it cannot enter. In_parse_gradle_root_project_nametheis_file()gate sat in front of thetrywhoseexcept OSErrorthe parser already had, so a manifest under a directory withrbut noxraisedPermissionErrorout of a call that used to answer "no manifest name". The gate moves inside thattry._collect_manifest_namesstats throughos.path.isfile, which reports rather than raises, matching the parsers it guards. (Path.is_file()propagatesEACCESon 3.9 through 3.13, all checked —EACCESis not in pathlib's_IGNORED_ERRNOS— and stops raising only on 3.14, where these tests would go quiet rather than fail.)splitno longer blocks on a FIFO at its own output name — nor writes through a broken link. The type gate inmain()covers the files the glob listed, butsplit_filebuilds its output names itself, so a pre-existing named pipe at one of them wedgedwrite_textin the kernel waiting for a reader. The gate asksos.path.lexists, notos.path.exists:exists()follows the link, so a dangling symlink at an output name reads as "nothing there" and the write goes through it, creating the target — a chunk landing wherever the link points instead of in the output directory. The two calls differ on that one case and agree on every other (regular file, symlink to a file, missing name, FIFO, symlink to FIFO, directory). Output names that are anything but a regular file are skipped with aSKIPline.Two smaller items:
test_gather_origin_samples_survives_an_unreadable_directorybroke under root rather than passing vacuously:CAP_DAC_OVERRIDEwalks into the0o444directory, the walled-off file stays readable, and the count assertion sees two samples instead of one. It now carries the sameneeds_unprivileged_posixgate as the three new permission tests.miner._read_text_no_follow:F_SETLEASEon a FIFO failsEINVAL, notENXIO(measured on Linux 6.18 / glibc 2.39). The code branches onEAGAINand is unaffected.How to test
The three permission tests skip as root (
geteuid() == 0), whereCAP_DAC_OVERRIDEmakes directory mode bits gate nothing — the state they need cannot be built there, so without the skip they fail rather than pass. Run them unprivileged. (unshare -r uv run pytest tests/test_non_regular_file_guards.py -qreproduces the root path: 40 passed, 3 skipped.)To see the
sweepregression, point it at a directory holding a broken symlink named like a transcript:On
develop@906b918athe unreadable entry is reported as a skip and the command succeeds:Before the gate existed —
9a3afc9d, the merge base of the #2223 branch — the same directory (plus a symlink loop) reportedWARNING: sweep failed on ...for each entry,2 file(s) failed to sweep, andexit=2. With this PR that accounting is back:Full suite locally: 4300 passed, 31 skipped, 0 failed. One caveat for anyone running it on a loaded machine:
tests/test_chroma_collection_lock.py, which this PR does not touch, flakes there — its helper re-importsmempalace.backends.chroma(and so chromadb) in a spawned child, and the parent waits for the child'sreadyflag for only500 × 0.01 sbefore asserting"holder failed to acquire lock". A pristine906b918aflakes the same file under load; both trees pass it 5/5 when the machine is quiet.Checklist
python -m pytest tests/ -v) — 4300 passed, 31 skipped, 0 failedruff check ., ruff 0.16.1 as pinned in CI;ruff format --check .clean)Refs
db29959and0f3f0c6miner.py,project_scanner.py,sweeper.pyand the test file — and every one of those conflicts is the branch's own earlier revision against its later one (miner.py's single hunk, for instance, is theENXIO/EINVALcomment). None is a collision with someone else's work. Resolved hunk by hunk it lands +144/−12 againstdevelop— the same three fixes this PR carries, as they stood before the dangling-symlink guard was added — with fix(miner): close four re-mine safety gaps in process_file #2088 intact. Resolved file-wise it does not: takingminer.pywholesale from the branch drops fix(miner): close four re-mine safety gaps in process_file #2088's work,chunk_totalgoing from 8 occurrences to 0, and the result balloons to +188/−122. mine --mode convos: file-level already-filed tracking can silently skip individual exchanges from an already-mined transcript #2183 and fix(chroma): reset chromadb System cache in ChromaBackend._client() on inode/mtime reopen #2032 are untouched either way. So a fresh branch was the option without that footgun, not the only workable one.