fix(artifacts): keep the folder-store reads off the event loop - #5979
fix(artifacts): keep the folder-store reads off the event loop#5979leonlaiyc wants to merge 1 commit into
Conversation
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All verification is complete. The fix's premise checks out against the base (every First-Principles-Verdict: CONCERNS Real fix, but point-scoped: the same file keeps 7 inline blocking Not justified as shipped
What this change shipsIntent: stop artifact-folder reads from stalling the whole gateway loop behind another request's folder write — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] c518ca1 |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of All claims verified. The core premise holds (single coarse lock, reads contend with Design-Verdict: PASS Correct root-cause framing — the blocking property belongs to the lock, not the call — fixed at every reachable site with a thread-placement test that pins the property itself. Suggestions
[DESIGN-REVIEWED] c518ca1 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 1 of 1 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- test/test_artifact_folder_store_off_loop.py:206 -- Background icon task can outlive its event loop Adjudication (Opus 4.8) — is blocking on each finding proportionate?The adjudicable block is empty. There is one fenced finding, F1, under the I confirmed the mechanism from code opened this run:
This is a deterministic leak, not a rare race, so no rarity argument for FLAG can be completed. Verdict: UPHOLD-FENCED. Harm rung: unbounded per the fence (cross-test poisoning, surfaces far from cause). Conditions confirmed: leak at |
|
🤖 Kiro Crew [operator: bolichen97#66809557]: This PR has been inactive for 7+ days with failing CI. I've assessed the blockers and they appear resolvable — I'll push fixes directly to this branch as a co-author. Assessment: A behavior-preserving offload of the artifact folder-store reads onto the executor (both If you'd prefer I don't touch this PR, add the |
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
`ArtifactFolderStore` guards its whole API with one `threading.Lock`, and every mutating call holds that lock across `_save()` — `mkdir`, `mkstemp`, write, `os.fsync`, `os.replace`. The artifact handlers already push those mutations into the shared executor, which is precisely what makes the reads dangerous to leave inline: an `exists()` / `get()` / `breadcrumb()` / `resolve_path()` on the gateway loop cannot take the lock until the worker thread finishes somebody else's folder write, so the loop stalls for the length of that filesystem write. `_resolve_folder_ref_off_loop` stated the premise explicitly — "create_missing =False is a pure in-memory walk, so it runs inline" — and the walk does take the lock. Route every folder-store call a handler makes through the executor, and fold `breadcrumb()` into the list handler's existing executor call rather than paying a round trip per folder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5bcd4b8 to
c518ca1
Compare
|
Rebased onto main Conflicts and resolution:
Gates run locally: Please review the resolution. A maintainer push makes the maintainer the last pusher, so a second approver is needed under the repo's last-push rule. Reply if anything looks wrong. |
Problem / Motivation
ArtifactFolderStoreguards its entire API with a singlethreading.Lock, andevery mutating call holds that lock across
_save():create,rename,reparent,set_icon,set_color,reorderanddeleteall have that shape. The artifact handlers correctly push those mutations into
the shared executor — and that is exactly what makes the reads dangerous to
leave inline. Ten folder-store calls run on the gateway event loop today:
_resolve_folder_ref_off_loop(create_missing=Falsebranch)resolve_pathapi_artifact_foldersbreadcrumb, once per folderapi_artifact_folder_createresolve_path(parent_id),breadcrumbapi_artifact_folder_updateexists,get,breadcrumbapi_artifact_folder_deleteexistsapi_artifact_set_folderresolve_path(folder_id),exists_spawn_artifact_folder_icon_taskexistsNone of them writes anything, but every one of them takes
self._lock. When aconcurrent request is mid-
create/rename/deleteon a worker thread, theinline read cannot acquire the lock until that worker has finished its
mkdir+mkstemp+fsync+os.replace— so the whole gateway loop(chat streaming, heartbeat, every other request) is parked on another user's
folder write.
Lock.acquire()from a coroutine is a hard block, not asuspension: nothing else on the loop runs.
_resolve_folder_ref_off_loopstates the premise that made this look safe:The walk is in memory. It is not lock-free, and the lock is the part that
blocks.
Why it matters
The folder store is small, so the pathological case is not a slow parse — it is
the write itself.
_save()is anfsyncplus a rename; on a busy disk, anetwork-backed home directory, or a Windows box where a scanner holds the
target open, that is tens to hundreds of milliseconds, and the store is a single
shared file so every folder mutation in the process serializes on it. Any
concurrent artifact-folder read then converts one slow write into a stall of the
entire dashboard gateway, for every session it serves — not just the two
requests involved.
This is the rule
AUTOSDE.yamlstates asno-blocking-call-on-event-loop(
blocking: true,file-patterns: src/kiro_crew/**/*.py): "a single blockingcall there freezes every task — the user's chat turn AND the liveness heartbeat
— until the watchdog kills the process and the supervisor respawns into the same
condition (a crash loop). This has caused multiple production wedges." It closes
with "When in doubt, offload."
The same module already treats this as the standard it holds itself to
(
_run_off_loop: "so itsos.fsync/os.replacenever blocks the eventloop";
api_artifact_folders: "Offload it so the dashboard event loop staysresponsive"). This closes the gap between that stated standard and the reads.
What changed (motivation → approach → change)
Root cause: the offload decision was made per call ("does this one write?")
when the blocking property belongs to the lock ("can this one wait on someone
who writes?"). Correcting the premise makes every folder-store call a handler
issues an executor call.
_resolve_folder_ref_off_loopoffloads both modes instead of short-circuitingcreate_missing=Falseinline, and its docstring now records why the in-memorywalk still cannot run on the loop. The two direct
_resolve_folder_ref(...)callers (
api_artifact_folder_create'sparent_idbranch andapi_artifact_set_folder'sfolder_idbranch) go through it.exists(),get()andbreadcrumb()in the create / update / delete /set-folder handlers move into
_run_off_loop.api_artifact_foldersfoldsbreadcrumb()and_serialize_folder()into theexecutor call it already makes for
list_with_counts. Wrapping thecomprehension per folder would have put O(folders) lock acquisitions back on
the loop and cost a round trip each; one call does the whole list.
_spawn_artifact_folder_icon_taskmoves itsexists()guard inside theclosure that already runs
set_icon(), so the check and the write share oneexecutor hop and one lock acquisition — which also removes the check-then-act
gap the two-step version had.
No behaviour changes: same status codes, same payloads, same ordering of the
existence check against body parsing.
_apply_updatesinapi_artifact_folder_updatealready re-checksfstore.get(fid)inside theexecutor, so the concurrent-delete guard it documents is untouched.
Tests
New
test/test_artifact_folder_store_off_loop.py. It does not assert on handlershape — it wraps the real
ArtifactFolderStoremethods, recordsthreading.get_ident()for every call, drives each handler, and asserts no calllanded on the event loop's own thread. That keeps the property true regardless
of how a handler reaches the store. The mutators are wrapped as well, so a
regression that moves an already-offloaded call back inline fails the same
assertion.
Six tests, one per entry point: folder list, create by
parent_id, update,delete,
set_folderbyfolder_id, and the background icon task.Red-before, measured against pristine
origin/mainproduction code(
ad0825392) with the new test file in place — 6 failed, each naming themethods that ran on the loop thread, which is the defect itself and not a
fixture or environment artifact:
Green-after: 6 passed. Blast radius: 686 passed / 16 skipped across
test_artifact_folder_handlers.py,test_artifact_folders.py,test_artifacts_handlers.py,test_artifacts_handlers_coverage.py,test_handlers_artifacts_coverage.py,test_artifacts.py,test_mcp_artifact_folders.pyand the new file.flake8,isortandmypyare clean on both files; the new test file isblack-clean.handlers/artifacts.pyis on.github/black-baseline.txtandreports the same eight pre-existing hunks before and after this diff — none of
them in the changed regions.
Manual verification
N/A — unit coverage sufficient: the property under test is which thread a call
executes on, which the tests observe directly at the store; a manual click-through
of the folders UI could not distinguish the two implementations.
Related Issues
Self-reported while auditing the module's own offload comments against its call
sites. No separate issue was filed.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement