anthropics / anthropics/claude-code

[BUG] Windows: `.oauth_refresh.lock` directory survives a mid-refresh exit and is never reclaimed despite `stale: 60000` — every `claude -p` call fails until it is deleted by hand

Open
#95,236 0 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
TypeScript
Stars
146k
Forks
23.8k
PR merge metrics
PR metrics pending

Description

### Preflight Checklist

- [x] I have searched [existing issues](https://github.com/anthropics/claude-code/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug) and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code

### What's Wrong?

A batch of headless `claude -p` calls (~48 per run, ~3 concurrent) all failed with "Failed to refresh OAuth token: another Claude Code process is refreshing it or exited mid-refresh". At that moment `%USERPROFILE%\.claude\.oauth_refresh.lock` existed and was an EMPTY DIRECTORY, and `.credentials.json` had last been written 2 days earlier (2026-09-14 21:32), so a refresh was plausibly due.

The error says "retry in a minute". We waited 180 s and retried: same failure. Deleting the empty directory by hand fixed it instantly — the next `claude -p` succeeded in 3.3 s, and the failure did not recur in ~200 later calls that day. So the lock is never reclaimed and only a human can clear it.

This contradicts the lock's own configuration. Strings extracted from the shipped binary — identical in 2.1.272 (where we hit this) and in 2.1.274 (current release, which I upgraded to before filing):

```js
{ lockfilePath: /.oauth_refresh.lock, realpath: false, stale: 60000, update: 5000,
onCompromised: (err) => log(`OAuth refresh lock compromised: ${err.message}`, {level:'error'}) }
```

`stale: 60000` should let the next acquirer reclaim a lock whose mtime is older than 60 s. Every caller here is a SHORT-LIVED `claude -p` child started minutes apart, so each should have seen an mtime far older than 60 s and reclaimed it. None did.

Two candidates we cannot distinguish from outside:
1. The failure comes from the SECOND (legacy) lock, not the one above: the same routine also acquires `${credentialsPath}.lock` and rethrows on `ELOCKED` (`tengu_oauth_refresh_legacy_lock_contended`), which would make the new lock's `stale` setting irrelevant on this path.
2. The staleness reclaim does not apply to the DIRECTORY form of the lock on Windows (the mkdir-as-mutex artifact), so the age check never runs.

Related: #62600 (closed 2026-06-27) reported the same Windows artifact — stale lock DIRECTORIES including `.oauth_refresh.lock` not removed after an abnormal exit — with credential persistence as the symptom. This is the same artifact with a different symptom, so the leak still appears reachable. Possibly related and open: #91708, #92330, #92630.

### What Should Happen?

A refresh lock abandoned by a process that exited mid-refresh should be reclaimed automatically once it is stale (the bundled options already say `stale: 60000`), so a later `claude -p` call recovers on its own without a human deleting a directory under `~/.claude`.

If a lock genuinely cannot be reclaimed, the error message should say so and name the path to remove. "This is usually transient; retry in a minute" is misleading when no retry will ever succeed.

### Error Messages/Logs

```shell
Failed to refresh OAuth token: another Claude Code process is refreshing it or exited mid-refresh.
This is usually transient; retry in a minute, and if it persists close other Claude Code windows.

# state at that moment
PS> Get-Item $HOME\.claude\.oauth_refresh.lock | Select-Object Mode, LastWriteTime
Mode LastWriteTime
---- -------------
d---- (empty directory, no files inside)

PS> Get-Item $HOME\.claude\.credentials.json | Select-Object LastWriteTime
2026-09-14 21:32 # ~2 days before the failure

# after removing the empty directory by hand
PS> Remove-Item $HOME\.claude\.oauth_refresh.lock
# next claude -p call: success in 3.3 s; no recurrence in ~200 later calls that day
```

### Steps to Reproduce

We could NOT reproduce this on demand, so these are the observed conditions rather than a recipe. It needs a token refresh to fall due while several `claude -p` children run.

1. Log in with a claude.ai subscription (`claude auth status` shows `"authMethod": "claude.ai"`), on Windows, native install.
2. Leave the session idle long enough that `.credentials.json` is due for a refresh (ours was last written ~2 days earlier).
3. From a script, spawn repeated headless calls, ~3 concurrent, no tools: `claude -p --safe-mode --tools "" --strict-mcp-config --no-session-persistence --permission-mode dontAsk --model claude-sonnet-5 --system-prompt-file --output-format json`, prompt on stdin.
4. A refresh falls due during the fan-out. One child wins the lock and exits or is killed mid-refresh, leaving `~/.claude/.oauth_refresh.lock` as an empty directory.
5. Every later call returns the envelope `is_error: true` with `result` = "Failed to refresh OAuth token: another Claude Code process is refreshing it or exited mid-refresh...".
6. Wait 180 s and retry: still fails. The directory is not reclaimed at any age.
7. `Remove-Item $HOME\.claude\.oauth_refresh.lock` — the very next call succeeds (3.3 s).

A faster path to the same state, if the age check is the bug: create the empty directory yourself (`New-Item -ItemType Directory $HOME\.claude\.oauth_refresh.lock`), leave it untouched for more than the 60 s `stale` window with no Claude Code process running, then force a refresh. If it is not reclaimed, that is the defect independent of how it was first left behind.

### Claude Model

Not sure / Multiple models

### Is this a regression?

I don't know

### Last Working Version

_No response_

### Claude Code Version

2.1.274 (Claude Code)

### Platform

Anthropic API

### Operating System

Windows

### Terminal/Shell

Non-interactive/CI environment

### Additional Information

**Version note:** the failure was observed on **2.1.272**. Before filing I upgraded to **2.1.274** (current) and confirmed the lock options are byte-identical there (`stale: 60000, update: 5000`) and the error string is unchanged, and that no 2.1.273/2.1.274 changelog entry touches the refresh lock. I have not seen the failure again since upgrading — but I could not reproduce it on demand on 2.1.272 either, so that says nothing.

**Environment:** Windows 11 Pro 10.0.28020, native install at `%USERPROFILE%\.local\bin\claude.exe`, claude.ai subscription login. The failing calls are headless `claude -p` children spawned by a Python orchestrator (hence "Non-interactive/CI environment"); the parent shell is PowerShell. The one occurrence we caught was in a run launched from inside a Claude Code session, so the child inherited that session's environment — we cannot say whether that inheritance matters.

**Suggested fixes, in our order of preference:**
1. Apply the `stale`/`update` age check to the directory form of the lock on Windows, so a lock abandoned mid-refresh is reclaimed automatically instead of waiting for a human.
2. Make the legacy `${credentialsPath}.lock` path honour the same staleness rules, or stop treating its `ELOCKED` as fatal when the new lock was acquired cleanly.
3. If a lock genuinely cannot be reclaimed, name the path to delete in the error message.

**Workaround, for anyone who hits this:** close other Claude Code processes, `Remove-Item "$HOME\.claude\.oauth_refresh.lock"` (it is an empty directory), and if calls still fail, `claude auth login`. On our side we now retry the same call once after 10 s on this specific error, then stop retrying for the rest of the run and fall back to an API key — a stuck lock otherwise added a 10 s wait to each of ~48 calls per run.

Contributor guide

No contributing guide indexed for this repository

Research direction

No repository file or test is named. Start by inspecting the OAuth refresh lock acquisition paths corresponding to ~/.claude/.oauth_refresh.lock and ${credentialsPath}.lock, then try the supplied Windows stale-directory reproduction. Done means an abandoned directory is reclaimed after 60 seconds, or the error identifies the lock path and recovery behavior is covered by a regression test.

Written by the indexing model from the issue text.

Assessment

Tech stack
powershell, python
Domain
authentication, cli
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.