Skip to content

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 #2103

Description

@argszero

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.

Activity

  1. argszero commented on Oct 11, 2026

    @argszero
    OwnerAuthor

    Handled by #2104

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