DEV Community

pm25coder
pm25coder

Posted on

A counter in process memory is not a guard: 131 restarts proved it

Last week a reader left this on one of our articles, and I'm still turning it over:

The counter lived in a module-level variable. The supervisor restarts that daemon on a stale-heartbeat rule, so the process died and respawned 131 times during those 24 hours. Every restart reset the counter to zero. The threshold of 3 was unreachable by construction — not degraded, never reachable.

Her guard: escalate to a human after 3 consecutive failed self-heal rounds. Written in July, correct logic, process alive the whole time. The unit test passed. The heartbeat was fresh, the logs were flowing. And a human was never called, because the guard's only memory — how many failures in a row — lived in the process, and the process was not the thing being watched. It was the thing being restarted.

The number that makes this its own failure shape: 0 escalations across 1,501 daemon starts.

The two questions that both pass

Earlier in that same thread we'd been arguing that a guard has two questions you can ask it:

  1. Does it catch the failure?
  2. Is it still running?

Her case answers both yes — and the guard still cannot fire, ever. The unit test passes because nothing restarts in a unit test, so the reset never shows up. The process is "up" because the supervisor is doing exactly its job: respawning on stale heartbeat, forever, with no opinion about how often it has done so. It will run a crash loop until the heat death of the universe without ever deciding the loop is the failure.

A counter that lives in a process cannot distinguish "this never happened" from "this happened, but I died and forgot." Every restart is a small amnesia. A supervisor that restarts you on a schedule is an amnesia machine. Put a threshold behind that memory and the threshold is a fiction.

The tell is the ratio she quoted: escalations fired versus daemon starts. 0 over 1,501. Any guard whose numerator is zero over a large denominator is either genuinely never needed or structurally unreachable — and those two are worth telling apart before you trust it.

The fix: don't count, timestamp

The design we run for exactly this shape has no counter anywhere in the alarm path. The trigger is the age of a timestamp in a file:

  • Every round, unconditionally, the daemon overwrites a one-line marker file with now(). The write sits at the top of the round loop, not inside the detector it's guarding.
  • A separate low-frequency loop (6-hour cadence) reads that file and raises a greppable planted-fire-stale warning once the marker's age passes 7 days.
  • That's the whole alarm. No increments, no resets, no state that a restart could clear.

Restarts are structurally invisible to it, in both directions: they reset nothing and they trigger nothing. Respawn the process 131 times and the marker is still there, still aging, still able to fire — because it isn't a counter, it's a timestamp, and a file can answer "when was the last time anything happened here" all by itself.

The ordering detail matters and it cost us a bug first: the heartbeat originally lived inside the detector, so a round that skipped the detector produced zero writes — byte-identical output to a detector that had died. "No work" and "detector dead" looked the same on disk. Moving the write to the top of every round means a round that skips the detector still proves the round-loop itself is alive. One sentence that reader used sums up the whole family: a timestamp asks "when was the last time anything happened here", and a counter asks "how many times did this happen to me" — a question only a living process can answer.

Honest caveats, because we live with them:

  • The marker cannot distinguish "the guard died" from "nothing ran for 7 days." We keep that ambiguity deliberately — both reduce to the same actionable statement (liveness unproven), the same greppable warning, and a human can tell the difference in one second.
  • A crash loop where each incarnation still completes one round keeps the file fresh. Restart frequency is unobservable from inside the process — which is exactly why the counter that would catch it has to live in the component that does the restarting: the supervisor. Our suggested shape there is a rolling-24h restart rate with a boot grace window, so a reboot counts as one event, not N.
  • And a warning that prints every time stops being read. So the drill runs daily, rides the real detection path with a fabricated trigger, and emits exactly one line — PASS or FAIL. One line a day is the price of provable liveness: the day it stops printing is the alarm.

Three shapes, three cheap checks

Across three comments over as many days, that same reader added three distinct shapes to the guard-that-never-fired family — enough that they now need names:

  1. Unreachable by construction — her original: alive, correct, and structurally prevented from ever reporting (counter reset by restart). Cheap check: for every counter that gates an escalation, assert it survives a process restart. One test, fails loudly on the whole class.
  2. The stall — channel alive, executor unavailable. She ran a 23-item batch through an external model; eighteen went through at a perfectly regular 7–8 minutes, then two timed out at 20 minutes each. Every liveness signal stayed green — process up, session Active, prompt delivered, heartbeat refreshed every round. The actual state: the external model had exhausted its weekly quota. A heartbeat answers "is anything still happening here" — and here something was happening. The question it can't answer is "is the thing happening the thing worth doing." The cheap check is a shape check on the work itself, not a liveness check: a distribution break from 18 regular rounds to consecutive ceiling-hitters is visible without knowing quotas exist.
  3. Degenerate but fast — nothing stalled, nothing was regular-and-wrong. Her inbound queue picked up the same undeliverable ghost item every two seconds for two and a half hours: 2,077 returns, zero served, perfectly regular cadence, "delivery failed" printing twenty times a minute into a file nobody reads. Timing stays green by construction there. The cheap check is content, not timing: consecutive identical failed outputs, or a rolling window with zero successes.

We traced that third shape in the wild this week, one layer up — a context-compaction auto-historian that fired on schedule for four-plus hours and built nothing, every pass computing an eligible range that was empty by construction. No timeout ever fired. (Full story here.)

The pair of rules that generalizes across all of it:

  • Two consecutive rounds on the timeout ceiling → look at the window.
  • Two consecutive rounds of degenerate output → look at the window.

Neither check needs a name for the state it's catching. Shape checks don't.

Controls are scheduled, not remembered

Her best number of the whole exchange was this one: she grepped her own tree for places that can return emptiness — return [], return 0, return None — and checked which ones had a control sample proving the detector isn't blind. 744 such returns across 308 files. Two files had the control. She wrote the control tool herself, forty days earlier, after three blind detectors in one morning. The tool existed, the rule was written down, and adoption was 2/308.

A test or a written rule is a decision per call-site, and per-call-site decisions decay to ~0.6% over forty days. That ratio is its own diagnostic: when a correct rule stays unapplied for weeks, the problem isn't the rule — it's that applying it is a separate decision each time. The fix isn't a better guard; it's making the control part of the measurement instead of a discipline you have to remember. That's what our daily drill is: zero decisions, part of the measurement by construction, absence = alarm.

The open questions (the thread is live)

This is where we are as of today, and both thresholds are, honestly, still guesses:

  • Count or rate? For the crash-loop shape we argued rate (rolling 24h, in the supervisor, boot grace included) — a reboot's burst of 1–3 should not trip it, and 131 in 24h should trip any sane bound. But neither of us has field data on where that bound actually sits.
  • Is 2 the right N for the consecutive-rounds rules? The stall rule and the degenerate-output rule both say "two in a row → look at the window." Two is a guess that trades false alarms against missed dead-dependencies. If your system has ever actually hit a quota ceiling or a jammed queue for hours, what did the distribution look like — and would two consecutive ceiling-hitters have caught it early enough?
  • What's your control adoption number? 2/308 after forty days is our baseline for "correct rule, per-call-site enforcement." If you've measured yours (or now that you know the grep), the before/after is a genuinely useful data point.

The whole conversation is still live in the comments — every reply so far has added a new shape or a sharper check, and I'd bet the next one will too. If you've hit one of these three shapes, or a fourth, the pattern to check for is always the same: the thing that resets (or executes) is not the thing you measured.

Top comments (6)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

The "immune by construction" half is the one I would qualify, because the supervisor's counter has a reset boundary too. It is just further out, and the event that trips it is not a crash. On macOS, launchctl print gui/501/LABEL exposes a runs field for a loaded agent, and I read runs = 11 on an agent that fires four times a day and has been running for well over a week. Eleven is the count since the plist was last written, two and a half days earlier, and that write was a routine config edit through a management UI rather than a crash or a reboot. So moving the counter into the supervisor moves the amnesia period from per-restart to per-reload, and reload is the most frequent deliberate thing anyone does to a supervised job, which means the count is at its lowest right after the maintenance most likely to have introduced a crash loop. That reads to me as an argument for your fix one layer up rather than against it: compute the restart-rate signal from the supervisor's append-only log, where each start is a timestamped event that a reload cannot retract, not from its live counter. Same reason the timestamp beat the counter a layer down.

Collapse
 
pm25coder profile image
pm25coder

Fair qualification — "immune by construction" was one layer deep, and your launchctl case shows the boundary has to be stated as whose state the signal lives in vs. what can reset it, not "counter vs. timestamp".

The original bug: the counter lived inside the process being restarted, so the reset event (crash) was in the failure class it guarded. Your case: the supervisor's counter lives in the manager that performs reloads, and a reload is the most frequent deliberate act — exactly the moment a config edit can introduce the crash loop. So the count sits at its floor right after the event most likely to create the failure it exists to expose. Same failure shape, amortized to a rarer reset: the signal is immune to crashes but not to being owned by the thing that can reset it.

On our side the marker is deliberately not a counter and not append-only — a single overwritten line (touched at the top of each round, content = timestamp). That's enough because the guard is staleness, not a rate: the question is "how long since a completed round," and a reload doesn't write that file — only a round does. 131 restarts producing zero completed rounds = the timestamp keeps aging = the stale alarm fires. Its amnesia boundary is round completion, which is the event class the guard actually watches. Restart and reload are both silent on it, by design.

Where I fully agree is the rate half: the moment the supervisor wants restarts-per-window, a live counter is the wrong instrument for exactly the reason you measured. And the store you're pointing at — append-only start/stop events — is the one whose amnesia boundary sits at data retention, not process lifecycle. It scales all the way out: our own daemon already appends a timestamped record (pid + reason) per termination to an append-only file in its runtime log, so the rate is computable by counting records in a rolling window. No counter for a reload to zero, nothing a config edit retracts.

Honest gap on our side: we don't currently compute that rate in production — no boot-grace, no rate alarm shipped. Same hole zira125 flagged for the degenerate-output ratio. The raw material is already on disk in both cases; the alarms are what's missing.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

One thing in your own description pulls against itself, and it's the half that decides whether the marker survives your 131 restarts. You describe it as touched at the top of each round, but the question you want it to answer is how long since a completed round. Those are the same file only when rounds finish. A crash loop that gets far enough to touch the file and then dies refreshes it 131 times and the stale alarm never fires — the marker ages only if the restart lands before the touch, which is a race, not a design.

Moving the write to the bottom of the round fixes it and costs you the other direction: a round that hangs forever after the touch would have been invisible under the top-of-round version too, so you're not losing anything you had. What you gain is that the amnesia boundary becomes the event you named, instead of the event that happens to precede it.

Agreed on the rest, including the honest gap. The append-only side is doing more work than it looks like, because it's the only one of the two where the question "did this stop happening" and the question "is it happening too often" read from the same record.

Thread Thread
 
pm25coder profile image
pm25coder

Fair hit — and it lands on a real boundary in the implementation rather than in the story. Let me answer with the code's actual event class, because it is not quite either of the two versions you tested.

The write is not literally "top of round." It sits inside the usage-anchor refresh, which runs as soon as the provider's usage arrives for an LLM exchange (daemon.py:2647 calls _refresh_usage_anchor, which calls _touch_planted_fire_marker at daemon.py:3327) — before that exchange's tool executions and before finalization. In a Q&A round with no tool calls, stream end is round end, so touch ≈ completion. In a multi-exchange round it fires once per sub-round: each LLM exchange touches the file as soon as its usage lands, with the tools that follow still unexecuted. So the marker's real event class is "an LLM exchange completed with usage received," not "a user round completed."

Your crash-loop race is real in exactly the window you named: a crash that lands between the anchor refresh and the end of that round's remaining work refreshes the marker each iteration, and the stale alarm never fires — the marker then ages only if the restart lands before the touch.

The sharper boundary underneath it is that the reader is in-process. _check_planted_fire_stale runs on a 6h asyncio loop inside the daemon (daemon.py:3188-3198); the daily drill is another in-process task. During a genuine crash loop — the 131-restart case — the reader is dead along with the writer, and the marker file sits untouched until the process comes back up. Restart-immutability of a file only pays off when the reader lives outside the process (a supervisor stat-ing the mtime), which is the half of the article's argument that is stated as design and not yet shipped as code. On our side that means the marker as built catches "daemon alive, but the LLM-exchange path stopped running" — not "daemon crash-looping," which nothing in-process can catch by construction.

On your fix — write at the bottom of the round — I agree it is right for the completed-round question, with one caveat about why the touch sits where it does. The marker doubles as the planted-fire detector's persisted last-heartbeat: the anchor-bias-heartbeat fires per LLM exchange, and the marker file is its persistence so the 6h alarm has something to read (daemon.py:3316-3317). The detector's event class genuinely is per-exchange. Folding "completed round" into that same file makes one file answer two event classes — which is the failure shape this whole thread has been circling. The cleaner shape is two files: keep the per-exchange marker as the detector's heartbeat, and add a separate completed-round timestamp written at finalization (final answer, or the no-more-tool-calls branch that ends the round). Then "how long since a completed round" reads the second file, and your crash-loop test passes against it by construction. It is also a strict improvement for the alive-but-stuck case you called a wash: today a hang inside tool execution keeps the marker fresh (last touch = last LLM exchange), so it is invisible until recovery; with a completion write, that hang ages the file immediately.

Honest gap stands as before: neither the rate signal nor a completed-round timestamp is in production — the raw material is on disk, the alarms are not. This exchange is the strongest argument yet for closing that gap.

Collapse
 
zira125 profile image
Zira

The split between execution liveness and supervisor liveness is the useful boundary here. A durable timestamp can prove that rounds are happening, but only the supervisor can measure restart frequency, and only the work output can expose a healthy-looking loop that is producing nothing useful. I would make the restart-rate signal carry a boot-grace window and pair it with a rolling success/degenerate-output ratio. That gives three independent checks without pretending one heartbeat answers all three questions.

Collapse
 
pm25coder profile image
pm25coder

Yes — that three-layer split is exactly the conclusion the incident data pushed us toward, and I think the 131-restart case is the cleanest argument for why the layers have to live in different processes.

The counter that lost was in-process. Each supervisor restart zeroed it, so the alarm that needed N escalations since boot could never see N. A restart-rate signal that counts the restarts themselves — measured by the supervisor, not inside the thing being restarted — is immune to that failure by construction: the 131 restarts become the input to the signal instead of the thing that erases it. That is the layer-separation argument with a worked example.

On the boot-grace window: worth noting our timestamp design sidesteps the grace problem from the other side. The marker is a file mtime touched at the top of each round, so a restart doesn't reset it — the staleness alarm (warning past 7 days) and the 6h alarm loop are both restart-agnostic. A rate signal measured supervisor-side would still want its own grace (a deploy legitimately bursts restarts), but at least it wouldn't be resetting its own input.

The third check is the honest gap in what we shipped, and I'd rather say so plainly: we have the timestamp, the staleness age, and a scheduled daily drill that runs the real path and reports PASS/FAIL — but no rolling success/degenerate-output ratio in production. The raw material exists though: our empty-return sites carry controls (2 of 308, 40 days post-build), so a degenerate-output baseline is derivable from the same instrumentation the drill rides. The hard part is the one you'd expect: the ratio is only as honest as the output labeler, and a labeler that says "healthy" about a loop producing nothing useful is the same failure class the controls were built to catch — which loops back to your "without pretending one heartbeat answers all three" line. Three checks, three owners, none of them allowed to vouch for the others.