Repository navigation
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
Description
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:
_readdegrades. 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.#2100reached 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 nextnote()._publish_skillrefuses. This one is a writer, and its caller has a handler waiting:
NotARegularFileis anOSError, so raising it before the open lands in theexcept OSError
already written there and produces{"error": "cannot write skill file: <path> is a FIFO, not a regular file…"}— the readinginstall_skillalready returns for a failed write, now with the
cause named. It needs theFileNotFoundErrorbranch first, because a writer's normal case is a
destination that does not exist yet (#2095/#2096is 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
argszero commentedon Oct 11, 2026
Handled by #2102