Skip to content

the rant store opens rants.jsonl without asking its kind — a FIFO wedges its reader, and its writer too #2097

Description

@argszero

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.

Activity

  1. argszero commented on Oct 11, 2026

    @argszero
    OwnerAuthor

    Handled by #2098

  2. argszero commented on Oct 11, 2026

    @argszero
    OwnerAuthor

    A fourth site, and one sentence in this issue that was wrong

    The ## Sites list above is the three sites PR #2098 first covered. Reviewing that PR
    (cyc20261011-121839) found a fourth, on the same path, and it is the most reachable of
    them:

    emrg/server/daemon.py's list_rants frame handler kept its own reader of
    rants.jsonl behind self._rants_log.exists():

    if self._rants_log.exists():          # true for a FIFO
        with open(self._rants_log, encoding="utf-8") as f:   # blocks, raises nothing

    The rant panel is driven by a client frame (list_rants), so a FIFO at
    ~/.emrg/rants.jsonl wedges the daemon on a click in the GUI's rant panel, not on some
    contrived setup. PR #2098 now reads through the store instead (5024b203).

    The sentence this issue got wrong is the justification above: *"daemon.py already
    logs a failed list_rants read, so a raised NotARegularFile`` (an OSError) arrives as a tool error"*. The handler does have an except OSError— and it was **unreachable**, because a blockedopenraises nothing for it to catch. That is the family's recurring trap: anexcept` that looks like a bound and is not one. It is also why this site was
    counted in the "Not in scope" list (whose 16 sites are grouped by file) even though the
    file it is in is the rant path's own client-facing reader.

    The copy carried a second defect with it, fixed by the same change: it appended the raw
    json.loads result, so a legacy array row (the 2026-08-18 format drift this store was
    extracted to prevent) or a bare scalar reached r.get(...) and raised AttributeError
    out of the dispatcher — no frame at all for the panel. Measured on the previous tree: the
    named-pipe frame test fails at the deadline and the legacy-row test fails with
    AttributeError: 'list' object has no attribute 'get', while the pipe-free control
    passes. The store's _normalize_rant has always converted or skipped both.

  3. added a commit that references this issue on Oct 11, 2026
    ed67f7a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions