Skip to content

emrg: the config loader asks its subject's kind before every read - #2104

Merged
pm25coder merged 1 commit into
masterfrom
fix/config-kind-before-open
Oct 11, 2026
Merged

pm25coder merged 1 commit into
masterfrom
fix/config-kind-before-open

Conversation

@argszero

Copy link
Copy Markdown
Owner

Closes #2103

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:

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

An 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 TemporaryDirectory and re-points config_path at it
before any call, so the host's real config is never resolved. The before column was read
from a detached worktree of 3f18f6a6 on PYTHONPATH, the after column from this
branch's 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, and the first run of this
table measured the packaged 0.3.9 copy without saying so.

arm regular directory FIFO (before) FIFO (after)
load_config(path=…) returns IsADirectoryError no return, 8 s raises NotARegularFile 0.00 s
load_config() returns IsADirectoryError no return, 8 s raises NotARegularFile 0.00 s
load_sandbox_config() returns returns defaults no return, 8 s returns defaults 0.00 s
load_update_config() returns returns defaults no return, 8 s returns defaults 0.00 s
fingerprint(path) returns a hash returns None no return, 8 s returns None 0.00 s
ConfigReloader(…, path=…) constructs constructs no return, 8 s constructs 0.00 s
ensure_config() returns 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. ensure_config is the writer, and it is measured unreachable rather than merely
unguarded: if cfg_path.exists(): return is its first act and exists() is true for a
FIFO, so its write_text never runs. That is why this guards three readers and one
fingerprint rather than a symmetric read/write pair.

Why it matters

daemon.py:637/:643/:656 call load_update_config(), load_sandbox_config() and
ConfigReloader(...) from EmrgServer.__init__ — so a FIFO at the config path stopped the
daemon from being constructed: no event loop, no client, no log line. load_config() at
emrg/server/__main__.py:155 is 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 an
exception, and here nothing is raised, so it is bypassed. fingerprint then runs on the
loop every POLL_INTERVAL_SECONDS, so a config that turns non-regular while the daemon runs
wedges 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 with
emrg.memory.NotARegularFile, an OSError that names the kind. Because it is an
OSError, every call site keeps the answer it already gives an unreadable file:

  • load_config refuses (the class of answer a directory already produced there, one kind over);
  • load_sandbox_config / load_update_config degrade to their defaults;
  • fingerprint returns None, its documented answer, which poll() 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_text in emrg/config.py to sit inside the helper written
for it, with the single write_text named as an enumerated exemption.

Verification

  • 53 passed in the two subject files (27 in tests/test_config.py)
  • full suite: 4759 passed, 16 skipped in 425.33 s
  • six static guards rc 0 (check-undefined-names, check-doc-count, check-citation-resolves, check_nonlocal, check_unbound_reads, check-extension-load) and actionlint rc 0
  • uv run python -c "from emrg.client.app import run_client" and uv run python -m emrg --help both fine
  • 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
  • 1 control SURVIVED (a harmless edit beside the guarded read)

CI: pending.

`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 pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 (base ed67f7a3); 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.py and emrg/__main__.py to ed67f7a3 → 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 pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 (base 60541e39); 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.py and emrg/__main__.py to 60541e39 → 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 argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.py and emrg/server/config_reload.py reverted to base 3f18f6a6, tests kept:
    7 failed, 46 passed, each FIFO arm failing as the 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_text in a new function makes
    test_no_read_text_reaches_the_config_unasked fail 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 pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@pm25coder
pm25coder merged commit 6fe9101 into master Oct 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants