Skip to content

fix: resolve the provider CLI off the event loop - #4195

Merged
bolichen97 merged 1 commit into
mainfrom
fix/provider-exec-resolve-off-loop
Aug 20, 2026
Merged

fix: resolve the provider CLI off the event loop#4195
bolichen97 merged 1 commit into
mainfrom
fix/provider-exec-resolve-off-loop

Conversation

@CrysisDeu

Copy link
Copy Markdown
Collaborator

What broke

On 2026-08-17 a single gateway was killed by LoopStallWatchdog three times in
fourteen hours (05:02, 05:19, 18:47 local). Every chat session died with it. The
crash dump's main-thread stack is unambiguous:

source_providers.py:4743  _refresh_check_status
source_providers.py:4831  _fetch_check_status
source_providers.py:721   _run_json
source_providers.py:262   _resolve_provider_executable   <- blocked here
asyncio/base_events.py:1936 _run_once                    <- on the event loop

_run_json resolved the gh/glab executable synchronously on the event
loop thread
. Resolution is stat-heavy: it walks every well-known install dir,
and for each hit validates the file plus every parent directory
(resolve(strict=True), Path.stat(), os.access() per component in
github_runner.validate_provider_executable). A miss additionally re-walks all
of PATH. When those syscalls got slow, the loop stopped serving every other
task — including the liveness heartbeat — the watchdog fired at its 25s
exit_after, and the supervisor respawned into the same condition.

Why it took this long to bite

The defect is compositional; no single diff contains it.

PR What it added Effect
#84 the four functions, resolution called synchronously only reachable on user interaction — a user waiting out a slow stat
#290 periodic owner-WS refresh loop the same call now fires on a timer with nobody present
#630 override -> well-known dirs -> PATH, relaxed ownership checks one resolution went from one candidate to a list, each with a full parent-chain walk
#443 turn-boundary forced refresh another unattended trigger

#84's synchronous call was nearly harmless on its own. #290 is what turned it
into something that can kill an idle gateway, and #630 multiplied the cost per
call. Each PR was individually defensible.

Worth noting: _run_json already offloads its SEL audit write with
asyncio.to_thread, and the same module offloads config reads and Jira auth the
same way. The offload habit was present; this one call site was simply missed.

The fix

One line — the resolution now runs on a worker thread:

resolved_executable = await asyncio.to_thread(_resolve_provider_executable, executable)

A slow filesystem now costs latency on one sidebar chip refresh instead of
freezing the gateway.

Deliberately not in this change

No resolution cache. github_runner.resolve_gh keeps a _RESOLVE_CACHE
keyed on the override env values, and mirroring it here would cut the repeat
cost — but that key does not cover KIROCREW_PROVIDER_BIN_STRICT, PATH, or
PROVIDER_EXECUTABLE_CANDIDATES, all three of which change what resolution
returns. A cache is a performance optimization with a real staleness surface; it
is not what keeps the loop alive, so it does not belong in a fix for a crash
loop.

Sibling call sites are untouched. The same shape exists elsewhere — at
minimum apps/builtins/dev_fleet/server.py:1102 (_trusted_bin, structurally
identical), apps/routes.py:1085 (an unbounded shutil.rmtree directly inside
an async def uninstall handler), and knowledge/watcher.py:302 (an os.stat
in a periodic scan whose seven sibling I/O calls all go through to_thread).
Those deserve their own change rather than being folded into an incident fix.

Tests

test_run_json_resolves_the_provider_cli_off_the_event_loop asserts the
resolver ran on a thread whose id differs from the loop thread's. It compares
thread identity rather than elapsed time, so it cannot flake on a loaded host.
Verified load-bearing: reverting the production line to the synchronous call
makes it fail, restoring the line makes it pass.

This is the first test in the repo that asserts a handler does not block the
event loop. test/test_loop_watchdog.py's 13 tests all cover the watchdog's own
decision logic — whether it dumps, debounces, and re-arms — never whether any
handler is off-loop.

One existing test needed a correction. fake_to_thread in
test_run_json_awaits_critical_audit_off_loop_before_spawn replaced
asyncio.to_thread wholesale, assuming the audit write was the only offload on
this path, and discarded the wrapped function's return value. It now dispatches
on the function it received and returns the real result, so it tests the audit
ordering it names instead of pinning "the audit is the only thing offloaded
here".

Review-gap note

AUTOSDE.yaml already carries a blocking: true rule named
no-blocking-call-on-event-loop, added by #82 five days before #84 introduced
this call. Its text covers both this defect's shapes — "large synchronous file
IO or filesystem walks" and "a sync helper called transitively from an async
handler" — and it predicts the watchdog-plus-supervisor crash loop.

The rule has no deterministic counterpart. The backend grep pre-gate in
code-review.yml checks four unrelated things and its comment routes this class
of rule to the semantic AI layer, and the repo pins bare flake8 with no
flake8-async / ASYNC rule set. So the rule was enforceable only by LLM
judgment, and on #84 that judgment read it as "does this use blocking
subprocess.run?", answered no, and passed — in the same review that described
the parent-directory stat walk as a security property.

Making the rule mechanical (the flake8-async ASYNC22x family, or promoting
history.py's OnLoopPersistError discipline into a general off-loop
assertion) is the durable follow-up. It is out of scope here.

Verification

  • pytest test/test_source_providers.py — 438 passed, 2 skipped
  • full backend suite — 55,659 passed, 259 skipped, 6 xfailed. Seven failures
    remain, all in test_artifact_source.py / test_artifacts_handlers.py, and
    all pre-existing on this host: stashing this diff and re-running the same
    files reproduces the identical seven. They assert paths are outside $HOME,
    which misreads a host where the real home path and $HOME differ by a symlink.
  • isort --check-only src/kiro_crew test — clean
  • flake8 src/kiro_crew test — clean
  • mypy src/kiro_crew/ (1.14.1, no faiss, matching CI) — no issues in 982 files
  • no frontend files in the diff, so tsc/vitest are unaffected

The sidebar PR check-status refresh resolved gh/glab synchronously on the
event loop thread. Resolution stats every candidate install dir plus the
full parent chain of each hit, and a miss re-walks PATH, so once those
syscalls turned slow the loop stopped serving every other task -- the
liveness heartbeat included -- until the loop watchdog killed the gateway
and the supervisor respawned into the same condition. One host took three
such kills in fourteen hours, each one ending every live chat session.

Offload the walk with asyncio.to_thread, the way this same function
already offloads its SEL audit write. A slow filesystem now costs latency
on one sidebar chip refresh instead of the whole gateway.

Add the first test that asserts a handler does not block the loop: it
compares the resolver's thread identity against the loop thread's rather
than measuring elapsed time, so it cannot flake on a loaded host.

Correct one existing test alongside it. Its fake to_thread replaced the
real one wholesale, assuming the audit write was the only offload on this
path, and dropped the wrapped call's return value; it now dispatches on
the function it received and returns the real result, so it tests the
audit ordering it names.
@CrysisDeu
CrysisDeu requested a review from a team as a code owner August 17, 2026 20:06
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Advisory design-level review of 97e30184406ee596300ed565f61135e0c004fa07 — updated in place on each push; does not block merge.

Design-Verdict: PASS

A one-line off-loop fix aimed precisely at the traced crash cause, with a thread-identity test that can't flake — proportionate and sound.

[DESIGN-REVIEWED] 97e3018

@github-actions

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 97e30184406ee596300ed565f61135e0c004fa07 — this comment is updated in place on each push.

Review details

The diff is a minimal, correct change: _resolve_provider_executable is offloaded via asyncio.to_thread, which propagates the thread's exception so the surrounding except SourceProviderError still catches an untrusted-executable rejection and audits it. The two test edits correctly account for the new offload (the passthrough in fake_to_thread preserves the audit-only assertion, and the new test verifies resolution runs off the loop thread). Read-only filesystem/env access makes the offload thread-safe. Nothing survives falsification, and no new grounded defect exists.

No findings.

[OPUS-REVIEWED] 97e3018

Verdict parsed from the review's SHA-scoped output markers for commit 97e30184406ee596300ed565f61135e0c004fa07.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 97e30184406ee596300ed565f61135e0c004fa07: <one-sentence reason>

@github-actions

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ PASS

Advisory premise-level review of 97e30184406ee596300ed565f61135e0c004fa07 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push; does not block merge.

Contract read and followed. I verified the diff against the repository: the fix is one production line in _run_json (source_providers.py:728) plus a new test and a required correction to an existing test.

Key checks I ran:

  • _resolve_provider_executable has exactly one call site (grep: 2 hits, definition + the fixed call), so no other path is left blocking on it.
  • The same function already offloads _load_provider_hosts (line 385) and Jira auth (line 2360) via asyncio.to_thread — the fix uses the module's existing mechanism, no new one.
  • The three declared unfixed siblings all exist as described: dev_fleet/server.py:1102 (_trusted_bin sync inside async _run_cmd), apps/routes.py:1085 (shutil.rmtree in the async uninstall handler), knowledge/watcher.py:302 (sync os.stat amid executor-offloaded siblings).
  • The fake_to_thread correction is forced by the fix itself (the wholesale replacement would have swallowed the new resolver offload), and the description declares it.
  • No new public surface, config key, or flag ships; harm is a reported defect (watchdog crash dump), so the requirement is derived, not inherited.

First-Principles-Verdict: PASS

A reported crash-loop fixed at the blocking call itself, using the file's existing offload mechanism, with siblings and the deferred general fix explicitly counted.

What this change ships

Intent: stop a slow filesystem from freezing the whole gateway when the PR sidebar refreshes — a FIX.

  1. Sidebar chip refresh on a slow disk now costs latency, not a gateway kill — justified (crash dump names the exact frame).
  2. New test pins the resolver to a worker thread by thread identity — justified, load-bearing per the revert check.
  3. Audit-ordering test now passes non-audit offloads through with real results — declared, required by the fix.

The fix sits at mechanism level; the description itself names the cause (no mechanical no-blocking-call-on-event-loop enforcement) and the three unfixed siblings as deferred, which is the accepted-and-deferred form, not a hidden point patch. The declined resolution cache is a considered subtraction, correctly reasoned (its key would miss three env inputs). Nothing rides along undeclared; no new surface exists to count consumers for.

[FIRST-PRINCIPLES-REVIEWED] 97e3018

@github-actions

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 97e30184406ee596300ed565f61135e0c004fa07 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 97e3018

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 97e30184406ee596300ed565f61135e0c004fa07: <one-sentence reason>

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 17, 2026
@bolichen97
bolichen97 enabled auto-merge (squash) August 20, 2026 03:54
@bolichen97
bolichen97 merged commit bb30879 into main Aug 20, 2026
58 checks passed
@bolichen97
bolichen97 deleted the fix/provider-exec-resolve-off-loop branch August 20, 2026 03:54
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 20, 2026
encomjp pushed a commit to encomjp/kirocrew-customapi that referenced this pull request Aug 22, 2026
The sidebar PR check-status refresh resolved gh/glab synchronously on the
event loop thread. Resolution stats every candidate install dir plus the
full parent chain of each hit, and a miss re-walks PATH, so once those
syscalls turned slow the loop stopped serving every other task -- the
liveness heartbeat included -- until the loop watchdog killed the gateway
and the supervisor respawned into the same condition. One host took three
such kills in fourteen hours, each one ending every live chat session.

Offload the walk with asyncio.to_thread, the way this same function
already offloads its SEL audit write. A slow filesystem now costs latency
on one sidebar chip refresh instead of the whole gateway.

Add the first test that asserts a handler does not block the loop: it
compares the resolver's thread identity against the loop thread's rather
than measuring elapsed time, so it cannot flake on a loaded host.

Correct one existing test alongside it. Its fake to_thread replaced the
real one wholesale, assuming the audit write was the only offload on this
path, and dropped the wrapped call's return value; it now dispatches on
the function it received and returns the real result, so it tests the
audit ordering it names.

Co-authored-by: t <t@t>
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.

2 participants