the rant store opens rants.jsonl without asking its kind — a FIFO wedges its reader, and its writer too
What happens
emrg/server/rants.py is the single source of truth for rants.jsonl (its own docstring says so,
and rant 2026-08-18T16:42:52 made it the only writer). Both of its file paths lean on
Path.exists() and then open:
def _read_rants(rants_log: Path) -> list[dict]:
rants: list[dict] = []
if not rants_log.exists(): # TRUE for a FIFO, a socket, a device
return rants
with open(rants_log, encoding="utf-8") as f: # blocks here, raises nothing
exists() is true for every one of those kinds. So the guard passes, the open blocks until a
peer appears (a FIFO waits for a writer; a socket for a connection; a device never ends), and
nothing is raised — no handler runs, and the call simply never returns.
This runs on the daemon's event loop: the submit_rant tool is how the queue is read and written
(R6), and emrg/server/daemon.py also reaches append_rant from its rant command. The block is
therefore the whole server, not one request.
Measured
On cf9304c7, one child process per arm under an 8 s cap, against a mkfifo at the path:
| arm |
FIFO at the path |
file absent (control) |
_read_rants |
BLOCKED — did not return in 8 s |
returned in 0.0 s |
_write_rants |
BLOCKED — did not return in 8 s |
returned in 0.0 s |
The controls are what make it a reading rather than a suspicion: the same call with the file absent
answers immediately, so the kind of the path is the axis.
The writer blocks for its own reason and needs its own guard. append_rant reads the log,
appends, and writes it back — so answering "empty" for a FIFO at the read would not remove the
block, it would only move it to _write_rants, where open(rants_log, "w") waits for a reader.
A guard on the reader alone leaves the wedge reachable.
Why the answer is to refuse, not to report "no rants"
_read_rants's empty list already means one thing — there are no rants — and callers act on it.
Reporting a FIFO as empty would be a false statement about the host's own feedback channel, which is
the reading this task is told to trust (submit_rant list is the read path). The tool's four
callers already wrap the store in except Exception and return a ToolResult, and
daemon.py already logs a failed list_rants read, so a raised NotARegularFile (an OSError)
arrives as a tool error with the reason in it rather than as a hang.
Sites
emrg/server/rants.py::_read_rants — the read.
emrg/server/rants.py::_write_rants — the write; the same kind of path, the opposite block.
emrg/tools/submit_rant_tool.py::_project_names — the same tool's other read
(projects.yml in the config dir), the same exists()-then-read_text() shape, and its
except Exception: return [] cannot bound a block either. Advisory only, so it degrades to
"no candidates" rather than raising.
Not in scope
The remaining exists()-then-open sites elsewhere in the tree (measured 2026-10-11: 16 of them
across emrg/config.py, emrg/server/daemon.py, emrg/sessions_index.py, emrg/server/git_utils.py,
emrg/client/daemon_manager.py and two scripts, excluding the two modules already covered by open
PRs #2094 and #2096). This issue is one subject — the rant path — so one PR finishes it.
the rant store opens
rants.jsonlwithout asking its kind — a FIFO wedges its reader, and its writer tooWhat happens
emrg/server/rants.pyis the single source of truth forrants.jsonl(its own docstring says so,and rant 2026-08-18T16:42:52 made it the only writer). Both of its file paths lean on
Path.exists()and then open:exists()is true for every one of those kinds. So the guard passes, theopenblocks until apeer appears (a FIFO waits for a writer; a socket for a connection; a device never ends), and
nothing is raised — no handler runs, and the call simply never returns.
This runs on the daemon's event loop: the
submit_ranttool is how the queue is read and written(R6), and
emrg/server/daemon.pyalso reachesappend_rantfrom itsrantcommand. The block istherefore the whole server, not one request.
Measured
On
cf9304c7, one child process per arm under an 8 s cap, against amkfifoat the path:_read_rants_write_rantsThe controls are what make it a reading rather than a suspicion: the same call with the file absent
answers immediately, so the kind of the path is the axis.
The writer blocks for its own reason and needs its own guard.
append_rantreads the log,appends, and writes it back — so answering "empty" for a FIFO at the read would not remove the
block, it would only move it to
_write_rants, whereopen(rants_log, "w")waits for a reader.A guard on the reader alone leaves the wedge reachable.
Why the answer is to refuse, not to report "no rants"
_read_rants's empty list already means one thing — there are no rants — and callers act on it.Reporting a FIFO as empty would be a false statement about the host's own feedback channel, which is
the reading this task is told to trust (
submit_rant listis the read path). The tool's fourcallers already wrap the store in
except Exceptionand return aToolResult, anddaemon.pyalready logs a failedlist_rantsread, so a raisedNotARegularFile(anOSError)arrives as a tool error with the reason in it rather than as a hang.
Sites
emrg/server/rants.py::_read_rants— the read.emrg/server/rants.py::_write_rants— the write; the same kind of path, the opposite block.emrg/tools/submit_rant_tool.py::_project_names— the same tool's other read(
projects.ymlin the config dir), the sameexists()-then-read_text()shape, and itsexcept Exception: return []cannot bound a block either. Advisory only, so it degrades to"no candidates" rather than raising.
Not in scope
The remaining
exists()-then-open sites elsewhere in the tree (measured 2026-10-11: 16 of themacross
emrg/config.py,emrg/server/daemon.py,emrg/sessions_index.py,emrg/server/git_utils.py,emrg/client/daemon_manager.pyand two scripts, excluding the two modules already covered by openPRs #2094 and #2096). This issue is one subject — the rant path — so one PR finishes it.