Owned01Fail-closed vs fail-open — refuse vs degrade
The plain explanation
Software has two paths: the one where everything works, and the one where a dependency has broken. The error path gets a try/except and a logger.warning, and it holds the real policy decisions.
There are only two things an error path can do:
- Refuse to act — stop, return an error, let the caller decide. This is fail-closed (also fail-safe, fail-secure, default-deny): if we cannot do it correctly, we do not do it.
- Act anyway, in a lesser form — keep going with whatever capability is left. This is fail-open (also fail-soft, degrade, default-permit): the work still happens, but less of it, or with less confidence.
fail-closed — from physical door hardware: a door that fails closed locks when the power dies (a prison cell), one that fails open unlocks (a fire exit). Same failure, opposite conclusion, each right only for its building. Software inherited the word because it has the same fork.
degrade — to serve a worse but still useful answer instead of an error. Real products do it: a search box that searches "only part of the corpus" instead of failing, a map that routes on cached data without a network, a video call that drops resolution instead of the call.
durability — the guarantee that once a write returns, the data survives a crash or a power cut. Not "the data was written"; "the data is on the disk and the disk promised us". Durability off is fast, and it is the most common fail-open decision in the industry.
sentinel — a value meaning "I have no answer", distinct from "the answer is nothing". An empty list is ambiguous; a sentinel is a specific signal. A bad sentinel is a lie: an empty list read downstream as "no data" when it means "I could not read".
The question:
When I cannot do this correctly, does anything still happen?
Two components in the same repository, by the same author in the same week, can answer that question in opposite directions. That is fine. Not knowing which side you chose is not.
Where this shows up in practice — one incident, four decisions
Four error strings from this host's own stack, all from the same 36 hours. Read the table as one column of "what it decided", not as four bugs.
| Decision | What the log/code actually says | Why that side, in one clause |
|---|---|---|
| 1fail-closed — refuse the turn | the turn was stopped because session storage could not be written (the transcript would have been lost on restart) |
A turn that cannot be durably recorded is a turn that will disappear on restart. Refusing is honest; proceeding silently destroys the user's history. |
| 2fail-open — keep writing | Automatic rebuild of stale FTS indexes failed (database disk image is malformed); canonical writes remain enabled with FTS detached. |
The full-text index is derived data. The canonical messages are the truth. Losing search temporarily beats losing everything. |
| 3sentinel — answer-shaped non-answer | Transcript read failed for session %s (returning empty; downstream must not treat this as data loss) |
The read path cannot refuse (its caller is a UI), so it returns [] plus a strong warning, and pushes the "this is not empty, this is broken" judgement downstream. |
| 4fail-open at the storage layer | WAL disabled → journal_mode=DELETE on filesystems where WAL cannot work |
Concurrency is traded away so that the feature still works — "Concurrency drops — concurrent readers are blocked during a write — but the feature works." |
Evidence, verbatim. Decisions 1 and 3 are from the installed source (run_agent.py, four cause-specific variants — taken-over, locked, corrupt, disk; and gateway/session.py). Decision 2 is from hermes_state_schema.py, logged twelve times between 08:28:52 and 08:54:58 on Sep 18. Decision 4 is from hermes_state.py's WAL handling.
Two jobs still carry decision 1 as their last recorded status in ~/.hermes/cron/jobs.json:
Japan Typhoon Monitor (Sep 2026 Trip) last_run 2026-09-18T07:54 status error
kyra-nightly-design-study last_run 2026-09-17T18:30 status error
Nine of the thirty-one scheduled jobs had no recorded run later than Sep 18 when the gateway came back. Some of those nine are legitimately weekly or monthly. But three daily 18:00/15:30 jobs (Nightly Compaction 0 18 * * *, Nightly Memory Keeper 30 15 * * *, Mara Nightly Design Scout 0 18 * * *) each show a three-run gap: the nights of Sep 18, 19 and 20 never happened, and the next recorded run is the catch-up at 00:34–00:40 on Sep 21.
Two mechanisms, stacked. The fail-closed path refused turns while the database was malformed (that is what still sits in kyra-nightly-design-study's last_error). Then the stop path removed the scheduler entirely for two and a half days. That is the price of the fail-closed decision: refusing correctly is not free. It costs work, and on a cron system it costs missed work, silently. The second mechanism makes it three nights, not one.
Note which decision is missing: nothing threw the turn away quietly. Each of the four picked a side and said so. That is the difference between an error path and a landmine.
The policy question
The question to ask of any error path is not "does it crash?" — it is:
Which side of the trade-off did the runtime pick, and was that right for this data?
Four questions:
- Is the thing I would lose recoverable? Derived index → degrade. The transcript → refuse.
- Who finds out? A fail-open that logs an ERROR is a decision; a fail-open that logs nothing is a lie, discovered by whoever needed the data.
- Is the refusal visible to the caller? "No reply" is a signal. An empty reply is not. Decision 3 exists because an empty list is indistinguishable from a legitimately empty transcript, and downstream code had already routed on that ambiguity (the comment names the bug number).
- Who owns the leftover? When decision 2 detaches the index, something must rebuild it. Here that was the state layer's recovery path: the journal shows the repair at
2026-09-21 00:29:33—Rebuilt stale state.db FTS indexes from canonical messages and restored sync triggers.A fail-open with no rebuild owner is not degradation; it is deferred data loss.
The same decision, wearing other clothes
The shape appears everywhere. Three families:
Approval gates. From Hermes' own security guide: "Approval prompts fail closed. If you don't respond to an approval prompt within the timeout (default 300 seconds), the command is denied. Walking away from your desk never silently approves anything." The policy is set for the worst case (an unattended agent), not the common one.
Blocklists / denylists. Also from that guide: "A hardline blocklist is the always-on floor. Some commands — rm -rf /, fork bombs, zeroing a physical disk — are refused regardless of approval mode, --yolo, or an explicit 'allow always'. The blocklist trips before the approval layer even sees the command, and there is no override flag." Default-deny by construction: no configuration exists in which the check runs and the answer is "permit". File-write protection is the same — "Blocked writes return an error immediately — there is no approval prompt and no way to override from the chat UI."
Storage durability. SQLite's PRAGMA synchronous=OFF is the purest fail-open in computing, and the docs are unusually blunt: "By setting PRAGMA synchronous=OFF, all sync operations are omitted. This makes SQLite seem to run faster, but it also allows the operating system to freely reorder writes, which could result in database corruption if a power failure or hard reset occurs prior to all content reaching persistent storage." The alternative — synchronous=NORMAL (WAL's default) — is also a fail-open, just narrower. On one platform it is not narrow enough: the installed state layer switches to synchronous=FULL on macOS, because Apple's fsync() does not promise data-on-platter — the kind of detail that turns a policy into a bug when nobody writes it down.
The 1975 articulation is still the best one, and it is free:
"Fail-safe defaults: Base access decisions on permission rather than exclusion. This principle, suggested by E. Glaser in 1965, means that the default situation is lack of access, and the protection scheme identifies conditions under which access is permitted."
Saltzer & Schroeder, The Protection of Information in Computer Systems (MIT, 1975), §I.A.3(b)Saltzer and Schroeder write about access control, but the sentence applies to every error path: the default is what happens when nobody is around to decide. They do not say "fail-closed is better"; they say design should rest on permission — naming the conditions under which the dangerous thing happens. An exclusion list fails open the moment a case is missing.
Google's SRE book reaches the same place from the availability side, in the chapter on overload: "A well-behaved backend, supported by robust load balancing policies, should accept only the requests that it can process and reject the rest gracefully." That is fail-closed policy for a service built to accept requests. The point is not the direction; it is that the direction was chosen and stated.
Why it matters
- The error path shows your system's real priorities. Marketing says "reliable"; the error strings say what happens when it is not.
- Two components choosing opposite sides in the same incident is correct — the index and the transcript have different recoverability. It only becomes a problem when nobody wrote down which is which, so the next person "fixes" the inconsistency.
- Every fail-open needs a rebuild owner and a visible log line, or it is just a delay.
- Every fail-closed needs a retry story for scheduled work, or an outage becomes a data gap. (Here: three daily jobs, three missed runs each — Sep 18, 19, 20.)
OrphanedOrphaned02Exit paths vs exit codes — “stopped” is not “crashed”No restart
The plain explanation
A service can stop existing in exactly three ways, and the difference between them decides whether anything should bring it back:
- It exits. Either cleanly (it finished, returned 0) or by crashing (unhandled error, non-zero exit).
- It is killed. Something outside it sends a signal —
SIGTERMfrom a stop request,SIGKILLfrom the OOM killer,SIGKILLafter a timeout. - It is deliberately stopped. A supervisor (
systemd), a script (hermes gateway stop), or a person runs the stop path. The process still dies by signal or exit — but the death was requested, and that changes everything.
Restart=always — the setting nearly everyone puts on a long-running unit and then forgets — covers (1) and most of (2). It explicitly does not cover (3). The man page is unambiguous; this is the sentence to remember:
"Restart= — Configures whether the service shall be restarted when the service process exits, is killed, or a timeout is reached. … When the death of the process is a result of systemd operation (e.g. service stop or restart), the service will not be restarted. Timeouts include missing the watchdog "keep-alive ping" deadline and a service start, reload, and stop operation timeouts."
man systemd.service — systemd 255 (255.4-1ubuntu8.17), read on this hostThat is the whole lesson in one clause.
Restart=always is not “always”. It is “always, except on purpose”.
exit code — the integer a process hands its parent when it finishes. 0 means "fine"; anything else means "not fine" in a way the program chose to describe. Unix also has the killing signal, a different channel with different conventions. systemd sees both.
exit path — the code that runs on the way out: flushing buffers, closing the database, writing an "I am stopping" note. A service can be terminated without running its exit path (
SIGKILL, OOM, VM death) — a different kind of stop, not a louder one.
Exit codes are a separate contract
Once "stopped" and "crashed" are different events, you want to say which non-zero exits deserve a restart. systemd gives you three independent layers. Confusing them is the most common supervision bug:
| Layer | Setting | What it answers |
|---|---|---|
| Classification | SuccessExitStatus= | "Which non-zero exits should be treated as clean?" |
| Policy | Restart=no|on-success|on-failure|on-abnormal|on-watchdog|on-abort|always | "Given the classification, restart or not?" |
| Override | RestartForceExitStatus= / RestartPreventExitStatus= | "Regardless of the policy above, force or forbid a restart for these codes." |
The two overrides, verbatim from man systemd.service:
"RestartForceExitStatus= — Takes a list of exit status definitions that, when returned by the main service process, will force automatic service restarts, regardless of the restart setting configured with Restart=."
"RestartPreventExitStatus= — Takes a list of exit status definitions that, when returned by the main service process, will prevent automatic service restarts, regardless of the restart setting configured with Restart=."
The sentence that ties them together:
"As exceptions to the setting above, the service will not be restarted if the exit code or signal is specified in RestartPreventExitStatus= (see below) or the service is stopped with systemctl stop or an equivalent operation. Also, the services will always be restarted if the exit code or signal is specified in RestartForceExitStatus=."
The two magic numbers are not invented: they are the BSD sysexits conventions, in /usr/include/sysexits.h on this host:
#define EX_TEMPFAIL 75 /* temp failure; user is invited to retry */
#define EX_CONFIG 78 /* configuration error */
- 75 = "temporary failure; user is invited to retry." The program tells its supervisor: not my fault, it will probably work next time — relaunch me. So
RestartForceExitStatus=75means "even if my restart policy says no, restart me on 75." - 78 = "configuration error." The program tells its supervisor: I am misconfigured; relaunching will fail the same way forever and fill your logs. So
RestartPreventExitStatus=78means "never restart me on 78" — a deliberate restart-storm circuit breaker owned by the program itself: the child gets a vote in its own supervision.
The concrete case, from this host's journal
Sep 18 08:56:31 kagami-core systemd[1]: Stopping hermes-gateway.service - Hermes Agent Gateway - Messaging Platform Integration... Sep 18 08:56:34 kagami-core systemd[1]: hermes-gateway.service: Main process exited, code=exited, status=1/FAILURE Sep 18 08:56:34 kagami-core systemd[1]: hermes-gateway.service: Failed with result 'exit-code'. Sep 18 08:56:34 kagami-core systemd[1]: Stopped hermes-gateway.service - Hermes Agent Gateway - Messaging Platform Integration. ... Sep 21 00:28:42 kagami-core systemd[1]: Started hermes-gateway.service - Hermes Agent Gateway - Messaging Platform Integration.
Read those four lines in order, because the order is the whole diagnosis:
Stopping …— systemd initiated a stop. This is the line that decides everything that follows. From this moment on, the death is "a result of systemd operation".Main process exited, code=exited, status=1/FAILURE— the process did come back with exit status 1. It exited, by exit code, not by signal. (Worth knowing: a stop request defaults toSIGTERM, andKillSignal=here isSIGTERM— a program that handles SIGTERM and then exits 1 is a program whose own shutdown code ran and returned non-zero.)Failed with result 'exit-code'.— systemd classifies the unit as failed. Different final state frominactive (dead).Stopped …— the unit is now stopped by request.
The unit on this host is configured, in /etc/systemd/system/hermes-gateway.service:
[Unit]
StartLimitIntervalSec=0
[Service]
Type=simple
Restart=always
RestartSec=5
RestartForceExitStatus=75
RestartPreventExitStatus=78
KillMode=mixed
KillSignal=SIGTERM
TimeoutStopSec=210
Restart=always is there. RestartForceExitStatus=75 is there. Yet nothing restarted the service for 2 days 15 hours 32 minutes, because Restart=always was never consulted. The question was never "should this be restarted?" It was "was this stopped?" The answer was yes, and systemd did what it was asked.
When a service is gone, the first question is not "why did it crash?" It is "did it exit, or was it stopped?" journalctl -u <unit> answers that in one line, and the answer decides whether you spend your day fixing code or fixing supervision:
journalctl -u hermes-gateway --since "2026-09-18 08:00" --until "2026-09-18 09:30" systemctl show -p ActiveState -p SubState -p Result -p NRestarts -p ExecMainStatus hermes-gateway.service
- log shows
Stopping …→ stopped by request. Supervision worked as designed. Something asked for this stop; find that something. Do not touchRestart=. - log shows an exit/signal with no preceding
Stopping …, and the unit isfailed→ it crashed. Now read the exit code and the stack trace; the supervisor may have already tried and given up (see §3). - log shows nothing at all for the window, and the unit is
inactive (dead)→ it was never started (boot ordering, enablement,Conditiongate).
The lifecycle ledger — the third kind of stop
systemd can tell you an exit happened. It cannot tell you, at the moment it happened, that no exit path ran: that is a statement about code that never executed. So a running process leaves itself a note, and the next process reads it. That is the Hermes lifecycle ledger: on boot it detects a previous life terminated without an exit path, in the only words that fit:
Previous gateway life (pid=2929504, started_at=2026-09-17T23:49:27.243856+00:00) exited UNCLEANLY (no exit path ran — SIGKILL / OOM / VM death). last_heartbeat_at=2026-09-18T01:21:13.707210+00:00 last_mem={'rss_kib': 309960, ...} suspected_oom=False
Three things to notice:
- The four stop-kinds are now named: clean exit, exit with an error, killed (SIGKILL/OOM), deliberately stopped. The ledger distinguishes the third from all the others because the others leave evidence behind.
- The ledger can only report. It runs inside the process that just started, so it is a historian, not a supervisor. Keep that distinction — it is the bridge into §3.
- It carries
last_heartbeat_atand memory figures, turning "it died" into "it died at 01:21 while holding 310 MB" — an anecdote becomes a diagnosis. (Hermes' web-dashboard surfaces these two conditions: memory critical, and "Your agent restarted unexpectedly, most likely because it ran out of memory".)
One more real-world detail, easy to miss and expensive to learn the hard way: Hermes' docs describe what happens when a requested restart does not line up with the supervisor's restart policy:
"--external-supervisor is a restart-policy contract: an in-chat restart or service-restart update exits with status 75, so the wrapper's supervisor must relaunch the gateway after that nonzero exit. For systemd, use Restart=on-failure or Restart=always and do not include 75 in RestartPreventExitStatus; for launchd, configure KeepAlive to relaunch after unsuccessful exits. Without that policy, a requested restart leaves the gateway stopped."
That last sentence is §2 in six words. A restart request is only a restart if somebody has agreed to catch the exit code. That is why RestartForceExitStatus=75 is in the unit file: the child says "start me again", and the supervisor promised to honour it.
Why it matters
- "Is it running?" is the wrong question. The right one is "is it supposed to be running?" — and only the log's
Stopping/Startedlines, not the process table, can answer it. Restart=alwayson a unit whose stop path is used by tooling (hermes gateway stop,hermes update, a/restartcommand, an installer) means each of those paths opens a window where nothing rescues the service. On a headless box, that window is measured in days.- Exit codes let a program vote on its own supervision. Use them: 75 for "retry me" (once the listener is back up), 78 for "I am misconfigured, do not loop".
OutsideOutside03Who supervises whomSupervisor
The plain explanation
Supervision is not a property of a program. It is a relationship, and the relationship has a direction. Somebody watches somebody else. Three properties decide whether that relationship can actually save you:
- Liveness vs readiness. Liveness = "the process exists." Readiness = "the process can do its job right now." These are different questions with different answers, and a supervisor that only asks the first one is blind to the most common failure mode there is: a process that is alive and useless.
- Fate-sharing. Does the watcher share the fate of the watched? If the watchdog runs inside the process it watches, then the failure it exists to detect also disables the detector. This is the single most common supervision mistake, and it is invisible during normal operation because a watchdog that never fires looks identical to a watchdog that works.
- Give-up policy. A supervisor that retries forever is a restart storm. A supervisor that gives up is a silent outage. Both are policies, and the defaults are chosen for servers, not for your laptop.
heartbeat — a periodic "I am still here" signal to something outside the process. Meaningful only if its absence triggers action by something that outlives the process.
liveness probe / readiness probe (Kubernetes vocabulary, but the idea is universal): the liveness probe asks "should I restart this thing?" and the readiness probe asks "should traffic go to this thing?" The Kubernetes docs draw the line exactly where you would hope: liveness exists because "Many applications running for long periods of time eventually transition to broken states, and cannot recover except by being restarted"; readiness exists because "Sometimes, applications are temporarily unable to serve traffic … In such cases, you don't want to kill the application, but you don't want to send it requests either."
What this host actually had (and why it was not supervision)
Three findings:
(a) The unit cannot distinguish "alive" from "working".
Type=simple # systemd's liveness model = "the main PID exists"
WatchdogUSec=0 # systemd's watchdog is disabled
Type=simple means systemd considers the service started as soon as the process is forked, and healthy as long as the main PID is alive. WatchdogSec= — the mechanism that would catch a wedged process — reads, verbatim:
"The service must call sd_notify(3) regularly with WATCHDOG=1 (i.e. the "keep-alive ping"). If the time between two such calls is larger than the configured time, then the service is placed in a failed state and it will be terminated with SIGABRT"
— is set to 0, i.e. off. So systemctl status printing active (running) is a statement about a PID, not about health. A process deadlocked on a database lock looks identical to a healthy one. This is the default shape of most systemd units, and why the app-level 30-second heartbeat exists.
(b) The watchdog that existed was a detector, and it shared fate with the thing it watched.
~/.hermes/cron/jobs.json contains a job named Restart Watchdog (7afae45c813e), no_agent: true, interval 5 minutes, 14,249 completed runs. Its entire script is this:
#!/usr/bin/env bash
# Restart detector — checks main daemon PID changes
# Silently notifies when the core process restarts.
PID_FILE="/tmp/.hermes_restart_pid"
CURRENT_PID=$(pgrep -f "hermes_cli" 2>/dev/null | head -1)
if [ -z "$CURRENT_PID" ]; then
exit 0
fi
...
if [ "$CURRENT_PID" != "$STORED_PID" ]; then
echo "$CURRENT_PID" > "$PID_FILE"
echo "🔄 Hermes core restarted"
fi
Three separate lessons in twenty-five lines:
- It detects restarts; it does not cause them. It is named "watchdog" and it can never save anything. Naming is not supervision.
If the gateway is down, pgrep finds nothing, and the script exit 0s silently. The failure it was built to report is the one case where it reports nothing.
- It runs from Hermes' own cron scheduler, i.e. inside the gateway. When the gateway stopped, the watchdog stopped with it. That is fate-sharing, and it is why its last_run_at is
2026-09-21T00:36— five minutes after the gateway came back, silent for the whole outage.
(c) There was no recovery layer underneath.
- The root crontab contains five entries (backup, mempalace capture, drei data generators) and no gateway check.
systemctl list-timers --allshows no unit that starts or verifies the gateway.- No
Upholds=(the systemd setting that continuously restarts a unit — "as long as this unit is up, all units listed inUpholds=are started whenever found to be inactive or failed"), noOnFailure=, noStartLimitAction=.
So the full picture was: a service with an app-level heartbeat only the app could read, a 5-minute detector inside that app, and no supervisor below it. The 2.5-day outage was the only possible outcome.
The fix pattern: recovery lives one layer down
The rule:
Whatever must survive a failure cannot run inside the thing that failed.
Cheapest to most correct:
- A root crontab line or a systemd timer that asks the OS, not the app:
systemctl is-active --quiet hermes-gateway || systemctl start hermes-gateway. This is one line, it lives outside the process, and it cannot fate-share. (A timer is slightly better: it gets its own lifecycle, logging in the journal, and can be inspected withsystemctl list-timers.) - Let systemd do it, but tell it how to tell "alive" from "working": add
Type=notify+WatchdogSec=so the app must pingsystemd, and useRestart=on-failure,RestartForceExitStatus=75,RestartPreventExitStatus=78. Now a wedged process is a failed process, and the supervisor can act on it. - Make the stop path impossible to hit by accident, and be explicit about the deliberate one.
systemctl stopis a request that says "stay down". If your tooling wants "restart", it must exit 75 and let the supervisor relaunch — and the supervisor must be configured to honour it (the--external-supervisorcontract quoted in §2).
Why a supervisor gives up — start rate limiting vs restart intensity
Anyone who has written a watchdog has met the storm: the service is broken in a way restarting cannot fix (bad config, missing file), and the supervisor restarts it forever at full speed, filling the disk with identical error logs. Every serious supervisor has a give-up rule. Two compared show how universal it is.
systemd (man systemd.unit):
"StartLimitIntervalSec= interval, StartLimitBurst= burst — Configure unit start rate limiting. Units which are started more than burst times within an interval time span are not permitted to start any more."
"Note that units which are configured for Restart=, and which reach the start limit are not attempted to be restarted anymore; however, they may still be restarted manually or from a timer or socket at a later point, after the interval has passed."
Defaults, from man systemd-system.conf on this host: "DefaultStartLimitIntervalSec= defaults to 10s. DefaultStartLimitBurst= defaults to 5." — i.e. 5 starts in 10 seconds, then start request repeated too quickly and a unit that stays down. With RestartSec=5, a few attempts and then silence.
Erlang/OTP (about twenty years earlier, and the terminology is better):
"The supervisors have a built-in mechanism to limit the number of restarts which can occur in a given time interval. … If more than MaxR number of restarts occur in the last MaxT seconds, the supervisor terminates all the child processes and then itself. The termination reason for the supervisor itself in that case will be shutdown. … The intention of the restart mechanism is to prevent a situation where a process repeatedly dies for the same reason, only to be restarted again."
The Erlang version is strictly better for one reason: when the supervisor gives up, it propagates upward — "the next higher-level supervisor takes some action. It either restarts the terminated supervisor or terminates itself." That is a ladder of supervision, each rung able to save the one below. systemd's give-up is a leaf: the unit goes failed, and that branch is dead. That is what a 2.5-day outage looks like.
One more detail, on unintentional storms:
"Do not add a systemd drop-in such as ExecStopPost=/bin/kill -9 $MAINPID — ExecStopPost fires on every stop, including clean restarts, so it SIGKILLs the freshly spawned instance before it stabilizes and Restart=always immediately respawns it. The result is an infinite restart loop."
That is a storm created not by a crash but by a hook on the stop path: the §2 lesson (which path are you on?) colliding with the §3 lesson (who restarts whom?). Two settings, both sensible alone, combining into an infinite loop.
Why it matters
- A monitor you cannot rely on during the failure is decoration. The test for every watchdog: "if the thing I am watching died right now, would I still find out?" If the watchdog is a cron job inside it, no.
- Liveness is cheap, readiness is what you actually want, and conflating them means a deadlocked service reports healthy forever.
- Every supervisor's give-up rule is a policy you inherited by not setting it. Know your numbers: 5 starts per 10s,
RestartSec, andTimeoutStopSec=210here — how long a stop may take beforeSIGKILL.
The deliberate trade in this host's unit: StartLimitIntervalSec=0 disables rate limiting. That is rational for a single-tenant box where a restart storm costs only log lines and an unresponsive service costs days. But the give-up rule is now "never", and the only guard against a broken config and an infinite loop is RestartPreventExitStatus=78 and no ExecStopPost kill hook. Count your own safety nets; don't assume the defaults hold.
04Decision table: symptom in logs → failure class → what to do
| Symptom in the log / status | Failure class | What to do |
|---|---|---|
Stopping <unit>… immediately before the exit |
Stopped by request (§2) | Nothing about Restart= is wrong. Find what asked — a tool, a script, a human, an installer, a /restart path. Its stop path exits the supervisor's contract. |
Exit line with no preceding Stopping, unit failed |
Crash (§2) | Read the exit code and traceback. Let the supervisor retry (75) or refuse to (78) deliberately, not by default. |
Unit inactive (dead), no log lines for the window at all |
Never started | Wrong layer entirely: check enablement, boot ordering (After=/Wants=), Condition*= gates, or a failed unit needing systemctl reset-failed. |
exited UNCLEANLY (no exit path ran — SIGKILL / OOM / VM death) |
Killed (§2) | Look for suspected_oom, the last heartbeat's memory figure, and any TimeoutStopSec expiry. A stop that ran out of time lands here too. |
Main process exited, code=… then Restart=always and nothing restarted |
Start rate limit hit (§3) | systemctl show -p NRestarts -p Result, look for start request repeated too quickly. systemctl reset-failed <unit> flushes the counter, then start it. |
active (running) but requests fail / no traffic served |
Liveness without readiness (§3) | Type=simple + WatchdogSec=0 cannot see this. App-level heartbeat + an external check, or Type=notify + WatchdogSec=. |
| Watchdog's own last run is stale, or it exited 0 while the service was down | Fate-sharing (§3) | Move the check out of the process: systemd timer or root crontab. The watcher must be able to outlive the watched. |
start request repeated too quickly |
Restart storm, supervisor gave up (§3) | Fix the underlying break (that is the point of the limit) or raise StartLimitBurst/StartLimitIntervalSec — do not set the interval to 0 to silence it. |
| Repeated identical restart messages | Path bug / hook loop (§3) | Look for an ExecStopPost/ExecStop hook that interferes with the restart, or a health check that fails during normal start-up. |
Automatic rebuild of stale FTS indexes failed … canonical writes remain enabled with FTS detached |
Fail-open, awaiting a rebuild owner (§1) | Verify derived data is still rebuildable from the canonical store, and confirm something owns the retry. Track how long the degraded mode has lasted. |
the turn was stopped because session storage could not be written (the transcript would have been lost on restart) |
Fail-closed, working as designed (§1) | This is the system protecting you. Fix storage (disk space, state.db health) — and then replay the scheduled work that was refused, or the refusal quietly becomes a data gap. |
Transcript read failed … (returning empty; downstream must not treat this as data loss) |
Sentinel (§1) | Never let a downstream consumer treat the empty value as meaningful. Treat the warning as an incident, not noise. |
The two marks in the first column, and what they name — stopped by request · no exit path ran, killed. The other ten rows have no mark: they are not endings, they are symptoms the same three shapes produce.
05Key takeaway
A service that was stopped is not a service that crashed — and no watchdog can save you if it lives inside the thing it is watching.
06Go deeper (the three per-gap lists, collected)
Gap 1 — fail-closed vs fail-open-
01
Saltzer & Schroeder, The Protection of Information in Computer Systems (MIT, 1975)
web.mit.edu/Saltzer/www/publications/protection/Basic.htmlFree full text. §I.A.3 lists the eight design principles; fail-safe defaults is (b), least privilege (f).
- 02
-
03
Google SRE Book, ch. 6 Monitoring Distributed Systems
sre.google/sre-book/monitoring-distributed-systems/Symptoms vs causes, black-box vs white-box.
-
04
SQLite, How To Corrupt An SQLite Database File
www.sqlite.org/howtocorrupt.html§3.2 is the sync-policy chapter.
-
05
www.sqlite.org/wal.html
OWASP ASVS has the same fork under "fail securely"; see also the Wikipedia entry Fail-safe for the physical-origin of the term.
Gap 2 — exit paths and exit codes-
06
man systemd.service (local; online mirror)
www.freedesktop.org/software/systemd/man/latest/systemd.service.html freedesktop.org serves an anti-scraper 418 challenge to scripted fetches while a normal browser gets the page, so treat the local man page as the citable copy.The
Restart=,RestartForceExitStatus=,RestartPreventExitStatus=,ExecStop=,WatchdogSec=entries. The table "Exit causes and the effect of the Restart= settings" is worth memorising. -
07
man sysexits.h / FreeBSD sysexits(3)
man.freebsd.org/cgi/man.cgi?query=sysexits -
08
Google SRE Book, ch. 12 Effective Troubleshooting and ch. 16 Tracking Outages
sre.google/sre-book/effective-troubleshooting/
Hermes docs, Messaging → Gateway service (website/docs/user-guide/messaging/index.md): the ExecStopPost warning explains a real infinite-restart-loop you can create by accident.
-
09
Erlang/OTP, Supervisor Behaviour
www.erlang.org/doc/system/sup_princ.htmlRestart strategies, restart intensity, and why a supervisor's own shutdown propagates upward.
-
10
Kubernetes, Configure Liveness, Readiness and Startup Probes
kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ -
11
Google SRE Book, ch. 22 Addressing Cascading Failures
sre.google/sre-book/addressing-cascading-failures/Retry storms, load-shedding, and why recovery mechanisms become amplifiers.
-
12
Joe Armstrong's PhD thesis, Making reliable distributed systems in the presence of software errors
erlang.org/download/armstrong_thesis_2003.pdfThe origin of "let it crash" and supervision trees; free.
man systemd.unit (StartLimit*), man systemd-system.conf (the defaults), man systemd.service (WatchdogSec=, WatchdogSignal=).
07Appendix — reproducing the evidence on this host
Read-only; nothing here starts, stops, or restarts anything.
# 1. Did it exit, or was it stopped? (§2)
journalctl -u hermes-gateway --since "2026-09-18 08:50" --until "2026-09-18 09:00"
journalctl -u hermes-gateway --since "2026-09-21 00:00" | grep -E "Started|Stopped|Stopping"
# 2. What is the unit's restart contract? (§2, §3)
systemctl cat hermes-gateway
systemctl show -p Type -p Restart -p RestartUSec -p RestartForceExitStatus \
-p RestartPreventExitStatus -p WatchdogUSec -p KillMode -p KillSignal \
-p TimeoutStopUSec -p StartLimitBurst -p Result -p NRestarts hermes-gateway
# 3. The exact wording being taught (§2) — man pages installed locally
man systemd.service # Restart=, RestartForce/PreventExitStatus=, WatchdogSec=
man systemd.unit # StartLimitBurst=, StartLimitIntervalSec=, Upholds=
man systemd-system.conf # DefaultStartLimit* (10s / 5)
man systemd.kill # KillMode= mixed, KillSignal=, SendSIGKILL=, FinalKillSignal=
# 4. The fail-open / fail-closed split in this stack (§1)
journalctl -u hermes-gateway --since "2026-09-17" | grep -iE "FTS|canonical writes remain enabled"
journalctl -u hermes-gateway --since "2026-09-17" | grep -c "must not treat this as data loss"
journalctl -u hermes-gateway --since "2026-09-01" | grep -i UNCLEANLY
# 5. Who supervises whom on this box (§3)
crontab -l # five entries, no gateway check
systemctl list-timers --all # no gateway timer
jq -r '.jobs[] | select(.name|test("watchdog";"i")) | [.name,.script,.schedule_display,((.last_run_at//"")[0:16])] | @tsv' \
~/.hermes/cron/jobs.json
One honest gap. man systemd.service documents WatchdogSec= as "Defaults to 0, which disables this feature" and RestartSec= as "Defaults to 100ms", but it does not state the effective default host value for every unit. Check with systemctl show rather than assuming — that is what step 2 is for. Anything else not verifiable from this machine is marked as such in place.