diff --git a/BuildMonitor.Test/AzureDevOpsSessionTests.cs b/BuildMonitor.Test/AzureDevOpsSessionTests.cs new file mode 100644 index 0000000..5eec92d --- /dev/null +++ b/BuildMonitor.Test/AzureDevOpsSessionTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.BuildMonitor.Test; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Covers the part of the Azure DevOps session lookup that can run without a live organization. +/// +/// +/// Building a session calls VssConnection.GetClient<T>(), which authenticates against +/// dev.azure.com and throws VssUnauthorizedException offline, so everything past the +/// credential check needs a real organization and a real token. The check itself does not, and it +/// is the branch that decides whether a connection is attempted at all. +/// +[TestClass] +public sealed class AzureDevOpsSessionTests +{ + /// + /// A provider with no credentials must report no session rather than attempting a connection. + /// Reaching the factory here would try to authenticate against an organization named by an empty + /// string, so this branch is what keeps an unconfigured provider off the network entirely. + /// + [TestMethod] + public void AProviderWithNoCredentialsHasNoSession() + { + AzureDevOps provider = new(); + + AzureDevOps.AzureDevOpsSession? session = provider.EnsureAzureDevOpsClients(); + + Assert.IsNull(session); + } + + /// + /// Asking twice still attempts nothing, so a repeated update on an unconfigured provider cannot + /// accumulate connections. + /// + [TestMethod] + public void RepeatedLookupsOnAnUnconfiguredProviderStayNull() + { + AzureDevOps provider = new(); + + Assert.IsNull(provider.EnsureAzureDevOpsClients()); + Assert.IsNull(provider.EnsureAzureDevOpsClients()); + } + + /// + /// The provider reports the name the rest of the application keys its configuration off, which is + /// also the JSON discriminator its credentials are persisted under. + /// + [TestMethod] + public void TheProviderReportsItsName() + { + AzureDevOps provider = new(); + + Assert.AreEqual(nameof(AzureDevOps), provider.Name.ToString()); + } +} diff --git a/BuildMonitor.Test/CredentialedSessionCacheTests.cs b/BuildMonitor.Test/CredentialedSessionCacheTests.cs new file mode 100644 index 0000000..1ff7578 --- /dev/null +++ b/BuildMonitor.Test/CredentialedSessionCacheTests.cs @@ -0,0 +1,276 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.BuildMonitor.Test; + +using System.Collections.Concurrent; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for the synchronization half of the Azure DevOps client lifecycle. +/// +/// +/// AzureDevOps.EnsureAzureDevOpsClients cannot be driven from a test: building a session +/// constructs a real VssConnection against dev.azure.com, and the update methods around it +/// need an ImGui application and live credentials. The part that went wrong is not any of that — +/// it is the caching and the handover, which holds +/// on its own and which a fake session exercises exactly. +/// +[TestClass] +public sealed class CredentialedSessionCacheTests +{ + /// + /// Enough concurrent callers to reliably overlap. UpdateAsync fans out over every owner, + /// repository, build and run at once, so this is not an unrealistic number of them. + /// + private const int ConcurrentCallers = 32; + + /// + /// Repeats of each concurrent scenario. A race is probabilistic, so one round proves nothing; + /// repeating narrows the chance of an unsynchronized cache slipping through to negligible. + /// + private const int Rounds = 40; + + private const string AccountId = "contoso"; + private const string Token = "pat-1"; + private const string RotatedToken = "pat-2"; + + /// + /// Stands in for the connection and clients an Azure DevOps session owns. The clients are what a + /// caller dereferences after its rate-limit delay, so the fake tracks whether it is still usable + /// at that point. + /// + private sealed class FakeSession : IDisposable + { + private int disposals; + + internal int Disposals => Volatile.Read(ref disposals); + + internal bool IsDisposed => Disposals > 0; + + internal string Credentials { get; } + + internal FakeSession(string credentials) => Credentials = credentials; + + public void Dispose() => Interlocked.Increment(ref disposals); + } + + /// + /// Builds a cache that records every session it hands out, widening the creation window so an + /// unsynchronized cache has a fair chance to interleave rather than passing by luck. + /// + private static CredentialedSessionCache CreateCache(ConcurrentBag created) => + new((accountId, token) => + { + Thread.Yield(); + FakeSession session = new($"{accountId}:{token}"); + created.Add(session); + return session; + }); + + /// + /// Releases every caller at once and collects what each one got back. + /// + private static List RunConcurrently(Func callerBody) + { + ConcurrentBag results = []; + using Barrier barrier = new(ConcurrentCallers); + + Parallel.For(0, ConcurrentCallers, index => + { + barrier.SignalAndWait(); + results.Add(callerBody(index)); + }); + + return [.. results]; + } + + /// + /// The leak half of the race. Callers arriving together on a cold cache must agree on one + /// session: unsynchronized, several of them each see no session, each build one, and every + /// connection but the last is overwritten without being disposed. + /// + [TestMethod] + public void ConcurrentCallersOnAColdCacheShareOneSession() + { + for (int round = 0; round < Rounds; round++) + { + ConcurrentBag created = []; + using CredentialedSessionCache cache = CreateCache(created); + + List handedOut = RunConcurrently(_ => cache.Get(AccountId, Token)); + + Assert.AreEqual(1, cache.SessionsCreated, $"round {round}: more than one session was built"); + Assert.HasCount(1, created, $"round {round}: more than one session was built"); + Assert.HasCount(ConcurrentCallers, handedOut); + Assert.IsTrue( + handedOut.TrueForAll(session => ReferenceEquals(session, handedOut[0])), + $"round {round}: callers were handed different sessions"); + } + } + + /// + /// No session may be dropped without being disposed, which is the leaked VssConnection the + /// issue names. Callers rotate the token underneath each other here, so sessions really are being + /// replaced while others are asking for one. + /// + [TestMethod] + public void EverySupersededSessionIsDisposedExactlyOnce() + { + for (int round = 0; round < Rounds; round++) + { + ConcurrentBag created = []; + CredentialedSessionCache cache = CreateCache(created); + + List handedOut = RunConcurrently( + index => cache.Get(AccountId, index % 2 == 0 ? Token : RotatedToken)); + + cache.Dispose(); + + Assert.AreEqual(created.Count, cache.SessionsCreated); + foreach (FakeSession session in created) + { + Assert.AreEqual(1, session.Disposals, $"round {round}: a session was disposed {session.Disposals} times"); + } + + Assert.HasCount(ConcurrentCallers, handedOut); + } + } + + /// + /// The half. A caller is handed its session before waiting + /// out the rate-limit delay and dereferences the clients afterwards. A rebuild behind it used to + /// null the shared fields in that window; holding the session as a value means the caller still + /// has what it checked. + /// + [TestMethod] + public void ASessionHandedToACallerSurvivesARebuildBehindIt() + { + for (int round = 0; round < Rounds; round++) + { + ConcurrentBag created = []; + using CredentialedSessionCache cache = CreateCache(created); + + List observed = RunConcurrently(index => + { + // Half the callers rotate the credentials, standing in for a PAT change or a second + // configured owner; the rest take a session and use it after a pause. + string token = index % 2 == 0 ? Token : RotatedToken; + FakeSession session = cache.Get(AccountId, token); + Thread.Yield(); + return session.Credentials; + }); + + Assert.HasCount(ConcurrentCallers, observed); + Assert.IsFalse( + observed.Exists(string.IsNullOrEmpty), + $"round {round}: a caller dereferenced a session it no longer held"); + } + } + + /// + /// Credentials that have not changed must not cause a rebuild, since that is the whole reason the + /// session is cached rather than built per request. + /// + [TestMethod] + public void UnchangedCredentialsReuseTheCachedSession() + { + ConcurrentBag created = []; + using CredentialedSessionCache cache = CreateCache(created); + + FakeSession first = cache.Get(AccountId, Token); + FakeSession second = cache.Get(AccountId, Token); + + Assert.AreSame(first, second); + Assert.AreEqual(1, cache.SessionsCreated); + Assert.IsFalse(first.IsDisposed); + } + + /// + /// A changed token rebuilds, and the session it replaces is disposed rather than dropped. + /// + [TestMethod] + public void ChangedCredentialsRebuildAndDisposeThePreviousSession() + { + ConcurrentBag created = []; + using CredentialedSessionCache cache = CreateCache(created); + + FakeSession first = cache.Get(AccountId, Token); + FakeSession second = cache.Get(AccountId, RotatedToken); + + Assert.AreNotSame(first, second); + Assert.AreEqual(2, cache.SessionsCreated); + Assert.IsTrue(first.IsDisposed); + Assert.IsFalse(second.IsDisposed); + } + + /// + /// A factory that throws — an unreachable organization, a malformed account name — must not leave + /// the credentials recorded, or the cache would answer the next caller with a session it never + /// built. + /// + [TestMethod] + public void AFailedBuildLeavesNothingCachedForThoseCredentials() + { + int attempts = 0; + using CredentialedSessionCache cache = new((accountId, token) => + ++attempts == 1 + ? throw new InvalidOperationException("connection refused") + : new FakeSession($"{accountId}:{token}")); + + _ = Assert.ThrowsExactly(() => cache.Get(AccountId, Token)); + + FakeSession recovered = cache.Get(AccountId, Token); + + Assert.AreEqual(2, attempts); + Assert.AreEqual($"{AccountId}:{Token}", recovered.Credentials); + } + + /// + /// Invalidation disposes what is cached and forces the next caller to build, which is what a + /// caller does after an authentication failure. + /// + [TestMethod] + public void InvalidateDisposesTheSessionAndForcesARebuild() + { + ConcurrentBag created = []; + using CredentialedSessionCache cache = CreateCache(created); + + FakeSession first = cache.Get(AccountId, Token); + cache.Invalidate(); + FakeSession second = cache.Get(AccountId, Token); + + Assert.IsTrue(first.IsDisposed); + Assert.AreNotSame(first, second); + Assert.AreEqual(2, cache.SessionsCreated); + } + + /// + /// Disposing the cache disposes the connection it holds, and is safe to repeat. + /// + [TestMethod] + public void DisposeDisposesTheCachedSessionOnce() + { + ConcurrentBag created = []; + CredentialedSessionCache cache = CreateCache(created); + FakeSession session = cache.Get(AccountId, Token); + + cache.Dispose(); + cache.Dispose(); + + Assert.AreEqual(1, session.Disposals); + } + + /// + /// Asking a disposed cache for a session is a programming error rather than a silently missing + /// client. + /// + [TestMethod] + public void GetAfterDisposeThrows() + { + ConcurrentBag created = []; + CredentialedSessionCache cache = CreateCache(created); + cache.Dispose(); + + _ = Assert.ThrowsExactly(() => cache.Get(AccountId, Token)); + } +} diff --git a/BuildMonitor/Providers/AzureDevOps.cs b/BuildMonitor/Providers/AzureDevOps.cs index 67f2ffa..7d9469e 100644 --- a/BuildMonitor/Providers/AzureDevOps.cs +++ b/BuildMonitor/Providers/AzureDevOps.cs @@ -17,55 +17,86 @@ internal sealed class AzureDevOps : BuildProvider internal static BuildProviderName BuildProviderName => nameof(AzureDevOps).As(); internal override BuildProviderName Name => BuildProviderName; - private VssConnection? Connection { get; set; } - private ProjectHttpClient? ProjectClient { get; set; } - private BuildHttpClient? BuildClient { get; set; } - private string? LastAccountId { get; set; } - private string? LastToken { get; set; } + private CredentialedSessionCache Sessions { get; } = new(CreateSession); private bool ShouldDiscoverProjects { get; set; } - private void EnsureAzureDevOpsClients() + /// + /// A connection to an Azure DevOps organization together with the clients built from it. + /// + /// + /// The clients are bound to the connection that made them, so they are kept and replaced as one + /// value. A caller holds the session it was handed for the whole of its request, which is what + /// keeps a rebuild behind it from nulling a client it is about to use. + /// + internal sealed class AzureDevOpsSession : IDisposable { - if (string.IsNullOrEmpty(AccountId) || string.IsNullOrEmpty(Token)) + private VssConnection Connection { get; } + + /// + /// Gets the client used to enumerate the organization's projects. + /// + internal ProjectHttpClient ProjectClient { get; } + + /// + /// Gets the client used to read build definitions, builds and runs. + /// + internal BuildHttpClient BuildClient { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The connection the session owns and disposes. + internal AzureDevOpsSession(VssConnection connection) { - return; + Connection = connection; + ProjectClient = connection.GetClient(); + BuildClient = connection.GetClient(); } - // Only recreate when credentials have changed - if (Connection != null && LastAccountId == AccountId.ToString() && LastToken == Token.ToString()) + /// + /// Disposes the underlying connection. + /// + public void Dispose() => Connection.Dispose(); + } + + private static AzureDevOpsSession CreateSession(string accountId, string token) + { + Uri collectionUri = new($"https://dev.azure.com/{accountId}"); + VssBasicCredential credentials = new(string.Empty, token); + return new(new VssConnection(collectionUri, credentials)); + } + + /// + /// Gets the session for the current credentials, or when there are none or + /// the connection could not be built. + /// + /// + /// Callers keep the returned session in a local rather than reading it again. Re-reading is what + /// let a rebuild on another thread null a client between a caller's null check and its use. + /// + /// The session, or . + internal AzureDevOpsSession? EnsureAzureDevOpsClients() + { + string accountId = AccountId.ToString(); + string token = Token.ToString(); + if (string.IsNullOrEmpty(accountId) || string.IsNullOrEmpty(token)) { - return; + return null; } - // Dispose old connection before creating a new one - Connection?.Dispose(); - Connection = null; - ProjectClient = null; - BuildClient = null; - try { - Uri collectionUri = new($"https://dev.azure.com/{AccountId}"); - VssBasicCredential credentials = new(string.Empty, Token); - Connection = new(collectionUri, credentials); - ProjectClient = Connection.GetClient(); - BuildClient = Connection.GetClient(); - LastAccountId = AccountId.ToString(); - LastToken = Token.ToString(); + return Sessions.Get(accountId, token); } catch (VssServiceException ex) { - Connection = null; - ProjectClient = null; - BuildClient = null; SetStatus(ProviderStatus.Error, $"{Strings.ConnectionErrorMessage} {ex.Message}"); + return null; } catch (UriFormatException ex) { - Connection = null; - ProjectClient = null; - BuildClient = null; SetStatus(ProviderStatus.Error, $"Invalid organization name: {ex.Message}"); + return null; } } @@ -118,17 +149,17 @@ internal async Task DiscoverProjectsAsync() return; } - EnsureAzureDevOpsClients(); - if (ProjectClient == null) + AzureDevOpsSession? session = EnsureAzureDevOpsClients(); + if (session == null) { - Log.Warning($"{Name}: DiscoverProjectsAsync skipped - ProjectClient is null after credential update"); + Log.Warning($"{Name}: DiscoverProjectsAsync skipped - no Azure DevOps session after credential update"); return; } Log.Info($"{Name}: Discovering projects for organization '{AccountId}'"); await MakeAzureDevOpsRequestAsync($"{Name}/discover", async () => { - IEnumerable projects = await ProjectClient.GetProjects().ConfigureAwait(false); + IEnumerable projects = await session.ProjectClient.GetProjects().ConfigureAwait(false); int projectCount = 0; foreach (TeamProjectReference? project in projects) @@ -159,10 +190,10 @@ internal override async Task UpdateRepositoriesAsync(Owner owner) return; } - EnsureAzureDevOpsClients(); - if (ProjectClient == null) + AzureDevOpsSession? session = EnsureAzureDevOpsClients(); + if (session == null) { - Log.Warning($"{Name}: UpdateRepositoriesAsync skipped for owner '{owner.Name}' - ProjectClient is null"); + Log.Warning($"{Name}: UpdateRepositoriesAsync skipped for owner '{owner.Name}' - no Azure DevOps session"); return; } @@ -171,7 +202,7 @@ await MakeAzureDevOpsRequestAsync($"{Name}/{owner.Name}", async () => { // In Azure DevOps, projects are the top-level containers // The owner name represents a project in Azure DevOps - IEnumerable projects = await ProjectClient.GetProjects().ConfigureAwait(false); + IEnumerable projects = await session.ProjectClient.GetProjects().ConfigureAwait(false); bool foundProject = false; int projectCount = 0; @@ -216,17 +247,17 @@ internal override async Task UpdateBuildsAsync(Repository repository) return; } - EnsureAzureDevOpsClients(); - if (BuildClient == null) + AzureDevOpsSession? session = EnsureAzureDevOpsClients(); + if (session == null) { - Log.Warning($"{Name}: UpdateBuildsAsync skipped for '{repository.Owner.Name}/{repository.Name}' - BuildClient is null"); + Log.Warning($"{Name}: UpdateBuildsAsync skipped for '{repository.Owner.Name}/{repository.Name}' - no Azure DevOps session"); return; } Log.Debug($"{Name}: UpdateBuildsAsync for '{repository.Owner.Name}/{repository.Name}'"); await MakeAzureDevOpsRequestAsync($"{Name}/{repository.Owner.Name}/{repository.Name}", async () => { - List definitions = await BuildClient.GetDefinitionsAsync(repository.Owner.Name).ConfigureAwait(false); + List definitions = await session.BuildClient.GetDefinitionsAsync(repository.Owner.Name).ConfigureAwait(false); Log.Info($"{Name}: Found {definitions.Count} build definition(s) for '{repository.Owner.Name}/{repository.Name}'"); foreach (BuildDefinitionReference? definition in definitions) { @@ -270,17 +301,17 @@ internal override async Task UpdateBuildAsync(Build build) return; } - EnsureAzureDevOpsClients(); - if (BuildClient == null) + AzureDevOpsSession? session = EnsureAzureDevOpsClients(); + if (session == null) { - Log.Warning($"{Name}: UpdateBuildAsync skipped for '{build.Owner.Name}/{build.Repository.Name}/{build.Name}' - BuildClient is null"); + Log.Warning($"{Name}: UpdateBuildAsync skipped for '{build.Owner.Name}/{build.Repository.Name}/{build.Name}' - no Azure DevOps session"); return; } Log.Debug($"{Name}: UpdateBuildAsync for '{build.Owner.Name}/{build.Repository.Name}/{build.Name}' (definition ID: {build.Id})"); await MakeAzureDevOpsRequestAsync($"{Name}/{build.Owner.Name}/{build.Repository.Name}/{build.Name}", async () => { - List builds = await BuildClient.GetBuildsAsync( + List builds = await session.BuildClient.GetBuildsAsync( build.Owner.Name, definitions: [int.Parse(build.Id, CultureInfo.InvariantCulture)], top: 10 @@ -306,17 +337,17 @@ internal override async Task UpdateRunAsync(Run run) return; } - EnsureAzureDevOpsClients(); - if (BuildClient == null) + AzureDevOpsSession? session = EnsureAzureDevOpsClients(); + if (session == null) { - Log.Warning($"{Name}: UpdateRunAsync skipped for run '{run.Name}' - BuildClient is null"); + Log.Warning($"{Name}: UpdateRunAsync skipped for run '{run.Name}' - no Azure DevOps session"); return; } Log.Debug($"{Name}: UpdateRunAsync for run '{run.Name}' (ID: {run.Id}) in '{run.Owner.Name}/{run.Repository.Name}/{run.Build.Name}'"); await MakeAzureDevOpsRequestAsync($"{Name}/{run.Owner.Name}/{run.Repository.Name}/{run.Build.Name}/{run.Name}", async () => { - Microsoft.TeamFoundation.Build.WebApi.Build azureBuild = await BuildClient.GetBuildAsync( + Microsoft.TeamFoundation.Build.WebApi.Build azureBuild = await session.BuildClient.GetBuildAsync( run.Owner.Name, int.Parse(run.Id, CultureInfo.InvariantCulture) ).ConfigureAwait(false); diff --git a/BuildMonitor/Providers/CredentialedSessionCache.cs b/BuildMonitor/Providers/CredentialedSessionCache.cs new file mode 100644 index 0000000..fadd15c --- /dev/null +++ b/BuildMonitor/Providers/CredentialedSessionCache.cs @@ -0,0 +1,117 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.BuildMonitor; + +/// +/// Holds the connection-backed session a provider talks to its API through, rebuilding it when the +/// credentials change and handing every caller a snapshot of its own. +/// +/// +/// +/// Provider updates run concurrently: UpdateAsync fans owners, repositories, builds and runs +/// out through Task.WhenAll, and each of those independently asks for the session. Two +/// things go wrong if that is left unsynchronized. +/// +/// +/// Two callers can both decide the session is stale and both build one, so one of the two +/// connections is overwritten without ever being disposed. And a caller that has already read the +/// session, then waits — inside a rate-limit delay, say — can find it replaced with null underneath +/// it by the time it dereferences, which is a rather than the +/// "skipped, no client" path the caller checked for. +/// +/// +/// Both follow from re-reading shared fields, so this hands back the session as a value instead. +/// Callers hold what they were given for the whole of their request, and a replacement built behind +/// them does not disturb a request already in flight. The factory runs under the lock, so building +/// a connection is serialized against another caller building one — which is the point, since that +/// is what the duplicate work and the leak came from. +/// +/// +/// The session type, which owns the connection and disposes it. +internal sealed class CredentialedSessionCache : IDisposable + where TSession : class, IDisposable +{ + private readonly Lock gate = new(); + private readonly Func createSession; + private TSession? session; + private string? lastAccountId; + private string? lastToken; + private bool disposed; + + /// + /// Initializes a new instance of the class. + /// + /// Builds a session from an account id and a token. + internal CredentialedSessionCache(Func createSession) => + this.createSession = createSession; + + /// + /// Gets the number of sessions built so far, which a test uses to show that concurrent callers + /// share one rather than each building their own. + /// + internal int SessionsCreated { get; private set; } + + /// + /// Gets the session for these credentials, building one if the cached session is missing or was + /// built for different credentials. + /// + /// The account the session authenticates against. + /// The token the session authenticates with. + /// The session, to be held by the caller for the whole of its request. + /// The cache has been disposed. + internal TSession Get(string accountId, string token) + { + lock (gate) + { + ObjectDisposedException.ThrowIf(disposed, this); + + if (session is not null && lastAccountId == accountId && lastToken == token) + { + return session; + } + + // Drop the stale session first, so a factory that throws cannot leave credentials + // recorded for a session that was never built. + Invalidate(); + + TSession created = createSession(accountId, token); + session = created; + lastAccountId = accountId; + lastToken = token; + SessionsCreated++; + return created; + } + } + + /// + /// Disposes the cached session and forgets the credentials it was built for, so the next caller + /// builds a fresh one. + /// + internal void Invalidate() + { + lock (gate) + { + session?.Dispose(); + session = null; + lastAccountId = null; + lastToken = null; + } + } + + /// + /// Disposes the cached session. + /// + public void Dispose() + { + lock (gate) + { + if (disposed) + { + return; + } + + Invalidate(); + disposed = true; + } + } +}