fix(config): order the write-back migration save against concurrent config writes - #7830
fix(config): order the write-back migration save against concurrent config writes#7830adiarora06 wants to merge 1 commit into
Conversation
…onfig writes KiroCrewConfig.load()'s write-back migration in _load_resolved called cfg.save() unconditionally after detecting a legacy config shape, with no ordering guard against a concurrent config write (a dashboard PATCH, a CLI write) landing between the migration's read and its save. That write would be silently discarded. The sibling publish_autocompact_pct(cfg, ticket) in the same load() already solves this class of problem with an ordering ticket drawn before the read: a publish holding a ticket lower than one already published is dropped. This extends that same ticket mechanism to save() itself: - KiroCrewConfig.save() gains an optional `ticket` parameter. Omitted (every existing call site), it draws a fresh ticket at write time, which always wins -- correct for the ordinary read/mutate/save callers throughout the codebase, since nothing could have raced them into being "newer". - _load_resolved passes the ticket it already draws before its read into the migration's cfg.save(ticket=ticket) call. If a save with a higher ticket has landed since -- the concurrent write -- this save is dropped instead of clobbering it with the stale snapshot. The compare, the actual write, and recording the new high-water ticket happen under one lock, closing the same TOCTOU window publish_autocompact_pct's docstring calls out for its own compare-and-set. Migration is one-shot but idempotent, so a dropped write just means this process's on-disk config stays in its legacy shape a little longer; the concurrent write's own load (or a later one) re-detects and retries. Kept to the ticket-ordering shape only, per the issue's own proposed options -- not the loader-lock alternative (the migration write is reachable from a worker thread via asyncio.to_thread, off the dashboard's own event-loop-bound lock, so sharing an asyncio.Lock across that boundary is its own hazard) and not the complementary load(migrate=False) read-only mode (unrelated API surface, not needed to close this race). Adds regression tests in test/test_config_loader.py: TestMigrationSaveOrderingAgainstConcurrentWrites reproduces the exact race (a migrating load, a concurrent write landing before the migration's save fires, asserting the concurrent write survives), pins save()'s ticket contract in isolation, and forces the compare-write-record critical section to interleave across threads. Closes kirodotdev#7793 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
3 similar comments
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
Closing — the same fix as #7937, which orders the write it missesVerified relationship: Both PRs say "Closes #7793" and both rewrite the same three lines on origin/main: This was adjudicated twice, independently; the second reviewer reached the same ruling ( (1) Conflict anchor: origin/main Evidence Same anchor: origin/main Why this one and not the otherYes — #7937 is correct, and it is the survivor on code facts rather than on the nomination. #7830 does not close the case it names: its ticket high-water is bumped only in Carry this over firstThis closure is about redundancy, and these items are the exception: they are not on One item, and it is a test shape plus a named residual, not the mechanism. #7830's From a repository-wide duplicate/overlap audit of every pull request open against |
Closes #7793
Problem
KiroCrewConfig.load()'s write-back migration in_load_resolved(src/kiro_crew/config/loader.py) callscfg.save()unconditionally after detecting a legacy config shape needing migration.cfgis a snapshot from a read earlier in the same call, and nothing orders that save against a concurrent config write (a dashboard PATCH, a CLI write) landing between the migration's read and its save — the concurrent write is silently discarded when the migration's write lands last. This is reachable today becausechat_runner.pyalready callsKiroCrewConfig.load()off the event loop viaasyncio.to_thread.The sibling
publish_autocompact_pct(cfg, ticket)in the sameload()already solves exactly this class of problem: it takes an ordering ticket drawn before the read, and a publish holding a ticket lower than one already published is dropped.Fix
Extends that same ticket-ordering contract to
save()itself, rather than inventing a new mechanism:KiroCrewConfig.save()gains an optionalticketparameter. Every existing call site omits it, sosave()draws a fresh ticket at write time — which always wins. That's correct for the overwhelmingly common shape (read, mutate, save, all in one go — nothing could have raced it into being "newer")._load_resolvedpasses the ticket it already draws before its own read into the migration'scfg.save(ticket=ticket)call. If a save carrying a higher ticket has landed on disk since (the concurrent write), this save is dropped instead of clobbering it with the stale snapshot.write_config_atomicallycall, and recording the new high-water ticket happen under one lock — closing the same compare-then-write TOCTOU windowpublish_autocompact_pct's own docstring calls out.Migration is one-shot but idempotent: a dropped write just means this process's on-disk config stays in its legacy shape a little longer, and the concurrent write's own load (or a later one) re-detects and retries.
Why not the other two shapes from the issue
asyncio.to_thread), off the dashboard's event-loop-bound_get_config_lock. Sharing anasyncio.Lockacross that thread boundary is its own hazard, so this would need a new cross-thread primitive rather than reusing what's there. The ticket mechanism is already thread-safe (threading.Lock) and needs no new concept.load(migrate=False)read-only mode — a reasonable complementary idea per the issue, but unrelated API surface not needed to close this specific race. Left out to keep this PR scoped to the ordering fix.Tests
Added
TestMigrationSaveOrderingAgainstConcurrentWritesintest/test_config_loader.py:test_concurrent_write_between_read_and_migration_save_survives— reproduces the exact race from the issue: a load detects migration-needed, a concurrent write (its own load + mutate + save, the same shape every dashboard handler uses) lands before the migration'scfg.save()fires, and the concurrent write survives on disk.test_save_with_a_stale_ticket_is_dropped— pinssave()'s ticket contract in isolation.test_concurrent_migration_saves_never_let_an_older_ticket_win— forces the compare-write-record critical section to interleave across real threads (mirrorstest_autocompact_default.py's equivalent test forpublish_autocompact_pct)..venv/bin/python3 -m pytest test/test_config_loader.py -q— 499 passed, 1 skipped (pre-existing skip, unrelated)..venv/bin/python3 -m pytest test/test_autocompact_default.py -q— 16 passed (confirms the shared ticket-counter machinery is untouched).black/isort/flake8/mypyclean on both touched files.CHANGELOG.mdnot touched, per this repo's fix-PR policy.🤖 Generated with Claude Code