Skip to content

Serialize MSAL cache updates across processes - #3

Open
gtbuchanan wants to merge 4 commits into
mainfrom
serialize-msal-cache
Open

Serialize MSAL cache updates across processes#3
gtbuchanan wants to merge 4 commits into
mainfrom
serialize-msal-cache

Conversation

@gtbuchanan

Copy link
Copy Markdown
Member

Closes #2.

Problem

FileCache.Export writes the MSAL cache atomically (temp + rename), which prevents torn reads but not lost updates. Two concurrent az-devops-token invocations can each Replace (load) the cache, each perform a network refresh, and the later Export clobbers the earlier writer's newer refresh-token/cache state. The realistic trigger is parallel mise shims that refresh concurrently; the forced-refresh path from #1 slightly widens the write window in a token's last ~20 min.

Fix

Serialize the whole acquire operation across processes with an exclusive cross-process advisory file lock (github.com/gofrs/flock), held at the application boundary.

Why the app boundary rather than inside the cache callbacks: MSAL calls Replace and Export as unpaired callbacks — the silent cache-hit path calls Replace with no matching Export, while the refresh path (AuthResultFromToken) does its own ReplaceWriteExport. A lock acquired in Replace and released in Export would therefore leak on the cache-hit path. main instead holds one exclusive lock across the silent attempt and any interactive login.

  • New token.WithLock(ctx, lockPath, fn): ensures the parent dir exists (mirrors Export), acquires the lock polling every 50 ms until acquired or ctx is done, runs fn, and releases via defer. Context expiry is a clean give-up (false, nil) so callers can degrade; a genuine lock error is surfaced.
  • Print (default) path: waits up to 30 s, then degrades to empty output (exit 0) so mise's config load never hangs and falls through to a clean 401.
  • Login path: waits up to 2 min so two interactive sign-ins serialize rather than race, then errors.
  • FileCache, Silent, and the DropAccessTokens forced-refresh from Guarantee minimum remaining token lifetime #1 are unchanged. Export's temp+rename stays (still guards torn reads).

The lock is an OS advisory lock, released automatically on process death — a crash leaves no stale lock. Serialization is also self-improving: once a holder finishes its refresh+export and releases, a waiter's AcquireTokenSilent finds the fresh token already cached and skips a redundant network refresh.

Alternatives considered

  • Adopt the official microsoft-authentication-extensions-for-go/cache — it solves cross-process locking, but couples it to OS-native encrypted storage (macOS Keychain, Windows DPAPI, Linux libsecret via keybase/go-keychain), which needs D-Bus/cgo and would break the headless mise-shim/CI use case, and it exposes no hook for this tool's DropAccessTokens forced-refresh. Notably, that library just vendors gofrs/flock — which this PR uses directly.
  • Compare-and-retry merge on Export (issue Serialize MSAL cache updates across processes in FileCache #2 option 2) — avoids a dependency but requires merging MSAL's undocumented internal cache JSON by key; fragile.

gofrs/flock is pinned to v0.12.1 because v0.13.0 requires Go 1.24; v0.12.1 needs only Go 1.21, keeping the module's go 1.23 directive. Its only dependency is golang.org/x/sys, already pulled in transitively.

Testing

  • go build ./..., go vet ./..., go test ./... pass.
  • New WithLock unit tests: runs fn and releases the lock; creates the parent dir when missing (pre-login state); gives up under contention without running fn.
  • Existing token tests unchanged and green.

FileCache.Export wrote the cache atomically (temp + rename), which
prevents torn reads but not lost updates: two concurrent invocations
could each load the cache, refresh, and have the later Export clobber a
peer's newer refresh-token/cache write.

MSAL calls Replace and Export as unpaired callbacks (the silent
cache-hit path calls Replace with no matching Export), so a lock scoped
to those callbacks would leak. Instead, serialize the whole acquire at
the app boundary: hold an exclusive cross-process advisory lock (via
gofrs/flock) around the silent attempt and any interactive login. The
lock is OS-released on process death, so a crash leaves no stale lock,
and it dedupes concurrent refreshes since a waiter finds the fresh
token already cached on release.

The print path waits briefly then degrades to empty output (exit 0) so
mise never hangs; the login path waits longer then errors. Pin
gofrs/flock to v0.12.1 to keep the go 1.23 directive.

Closes #2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 22b94cb1-b747-4bc7-9512-b38c86473687

📥 Commits

Reviewing files that changed from the base of the PR and between 6f85306 and af6675a.

📒 Files selected for processing (2)
  • internal/token/token.go
  • internal/token/token_test.go

📝 Walkthrough

Walkthrough

The change adds cross-process advisory locking around token acquisition and cache updates. print and login now use command-specific lock timeouts and handle lock acquisition, cached-token, interactive-login, and failure outcomes.

Changes

Token cache locking

Layer / File(s) Summary
Locking API and dependency
go.mod, internal/token/token.go
Adds gofrs/flock, updates golang.org/x/sys, and introduces context-aware token.WithLock with directory creation, polling, timeout handling, and deferred release.
Lock behavior validation
internal/token/token_test.go
Tests callback execution, lock release, parent-directory creation, and cross-process contention behavior.
Print and login integration
main.go
Serializes token operations using a derived lock path, applies separate print and login wait limits, and handles token, timeout, and interactive-login outcomes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant main
  participant token.WithLock
  participant TokenCache
  main->>token.WithLock: Acquire cache lock with command-specific timeout
  token.WithLock->>TokenCache: Fetch token or perform interactive sign-in
  TokenCache-->>token.WithLock: Return token or login error
  token.WithLock-->>main: Release lock and return acquisition status
  main-->>main: Print token, status, or error
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: serializing MSAL cache updates across processes.
Description check ✅ Passed The description matches the changeset and explains the new cross-process lock and its motivation.
Linked Issues check ✅ Passed The PR implements #2 by adding a cross-process file lock around token acquisition and cache export.
Out of Scope Changes check ✅ Passed No obvious unrelated changes are present; the dependency and test updates directly support the locking fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

Comment @coderabbitai help to get the list of available commands.

@gtbuchanan
gtbuchanan marked this pull request as ready for review July 23, 2026 22:21

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/token/token_test.go`:
- Around line 84-120: Update TestWithLockContendedGivesUp to use an
os/exec-launched helper process that acquires and holds the lock, rather than a
goroutine. Have the parent attempt WithLock with an expired or short-timeout
context, verify it returns without acquiring the lock or invoking the callback,
then wait for the helper to exit and confirm the lock can be acquired afterward.

In `@main.go`:
- Around line 143-151: Update the !locked handling in the main command flow so
the non-login print path returns without writing anything to stderr after the
timeout; retain the existing timeout message and exit behavior for login.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 754ba782-aa5b-411a-a8d1-52117128307c

📥 Commits

Reviewing files that changed from the base of the PR and between 0e5fe50 and 1b1ded0.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (4)
  • go.mod
  • internal/token/token.go
  • internal/token/token_test.go
  • main.go

Comment thread internal/token/token_test.go Outdated
Comment thread main.go
Address CodeRabbit review on the cache-lock change:

- main.go: on a lock-acquisition timeout the print path now returns
  silently (empty output, exit 0) instead of writing a diagnostic to
  stderr, matching the adjacent comment and the clean-degrade contract.
  The login path keeps its timeout message and exit 1.

- token_test.go: replace the goroutine-based contention test with one
  that re-execs the test binary as a separate lock holder, so it
  exercises the genuine cross-process advisory lock and confirms the
  lock frees once the holder exits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/token/token_test.go`:
- Around line 136-140: The readiness wait around the child-process scanner must
be bounded and validate successful signaling. Update the lock-held wait in the
token test to enforce a timeout, detect scanner errors or EOF before
lockHeldSignal, and fail immediately with a clear diagnostic; only continue to
the contended-lock assertion after the signal is received.
- Around line 111-116: Add an explicit gosec G204 suppression annotation to the
exec.Command invocation in TestWithLockCrossProcess, documenting that os.Args[0]
re-executes the current test binary and is not externally tainted. Keep the
command and environment behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e8b00504-d3ab-40c8-aaa4-fa468a5fb492

📥 Commits

Reviewing files that changed from the base of the PR and between 1b1ded0 and 46cab94.

📒 Files selected for processing (2)
  • internal/token/token_test.go
  • main.go

Comment thread internal/token/token_test.go
Comment thread internal/token/token_test.go Outdated
Address CodeRabbit review on the cross-process lock test:

- Bound the readiness wait: read the child's stdout in a goroutine and
  select against a timeout, so a stalled child or one that exits before
  signalling (EOF) fails fast with a clear diagnostic instead of hanging
  or falling through to a misleading contended-lock assertion. Also
  check scanner.Err().

- Annotate the re-exec with //nolint:gosec: exec.Command(os.Args[0], ...)
  re-runs this test binary itself, a standard helper-process idiom that
  gosec's G204 flags as a false positive. Add a reason to the existing
  errcheck directive too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/token/token_test.go`:
- Line 114: Update the cross-process command in TestWithLockCrossProcess to use
exec.CommandContext with a cancellable context, and invoke that cancellation
from the test’s existing cleanup so the helper process is stopped if the test
exits early. Preserve the current self-reexec arguments and behavior.

In `@internal/token/token.go`:
- Line 123: Update WithLock to capture and return any error from the deferred
fl.Unlock() call instead of suppressing it, while preserving the existing
callback result and ensuring unlock errors are reported to callers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: bb83aab7-59fa-4da4-942b-2d985ce8ee24

📥 Commits

Reviewing files that changed from the base of the PR and between 46cab94 and 6f85306.

📒 Files selected for processing (2)
  • internal/token/token.go
  • internal/token/token_test.go

Comment thread internal/token/token_test.go Outdated
Comment thread internal/token/token.go Outdated
Address CodeRabbit review on the cache-lock change:

- token_test.go: switch the cross-process helper to exec.CommandContext
  with a cancellable context cancelled from t.Cleanup, so the child is
  stopped if the test exits early (satisfies noctx and removes the
  explicit Process.Kill).

- token.go: replace the //nolint:errcheck on the deferred Unlock with an
  explicit best-effort ignore and a rationale comment. The unlock error
  is deliberately not surfaced: this is a short-lived CLI about to exit,
  the OS releases the advisory lock and closes the descriptor on exit
  regardless, and folding a release hiccup into WithLock's return would
  make main discard an already-acquired token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serialize MSAL cache updates across processes in FileCache

1 participant