fix: serialize Azure DevOps client creation and hand callers a session [patch] - #289
Conversation
…n [patch] EnsureAzureDevOpsClients read, disposed, nulled and reassigned the shared Connection/ProjectClient/BuildClient fields with no lock, while UpdateAsync runs UpdateRepositoriesAsync/UpdateBuildsAsync/UpdateBuildAsync concurrently over many owners and builds, each calling it independently. Two concurrent callers could both find the fields stale, both dispose and null them, and both build a VssConnection — leaving one connection overwritten and undisposed. Worse, a caller already past its own null check but delayed inside MakeAzureDevOpsRequestAsync's pacing delay dereferenced BuildClient after another caller had nulled it, which is a NullReferenceException rather than the "skipped, no client" path it checked for. Both followed from re-reading the shared fields. The connection and the two clients bound to it now live in an AzureDevOpsSession, cached by CredentialedSessionCache, which rebuilds under a lock when the credentials change and hands each caller the session as a value. Callers hold what they were given for the whole of their request, so a rebuild behind them cannot disturb a request in flight, and a superseded session is always disposed. Fixes #287 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDNXPxBHkugDsgFMcgSdvP
…offline EnsureAzureDevOpsClients becomes internal so the branch that decides whether a connection is attempted at all can be tested. A provider with no credentials must report no session rather than reaching the factory, which would otherwise try to authenticate against an organization named by an empty string. Raises new-code coverage from 46.6% to 54.8%. The remaining lines build or use a VssConnection, and VssConnection.GetClient<T>() authenticates against dev.azure.com on the spot, so they cannot run in a test without a live organization and a real token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDNXPxBHkugDsgFMcgSdvP
The local SDK tooling rewrote .gitignore during a build and it was swept into the previous commit. It has nothing to do with this fix, so this restores main's version and keeps the branch's diff to the change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDNXPxBHkugDsgFMcgSdvP
SonarCloud quality gate: coverage on new codeFailing check:
Where the new code sitsSonar's own measure (
The synchronization fix — the part with the bug in it — is fully covered. The shortfall is entirely That reading predates What the remaining 31 lines needThree groups:
Three options
My suggestion is (1) now, and (3) as a separate issue if the provider is worth making testable. I'd avoid (2). Happy to open that issue, or to do any of these here if you'd rather. Generated by Claude Code |
Takes SonarCloud's four MSTEST0037 findings on this file. Assert.HasCount reports the collection's actual contents when it fails, where Assert.AreEqual on .Count reports only two numbers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDNXPxBHkugDsgFMcgSdvP
|


Fixes #287
What was wrong
EnsureAzureDevOpsClientsread, disposed, nulled and reassigned the sharedConnection/ProjectClient/BuildClientfields with no lock.BuildMonitor.UpdateAsyncrunsUpdateRepositoriesAsync/UpdateBuildsAsync/UpdateBuildAsyncconcurrently viaTask.WhenAllacross many owners and builds, and each independently calls it.Two failure modes followed, both from re-reading those shared fields:
Dispose()/null them, and both build aVssConnection. One is overwritten without ever being disposed.NullReferenceException. Every call site didEnsureAzureDevOpsClients(); if (BuildClient == null) return;and then dereferenced the field inside an async lambda thatMakeAzureDevOpsRequestAsynconly invokes after its semaphore and pacing delay. Another caller nulling the field in that window turned a checked-for "skipped, no client" path into an NRE.What changed
New
BuildMonitor/Providers/CredentialedSessionCache.cs— holds a connection-backed session, rebuilds it under a lock when the credentials change, disposes whatever it replaces, and returns the session as a value.BuildMonitor/Providers/AzureDevOps.csAzureDevOpsSession, replaced as one value rather than three fields.EnsureAzureDevOpsClients()returnsAzureDevOpsSession?instead of mutating fields, keeping the existingVssServiceException/UriFormatExceptionhandling andSetStatusbehaviour.session.ProjectClient/session.BuildClient, so nothing re-reads shared state across an await.AccountId/Tokenare snapshotted into locals once, rather than read separately for the staleness check and the connection.The factory runs under the lock, so connection construction is serialized against another caller constructing one — that is the point, since the duplicate work and the leak came from exactly that overlap.
Acceptance criteria
Both are asserted directly by the new tests.
Testing
BuildMonitor.Test/CredentialedSessionCacheTests.csexercises the cache with a fake session. The concurrent scenarios release 32 callers through aBarrierand repeat 40 rounds, since one round proves nothing about a race.Proven to fail without the fix: with the
lockremoved fromGet(the original's state), 3 tests fail —Both issue failure modes reproduced: duplicate/undisposed connections, and the
NullReferenceExceptionby name. With the lock restored, all 44 pass.BuildMonitor.Test/AzureDevOpsSessionTests.cscovers the credential check — the branch deciding whether a connection is attempted at all, and the one part of the provider that runs offline.dotnet build BuildMonitor.sln: 0 warnings, 0 errors.Known: the SonarCloud coverage gate
The gate wants 80% coverage on new code. Sonar measures 65 new lines with 36 uncovered (44.6%) — all 36 in
AzureDevOps.cs;CredentialedSessionCache.cs, which holds the actual fix, is 29/29 covered.4997dfacovers 5 more, so expect roughly 52% on re-analysis.Of the rest, 13 lines build or call through a
VssConnectionand need a live organization and a real PAT —GetClient<T>()authenticates on the spot. The other 18 could be reached with a test seam, landing at about 80.0% with no margin.The comment below has the measurement and three options, for the repo owner to choose between.
🤖 Generated with Claude Code
https://claude.ai/code/session_01SDNXPxBHkugDsgFMcgSdvP