the config loader and the hot-reload fingerprint open config.toml without asking its kind — a FIFO wedges the daemon at startup and on every reload tick
What is wrong
emrg/config.py is the module every other one reaches through to read ~/.emrg/config.toml, and
it never joined the kind-before-open family. grep -n "non_regular_kind\|NotARegularFile\|special_file_kind" emrg/config.py emrg/server/config_reload.py answers nothing in both files, and their readers are the shape every carrier already fixed: an exists() gate (true for a FIFO) and then a bare read_text/read_bytes inside try/except OSError.
An open is not a read: on a FIFO the open waits until a writer appears and raises nothing, so the handler around it cannot fire and the call never returns.
Four sites, one subject:
| site |
shape |
emrg/config.py::load_config |
exists() → cfg_path.read_text() |
emrg/config.py::load_sandbox_config |
exists() → read_text() inside try/except (OSError, TOMLDecodeError) |
emrg/config.py::load_update_config |
the same |
emrg/server/config_reload.py::fingerprint |
read_bytes() inside try/except OSError |
Measured on master 3f18f6a6
2026-10-11 (cycle cyc20261011-134612). One child process per arm under this parent's 8 s cap; the arm builds the subject under a TemporaryDirectory and re-points emrg.config.config_path / emrg.server.config_reload.config_path at it before any call, so the host's real ~/.emrg/config.toml is never resolved — a regular subject is the control, the axis is the path's kind. The "before" column was read from a detached worktree of 3f18f6a6 on PYTHONPATH, the "after" column from the working tree: a probe run by path imports whatever emrg is installed, because sys.path[0] is the script's own directory rather than the cwd — the first run of this table measured the packaged 0.3.9 copy and said nothing about it.
| arm |
regular |
directory |
FIFO |
load_config(path=…) |
RETURNED EmrgConfig 0.00 s |
RAISED IsADirectoryError |
DID NOT RETURN within 8 s |
load_config() (implicit path) |
RETURNED EmrgConfig 0.00 s |
RAISED IsADirectoryError |
DID NOT RETURN within 8 s |
load_sandbox_config() |
RETURNED SandboxConfig |
RETURNED defaults |
DID NOT RETURN within 8 s |
load_update_config() |
RETURNED UpdateConfig |
RETURNED defaults |
DID NOT RETURN within 8 s |
fingerprint(path) |
RETURNED a hash |
RETURNED None |
DID NOT RETURN within 8 s |
ConfigReloader(live, path=…) |
RETURNED |
RETURNED |
DID NOT RETURN within 8 s |
ensure_config() |
RETURNED |
RETURNED |
RETURNED 0.00 s — not a defect |
The directory column is the reading that fixes the design: it already gives each site an answer, and
it is the same answer the refusal should reach. ensure_config is measured unreachable rather than
merely unguarded: its first act is if cfg_path.exists(): return, and exists() is true for a
FIFO, so the writer's write_text is never reached — which is why this change guards three readers
and one fingerprint rather than a symmetric read/write pair.
Why it matters
Two of the four are on the daemon's own startup path, before any handler can fire:
emrg/server/daemon.py:637 / :643 call load_update_config() and load_sandbox_config() from
EmrgServer.__init__, and :656 builds a ConfigReloader, whose __init__ fingerprints the file
as its baseline. A FIFO at the config path means the daemon cannot be constructed.
emrg/server/__main__.py:155 calls load_config() inside the startup guard added for the
2026-09-27 incident — a corrupt line in the host's config.toml raised out of main() with both
log channels silent, and the client was told only "the child is already exited (exit=1)". That
guard catches an exception; here nothing is raised, so it is bypassed and the daemon hangs with
emrgd.log empty — the failure mode the guard exists to end, now with no exit and no log at all.
A client that auto-detects and starts the daemon waits for a port that never opens.
- The remaining two are on the event loop:
daemon.py:1107 awaits _reload_config_once(), whose
poll() reads the file every POLL_INTERVAL_SECONDS tick — so a config that turns non-regular
while the daemon runs wedges the loop one tick later, on every connected client.
The subject is host runtime state this project's own tooling has already been observed to mutate: the
2026-09-27 incident above was a stray probe line written into the host's file by a sandbox probe.
A pipe at that path is the same event with a worse end.
The fix
Ask the kind before the open, through the tool layer's path-taking reader
emrg.tools.base.non_regular_kind — imported, not restated (#2076's argument), and refuse with
emrg.memory.NotARegularFile, which is an OSError that names the kind. Because it is an
OSError, every call site keeps the answer it already gives an unreadable file, so no reader's
contract changes; the only new behaviour is that the answer arrives:
load_config refuses. It already raises FileNotFoundError for a missing file, and a
directory already raises IsADirectoryError out of it — so the refusal is the same class of answer
one kind over. Callers are unchanged (poll() catches Exception; the CLI prints it).
load_sandbox_config / load_update_config degrade to their defaults, through the
except (OSError, TOMLDecodeError) already written around the read — exactly what a directory
already gets there.
fingerprint returns None, which is its documented answer for a subject it cannot read
("a content hash of path, or None when it cannot be read"), and poll() already answers None
to that by keeping the live configuration.
ensure_config is left alone, with the measurement above saying why.
- The CLI (
emrg/__main__.py) answers the refusal the way it answers a missing file — one line and
exit 1 — instead of a traceback.
A source-shape leg (in #2095/#2096's spirit) requires every read_text in emrg/config.py to sit
inside the one helper written for it, so a reader added later cannot quietly skip it.
the config loader and the hot-reload fingerprint open
config.tomlwithout asking its kind — a FIFO wedges the daemon at startup and on every reload tickWhat is wrong
emrg/config.pyis the module every other one reaches through to read~/.emrg/config.toml, andit never joined the kind-before-open family.
grep -n "non_regular_kind\|NotARegularFile\|special_file_kind" emrg/config.py emrg/server/config_reload.pyanswers nothing in both files, and their readers are the shape every carrier already fixed: anexists()gate (true for a FIFO) and then a bareread_text/read_bytesinsidetry/except OSError.An open is not a read: on a FIFO the open waits until a writer appears and raises nothing, so the handler around it cannot fire and the call never returns.
Four sites, one subject:
emrg/config.py::load_configexists()→cfg_path.read_text()emrg/config.py::load_sandbox_configexists()→read_text()insidetry/except (OSError, TOMLDecodeError)emrg/config.py::load_update_configemrg/server/config_reload.py::fingerprintread_bytes()insidetry/except OSErrorMeasured on master
3f18f6a62026-10-11 (cycle
cyc20261011-134612). One child process per arm under this parent's 8 s cap; the arm builds the subject under aTemporaryDirectoryand re-pointsemrg.config.config_path/emrg.server.config_reload.config_pathat it before any call, so the host's real~/.emrg/config.tomlis never resolved — a regular subject is the control, the axis is the path's kind. The "before" column was read from a detached worktree of3f18f6a6onPYTHONPATH, the "after" column from the working tree: a probe run by path imports whateveremrgis installed, becausesys.path[0]is the script's own directory rather than the cwd — the first run of this table measured the packaged 0.3.9 copy and said nothing about it.load_config(path=…)EmrgConfig0.00 sIsADirectoryErrorload_config()(implicit path)EmrgConfig0.00 sIsADirectoryErrorload_sandbox_config()SandboxConfigload_update_config()UpdateConfigfingerprint(path)NoneConfigReloader(live, path=…)ensure_config()The directory column is the reading that fixes the design: it already gives each site an answer, and
it is the same answer the refusal should reach.
ensure_configis measured unreachable rather thanmerely unguarded: its first act is
if cfg_path.exists(): return, andexists()is true for aFIFO, so the writer's
write_textis never reached — which is why this change guards three readersand one fingerprint rather than a symmetric read/write pair.
Why it matters
Two of the four are on the daemon's own startup path, before any handler can fire:
emrg/server/daemon.py:637/:643callload_update_config()andload_sandbox_config()fromEmrgServer.__init__, and:656builds aConfigReloader, whose__init__fingerprints the fileas its baseline. A FIFO at the config path means the daemon cannot be constructed.
emrg/server/__main__.py:155callsload_config()inside the startup guard added for the2026-09-27 incident — a corrupt line in the host's
config.tomlraised out ofmain()with bothlog channels silent, and the client was told only "the child is already exited (exit=1)". That
guard catches an exception; here nothing is raised, so it is bypassed and the daemon hangs with
emrgd.logempty — the failure mode the guard exists to end, now with no exit and no log at all.A client that auto-detects and starts the daemon waits for a port that never opens.
daemon.py:1107awaits_reload_config_once(), whosepoll()reads the file everyPOLL_INTERVAL_SECONDStick — so a config that turns non-regularwhile the daemon runs wedges the loop one tick later, on every connected client.
The subject is host runtime state this project's own tooling has already been observed to mutate: the
2026-09-27 incident above was a stray
probeline written into the host's file by a sandbox probe.A pipe at that path is the same event with a worse end.
The fix
Ask the kind before the open, through the tool layer's path-taking reader
emrg.tools.base.non_regular_kind— imported, not restated (#2076's argument), and refuse withemrg.memory.NotARegularFile, which is anOSErrorthat names the kind. Because it is anOSError, every call site keeps the answer it already gives an unreadable file, so no reader'scontract changes; the only new behaviour is that the answer arrives:
load_configrefuses. It already raisesFileNotFoundErrorfor a missing file, and adirectory already raises
IsADirectoryErrorout of it — so the refusal is the same class of answerone kind over. Callers are unchanged (
poll()catchesException; the CLI prints it).load_sandbox_config/load_update_configdegrade to their defaults, through theexcept (OSError, TOMLDecodeError)already written around the read — exactly what a directoryalready gets there.
fingerprintreturnsNone, which is its documented answer for a subject it cannot read("a content hash of
path, orNonewhen it cannot be read"), andpoll()already answersNoneto that by keeping the live configuration.
ensure_configis left alone, with the measurement above saying why.emrg/__main__.py) answers the refusal the way it answers a missing file — one line andexit 1 — instead of a traceback.
A source-shape leg (in
#2095/#2096's spirit) requires everyread_textinemrg/config.pyto sitinside the one helper written for it, so a reader added later cannot quietly skip it.