Skip to content

the abort-run store and the skill publisher open their subject without asking its kind — a FIFO wedges the abort path and the install path #2101

Closed

Description

@argszero

the abort-run store and the skill publisher open their subject without asking its kind — a FIFO wedges the abort path and the install path

What is wrong

Two more sites in the same family, each one open away from a wedge.

(a) emrg/server/abort_runs.py::AbortRuns._read — the abort path

def _read(self) -> dict[str, dict]:
    try:
        raw = json.loads(self._path.read_text(encoding="utf-8"))   # blocks here
    except FileNotFoundError:
        return {}
    except (OSError, ValueError) as exc:      # cannot fire: nothing is raised
        ...

The subject is ~/.emrg/logs/abort-runs.json (default_path()). read_text on a FIFO waits at the
open for a writer and raises nothing, so both handlers are unreachable and the call never returns.

Where it is reached matters more here than in the other sites of this family. AbortRuns.note()
reads and then writes, and it is called from the content-filter path — the moment a turn has just
lost its answer to a filter and the retry ladder is spent (emrg/server/daemon.py:4352 and :4410).
The call is the argument of the logger.error that reports the run, and the _broadcast of the
error frame comes after it. So the block does not merely hang the daemon: it hangs it at the exact
point where the client is waiting for the explanation of a failed turn, and the count this store
exists to keep (rant 2026-09-28T15:57:31, requirement L3 — 53 aborts over two days with nothing
saying "this is the 53rd") is lost with it. clear() (daemon.py:4438) reads through the same
function on the same loop.

The writer half needs no guard, and that is measured below rather than assumed: _save goes
through atomic_write_bytes, which is mkstemp + os.replace and therefore renames over the
destination instead of opening it — there is no block for a guard to move onto the writer.

(b) emrg/skills/installer.py::_publish_skill — the install path

dest.mkdir(parents=True, exist_ok=True)
target = dest / f"{name}.md"
try:
    target.write_text(skill_text + "\n", encoding="utf-8")     # blocks here
except OSError as e:
    return {"error": f"cannot write skill file: {e}"}

Path.write_text is open(path, "w"), and opening a FIFO for writing waits for a reader — the
opposite peer to the read case, so a guard elsewhere in the family would not have covered it. The
path is ~/.emrg/skills/<name>.md, the same directory #2084 and #2094 guarded on the read side
and the directory this instance installs skills into; skills_install runs on the event loop.

Measured on master 3f18f6a6

One child process per arm under this parent's 8 s cap, emrg imported from the checkout:

arm subject reading
AbortRuns(path).snapshot() a regular file RETURNED 0.0 s
AbortRuns(path).snapshot() a mkfifo at the path DID NOT RETURN within 8 s
AbortRuns(path).note("k", "s") — the abort-path call a mkfifo DID NOT RETURN within 8 s
_publish_skill(entry, runner) a regular probe.md RETURNED 0.0 s, {"ok": True, …}
_publish_skill(entry, runner) a mkfifo at probe.md DID NOT RETURN within 8 s

The regular-file arms are the controls: the kind of the path is the axis, not absence.

The two answers, and why they differ

They are the family's existing two, chosen by what each caller does with the answer:

  • _read degrades. The module's own contract is stated in its docstring — "Every read and write
    here is best-effort. An unreadable file reads as 'no run open'"
    — so a non-regular subject takes
    the answer an unreadable file already gets: logged (naming the kind) and {}. Raising would make
    the abort path raise while it is trying to report an abort, which is worse than the hang it
    replaces. #2100 reached the same conclusion for the same reason, and the same safety argument
    holds: the empty answer is safe only because the store re-derives its state on the next note().
  • _publish_skill refuses. This one is a writer, and its caller has a handler waiting:
    NotARegularFile is an OSError, so raising it before the open lands in the except OSError
    already written there and produces {"error": "cannot write skill file: <path> is a FIFO, not a regular file…"} — the reading install_skill already returns for a failed write, now with the
    cause named. It needs the FileNotFoundError branch first, because a writer's normal case is a
    destination that does not exist yet (#2095/#2096 is where that was learned).

The fix

Both ask non_regular_kind (the tool layer's path-taking reader) before the open, in the shape
emrg/skills/registry.py, emrg/server/scheduler.py and emrg/sessions_index.py already use. No
exists() is kept as a second opinion: it is true for a FIFO, which is exactly the reading that
let the block through.

Activity

  1. argszero commented on Oct 11, 2026

    @argszero
    OwnerAuthor

    Handled by #2102

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