fix(artifacts): guard artifact-folder icon write-back with a per-folder epoch - #8097
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix at the right layer: a CAS epoch owned by the store's own lock, with the arming value returned from the mutation's critical section. Suggestions
[DESIGN-REVIEWED] e5af344 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All verification is done. The mechanism claimed as "already existing" ( First-Principles-Verdict: PASS A user's explicit icon choice no longer silently loses to a stale background LLM result; every added piece is the guard or its atomicity carrier. What this change shipsIntent: stop a background-generated folder icon from overwriting what the user did meanwhile (pick, clear, rename) — a FIX.
No duplicate mechanism exists: grep [FIRST-PRINCIPLES-REVIEWED] e5af344 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
fb42eef to
d3a2de2
Compare
Legitimate, and the more interesting of the two: the guard was correct at both ends and wrong in the middle. Confirmed reachable exactly as described.
The PATCH handler bumped the epoch inside Fix: the mutation now returns the epoch its own bump produced, from inside the same critical section — Red-before proven on this mechanism alone. With everything else fixed and only the late capture restored, the new test fails as a real clobber — the generated emoji overwriting the manual one: The test drives the window deterministically by hooking Recorded as a rule candidate in the PR's |
Legitimate. The comment was wrong about the state a failed
Code behaviour is unchanged — the pop placement was already correct, only its justification was inaccurate. |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
d3a2de2 to
56ce10d
Compare
56ce10d to
9d66bd4
Compare
Accepted on hygiene grounds, and applied at the chokepoint rather than per call site. One correction to the stated consequence, below.
The structural point holds: five tests parked a generation task on Where the finding overstates the consequence: I probed it rather than assuming. A temporary test that deliberately fails inside a held block, followed by a test asserting the module-global set holds no pending task, passes even with the pre-fix shape — the parked task is cancelled at event-loop teardown and its done-callback discards it from the set. So the "remains strongly referenced across event loops" step did not reproduce, and this was not on the path to the cross-loop Keeping the fix anyway: the 28 tests in the file still pass, and the sibling suites ( |
…er epoch (#7991) The async icon generator for artifact folders guarded its write-back with a bare fstore.exists(folder_id) check, which only catches deletion. Three stale-write-back races stayed open, all of them the ones the chat-folder subsystem closes with a per-folder icon epoch: * a manual icon set while generation was in flight was clobbered by the stale generated result; * an icon clear mid-generation was overwritten -- the icon VALUE goes absent -> absent, so no value-pin could have caught it either; * a rename mid-generation landed an icon derived from the old name, and since an artifact-folder rename REGENERATES, a rename followed by a manual pick lost to the regenerated result. Ported the chat-folder guard to the artifact folder store. ArtifactFolderStore carries a per-folder icon epoch, bumped under the store lock by every mutation a generated icon must not outlive (set_icon, which covers both a manual set and a clear, and rename). set_icon_if_epoch re-finds the folder, checks the epoch and writes inside ONE critical section, so nothing can interleave between the check and the write. A confirmed delete pops the entries -- after _save(), so a failed delete keeps the guard armed. Arming is atomic with the mutation, which is the subtle half. rename() returns the epoch its own bump produced, from inside that same critical section, and the PATCH handler arms generation with THAT value. Renaming and then READING the epoch back would be two lock acquisitions: a manual icon set landing between them bumps the epoch again, the later read captures THAT epoch, and the generated icon then satisfies set_icon_if_epoch and overwrites the user's pick -- the very race the epoch exists to prevent. The create path pins 0 for the same reason; a fresh id has no registry entry, and a read could only pick up a competing bump. No epoch getter ships. Returning the value from rename() and pinning 0 on create are the only two ways a caller obtains one, so a public read-back accessor would have had zero production callers -- and the read it enables is precisely the bug above. The tests read the registry directly through a local helper. rename() is likewise the single spelling of that mutation, returning (folder, epoch), rather than a dict-returning wrapper beside it that would drift. The epoch lives on the store INSTANCE rather than module-level as in the chat-folder original, whose folders live on DashboardState rather than in a store object. A module-level dict keyed only by folder id would let two stores over different JSON paths alias each other's ids. The exists() pre-check is gone rather than retained: it was itself a TOCTOU gap, and re-finding the folder under the lock subsumes it. That also removes a lock acquisition from the event loop inside the background task. Tests: test/test_artifact_folder_icons.py, mirroring test/test_chat_folder_icons.py. 27 tests over both layers -- the store's epoch bookkeeping (bump on set/clear/rename, generated write-back does not bump, drop on stale epoch or deleted folder, pop on confirmed delete including a cascade, guard survives a failed delete commit, per-instance isolation, unrelated folders do not invalidate, rename returns its own bump and that value is what a read-back would have lost) and the races end-to-end through the real handlers, including a manual pick landing inside the arming window. Red-before proven for each mechanism separately: with the source reverted the four original races fail as genuine clobbers; with only the late capture restored the arming-window test fails '🧬' == '🧪'. A _held_generation context manager owns the release Event and drains in a finally, so an aborting assertion cannot leave a task parked on it. The drain helper filters in-flight tasks to the CURRENT running loop: _ARTIFACT_FOLDER_ICON_TASKS is module-global and other test files populate it via the create handler, so gathering the whole set awaits futures from earlier tests' closed loops -- ValueError: The future belongs to a different loop. ## Pattern harvest Rule candidate: semgrep Pattern: a versioned-guard counter (epoch / generation) read in a SEPARATE lock acquisition from the mutation that bumped it, then used as the expected value for a later compare-and-set. The read can observe a competing writer's bump, so the stale actor's write satisfies the comparison and lands. The guard has to be returned from inside the mutation's own critical section; a subsequent read-back of the same counter is never equivalent -- which is why this change ships no accessor for it.
9d66bd4 to
e5af344
Compare
Legitimate, and the count is exactly right. Verified before cutting:
Deleted. The tests read the registry through a local Also took the subtraction, in the same commit:
Collapsed to one method named Worth noting what this shed: the previous revision had repointed 486 tests green across the six artifact suites; |
Legitimate. The docstring was left describing the mechanism the previous round replaced.
"Read by the caller AFTER its own mutation committed" was true of the revision that had the late-capture bug, and became false the moment the epoch started coming back from inside Also corrected the neighbouring Advisory, and the fix is comment-only — no behaviour change, no test change. |
chenmingwei23
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (4 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix(artifacts) - per-folder icon epoch closes a stale-write-back TOCTOU race on artifact-folder icon generation (ported guard, issue #7991), clear root cause, no auth/trust-boundary/input-parsing surface.
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. |
Problem / Motivation
_spawn_artifact_folder_icon_task(src/kiro_crew/dashboard/handlers/artifacts.py) guarded its async icon write-back with only an existence check (fstore.exists(folder_id)), which catches deletion and nothing else. Three stale-write-back races stayed open for ARTIFACT folders — the same three the chat-folder subsystem closes with a per-folder icon epoch:Why it matters
The user's own explicit choice silently loses to a background LLM result, seconds after they made it. There is no error and no retry affordance — the icon they picked just turns into a different one, and the only recovery is to notice and pick again. The handler even carries a comment asserting that "an explicit icon wins", which the async path could violate.
What changed (motivation → approach → change)
Root cause: nothing carried a per-folder version, so the write-back had no way to tell "the folder still wants this icon" from "the user has since acted".
exists()answers a different question.Approach: port the chat-folder guard (
_CHAT_FOLDER_ICON_EPOCHS/_bump_icon_epochinsrc/kiro_crew/dashboard/chat_folders.py, PR #7353) to the artifact folder store, preserving its ordering contract:set_icon/rename, same critical section as the field writeset_icon_if_epoch, one critical section_save()confirmed the removalConcretely:
ArtifactFolderStoregainsself._icon_epochs,_bump_icon_epoch_locked()andset_icon_if_epoch().set_iconbumps — one call site covers both a manual set and a clear (icon == "").renamebumps and returns(folder, epoch)— the epoch its own bump produced.deletepops the affected ids (whole subtree on cascade) after_save(), so a failed commit keeps the guard armed.renamereturned. The create path pins0.Arming has to be atomic with the mutation — this is the subtle half, and the first revision of this PR got it wrong (caught by the GPT lane; see the disposition comments).
rename()followed by a separate epoch read is two lock acquisitions: a manual icon set landing between them bumps the epoch again, the later read captures that epoch, and the generated icon then satisfiesset_icon_if_epochand overwrites the user's pick — reintroducing exactly the race the epoch exists to prevent. Returning the value from inside the bump's own critical section closes the window by construction: there is no read to lose. The create path pins0for the same reason — a fresh id has no registry entry (anddeletepops, so even a reused id reads 0), and a read there could only pick up a competing bump.No epoch getter ships, deliberately. Those two paths are the only ways a caller obtains an epoch, so a public read-back accessor would have had zero production callers — and the read it enables is the bug above. An earlier revision did add one; the First Principles lane flagged it as a rider and it is gone (see its disposition). Tests read the registry through a local helper instead. For the same reason
rename()is the single spelling of that mutation rather than a dict-returning wrapper sitting beside a tuple-returning twin, which would drift.Two deliberate deviations from the chat-folder original, both because the artifact side is a store object rather than
DashboardState:ArtifactFolderStoreis constructed per JSON path (and tests construct their own overtmp_path); a module-level dict keyed only by folder id would let two stores alias each other's ids. Locked toself._lock, which is the lock that orders the bump against the check.set_icon_if_epochis a store method rather than a callback. Chat folders mutate throughstate.mutate_folders(callback), so the epoch check can live inside the caller's callback.ArtifactFolderStoreexposes discrete lock-taking methods, so the check-and-write has to be one method to stay in one critical section.The
exists()pre-check is removed rather than kept alongside the epoch: it was itself a TOCTOU gap (the folder could vanish, or its icon change, between the check and the write), and re-finding the folder under the lock subsumes it. That also drops a lock acquisition from the event loop inside the background task.Note on PR #7353
#7353 is still open, so
_CHAT_FOLDER_ICON_EPOCHSis not onmainyet. This PR is nonetheless self-contained and lands onmainas-is: the artifact-side bug is real onmaintoday (see the red-before results below), and #7353 touches neithersrc/kiro_crew/artifacts.pynorsrc/kiro_crew/dashboard/handlers/artifacts.py, so there is no file collision and no ordering dependency. The pattern was read off #7353's branch to match it.Worth flagging for whoever reviews #7353: the late-capture defect found here applies to its shape too — it captures
_CHAT_FOLDER_ICON_EPOCHS.get(fid, 0)aftermutate_folders(_apply)returns, which is the same two-step read this PR had to replace.Tests
test/test_artifact_folder_icons.py, mirroringtest/test_chat_folder_icons.py. 27 tests across both layers.Store layer (
TestStoreIconEpoch):set_iconbumps; an icon clear bumps (the case a value-pin cannot see);renamebumps;set_icon_if_epochwrites on a matching epoch, and does not bump (a generated result landing is not a user mutation);set_icon_if_epochdrops a stale write, and drops a write for a deleted folder;renamereturns its own bump, and that returned value is exactly what a read-back would have lost — the atomicity contract.Handler layer (
TestIconRacesThroughHandlers) — the races end-to-end through the real handlers:Red-before proven per mechanism, separately. With the two source files reverted and the tests kept, the four original races fail as genuine clobbers, not as missing API:
With only the late epoch capture restored (everything else fixed), the arming-window test fails on its own:
Test-hygiene notes, both fixing real CI reds this PR hit: the drain helper filters in-flight tasks to the current running loop, because
_ARTIFACT_FOLDER_ICON_TASKSis module-global and other test files populate it via the create handler — gathering the whole set awaits futures from earlier tests' closed loops (ValueError: The future belongs to a different loop, 7 shard failures). And a_held_generationcontext manager owns the release Event and drains in afinally, so an aborting assertion cannot leave a task parked on it.Local gates: the baselined
blackgate (scope clean, no baseline churn),isort,flake8,mypy,check_sync_io_in_async,check_testpaths_coverage,check_loop_bound_locks,check_brand_name,check_harness_parityall pass. 486 tests green across the six artifact suites.Manual verification
N/A — unit coverage sufficient. Every race here is timing-dependent and is driven deterministically instead: an
asyncio.Eventholds the generator open while the competing mutation lands through the real HTTP handler, and the arming-window case hooks_run_off_loopso the competing pick lands the instant the rename commits. That is more reliable than reproducing them by hand.Screenshots / video
Why no screenshot: backend-only change (store + its handler). The rendered glyph is unchanged; it just stops being the wrong one.
Pattern harvest
Rule candidate: semgrep
Pattern: a versioned-guard counter (epoch / generation) read in a separate lock acquisition from the mutation that bumped it, then used as the expected value for a later compare-and-set. The read can observe a competing writer's bump, so the stale actor's write satisfies the comparison and lands — silently defeating the guard it was supposed to arm. The counter must be returned from inside the mutation's own critical section; a subsequent read-back is never equivalent, which is why this change ships no accessor for it.
This is the defect the GPT lane caught in this PR's first revision, and it is worth a rule precisely because the code reads as correct: the bump is under the lock, the check is under the lock, and only the capture in between is not.
Closes #7991