fix: prevent concurrent token cache corruption (JSONDecodeError + unbuffered I/O)#150
Open
rob-zz wants to merge 3 commits into
Open
fix: prevent concurrent token cache corruption (JSONDecodeError + unbuffered I/O)#150rob-zz wants to merge 3 commits into
rob-zz wants to merge 3 commits into
Conversation
…er concurrency
Two races in the original CrossPlatLock design corrupted the shared MSAL
token cache when many processes (e.g. 300+ parallel az-cli invocations)
accessed it simultaneously:
Race 1 — LOCK_NB bypasses the lock entirely
portalocker.LOCK_EX | portalocker.LOCK_NB raises LockError immediately
when any other process holds the lock. The caller catches LockError and
continues without the lock, so all but the first concurrent process skip
the critical section and proceed to write the cache simultaneously.
Race 2 — lockfile deletion breaks POSIX inode-based exclusion
__exit__ calls os.remove(lockfile). If process A deletes the file and
process B immediately creates a new one at the same path, A and B now hold
exclusive locks on *different inodes*. POSIX fcntl locks are per-(pid,inode)
pair, so both hold a valid exclusive lock — mutual exclusion is lost.
Fix:
- Replace LOCK_EX | LOCK_NB with blocking LOCK_EX only.
All concurrent processes now queue up and take turns; none bypass the lock.
- Open the lockfile in append mode ('a') so it is never truncated.
This preserves the existing inode across the lifetime of the process group.
- Never delete the lockfile on __exit__.
The file remains on disk across invocations; all processes open the same
inode, so POSIX exclusion works correctly for the full session.
…t cache corruption
Defense-in-depth fix for FilePersistence.save, complementing the CrossPlatLock
fix in the previous commit.
The original code wrote directly to the cache file path:
with os.fdopen(_open(self._location), 'w+') as handle:
handle.write(content)
If two processes somehow bypass the lock (e.g. before the CrossPlatLock fix),
both write to the *same* temp path (${location}.tmp). A shorter write arriving
while a longer one is in progress leaves behind leftover bytes from the previous
longer write. When the cache is later read back, json.loads() sees two JSON
objects concatenated and raises:
JSONDecodeError: Extra data: line N column C (char K)
Fix: write to a per-process, per-invocation unique temp file
(${location}.tmp.${pid}.${random_hex}) and then atomically rename it over the
target with os.replace(). Because os.replace() is atomic on POSIX, readers
always see either the old complete file or the new complete file — never a
partial write. The temp file is cleaned up on any exception.
…Python 3) portalocker.Lock() defaults to mode='a' (text mode). Python 3 raises ValueError: can't have unbuffered text I/O when buffering=0 is passed with a text mode. Since the lockfile is never written to, buffering has no effect and can be removed entirely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Author
|
@microsoft-github-policy-service agree |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When multiple
az login/ token acquisition calls run concurrently (e.g. parallel CI jobs or Terraform runs), the MSAL extensions token cache can become corrupted. Two distinct but related bugs cause this:CrossPlatLockopens the lock file withbuffering=0which is invalid for text mode in Python 3 (valid only for binary mode).Both bugs manifest in azure-cli and, by extension, in Terraform (via the AzureRM / AzureAD providers which use the same MSAL token cache): Azure/azure-cli#30046
Terraform users typically see these errors when running
terraform applyorterraform planwith the default parallelism (-parallelism=10): multiple provider goroutines call into the Azure SDK simultaneously, which all share the single on-disk MSAL token cache and trigger the same race conditions.A third observable symptom is:
This appears because when
_reload_if_necessary()fails due to a corrupted cache file, the in-memory cache is left empty (or not updated). MSAL'sfind_account()then returnsNone, and az-cli reports the user as absent — even though the user was previously logged in. This is not a separate bug: fixing the cache corruption eliminates this symptom too.This was originally reported in the Terraform AzureRM provider as a "MSAL token (timing?) issue": hashicorp/terraform-provider-azurerm#27573
Root causes & fixes
1.
CrossPlatLockusesbuffering=0in text mode (Python 3 incompatible)cache_lock.pyopened the lock file withopen(..., 'w', buffering=0). Python 3 raisesValueError: can't have unbuffered text I/Ofor text-mode files withbuffering=0. Fixed by removing thebufferingargument (default buffering is fine for a lock file).2.
CrossPlatLocknot used consistently / race conditionstoken_cache.py'smodify()acquires the lock after reading the cache but before writing. A concurrent writer can modify the file between the read and the write, causing the in-memory state to be stale, and the lock itself had the buffering bug above which caused it to crash before ever protecting anything.3.
FilePersistence.savewrites directly, risking partial writes seen by concurrent readerspersistence.py'ssave()wrote directly to the target file. A concurrent reader could see a partial write, leading to JSON parse errors. Fixed by writing to a per-process temp file in the same directory and thenos.replace()-ing it atomically.Commits
fix: CrossPlatLock races that cause JSONDecodeError('Extra data') under concurrencyfix: FilePersistence.save uses atomic per-process temp file to prevent cache corruptionfix: remove buffering=0 from CrossPlatLock (invalid for text mode in Python 3)Repro
Tested locally with azure-cli 2.87.0 / Python 3.13. The following script reliably triggers both errors:
Without the fix: mix of
JSONDecodeError: Extra dataandValueError: can't have unbuffered text I/Oacross the 300 parallel processes.With the fix: all 300 processes complete successfully.
The same race conditions are triggered by
terraform apply(default-parallelism=10) when using the AzureRM or AzureAD provider, manifesting as:User '<upn>' does not exist in MSAL token cache(corrupted cache leaves in-memory state empty)Related
azurerm_storage_accounthashicorp/terraform-provider-azurerm#27573