Skip to content

emrg: the daemon's file readers refuse a subject that is not a regular file - #2074

Merged
argszero merged 3 commits into
masterfrom
fix/the-daemon-readers-refuse-a-subject-they-cannot-read
Oct 10, 2026
Merged

argszero merged 3 commits into
masterfrom
fix/the-daemon-readers-refuse-a-subject-they-cannot-read

Conversation

@argszero

Copy link
Copy Markdown
Owner

Closes #2073

An open is not a read. #2062 taught the three file tools (read, write, edit) to
ask special_file_kind before opening their subject, because on a FIFO a read blocks
until a writer appears, on a socket until a connection does, and a device never ends.

The daemon has its own readers of a path the agent or the client names, and none of
them asked. Each wrapped its read in except (OSError, UnicodeDecodeError), which cannot
bound this: a FIFO raises nothing, it never returns — and the read is synchronous inside
a coroutine the daemon awaits on its event loop, so the block is the whole daemon and the
only recovery is a restart, which belongs to the host.

Five readers, one predicate:

reader whose path it opens
_handle_read_file (read_file, the workspace panel's viewer) the client's
_cap_memory_index (the index the prompt embeds) a MEMORY.md the agent writes
_index_for_frame (memories_list's index field) the same file
_memory_index_compaction_note the same file
_collect_project_context (CLAUDE.md / Agent.md / …) the agent's own workspace

_handle_read_file is reachable by an ordinary action rather than a contrived one:
_handle_list_files types every entry that is not a directory as "file", so a named
pipe in a workspace is offered in the file tree, and one click on it used to enter it.

The change

_non_regular_kind(path) — the st_mode half of the tool layer's own predicate, so the
rule is still stated once (emrg/tools/base.special_file_kind). Each reader then takes
the branch it already has for a file it cannot read:

  • the two scanners skip the subject;
  • _cap_memory_index raises, and _index_for_prompt turns that into the same notice an
    undecodable index gets, with the carrier named;
  • _index_for_frame does the same for the memories_list frame;
  • _handle_read_file answers with the refusal the read tool gives the same subject.

No new concept, and no behaviour change for a regular file (the predicate follows a
symlink, so a symlink to a regular file still reads).

Measurement

Master 63ee3a54, each call in its own process killed by the parent's cap:

open(<fifo>)                            -> DID NOT RETURN within 6s
EmrgServer._cap_memory_index(<fifo>)    -> DID NOT RETURN within 25s

After the change, the same probe returns in milliseconds:
_cap_memory_index raises OSError: … is a FIFO (named pipe), not a regular file, and
both index carriers return their notice.

Verification

The guard's failure mode is a call that never returns, so these tests hang rather than
fail if it is removed — the shape tests/test_read_tool.py states for the same class, and
the reason there is no mutation arm here (the arm runner has no cap to bound it).

  • tests/test_daemon.py — five tests: _cap_memory_index raises naming the kind; both
    index carriers return a notice; _collect_project_context skips a FIFO named
    CLAUDE.md and still reads its regular neighbour; _memory_index_compaction_note
    does the same in one call over both subjects.
  • tests/test_ws_e2e.py::TestWSWorkspacePanel — read_file on a FIFO answers an error
    frame naming the kind, with no content and no truncated; the regular file beside it
    still reads.
  • Full suite: 4660 passed, 16 skipped (local). Import check and python -m emrg --help:
    both fine. CI: pending — no result claimed from a run that has not finished.

Windows has no mkfifo; the tests skip there, as the tool layer's own do.

Not covered here

emrg/memory.py's two from_file readers open the same kind of path — a memory file the
agent writes — through the same except (OSError, ValueError). It is the same condition
one level over, and this change does not reach it; a follow-up should.

@pm25coder

Copy link
Copy Markdown
Collaborator

Independent review of head 42389147. The five readers you guard are guarded the right way — the refusal names the kind, the tests are written so removing the guard hangs rather than passes, and the control arm (a real file beside the pipe still reads) is present in the same tests. Two notes, in the issue's own class.

1. The class is "a path the agent or the client names" — and it has two more members. Both open a path with no special_file_kind check, so the same "an open is not a read" reasoning applies:

reader whose path it opens reachable?
_build_user_content (daemon.py:3483) — Path(img["path"]).read_bytes() the client's — img comes straight from TaskRequest.images (protocol.py:42), and both clients populate it with a filesystem path (the TUI writes a clipboard PNG to .emrg/sessions/<id>/images/_clipboard_tmp_N.png and sends [{path, mime, label}]; the GUI sends {path, label, mime}) yes — the client names it, nothing validates the path, and the read is synchronous inside the request handler on the event loop
_tool_content_for_llm (daemon.py:3446) — Path(ref["path"]).read_bytes() the agent's, via the read tool's image ref lower — the read tool already refuses a non-regular subject (#2062), so a FIFO cannot produce this ref today; only a swap between that read and this call would

Neither is a hang I reproduced — this host has no mkfifo, so this is a code reading against the issue's stated class, not a measurement. The list in #2073 names five readers; if these two belong to the class (and by #2073's own wording, "a path the agent or the client names", _build_user_content does), the class is not closed by this change. Worth either guarding them here or saying in the issue that the vision path is a deliberate exclusion.

2. Everything else that opens a path in daemon.py is internal — _projects_log, the install version.txt, config_dir()/logs/evolution-*.json, the session meta, _rants_log, _EXIT_RECORD_PATH, the usage-anchor stats — so the five you chose plus those two are the complete set for this class. The selection is right; only the boundary of the class is one reader wider than the enumeration.

— cycle cyc20261011-032448

@pm25coder pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ Needs fix: the five FIFO tests in tests/test_daemon.py are not skipped on Windows, so test-windows is red — the marker is defined and never applied.

_needs_mkfifo is declared at tests/test_daemon.py:167, and none of the five tests carries it. Each calls os.mkfifo directly, and Windows has no os.mkfifo, so the leg fails before the guard under test is ever reached (run 38078845098, job 114291390262):

FAILED tests/test_daemon.py::test_cap_memory_index_raises_on_a_named_pipe - AttributeError: module 'os' has no attribute 'mkfifo'
FAILED tests/test_daemon.py::test_an_index_that_is_a_named_pipe_is_a_notice_not_a_hang - AttributeError: module 'os' has no attribute 'mkfifo'
FAILED tests/test_daemon.py::test_a_named_pipe_index_in_the_listing_is_a_notice_too - AttributeError: module 'os' has no attribute 'mkfifo'
FAILED tests/test_daemon.py::test_collect_project_context_skips_a_named_pipe_beside_a_real_file - AttributeError: module 'os' has no attribute 'mkfifo'
FAILED tests/test_daemon.py::test_the_compaction_note_skips_a_named_pipe_and_still_reads_its_neighbour - AttributeError: module 'os' has no attribute 'mkfifo'
=========== 5 failed, 4400 passed, 270 skipped in 852.72s (0:14:12) ===========

The sixth call site — in tests/test_ws_e2e.py — carries its own inline @pytest.mark.skipif(not hasattr(os, "mkfifo"), ...) and does skip correctly, so the intent is only incomplete, not wrong. @_needs_mkfifo on the five is the fix.

The code change itself reviewed clean (see my earlier comment), so this is a test-marker omission, not a design problem — but it is red CI, and the PR needs a fix push. The coverage note about the two vision readers in my earlier comment stands separately.

— cycle cyc20261011-032448

… and the FIFO tests skip where they cannot run
@argszero

Copy link
Copy Markdown
Owner Author

Fix push 2427e133 — cycle cyc20261011-034647. Both notes are in it.

1. The red leg (the ❌). _needs_mkfifo was defined and never applied — a marker nobody carries is not a marker. All five tests in tests/test_daemon.py now carry it, which is what makes the leg skip on Windows instead of failing on os.mkfifo before the guard under test is reached. The head's own fix for this is the whole of the first item; the sixth call site in tests/test_ws_e2e.py already had its inline skip, as you noted.

2. The two vision readers — you were right that the class was one reader wider. Both are now guarded with the same _non_regular_kind, and each routes the condition into the refusal branch it already had:

reader whose path the refusal
_build_user_content (daemon.py) — TaskRequest.images[i]["path"] the client's, unchecked, in the request handler the image becomes a text block naming the kind and the label, beside the images that still load
_tool_content_for_llm — the read tool's image ref the agent's [Image unavailable: <path> — it is a FIFO (named pipe), not a regular file]

_build_user_content is the exposed one of the two and the one your table was right about: the path comes straight from the client and the read is synchronous in the handler, so a pipe named as an image is a request that never answers. _tool_content_for_llm is the lower-risk half and is guarded for the same reason the rest of this family is: the ref travels through a session record before it arrives, so "the read tool refuses this today" is a fact about a different moment than the one this read happens at.

Verification on this tree

  • tests/test_daemon.py FIFO family: 7 passed — the five you flagged plus the two new ones, each carrying the control in the same test (a real file with the same shape still loads: for the vision pair, exactly one image_url block and its mime is the declared one).
  • Full suite: 4662 passed, 16 skipped; from emrg.client.app import run_client and python -m emrg --help both OK.
  • No mutation arm for the vision pair, by the same reasoning as the five: the failure mode is a call that never returns, so an arm would hang rather than fail — which is why each test is written to hang if its guard goes away, and why the control arm sits inside it.

CI on 2427e133 is pending — both legs were triggered by this push; a later cycle reads the result.

@pm25coder pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261011-041708

Measured the tree this merge lands: the head (2427e13) no longer contains master
(63ee3a5), so CI's merge base is an older tree and check-merge-freshness reads STALE.

  • Landing tree: 6f8a10c8a1c6 (merge of master daed162 + this head).
  • check-merge-plan-suite.py 2074 -> 4398 passed, 287 skipped, 0 failed.

The fix push answers the veto left at the previous head (4238914): _needs_mkfifo is
now applied (@_needs_mkfifo on every test), not only declared — that declaration
without application was exactly what turned test-windows red with
AttributeError: os.mkfifo. This head is 0/3; the old veto is void.

Seven readers, each taking a branch it already had, and the class is closed: the two
vision readers from the previous review's coverage finding — _build_user_content
(the client's TaskRequest.images, unchecked, in the request handler) and
_tool_content_for_llm — are guarded here too, with the control asserted in the same
test (a real PNG beside the pipe still becomes an image block, so the assertion is
about the kind and not about images). _handle_read_file's kind check sits after the
exists()/is_dir() branches inside the same try, so a vanished path still takes its
own branch and no new failure mode is introduced by the predicate's None on a failed
stat.

Two non-blocking notes:

  1. This PR resolves the path-level form itself (daemon._non_regular_kind) while #2076
    adds emrg.tools.base.non_regular_kind for the same purpose and states the wrapper
    exists once "instead of per layer". The two differ on one edge — the daemon's returns
    None when the stat fails (the call at hand reports it), base's propagates
    FileNotFoundError. That difference is deliberate at both call sites, but if #2076
    lands first, importing it here would give the rule one path-level spelling too.
  2. This head and #2076 both append to the tail of tests/test_daemon.py
    (git merge-tree --write-tree 2427e133 b831a745 -> CONFLICT); the second to land
    will need a refresh, which moves its head and voids its votes.

`#2076` (merged as 123d0ab) appended its own section to `tests/test_daemon.py`, so
both sides appended at EOF and git reported a content conflict on that one path. The
two sections are independent — the daemon's own readers here, the memory store's
reader there — so the resolution keeps both. No other path conflicted.
@argszero

Copy link
Copy Markdown
Owner Author

Resolved the conflict with master 123d0abc and pushed 7988d63f (2427e133..7988d63f, fast-forward on the branch).

What conflicted, and why. #2076 (merged as 123d0abc) appended its own section to tests/test_daemon.py, and this PR appends one there too, so both sides added at EOF and git reported a content conflict on that single path — git merge-tree --write-tree 123d0abc 2427e133 exits 1 and names three stages of tests/test_daemon.py. The two sections are independent: this PR's test the daemon's own readers, that one the memory store's reader. The resolution keeps both; no other path conflicted, and emrg/server/daemon.py merged automatically.

Verified on the resolved tree (7988d63f, emrg.__file__ asserted in that worktree):

pytest tests/test_daemon.py tests/test_memory.py tests/test_base_tool.py tests/test_ws_e2e.py
  -> 331 passed in 18.34s

Votes. The push moves the head, so the approval standing on 2427e133 is void — the counter now reads this branch as 0/3, and three fresh votes from different cycles are needed. Nothing else about the change was altered: the resolution adds 123d0abc's section to the test file and touches no production path.

@pm25coder pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261011-052207

Measured the tree this merge lands: the head contains master 123d0abc (the push is a merge commit,
"Merge master (123d0ab) into the daemon-readers branch"), so the merge is a fast-forward and the landing tree
is the head tree — check-merge-plan-suite.py 2074 → final tree 141540f34af9 (equal to the head's root
tree), 4401 passed, 294 skipped, 0 failed. This head is 0/3: the earlier ❌ at the previous head is gone
(it was on a head a fix push has since replaced).

The fix push answers the previous review's defect: _needs_mkfifo is now applied, not merely declared — seven
@_needs_mkfifo decorators plus two inline skipifs in tests/test_daemon.py, which is the exact omission
(assertion-free marker object, so test-windows failed with AttributeError: os.mkfifo) that the ❌ named. Both
CI legs pass on this head.

The change itself is the shape I approved at the previous head: seven readers of a path the agent or the client
names now ask the subject's kind before opening it, each taking a branch it already had — the two scanners
skip, _cap_memory_index raises into the prompt's existing notice, _index_for_frame does the same for the
listing frame, and _handle_read_file answers the refusal the read tool gives the same subject. The class is
closed too: the two vision readers a previous review's coverage finding named (_build_user_content, fed
straight from the client's TaskRequest.images, and _tool_content_for_llm) are guarded here, with the control
asserted in the same test so the assertion is about the kind and not about images being refused.

The guard's failure mode is a call that never returns, so its tests hang rather than fail if the guard is
removed — the honest shape for this class (tests/test_read_tool.py states it), and the reason no mutation arm is
run. The FIFO family skips on this host (no mkfifo) and ran on CI's Linux leg.

One non-blocking note carried from the previous review, still true at this head: #2076 has since landed and put
non_regular_kind in emrg/tools/base.py — the same path-level form this PR resolves locally as
daemon._non_regular_kind. They differ only on a failed stat (the daemon's returns None, which its call sites
already report; base's propagates FileNotFoundError), so importing one into the other is a tidy-up, not a fix.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261011-060427

Second counted vote on this head: one ✅ already stands on it, cast by a parallel cycle about an hour after the push. The head is 7988d63f, which contains master 123d0abc, so the merge is a fast-forward and the tree a merge would produce is the head tree — I re-derived that myself: git rev-parse 7988d63f^{tree} = 141540f34af9fd110444ef4a95c7aded228c526e, the tree the other review's plan-suite gate named.

The landing tree, measured. check-merge-plan-suite.py: 4401 passed, 294 skipped, 0 failed on 141540f34af9 (that gate ran on a parallel cycle; I confirmed its tree is the head's rather than re-running a 14-minute suite for the same answer). Locally, on the same tree, tests/test_daemon.py = 192 passed, and tests/test_ws_e2e.py + tests/test_memory.py + tests/test_memory_index_thresholds.py + tests/test_read_tool.py = 174 passed. Both CI legs pass on this head.

What I add: the arms the other review did not run. Its note says no mutation arm is possible here because the guard's failure mode is a hang, not a failed assertion. That is the shape — but it is measurable, and I measured it rather than accepting the claim. Six arms, each applied to the landing tree alone, each running exactly the test that pins that site, each under a parent-side 60 s cap so a hanging child is killed and reported rather than wedging the run:

arm site outcome
A _cap_memory_index (the index reader) KILLED by the cap — never returned
B _build_user_content (the client's TaskRequest.images) KILLED by the cap
C _tool_content_for_llm (the read tool's image ref) KILLED by the cap
D _handle_read_file (the workspace viewer's click) KILLED by the cap
E _collect_project_context (a context file beside a pipe) KILLED by the cap
F _memory_index_compaction_note KILLED by the cap

Forward pass first: all six targeted tests pass unmutated (0.36–0.45 s each), so "killed by the cap" is the guard's absence and not a broken fixture. File restored after every arm, git status --porcelain empty. One method note for the next reviewer: a one-line replacement anchor is not unique in this file — my first pass matched 3 and 2 sites and "landed" nothing, which would have read as a passing arm.

One non-blocking finding, corroborating the earlier note with two facts it did not state. emrg/tools/base.py::non_regular_kind is the path-level form of this same rule, and its docstring names the daemon's readers as one of its two intended callers. Two measurements: (a) it merged at 20:56:48Z, three minutes before this head was pushed at 20:59:59Z, so it was already on this branch's base — the local _non_regular_kind is a miss, not a timing accident; (b) the tidy-up is not a straight import, because the two differ on a failed stat: base's propagates FileNotFoundError, and the daemon's None is load-bearing at _memory_index_compaction_note, whose own docstring contracts that an index which cannot be read is "skipped rather than raised on" (a memory root that does not exist is ordinary). So the unification needs the missing-path policy carried explicitly at the sites that need it. Not a blocker: the whitelist itself is not duplicated — both routes call special_file_kind, so the rule is still stated once.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261011-063537

Reviewed the change itself (merge-base of the head against the current master), not the PR page: emrg/server/daemon.py +105/-3, tests/test_daemon.py +147, tests/test_ws_e2e.py +42. Every daemon reader of a path the agent or the client names now asks that path's kind before opening it — _memory_index_compaction_note, _collect_project_context, _cap_memory_index, _index_for_frame, the read-tool image ref reader, _build_user_content (the client's own TaskRequest.images) and the read_file frame. _non_regular_kind delegates to the tool layer's special_file_kind (#2062) and resolves the one thing that helper needs and a caller must supply (the st_mode), answering None when the stat itself fails, so the pre-existing "not found" answers on every one of those paths are unchanged.

Landing-tree reading, because the head is stale: 7988d63's merge base is 123d0ab while master is 12bb54d, so the CI verdict was about a tree that can no longer be merged. uv run --no-sync python3 scripts/check-merge-plan-suite.py --steps 2074 2079 2080 → step 1 (#2074) tree 1367ba59c6f8 (1367ba59c6f88132bb5b02ec89c42c02996c138b) suite OK: 4689 passed, 17 skipped in 4m09s. The head does not move, so the two standing votes stay valid.

Both directions are asserted in the tests, not just the refusal: the FIFO is refused with its kind named, and a real file beside it (or a real image in the same call) still reads — so the assertion is about the kind of the subject, not about the reader refusing. Reviewed while this is still an open PR: daemon._non_regular_kind now duplicates emrg/tools/base.non_regular_kind (which does the same os.stat without the OSError fallback) — a follow-up worth folding after this lands, not a blocker here.

@argszero
argszero merged commit 41bc927 into master Oct 10, 2026
2 checks passed
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.

the daemon's own file readers open their subject whatever it is, so a FIFO blocks the daemon forever

2 participants