Every message reads your config: Hermes just took the lock off that hot path


You ask Hermes to bump a compression threshold. It does, and writes the change back to config.yaml. During those few hundred milliseconds you fire off a message to your own bot on Telegram — and nothing comes back. It is not the model and it is not the network. That message is waiting on a lock.

That sounds like a corner case. It is not; it happens daily. The reason is mundane: before Hermes can answer “which model am I using”, “is this plugin on”, or “how long is this timeout”, most of its critical paths have to read the config. And reading the config has always been an expensive operation that also has to queue. This batch of work (#118961, which folds in #115069, #115070 and #117440) removes the redundant parsing and the global lock from that path.

Where the cost actually came from

Config is YAML, so reading it means: locate the file, parse the YAML, deep-merge it over the defaults, then hand the caller a copy. To avoid reading a half-written file, all of that ran under one global _CONFIG_LOCK.

Two problems follow:

  1. Every read re-parsed. Even when not one byte had changed. Some paths were worse than that — streaming output re-read the config per reasoning token to decide whether to emit deltas, and heartbeat restore re-entered each profile scope per row.
  2. Reads queued behind writes. If a writer (save_config(), say) held the lock, every read waited. On a gateway that read sits on the hook path of every inbound message, so one save could stall everyone’s message handling.

Fix one: read once per stream, not per token

Three folded-in changes, each removing one repetition:

  • Streaming delivery (agent/stream_delivery.py): plugins.stream_reasoning_deltas is now resolved once per stream rather than once per reasoning token. The PR’s measurement: roughly 340 µs down to about 23 µs per read.
  • Heartbeat restore (gateway/run_heartbeat_restore.py): each profile scope is entered once per restore scan instead of once per row.
  • Profile config parsing (utils.load_yaml_file_readonly): parses of a profile’s config.yaml are cached on the file signature (mtime_ns, size, inode, ctime_ns) — positive results only, so a changed file is re-parsed rather than served stale.

Fix two: a cache hit no longer takes the lock

This is the important one. load_config() in hermes_cli/config.py now has a lock-free fast path, and the source comment states the stakes plainly:

# Lock-free fast path for cache hits — same publication contract as
# `_read_raw_config_impl` above (whole-tuple replace, `_CONFIG_LOCK` only
# serializes rebuilds and writers). A hit costs ~0.024ms; behind a lock held
# by `save_config()` the same read measured 10010ms, and on a gateway that
# stalls every inbound message's hook path. A lost race falls through to the lock.

Why is it safe? Because of an explicit publication contract: the cache entry is replaced as a single whole tuple, and the lock only serializes rebuilds and writers. A reader therefore sees either the complete old tuple or the complete new one — never a torn intermediate state. After taking the snapshot it re-checks the file signature; if the signature does not match, the read is treated as a miss and goes down the locked path.

In other words, reads no longer need to queue because they are not reading state under protection — they are reading a snapshot that is never mutated in place. And losing the race is not a correctness gamble: the fall-through is the original, fully defensive implementation.

The same fast path was extended to read_raw_config() and read_raw_config_readonly().

The probe numbers in the PR (on the author’s machine, with a writer holding _CONFIG_LOCK for 2 seconds):

Read Before After
Cached load_config() hit ~2008 ms 0.3–1.9 ms
read_raw_config() ~2001 ms 0.1–0.2 ms
Worst case recorded in the source comment 10010 ms ~0.024 ms

It was also verified that switching HERMES_HOME A → B → A shows no cross-home cache leakage, which is exactly what matters in multi-profile and multi-gateway deployments.

Fix three: deleting a memoization that stopped paying for itself

There is a nice detail buried in this batch: the author removed a memoization rather than keeping it.

hermes_cli/plugins.py had a memo for hook_callback_timeout. Once load_config hits became lock-free, the memo saved almost nothing — the PR gives 8.4 µs for a memo hit against 9.0 µs for calling load_config_readonly() directly, a 0.6 µs difference. But it had a real side effect: it bypassed load_config’s environment-snapshot check, so if you had expressed that timeout as an environment reference — the ${VAR} form — changing the variable would not have refreshed it.

Saving 0.6 µs in exchange for a “changed the env var and nothing happened” trap is a bad trade. So it was deleted rather than optimised.

What it means for you

  • Steadier message latency on gateways. “Saving config” and “receiving messages” no longer interfere.
  • Nothing to configure. This is not a switch you turn on; it is default behaviour.
  • Multi-profile and multi-gateway setups benefit most, because heartbeat restore and per-profile scope entry were costs that scaled with profile count.

If what you were hitting instead was “the machine feels slow, and CPU is busy while it is idle”, that is a different perf sweep — see the slow-disk / idle-CPU write-up. If your pain is long tasks stalling part-way through, tuning for long tasks is the closer read. To see how config is read and written from the CLI, the reference page is hermes config; and for why collapsing several profiles onto one host pays off, see one gateway per host.

Wrapping up

No new features and no new config keys here — just one idea: reading config should neither queue nor re-parse. A cache hit went from “take the lock and re-parse” to “read a snapshot that is never mutated in place, then verify the signature,” with the original locked implementation as the fall-back. The performance numbers are impressive, but the more valuable part is the trade-off: a memoization worth 0.6 µs, which could silently ignore an environment-variable change, is not worth keeping.