Skip to content

Race conditions cause "token_expired" loop when running multiple concurrent opencode instances sharing one account #31

Description

@GaboEI

Summary

Running multiple opencode processes concurrently (common when you keep several terminal sessions open) against the same store causes an infinite loop of: re-auth succeeds → minutes later the account is marked authInvalid with a 401 token_expired from the Usage API → re-auth again → repeat. Re-authenticating never actually fixes it for more than a few minutes.

Reproduced on v1.4.3 (npm) with 4 concurrent opencode processes + the web dashboard, all pointed at the same ~/.config/opencode-multi-auth/accounts.json.

Root causes found (2 distinct bugs)

1. rotation.jsgetNextAccount() saves a stale in-memory snapshot after an await

let store = loadStore(); // snapshot taken here
...
const token = await ensureValidToken(candidate); // network call, can take seconds
...
store = updateAccount(candidate, { usageCount: ..., lastUsed: now, ... }); // this part is safe, reloads internally
store.activeAlias = candidate;
store.rotationIndex = nextIndex(candidate);
saveStore(store); // <-- writes the ENTIRE store using the snapshot from before the await

If another process refreshes/re-authenticates the same account while this await is in flight, saveStore(store) overwrites the whole file with the pre-await copy, clobbering the fresh tokens the other process just wrote. Same pattern exists in the force-mode branch a few lines above.

Confirmed with a minimal repro (two async flows: one does load → 300ms delay (simulating the network await) → save; the other does a concurrent "re-auth" at the 100ms mark). Without a fix, the final accessToken on disk can be the stale one depending on timing.

2. limits-refresh.js / usage-limits.js — rate-limit refresh queue uses a stale accessToken snapshot

refresh-queue.js builds a queue from an accounts array captured once at the start of a refresh cycle (startRefreshQueue(accounts, alias)), then calls refreshRateLimitsForAccount(account) for each one. With concurrency limits, an account near the end of the queue can be processed minutes after the snapshot was taken. usage-limits.js:95 then does:

const token = account.accessToken?.trim();

using that stale token to call the real OpenAI Usage API. If the token was rotated via re-auth in the meantime, this triggers a genuine 401 token_expired from OpenAI's servers — even though the token stored on disk is valid — and the account then gets markAuthInvalid()'d.

This matches the exact pattern observed in logs/codex-soft.log:

11:13:34 Re-auth completed for personal by dashboard
11:32:33 Skipping limits probe for personal: Usage API returned 401 token_expired   <- 19 min later, stale snapshot
11:35:33 Skipping limits probe for personal: Usage API returned 401 token_expired

Contributing factor

store.js's saveStore() does a full read-modify-write of accounts.json with only an in-memory writeLock/writeLockQueue (scoped to a single process). There's no cross-process locking (no flock, no lockfile), so any two processes doing load→mutate→save around the same time can race, independent of bugs 1 and 2 above.

Suggested fixes (I've applied and tested these locally as a patch)

  1. In getNextAccount(), reload the store fresh from disk (loadStore()) right before the final saveStore(store) call, instead of reusing the pre-await snapshot. Applied to both the force-mode branch and the normal candidate-selection branch.
  2. In refreshRateLimitsForAccount(account), reload the account fresh from the store (loadStore().accounts[account.alias]) before using account.accessToken, instead of trusting the snapshot passed in from the queue.
  3. (defense in depth) Add a real cross-process lock (e.g. a lockfile with mkdirSync/stale-lock detection, or proper-lockfile) around saveStore/the load-mutate-save cycle in addAccount/removeAccount/updateAccount/setActiveAlias, since the current writeLock only protects against races within a single process.

Repro / environment

  • Package: @guard22/opencode-multi-auth-codex@1.4.3 (npm, matches latest published)
  • 4 concurrent opencode CLI processes + 1 opencode-multi-auth web dashboard, all sharing one OpenAI "personal" account
  • Node v22.23.2, Linux

Happy to open a PR with the patch (points 1 and 2 fully tested; point 3 is a simple lockfile-based mutex, also tested) if that's useful — let me know your preference on locking implementation (custom mkdirSync-based vs. a dependency like proper-lockfile).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions