Skip to content

fix(watcher): back off re-forking a hard-failing index worker - #2075

Open
halindrome wants to merge 4 commits into
DeusData:mainfrom
halindrome:fix/watcher-index-failure-backoff
Open

fix(watcher): back off re-forking a hard-failing index worker#2075
halindrome wants to merge 4 commits into
DeusData:mainfrom
halindrome:fix/watcher-index-failure-backoff

Conversation

@halindrome

Copy link
Copy Markdown
Contributor

Closes #2015

What

poll_project() treats a hard index failure (index_fn returning rc < 0) as a transient event: it emits watcher.index.err and schedules the next poll at the plain interval. A persistent start failure — a poisoned coordination endpoint, an unreadable DB — therefore re-forks an identically-failing worker at the normal cadence for as long as the daemon lives (2,233 workers in 4h43m in the report).

This adds a per-project consecutive-failure counter with a doubling, capped backoff on that path only:

  • index_failures on project_state_t, incremented on rc < 0, reset to 0 by any successful reindex.
  • cbm_watcher_index_backoff_ms(interval_ms, consecutive_failures) — pure function, doubling per consecutive failure up to a shift cap and an absolute ceiling, so a permanently-failing project decays to occasional retries instead of every-poll retries.
  • watcher.index.err now carries rc and consecutive so the streak is visible in the log rather than inferred from line count.
  • cbm_watcher_index_failure_count() accessor so the counter is testable without reaching into the struct.

The watcher guarantee is preserved

Per the maintainer note on #2015 — a failed observation must not be silently committed. It isn't: the rc < 0 arm only logs and lengthens the next poll. It does not touch last_dirty_sig, pending_dirty_sig, or pending_head. The baseline stays uncommitted exactly as #937 intends, so the pending change is still picked up whenever the underlying failure clears; only the retry cadence decays. The busy-skip (rc > 0) path is untouched.

Field evidence

The patched build has been running locally since 2026-09-02. On 2026-09-06 an unrelated daemon-coordination fault made every index worker fail to start — the same permanent-failure class as the original report — across three watched projects.

build watcher.index.err events for a permanent failure
before (unpatched) 2,226
after (this patch) 14 total, across 3 projects; longest streak 6

The underlying fault was not fixed by this change and is not meant to be — the point is that the watcher stopped re-forking within seconds instead of accumulating thousands of doomed workers, and the streak counter in the log made the fault diagnosable at a glance.

Tests

tests/test_watcher.c adds coverage for the backoff arithmetic (monotonic, capped, saturating), the counter's increment/reset contract, and the guarantee that a failing poll leaves the baseline uncommitted.

Notes

Opened as a draft. Three QA rounds were run against this diff before submission (commits 4fc5999a, 61ccd905, 08642202); reports follow as comments.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y9gerhYqHmbgnCxmqpVKFc

shanemccarron-maker and others added 4 commits September 2, 2026 13:02
A hard index failure (index_fn < 0) left the retry cadence untouched, so a
persistently failing project re-forked an index worker every poll for as
long as the daemon lived. Observed on 0.10.8/macOS: 2233 consecutive failed
workers over 4h43m, ~8/min, every one dying at the same worker-side
coordination seal. Read queries stayed healthy throughout, so nothing
surfaced to the user while no project could be indexed at all.

DeusData#937 deliberately leaves the baseline uncommitted on a failed reindex so the
change is retried rather than lost. That guarantee is kept; what changes is
the cadence. Each consecutive hard failure doubles the delay, capped at five
minutes, taking a permanently failing project from ~480 attempts/hour to
~12 while still recovering on its own once the cause clears. Busy-skip
(rc > 0) and success paths are untouched.

The delay arithmetic is a pure exported helper so it is unit-testable
without a clock. watcher.index.err now carries rc and the consecutive-
failure count, and a distinct watcher.index.sustained_failure names a
project whose failures are clearly not transient.

Note: itoa_buf returns a single shared per-thread buffer, so the two-value
log line uses local buffers rather than two itoa_buf calls.

Refs DeusData#2015

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbrM52pmB1m2SR1vRRoNnf
Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
F1 (major) — the failure state machine the backoff feeds had no test
coverage. Deleting the streak reset, or widening the sustained-failure
comparison, left all 7788 tests green: the index_backoff_* tests cover the
delay arithmetic, and nothing covered the counter that selects which delay
applies. A streak that never increments silently degrades the backoff to the
unbounded retry it exists to prevent.

Adds cbm_watcher_index_failure_count(), alongside the existing
cbm_watcher_watch_count() in watcher.h's "Introspection (for testing)"
section, and a test that drives a real git repo through fail/fail/succeed on
the failing_index_callback seam already used by the DeusData#937 test, asserting the
streak goes 0 -> 1 -> 2 -> 0. Verified by mutation: removing the reset turns
it red at the reset assertion while every index_backoff_* test stays green.

The delay-GATING half stays uncovered on purpose. Every integration test
calls cbm_watcher_touch first, which zeroes next_poll_ns, so whether the
scheduler honours the computed deadline is unobservable without an
injectable clock the watcher does not have. That harness is a larger change
than this fix.

F3 (minor) — cbm_watcher_index_backoff_ms was non-monotonic for an interval
above the ceiling: zero failures returned the interval, one failure returned
the smaller ceiling. Unreachable today (POLL_MAX_MS < the ceiling), but the
function is exported, so the clamp is now a floor as well as a cap and
backing off can never schedule sooner than the project's own cadence.

F2 (minor) needed no code change: a busy-skip following an unresolved
failure streak keeps the backed-off delay, which is deliberate — only a
success clears the streak, and treating rc > 0 as recovery would let a
project alternating fail/busy never back off at all. The PR description's
over-strong "busy-skip is behaviourally unchanged" claim is corrected there.

Refs DeusData#2015

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbrM52pmB1m2SR1vRRoNnf
Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
Retracts an incorrect justification from the round-1 commit (4fc5999) and
closes the coverage it was used to excuse.

MAJOR — the delay wire-up had no coverage, and the reason given for that was
wrong. Round 1 claimed the gating half was "structurally unobservable without
an injectable clock". It is not. cbm_watcher_touch zeroes next_poll_ns, so a
wire-up that never sets a deadline leaves it at zero and the callback fires on
every poll: simply NOT calling touch between polls observes gating
deterministically, no clock involved. Only the delay's MAGNITUDE needs one.

watcher_index_failure_backoff_gates_repolling_issue2015 covers it — one hard
failure, then five polls without touch asserting no re-fork, then a touch
proving the retry still gets through (so the test cannot pass by observing a
dead watcher). Mutation-verified: deleting the wire-up yields
"failing_index_calls == 6, expected 1".

MINOR — round 1's F1 was half-fixed. Of the two single-token mutations that
finding named, only the streak reset was covered; widening the
sustained-failure "==" to ">=" still passed. watcher_sustained_failure_logs_
once_issue2015 drives 14 consecutive failures through a cbm_log_set_sink and
asserts exactly one emission. Mutation-verified: ">=" yields
"sustained_log_hits == 5, expected 1".

All three mutations F1 named are now demonstrated caught rather than argued
about: reset removal (round 1), wire-up deletion, and the "==" widening.

MINOR — cbm_watcher_index_failure_count reads index_failures under
projects_lock while poll_project writes it lock-free from a state snapshot.
That is the discipline every other per-project field here already follows, so
this documents the visibility contract rather than making one field atomic in
isolation: the accessor is a diagnostic and test seam, not a synchronisation
point, and must not carry scheduling decisions without atomic accessors first.

MINOR — watcher.h still described the pre-F3 backoff contract. It now states
the clamp is a floor as well as a cap, why the floor exists for an exported
function whose reachable inputs cannot currently hit it, and that negative
inputs are treated as zero.

Still uncovered, now stated precisely rather than hand-waved: the delay's
MAGNITUDE. Nothing asserts that ten failures produce a five-minute delay
rather than a five-second one. Gating is proven; the numbers are not, and
that does need a controllable clock.

Refs DeusData#2015

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbrM52pmB1m2SR1vRRoNnf
Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
Documentation and claim accuracy only — no behavioural change. Two of the
three findings are corrections to inaccurate statements the round-2 commit
(61ccd90) introduced.

The gating test's framing was overstated, and this time the overstatement was
measured rather than argued. Replacing the backoff call with the pre-change
`ctx->now + interval_ms * US_PER_MS` leaves the watcher suite green at 82/82:
the index_backoff_* tests pass because they exercise the pure function
directly, which the reverted scheduler no longer calls, and the streak tests
pass because the counter is untouched. So no test in this suite fails when
this change's behaviour is removed. The test now records that, with the
figure, instead of claiming it demonstrates the backoff. It is a regression
guard on a deadline being assigned at all — real, but narrower than round 2
said.

Closing that gap needs either a controllable clock or a further accessor
exposing next_poll_ns. Both were judged out of scope: a third exported symbol
purely for testing, on a change already carrying two, is the API widening
earlier rounds flagged. The gap is recorded in the test, the PR description
and the round-3 note rather than papered over.

The memory-visibility comment claimed index_failures follows "the same
discipline every other per-project field in this struct already follows".
That is false: active_git is serialized by projects_lock and registered is an
atomic_bool. Narrowed to the poll-mutated fields, which are now enumerated.
The round-2 decision to document rather than lock still stands — the panel
verified the underlying premise — but it was resting on an overbroad claim.

watcher.h said the delay "doubles up to a fixed ceiling", which skips the
shift cap: the delay plateaus at interval_ms << INDEX_FAIL_SHIFT_MAX, and
that reaches the ceiling only for interval_ms >= 4688 ms. Every interval this
watcher generates is >= POLL_BASE_MS so the ceiling is always reached in
practice, but the function is exported and a caller may pass less.

Refs DeusData#2015

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbrM52pmB1m2SR1vRRoNnf
Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

@DeusData

DeusData commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Thank you for this one — the per-project failure counter with a capped doubling backoff on the rc < 0 path only, leaving the dirty-signature baseline alone so #937's at-least-once guarantee survives, is exactly the shape we wanted here, and the 2,233-worker log from #2015 made the case better than any description could.

We are assembling a patch release (v0.10.9) around the install and memory reports, and this belongs in it. Whenever you consider the draft ready, please mark it ready for review and we will take it through the normal review right away. If there is a piece you are still unsure about, say so in the PR and we can work through it together rather than wait.

@halindrome

Copy link
Copy Markdown
Contributor Author

Marked ready for review — thanks for the confirmation on the shape, and glad it lands in v0.10.9.

Three QA rounds have been run against the diff and are already pushed (4fc5999a, 61ccd905, 08642202); the branch is current with main and mergeable.

On the one red check: test / test-windows-guards is failing, and I believe it is pre-existing rather than something this PR introduced. The failure is in tests/windows/test_daemon_stability.py:

RED: cold-storm client 0 failed (racing daemon spawn)

This diff touches only src/watcher/watcher.c, src/watcher/watcher.h, and tests/test_watcher.c — no daemon startup, no client path, nothing Windows-specific. The symptom matches #2057 ("test-windows-guards: test_daemon_stability turns setup failures and timeouts into REGRESSION verdicts on unrelated PRs") exactly. Everything else in the matrix is green, including all three pr-smoke platforms, tsan/msan/lsan, and the full test-diag shard.

Happy to re-run that job if you would like a second data point, or to work through anything else you want changed during review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Watcher re-forks a permanently-failing index worker forever — no backoff on rc < 0 (2233 workers in 4h43m); gap in #937

3 participants