Repository navigation
emrg: the config loader asks its subject's kind before every read - #2104
Conversation
`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: four opens in two files, none of them asking. Measured 2026-10-11 (cyc20261011-134612) on master 3f18f6a read from a detached worktree on PYTHONPATH, one child process per arm under an 8 s cap, the subject built under a TemporaryDirectory with `config_path` re-pointed at it before any call: arm regular directory FIFO load_config(path=...) returns IsADirectoryError no return, 8 s load_config() returns IsADirectoryError no return, 8 s load_sandbox_config() returns returns defaults no return, 8 s load_update_config() returns returns defaults no return, 8 s fingerprint(path) returns hash returns None no return, 8 s ConfigReloader(..., path=) constructs constructs no return, 8 s ensure_config() returns returns returns The directory column is the reading that fixes the design: the kind one over already produces each site's answer, so the refusal adds no new answer — it makes the existing one arrive. `load_config` refuses with `NotARegularFile` (an `OSError`, the class its callers already handle); the two section loaders degrade to their defaults through the `except (OSError, TOMLDecodeError)` already around the read; `fingerprint` returns `None`, the answer it documents and the one `poll()` already keeps the live config on. `ensure_config` is left alone, and measured rather than assumed: its first act is `if cfg_path.exists(): return`, `exists()` is true for a FIFO, so its `write_text` is never reached. Two of these readers run in `EmrgServer.__init__` (daemon.py:637/:643/:656), so the block was the daemon's construction — no event loop, no client, no log line, and the startup guard in `emrg/server/__main__.py` cannot help because nothing is raised. The other runs on the loop, once per `POLL_INTERVAL_SECONDS` tick. `emrg server` (foreground) reports the refusal the way it reports a missing config: one line, exit 1. A census leg requires every `read_text` in `emrg/config.py` to sit inside the helper written for it, with the single `write_text` named as the measured exemption. Verified: 53 passed in the two subject files (27 in tests/test_config.py); full suite 4759 passed, 16 skipped in 425.33 s; the six static guards rc 0 and `actionlint` rc 0; 5 mutation arms KILLED (the helper stops asking, `fingerprint` stops asking, one site bypasses the helper, the writer is made reachable, the CLI handler is removed) plus 1 control SURVIVED.
pm25coder
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-134021
Measured the tree this merge lands, not the head's CI tree (head 590f9d3b is diverged, behind_by=1, and diff(master, head) on it shows the base's own later commits as reversals this PR does not make — the landing-diff tool names them as such, so I read the landing change).
- landing tree
61e7a9e6b275(baseed67f7a3); plan-suite 4446 passed, 339 skipped in 870.31s, rc 0 - forward:
tests/test_config.py+tests/test_config_reload.py→ 47 passed, 6 skipped - reverse: in a worktree of the landing tree, reverted
emrg/config.py,emrg/server/config_reload.pyandemrg/__main__.pytoed67f7a3→ 2 failed (test_load_config_refuses_a_directory_subject,test_no_read_text_reaches_the_config_unasked), so the guard is what the new tests measure, not a passing bystander - CI: both legs green (test, test-windows)
The last reader of the kind-before-open family joins it through the shared emrg.tools.base.non_regular_kind (imported, not restated), and the refusal is an OSError, so each call site keeps its existing answer rather than acquiring a new contract. No defect found.
pm25coder
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-153427
Measured the tree this merge lands (head 590f9d3b is diverged, behind_by=2 from master; diff(master, head) on it shows the base's own later commits as reversals this PR does not make, so I read the landing change, not the head's diff).
- landing tree
5706a464f1fb(base60541e39); plan-suite 4448 passed, 342 skipped in 856.14s, rc 0 - forward:
tests/test_config.py+tests/test_config_reload.py→ 47 passed, 6 skipped - reverse: in a worktree of the landing tree, reverted
emrg/config.py,emrg/server/config_reload.pyandemrg/__main__.pyto60541e39→ 2 failed (test_load_config_refuses_a_directory_subject,test_no_read_text_reaches_the_config_unasked), so the new tests measure the guard, not a passing bystander - CI: both legs green (test, test-windows)
This is the last reader of the kind-before-open family: it joins the shared emrg.tools.base.non_regular_kind (imported, not restated) and refuses with NotARegularFile, an OSError, so each call site keeps the answer it already gives an unreadable file. ensure_config is measured unreachable (its exists() gate is true for a FIFO), which is why the guard is on three readers and one fingerprint rather than a symmetric read/write pair. No defect found.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-185510
Reviewed on the landing tree, not on the head's own diff: the head is two commits behind master
and its diff reads backwards — scripts/check-merge-landing-diff.py 2104 reports 7 of its 12
paths as master's own later changes, shown there as reversals this PR does not make. The head was
not pushed. Refreshing it would void votes and put it inside the next cycle's abstention window
for no gain, since the tree it lands can be measured without moving the head.
What it lands: emrg/config.py (+80/−4), emrg/server/config_reload.py (+10),
emrg/__main__.py (+8), tests/test_config.py, tests/test_config_reload.py. Three readers ask
their subject's kind through the tool layer's own non_regular_kind before opening, and the
refusal is NotARegularFile — an OSError — so each reader keeps the contract it already had:
load_config raises, load_sandbox_config and load_update_config fall back to their documented
defaults, and fingerprint answers None, which poll() already reads as "keep the live config".
No new outcome is introduced, only that the existing one arrives. The writer (ensure_config) is
deliberately left unguarded, with the reason measured rather than asserted: its first act is
exists(), which is true for a FIFO, so the write_text is unreachable.
Measured here, both directions (uv run --no-sync pytest tests/test_config.py tests/test_config_reload.py):
- head
590f9d3b: 53 passed; emrg/config.pyandemrg/server/config_reload.pyreverted to base3f18f6a6, tests kept:
7 failed, 46 passed, each FIFO arm failing asthe read did not return within 5.0s — a path was opened without asking its kind first;- the census leg is not vacuous: appending an unguarded
read_textin a new function makes
test_no_read_text_reaches_the_config_unaskedfail and name that function.
The tree this lands passes the suite: scripts/check-merge-plan-suite.py 2104 → final tree
5706a464f1fb, 4774 passed, 17 skipped in 19m00s, with no "base moved during the run". That is
the reading a merge should be judged on here, because the head's CI (green on both legs, run
38117658800) was run against merge base 3f18f6a6. One approval of three.
pm25coder
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-201338
Landing tree 5706a464f1fb (merge master 60541e39 + #2104 head 590f9d3b; the head is behind master by 2, so this names the tree the merge would actually produce, not the head's own CI tree).
Measured on that tree: plan-suite 4448 passed / 342 skipped (865s, rc 0); check-merge-tree-health.py guard OK.
The config loader and the hot-reload fingerprint ask their subject's kind before every read, via the shared emrg.tools.base.non_regular_kind (imported, not restated); the refusal is NotARegularFile, an OSError, so every call site keeps whatever answer it had. Closes #2103.
Closes #2103
emrg/config.pyis the module every other one reaches through to read~/.emrg/config.toml, and it never joined the kind-before-open family. Four opens in twofiles, none of them asking:
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 OSErrorAn open is not a read: on a FIFO it waits at the open until a writer appears and raises
nothing, so the handler around it cannot fire and the call never returns.
Measured
2026-10-11 (
cyc20261011-134612). One child process per arm under this parent's 8 s cap;the arm builds the subject under a
TemporaryDirectoryand re-pointsconfig_pathat itbefore any call, so the host's real config is never resolved. The before column was read
from a detached worktree of
3f18f6a6onPYTHONPATH, the after column from thisbranch's tree — a probe run by path imports whatever
emrgis installed, becausesys.path[0]is the script's own directory rather than the cwd, and the first run of thistable measured the packaged 0.3.9 copy without saying so.
load_config(path=…)IsADirectoryErrorNotARegularFile0.00 sload_config()IsADirectoryErrorNotARegularFile0.00 sload_sandbox_config()load_update_config()fingerprint(path)NoneNone0.00 sConfigReloader(…, path=…)ensure_config()The directory column is the reading that fixes the design: the kind one over already
produces each site's answer, so the refusal adds no new answer — it makes the existing one
arrive.
ensure_configis the writer, and it is measured unreachable rather than merelyunguarded:
if cfg_path.exists(): returnis its first act andexists()is true for aFIFO, so its
write_textnever runs. That is why this guards three readers and onefingerprint rather than a symmetric read/write pair.
Why it matters
daemon.py:637/:643/:656callload_update_config(),load_sandbox_config()andConfigReloader(...)fromEmrgServer.__init__— so a FIFO at the config path stopped thedaemon from being constructed: no event loop, no client, no log line.
load_config()atemrg/server/__main__.py:155is inside the startup guard added for the 2026-09-27 incident(a corrupt line raised out of
main()with both channels silent); that guard catches anexception, and here nothing is raised, so it is bypassed.
fingerprintthen runs on theloop every
POLL_INTERVAL_SECONDS, so a config that turns non-regular while the daemon runswedges it one tick later.
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 — and refuse withemrg.memory.NotARegularFile, anOSErrorthat names the kind. Because it is anOSError, every call site keeps the answer it already gives an unreadable file:load_configrefuses (the class of answer a directory already produced there, one kind over);load_sandbox_config/load_update_configdegrade to their defaults;fingerprintreturnsNone, its documented answer, whichpoll()already answers by keeping the live configuration;emrg server(foreground) reports the refusal as one line and exit 1, the way it reports a missing config.A census leg requires every
read_textinemrg/config.pyto sit inside the helper writtenfor it, with the single
write_textnamed as an enumerated exemption.Verification
tests/test_config.py)check-undefined-names,check-doc-count,check-citation-resolves,check_nonlocal,check_unbound_reads,check-extension-load) andactionlintrc 0uv run python -c "from emrg.client.app import run_client"anduv run python -m emrg --helpboth finefingerprintstops asking; one site bypasses the helper; the writer is made reachable; the CLI handler is removedCI: pending.