fix(accounts): account removal must stick — incoming set authoritative + lock-held read-modify-write#17
Conversation
There was a problem hiding this comment.
1 issue found across 7 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…uthoritative + lock-held mutateAccounts RMW Removing a fallback account (/openai-account remove, CLI remove) did not stick: mergeStorageForSave unioned the on-disk and incoming account arrays and never deleted, so a removed account was resurrected on the next load. - mergeStorageForSave: incoming.accounts is authoritative for the account set, so a removal sticks; non-account top-level fields still fall back to the latest on-disk snapshot for fields incoming omits. - mutateAccounts: a lock-held read-modify-write helper that re-reads config and state under the lock and applies the mutation to fresh data, so a concurrent (cross-process) add is preserved rather than wiped by a stale snapshot. All mutation paths (commands switch/remove/order/add, CLI login/remove, loader mainAccountId + main-refresh state) route through it. - saveAccountState full-save no longer prunes the accounts map: removal cleans per-account state via the saveAccounts -> stateFromStorage rebuild, and the prune was reachable only from the lock-free background-refresh path where it could wrongly drop a concurrently-added account's state.
39db9da to
280f367
Compare
|
Closing as superseded by upstream.
Rebasing this branch onto current One piece here isn't yet covered upstream: a test asserting the state file is scrubbed of a dropped account's Thanks for the audit train — converged independently on the same design. |
Problem
Removing a fallback account (
/openai-account remove <id>, the TUI account modal, or theopenai-authCLIremove) did not stick.mergeStorageForSaveunioned the on-disk (latest, re-read under the lock) andincomingaccount arrays via a Map and never deleted, so a removed account was resurrected on the next load. A second, quieter facet: orphaned per-account state could linger.Proven with a repro test before fixing: seed
{a, b}→ reload → spliceb→ save → reload returns{a, b}(b resurrected).Fix
mergeStorageForSave— incoming account set is authoritative. Returns{...latest, ...incoming, accounts: incoming.accounts}, so a removal (splice) sticks. Non-account top-level fields still fall back to the latest on-disk snapshot for fieldsincomingomits.mutateAccounts— a lock-held read-modify-write helper. Because the incoming set is now authoritative, a caller that didloadAccounts()(lock-free) → mutate →saveAccounts()had an inverse TOCTOU: a concurrent (cross-process) add landing between the load and the lock-held save would be wiped by the stale snapshot.mutateAccountsre-reads config+state under the lock (same config→state lock order assaveAccounts) and applies the mutation to fresh data, so a concurrent add is preserved. All mutation paths route through it: commandsswitch/remove/order/add, CLIlogin/remove, and the loader'smainAccountId+ main-refresh-state writes.saveAccountStatefull-save no longer prunes the accounts map. Removal cleans per-account state via thesaveAccounts→stateFromStoragerebuild (which rebuilds the state file from the authoritative account set). The prune was reachable only from the lock-free background-refresh path, where a concurrent add could make it wrongly drop the new account's state (token loss). Reverted; the scoped-save prune is unchanged.Review
Cross-family race council (MiniMax-M3 + gemini-3.1-pro + claude-sonnet-4-6). Round 1 caught two real issues in an initial version (the full-save prune was a new regression; the inverse-wipe window) — both addressed by this revision (revert +
mutateAccounts). Round 2: APPROVE ×3.Verification
mutateAccountspreserves[a, b, c]where a naive stale-snapshotsaveAccountswipes it to[a, c]; a production-removal-path test proves thestateFromStoragerebuild drops removed state without the reverted prune.bun run buildclean ·bun run typesclean ·bun test400 pass / 0 fail ·bunx biome check src0 warnings.Discovered via a cross-tree review against the Anthropic sibling plugin; the sibling's tree keeps the config account list authoritative from the start and does not have the resurrection facet.
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.Summary by cubic
Fixes account removal not persisting by making the incoming account list authoritative and adding a lock-held mutation path to avoid lost concurrent writes. Removed accounts no longer resurrect on reload, and concurrent additions are preserved.
Bug Fixes
mergeStorageForSavenow treatsincoming.accountsas the source of truth, so removals stick; other top-level fields fall back to on-disk values if missing from incoming.saveAccountStatefull saves no longer prune the state map; state is rebuilt from the authoritative account list. Scoped prunes remain limited to the requested ids.New Features
mutateAccountsfor lock-held read-modify-write. It re-reads config and state under the lock, applies the mutation, and writes both files. All mutation paths (openai-account switch/remove/order/add, CLI login/remove, and main account/refresh-state writes) now use it to prevent TOCTOU wipes.Written for commit 280f367. Summary will update on new commits.