fix(channels): drain the .env write before releasing the config lock - #5067
Conversation
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've reviewed the diff, the Candidate 1 claims the cancel-drain path (
The candidate's own author scored it "low." Under the 80+ bar for a real defect it does not survive: the trigger is a compound rare event and the consequence is benign. Dropped. No new grounded findings emerge from the diff — the shield/drain/re-raise idiom is the correct pattern, all six sites hold the lock across the offloaded write, and the normal path propagates exceptions correctly. No findings. [OPUS-REVIEWED] c532a04 |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS The fix closes the cancel-under-lock hole for Watch
Suggestions
[DESIGN-REVIEWED] c532a04 |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of First-Principles-Verdict: CONCERNS The drain is derived and earns its place, but the named cause — a bare offload under What this change shipsIntent: stop a cancelled channel-token save from silently losing a credential by releasing the config lock while the
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] c532a04 |
61ec1e5 to
f1988ae
Compare
f1988ae to
5c3991c
Compare
Rescoped onto current main. The original defect this PR opened against -- three of the six channel saves calling _write_env_updates inline on the gateway loop -- was fixed upstream by kirodotdev#5269 and siblings, so all six now reach it through asyncio.to_thread. That half of the change is dropped rather than re-landed. What the offload did not bring with it is the drain. Every channel save runs its .env write inside `async with _get_config_lock()`, and a thread cannot be cancelled: a bare `await asyncio.to_thread(...)` lets a cancelled request -- a client disconnecting mid-save, a gateway shutdown -- unwind the `async with` while the worker is still rewriting .env. The next channel save then enters the critical section against a file that is still being replaced, and writes it back from lines it read before the first write landed, discarding whichever credential that save was persisting. The failure is silent: both saves answer 200. _write_env_off_loop shields the worker and drains it before the lock is released. Draining cannot change WHETHER the write happens -- the thread runs to completion either way -- so the only thing it decides is whether the lock outlives it. The CancelledError is re-raised, never swallowed. All six call sites route through the helper, including the three that were already offloading before this PR: the bare offload is the hole, so covering a subset would leave the same window open in the rest. Tests: the cancellation regression forces the ordering with events rather than sleeps -- the worker parks inside the write, the caller is cancelled while it is parked, and a second writer must not get through until the first worker finishes. Fail-before verified by removing the shield/drain from the helper on this same tree: `test_cancelling_a_save_drains_the_env_write_before_releasing_the_lock` fails with the lock handed over mid-write, 5 passed / 1 failed. The four parametrised off-loop assertions are a GUARD, not a fail-before: they pass on main as it stands, and exist so the next channel cannot reintroduce an inline write. The caller contract is now stated on _write_env_updates itself so the reason is inherited along with the shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Head branch was pushed to by a user without write access
5c3991c to
c532a04
Compare
Problem / Motivation
Every channel token save runs its
.envwrite insideasync with _get_config_lock(), and on currentmainall six reach it as a bare offload:A thread cannot be cancelled. When the request is cancelled — a client disconnecting mid-save, a gateway shutdown — the
awaitraisesCancelledErrorand theasync withunwinds while the worker is still rewriting.env. The lock is released; the worker is not.The next channel save then enters the critical section against a file that is still being replaced, and writes it back from lines it read before the first write landed. Whichever credential the cancelled save was persisting is discarded. Both saves answer
200.All six call sites on
mainhave this shape:_slack_config_save_locked,_discord_config_save_locked,_telegram_config_save_locked,api_teams_config_save,api_webex_config_save,_wecom_config_save_locked.Why it matters
The lost write is a credential, and the loss is silent — the save that gets clobbered has already returned success to the user, so the dashboard shows the token as installed while
.envholds the other one. Recovery requires noticing that a channel stopped authenticating and re-entering a token by hand.Cancellation here is ordinary, not exotic: an aiohttp client disconnect during a save that is validating a token against a remote API (which is what makes the save slow enough to overlap another) is enough.
What changed (motivation → approach → change)
Motivation — keep the lock's guarantee intact across the hop the offload introduced.
Approach — do not widen the lock or make the write cancellable; put the lock release after the worker. Shield the future so the cancellation does not propagate into it, drain it, then re-raise. This is the same shape
run_config_writeuses indashboard/chat_utils.py, so the two config-write paths behave alike.Change — one helper,
_write_env_off_loop, and all six call sites route through it:Draining cannot change whether the write happens — the thread runs to completion either way — so the only thing it decides is whether the lock outlives it. The
CancelledErroris re-raised, never swallowed.All six, not a subset. The bare offload is the hole, so the three sites that were already offloading before this PR carry it too. Covering only some would leave the same window open in the rest and re-create a per-site split.
The caller contract is now stated on
_write_env_updatesitself, so the next channel inherits the reason and not just the shape.Tests
test/test_channel_env_write_off_loop.py(new) pins two distinct properties:Cancellation drain —
test_cancelling_a_save_drains_the_env_write_before_releasing_the_lock. Ordering is forced with events, never slept for: the worker parks inside the write, the caller is cancelled while it is parked, a second writer then tries to take the lock, and must not get through until the first worker finishes.Fail-before, verified on this same tree by replacing the helper body with a bare
await asyncio.to_thread(...):Restored:
6 passed.Off-loop guard — four parametrised cases (slack, discord, webex, telegram) drive each save over a real HTTP client and assert on the thread the real
_write_env_updatesexecuted on. These pass onmainas it stands and are a guard, not a fail-before: they exist so the next channel cannot reintroduce an inline write, plus a meta-test that fails if a channel is quietly dropped from the table.Gates run locally on the rebased head:
flake8·isort --check-only·scripts/check_black_formatting.py(2 files in scope, passed) ·scripts/check_loop_bound_locks.py(passed).Manual verification
N/A — unit coverage sufficient: the regression turns on the interleaving of a cancellation and a worker thread, which the event-forced ordering reproduces deterministically and a hand test cannot.
Related Issues
#5065 reported the inline-write half. That half is already fixed on
mainby #5269 and siblings, so this PR does not claim to close it — the issue can be closed independently of this change.Same family as #4118 / #3803 / #4550, and the
run_config_writedrain in #5059.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)_write_env_updatesitself.Contribution License Agreement
🤖 Generated with Claude Code