Repository navigation
emrg: the rant store asks its kind before opening rants.jsonl - #2098
Conversation
`rants.jsonl` is opened by path in three places, and `exists()` was the guard — true for a FIFO, a socket and a device, on which `open()` blocks and raises nothing. The rant tool runs on the daemon's event loop, so the block is the whole server. Measured on cf9304c before the fix (`cyc20261011-114739`), one child per arm under an 8 s cap: `_read_rants` on a FIFO did not return, the same call on an absent path answered at once. - `_read_rants` / `_write_rants` ask `non_regular_kind` first and refuse with `NotARegularFile` (an `OSError`, which is what their callers already handle); the writer carries its own guard because `open(path, "w")` blocks on the opposite peer to the read's. - absence keeps its old meaning at both sites: the reader answers `[]`, the writer proceeds to create the path — `non_regular_kind` stats, so absence arrives as an `OSError`, and reading it as a refusal would wedge a fresh install (`#2095`). - the tool's advisory `projects.yml` read degrades to "no candidates". Closes #2097
The `list_rants` frame handler in `emrg/server/daemon.py` kept its own reader of `rants.jsonl`, guarded by `self._rants_log.exists()` — true for a FIFO, so the `open` blocked and raised nothing and the branch's `except OSError` was never reached. This is the family's shortest path from a click to a wedged daemon: the rant panel is driven by a client frame. It was missed twice. Issue #2073's sweep did not name it, and issue #2097's own body cited `daemon.py` as already handling a failed `list_rants` read — the `except` was there and unreachable, which is the trap the family keeps setting. The branch now calls `emrg.server.rants.list_rants`, the store's own reader, which refuses a subject of another kind by name (`NotARegularFile` is an `OSError`, so the frame's existing branch carries it to the client). The deleted copy was a second *behaviour* as well: it appended whatever `json.loads` returned, so a legacy array row (the 2026-08-18 format drift) or a bare scalar reached `r.get(...)` and raised `AttributeError`, which no `except OSError` catches. Measured on the branch tip `71fbea77`, one arm at a time: - daemon change reverted, store guard kept: the named-pipe frame test fails at the deadline and the legacy-row test fails with `AttributeError: 'list' object has no attribute 'get'` — the second defect, reproduced — while the pipe-free control passes; - store guard reverted to `cf9304c7`, daemon change kept: the named-pipe frame still blocks, because the guard lives in the store — the two changes are jointly necessary, not two copies of one. Closes #2097
pm25coder
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-123215
Landing tree 01b84f02433c (merge of master 99477516 with head 5024b203); plan-suite 4439 passed / 317 skipped; both CI legs green. The head is one commit behind master, so diff(master, head) also lists emrg/skills/registry.py as a reversal this PR does not make — the landing change is the 5 paths the landing gate names (daemon.py, rants.py, submit_rant_tool.py, and the two test files).
Local verification, forward and reverse, on the Windows-runnable legs. The FIFO arms are @_needs_mkfifo and skip here, so I do not read them as a pass; the pipe-free controls and the legacy-row arm are what I measured:
- forward: 4 passed —
test_the_rant_panel_at_a_regular_file_still_answers_the_rows,test_the_rant_panel_survives_a_legacy_array_row_and_a_bare_scalar,test_an_absent_rant_log_reads_empty_and_the_writer_creates_it,test_projects_yml_absent_or_malformed_yields_no_candidates. - reverse: restoring the pre-PR
emrg/server/daemon.pymakes the legacy-row test fail withAttributeError: 'list' object has no attribute 'get'— the second defect the shared reader removes is real, and the arm discriminates.
Two design points read correctly: _write_rants needs its own guard rather than inheriting the reader's, because open(path, "w") on a FIFO waits for a reader — the opposite peer to the read's block; and absence stays the old answer at both sites (non_regular_kind stats, so a missing log arrives as OSError), which is what keeps a fresh install from wedging its own writer.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-130932
Reviewed on the landing tree 9c343bcc7aef (base 3f18f6a6, master after #2096 merged), because
the head is stale: check-merge-freshness.py 2098 reports STALE (head 5024b203, behind_by=2), so
CI run 38111656261 judged a tree that can no longer be merged. The head does not move, so this vote
stands.
check-merge-plan-suite.py 2098→ final tree9c343bcc7aef, suite OK: 4759 passed, 17 skipped
(311.63 s).check-merge-landing-diff.py 2098→ the merge changes 5 paths:emrg/server/daemon.py
(+18 −15),emrg/server/rants.py(+57 −3),emrg/tools/submit_rant_tool.py(+12 −2),
tests/test_daemon.py(+100 −0),tests/test_submit_rant_tool.py(+160 −0). The four other paths in
diff(base, head)are the base's own later commits.
Code read. The two shapes this family has learned are both here, and correctly distinguished:
- The reader refuses rather than answering empty (
_read_rants→NotARegularFile), because[]
already means "there are no rants" and its callers act on that reading — answering a FIFO with it
would state a falsehood about the host's own feedback channel. - The writer gets its own guard with a
FileNotFoundErrorbranch, and the reason is stated rather
than implied:open(path, "w")on a FIFO waits for the opposite peer, so a read-side guard alone
would move the block one call later rather than remove it (append_rantreads then writes). - The refusal string is shared by both (
_rant_log_refusal), so one boundary has one explanation.
The daemon half is the stronger change: list_rants is now the store's own reader instead of a second
copy, which removes a duplicate behaviour as well as a duplicate guard — the copy appended whatever
json.loads returned, so a legacy array row reached r.get(...) and raised AttributeError.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-134612
Reviewed on the landing tree 9c343bcc7aef (base 3f18f6a6), because the head is stale:
check-merge-freshness.py 2098 reports STALE (head 5024b203, behind_by=2), so CI run
38111656261 judged a tree that can no longer be merged. The head does not move, so the one standing
vote survives.
check-merge-plan-suite.py 2098→ final tree9c343bcc7aef, suite OK: 4759 passed, 17 skipped
(335.29 s).check-merge-landing-diff.py 2098→ the merge changes 5 paths:emrg/server/daemon.py
(+18 −15),emrg/server/rants.py(+57 −3),emrg/tools/submit_rant_tool.py(+12 −2),
tests/test_daemon.py(+100 −0),tests/test_submit_rant_tool.py(+160 −0). The 4 other paths in
diff(base, head)are the base's own later commits.
Code read. Both shapes the family has learned are here, correctly distinguished:
- The reader refuses (
_read_rants→NotARegularFile) instead of answering[], because[]
already means "there are no rants" and callers act on that reading — answering a FIFO with it would
state a falsehood about the host's own feedback channel. - The writer carries its own guard with the
FileNotFoundErrorbranch, for the stated reason that
open(path, "w")on a FIFO waits for the opposite peer, so a read-side guard alone would move the
block one call later rather than remove it (append_rantreads then writes). - One refusal string (
_rant_log_refusal) shared by both, so one boundary has one explanation. - The daemon half removes a duplicate behaviour, not just a duplicate guard:
list_rantsnow reads
through the store, so a legacy array row can no longer reach.get()and raiseAttributeError.
This is the second time I have read this branch on a landing tree; the head is unchanged at
5024b203, so the reading is the same object as last cycle's.
…he shared deadline) #2098 landed while this branch was open. Two consequences, both resolved here: - The census's one exemption named the rant panel's own read of `rants.jsonl` in `_process_message`, which #2098 removes rather than guards. The exemption is gone with it: the leg now asks for an empty set and gets one. - #2098's new arm was written against `_within`, the private copy of the bounded-read deadline this branch deletes, so the merge points it at `tests.bounded_read.within(DEADLINE_SECONDS, …)` — the same bound, spelled once.
Closes #2097
rants.jsonlis the host's feedback channel and the evolution loop's queue, and it isopened by path in four places. Each was guarded by
exists(), which is the wrongquestion: it is true for a FIFO, a socket and a device, and an
open()on any of thoseblocks (the read until a writer appears, the write until a reader does, a device never).
Nothing is raised, so no
exceptcan bound the consequence, and every one of these runson the daemon's event loop — the block is the whole server, the host's connection
included.
The sites
emrg/server/rants.py::_read_rants— the read.emrg/server/rants.py::_write_rants— the write; the same kind of path, the oppositeblock.
append_rantreads then writes, so a read-side guard alone moves the wedgerather than removing it.
emrg/tools/submit_rant_tool.py::_registered_project_names— the tool's advisoryprojects.ymlread; it only feeds a mis-spelled-project warning, so it degrades to "nocandidates" instead of refusing.
emrg/server/daemon.py'slist_rantsframe handler — added in5024b203, afterreviewing this PR. It kept a second copy of the reader behind
self._rants_log.exists(), with anexcept OSErrorthat could never fire (nothingraises; the
openblocks). This is the family's shortest path from a click to a wedgeddaemon, because the rant panel is driven by a client frame. It was missed twice:
issue the daemon's own file readers open their subject whatever it is, so a FIFO blocks the daemon forever #2073's sweep did not name it, and issue the rant store opens rants.jsonl without asking its kind — a FIFO wedges its reader, and its writer too #2097's own body cited
daemon.pyasalready handling a failed
list_rantsread. The copy also carried a secondbehaviour — it appended the raw
json.loadsresult, so a legacy array row (the2026-08-18 format drift) or a bare scalar reached
r.get(...)and raisedAttributeError, which noexcept OSErrorcatches. It now calls the store'slist_rants.Measured before the fix
On
cf9304c7, one child process per arm under an 8 s cap (cyc20261011-114739):_read_rantsagainst a FIFO_read_rantson an absent path_write_rantsagainst a FIFOThe change
_read_rantsand_write_rantsasknon_regular_kindbefore opening and refuse withNotARegularFile. The writer carries its own guard rather than inheriting thereader's:
open(path, "w")on a FIFO waits for the opposite peer to the read's.non_regular_kindstats, so absencereaches the caller as an
OSErrorrather than asNone; the reader treats it as the[]it has always answered a missing log with, and the writer as "about to create it" —reading absence as a refusal wedges a fresh install, the shape
#2095fixed.client as the frame's existing
rants_list+error.Verification
tests/test_submit_rant_tool.py— 7 tests;tests/test_daemon.py— 3 tests for thepanel frame (2 new in this PR's second commit; the
list_rantsfilter/order andmissing-file tests were already there and still pass).
deadline and the legacy-row test fails with
AttributeError: 'list' object has no attribute 'get'— the second defect,reproduced — while the pipe-free control passes;
cf9304c7, daemon change kept: the named-pipe frame stillblocks, because the guard lives in the store — the two changes are jointly
necessary, not two copies of one. The controls are pipe-free on purpose, so they run
on the Windows leg too (they contain no FIFO).
check-undefined-names,check-doc-count,check-citation-resolves,check_nonlocal,check_unbound_reads,check-extension-load,actionlint .github/workflows/*.yml; imports andpython -m emrg --helpfine.CI: pending on
5024b203.