From 4d9d0fd37097bc5c6a3ad21c3eecb9b426d7f689 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:23:07 -0400 Subject: [PATCH 01/17] test: Log captured entries on MobileData_ShouldNotLogIssues failure Adds ITestOutputHelper and a DumpLogs helper that writes all captured log entries (including Debug/Info) before the assertion. xunit shows this output when the test fails, giving full context on what the RelayPushRegistrationService logged during the CI run. Package lock files updated as a side effect of restore. --- .../RelayPushRegistrationServiceTests.cs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index fa7a741b2797..eb7e6169b739 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging.Testing; using NSubstitute; using Xunit; +using Xunit.Abstractions; namespace Bit.Api.IntegrationTest.Platform; @@ -17,9 +18,11 @@ public class RelayPushRegistrationServiceTests private readonly Guid _fakeInstallationId; private readonly FakeLogCollector _logCollector; private readonly RelayPushRegistrationService _sut; + private readonly ITestOutputHelper _output; - public RelayPushRegistrationServiceTests() + public RelayPushRegistrationServiceTests(ITestOutputHelper output) { + _output = output; _cloudApi = new ApiApplicationFactory(); _cloudApi.SubstituteService(service => { }); @@ -84,6 +87,21 @@ await _sut.CreateOrUpdateRegistrationAsync( Assert.DoesNotContain(logs, l => l.Level >= LogLevel.Warning); } + private void DumpLogs(string context) + { + var logs = _logCollector.GetSnapshot(); + _output.WriteLine($"--- Captured logs ({context}): {logs.Count} entries ---"); + foreach (var log in logs) + { + _output.WriteLine($"[{log.Level}] {log.Message}"); + if (log.Exception is not null) + { + _output.WriteLine($" Exception: {log.Exception}"); + } + } + _output.WriteLine("---"); + } + [Fact] public async Task MobileData_ShouldNotLogIssues() { @@ -105,6 +123,8 @@ await _sut.CreateOrUpdateRegistrationAsync( var logs = _logCollector.GetSnapshot(); + DumpLogs("after CreateOrUpdateRegistrationAsync"); + Assert.DoesNotContain(logs, l => l.Level >= LogLevel.Warning); // Mobile should also actually successfully make it to the cloud push registration service From df8ce27fc5113113bd04736dd497b8f848d7822f Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:11:07 -0400 Subject: [PATCH 02/17] Add more diagnostics --- .../Platform/RelayPushRegistrationServiceTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index eb7e6169b739..23a7b07bd136 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -41,6 +41,10 @@ public RelayPushRegistrationServiceTests(ITestOutputHelper output) var cloudApiHttpClient = _cloudApi.CreateClient(); var cloudIdentityHttpClient = _cloudApi.Identity.CreateClient(); + // Default HttpClient.Timeout is 100 s. Lower it so that thread-pool starvation + // (the suspected flakiness cause) surfaces as a fast failure rather than a + // 100-second hang, making it easier to reproduce and cheaper to observe in CI. + cloudIdentityHttpClient.Timeout = TimeSpan.FromSeconds(10); var httpClientFactory = Substitute.For(); @@ -111,6 +115,10 @@ public async Task MobileData_ShouldNotLogIssues() var organizationId = Guid.NewGuid(); var installationId = Guid.NewGuid(); + ThreadPool.GetAvailableThreads(out var workersBefore, out var ioBefore); + ThreadPool.GetMaxThreads(out var maxWorkers, out var maxIo); + _output.WriteLine($"ThreadPool before call: {workersBefore}/{maxWorkers} workers, {ioBefore}/{maxIo} IO available"); + await _sut.CreateOrUpdateRegistrationAsync( new PushRegistrationData("PushToken"), deviceId.ToString(), @@ -121,6 +129,9 @@ await _sut.CreateOrUpdateRegistrationAsync( installationId ); + ThreadPool.GetAvailableThreads(out var workersAfter, out var ioAfter); + _output.WriteLine($"ThreadPool after call: {workersAfter}/{maxWorkers} workers, {ioAfter}/{maxIo} IO available"); + var logs = _logCollector.GetSnapshot(); DumpLogs("after CreateOrUpdateRegistrationAsync"); From e67e3911f7e20ddf00330491869da3e996e9bd61 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:40:56 -0400 Subject: [PATCH 03/17] diag: Capture identity server logs for flakiness investigation Replace NullLoggerFactory on the identity factory with FakeLogging so the test can dump identity server logs alongside the SUT logs. If the token request hangs, the identity server's own log output will now appear in the xunit failure report. --- .../RelayPushRegistrationServiceTests.cs | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index 23a7b07bd136..a1cd64ddf84d 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -5,6 +5,10 @@ using Bit.Core.Platform.PushRegistration; using Bit.Core.Platform.PushRegistration.Internal; using Bit.Core.Settings; +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using NSubstitute; using Xunit; @@ -39,6 +43,13 @@ public RelayPushRegistrationServiceTests(ITestOutputHelper output) }); }); + // Replace the NullLoggerFactory so we can capture identity server logs for diagnostics. + _cloudApi.Identity.ConfigureServices(services => + { + services.RemoveAll(); + services.AddLogging(b => b.AddFakeLogging()); + }); + var cloudApiHttpClient = _cloudApi.CreateClient(); var cloudIdentityHttpClient = _cloudApi.Identity.CreateClient(); // Default HttpClient.Timeout is 100 s. Lower it so that thread-pool starvation @@ -93,8 +104,14 @@ await _sut.CreateOrUpdateRegistrationAsync( private void DumpLogs(string context) { - var logs = _logCollector.GetSnapshot(); - _output.WriteLine($"--- Captured logs ({context}): {logs.Count} entries ---"); + DumpCollector($"RelayPushRegistrationService logs ({context})", _logCollector); + DumpCollector($"Identity server logs ({context})", _cloudApi.Identity.GetService()); + } + + private void DumpCollector(string label, FakeLogCollector collector) + { + var logs = collector.GetSnapshot(); + _output.WriteLine($"--- {label}: {logs.Count} entries ---"); foreach (var log in logs) { _output.WriteLine($"[{log.Level}] {log.Message}"); @@ -115,9 +132,19 @@ public async Task MobileData_ShouldNotLogIssues() var organizationId = Guid.NewGuid(); var installationId = Guid.NewGuid(); - ThreadPool.GetAvailableThreads(out var workersBefore, out var ioBefore); ThreadPool.GetMaxThreads(out var maxWorkers, out var maxIo); - _output.WriteLine($"ThreadPool before call: {workersBefore}/{maxWorkers} workers, {ioBefore}/{maxIo} IO available"); + + using var pollCts = new CancellationTokenSource(); + var pollTask = Task.Run(async () => + { + var sw = Stopwatch.StartNew(); + while (!pollCts.Token.IsCancellationRequested) + { + ThreadPool.GetAvailableThreads(out var w, out var io); + _output.WriteLine($"[+{sw.Elapsed.TotalSeconds:F1}s] ThreadPool: {w}/{maxWorkers} workers, {io}/{maxIo} IO available"); + await Task.Delay(TimeSpan.FromSeconds(5), pollCts.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + }, pollCts.Token); await _sut.CreateOrUpdateRegistrationAsync( new PushRegistrationData("PushToken"), @@ -129,8 +156,8 @@ await _sut.CreateOrUpdateRegistrationAsync( installationId ); - ThreadPool.GetAvailableThreads(out var workersAfter, out var ioAfter); - _output.WriteLine($"ThreadPool after call: {workersAfter}/{maxWorkers} workers, {ioAfter}/{maxIo} IO available"); + await pollCts.CancelAsync(); + await pollTask; var logs = _logCollector.GetSnapshot(); From 2b810909250e43bbde0629a3bc254940c310c1e7 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:15:34 -0400 Subject: [PATCH 04/17] diag: Add identity request timing handler to isolate pipe-read hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the identity server's in-process TestHost handler with a DelegatingHandler that logs two timestamps per token request: [identity→] when the request is sent [identity←] when the inner handler returns (headers ready, body still open) If the server shows "Request finished 200" but [identity←] never appears, the hang is in the TestHost pipeline itself. If [identity←] appears quickly but the overall SendAsync still times out, HttpClient is stuck buffering the response body from the TestHost pipe (ResponseContentRead mode). Also lowers the identity client Timeout to 10 s by building the HttpClient directly from Server.CreateHandler() instead of Identity.CreateClient(). --- .../RelayPushRegistrationServiceTests.cs | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index a1cd64ddf84d..324b311ac649 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -51,11 +51,16 @@ public RelayPushRegistrationServiceTests(ITestOutputHelper output) }); var cloudApiHttpClient = _cloudApi.CreateClient(); - var cloudIdentityHttpClient = _cloudApi.Identity.CreateClient(); - // Default HttpClient.Timeout is 100 s. Lower it so that thread-pool starvation - // (the suspected flakiness cause) surfaces as a fast failure rather than a - // 100-second hang, making it easier to reproduce and cheaper to observe in CI. - cloudIdentityHttpClient.Timeout = TimeSpan.FromSeconds(10); + // Access the identity server's in-process handler directly so we can wrap it with + // a timing handler. _cloudApi.CreateClient() above already triggered identity server + // startup (ApiApplicationFactory wires the JWT backchannel to the identity TestServer), + // so Server is ready here without double-starting. + var identityBaseHandler = _cloudApi.Identity.Server.CreateHandler(); + var identityTimingHandler = new IdentityTimingHandler(output) { InnerHandler = identityBaseHandler }; + var cloudIdentityHttpClient = new HttpClient(identityTimingHandler) + { + Timeout = TimeSpan.FromSeconds(10) + }; var httpClientFactory = Substitute.For(); @@ -108,6 +113,43 @@ private void DumpLogs(string context) DumpCollector($"Identity server logs ({context})", _cloudApi.Identity.GetService()); } + /// + /// Wraps the identity server's in-process handler to log the timing of each HTTP phase. + /// + /// When the identity server finishes writing the response, the server-side log shows + /// "Request finished … 200 … Xms". But the client can still hang reading the response + /// body from the TestHost pipe. This handler reveals which phase stalls: + /// + /// If "inner handler returned" never appears, the TestHost pipeline itself is stuck. + /// If "inner handler returned" appears but the test still hangs, HttpClient is stuck + /// buffering the response body (ResponseContentRead mode) from the pipe. + /// + /// + /// + private sealed class IdentityTimingHandler(ITestOutputHelper output) : DelegatingHandler + { + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var sw = Stopwatch.StartNew(); + output.WriteLine($"[identity→] {request.Method} {request.RequestUri} (sending)"); + try + { + var response = await base.SendAsync(request, cancellationToken); + output.WriteLine( + $"[identity←] inner handler returned {(int)response.StatusCode} at {sw.ElapsedMilliseconds}ms " + + $"(headers ready; HttpClient will now read body)"); + return response; + } + catch (Exception ex) + { + output.WriteLine( + $"[identity✗] {ex.GetType().Name} after {sw.ElapsedMilliseconds}ms: {ex.Message}"); + throw; + } + } + } + private void DumpCollector(string label, FakeLogCollector collector) { var logs = collector.GetSnapshot(); From 014a4f869bfce8215fab2f77bdb6151414cc8c67 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:53:13 -0400 Subject: [PATCH 05/17] fix: Disable LaunchDarkly in identity server test instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LdClient tries to open a streaming connection to LaunchDarkly on the first request to the identity TestServer. In CI, outbound packets to LaunchDarkly are dropped (not refused), so the TCP handshake hangs for ~88 s (OS SYN-timeout) before the constructor can complete. This keeps the TestHost response-body pipe writer open even though the server logs "Request finished 200" within ~1 s, stalling the client's PipeReader for the same duration until HttpClient's 10 s timeout fires. Setting globalSettings:launchDarkly:sdkKey to null makes LaunchDarklyClientProvider take the string.IsNullOrEmpty branch: TestData.DataSource() + NoEvents — zero outbound network activity. --- .../Platform/RelayPushRegistrationServiceTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index 324b311ac649..45ca6c48c023 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -50,6 +50,15 @@ public RelayPushRegistrationServiceTests(ITestOutputHelper output) services.AddLogging(b => b.AddFakeLogging()); }); + // Null SDK key forces LaunchDarkly into offline mode (TestData.DataSource + NoEvents), + // eliminating all outbound network connections. Without this, LdClient tries to open + // a streaming connection to LaunchDarkly on the first request. In CI, outbound packets + // are dropped rather than refused, so the TCP handshake hangs for ~88 s (OS SYN-timeout) + // before the LdClient constructor can complete — which keeps the TestHost response-body + // pipe writer open, stalling the client's PipeReader for the same duration even though + // the server logged "Request finished 200" within ~1 s. + _cloudApi.Identity.UpdateConfiguration("globalSettings:launchDarkly:sdkKey", null); + var cloudApiHttpClient = _cloudApi.CreateClient(); // Access the identity server's in-process handler directly so we can wrap it with // a timing handler. _cloudApi.CreateClient() above already triggered identity server From 1f43c742fe054fcef91c1e3d21be4df25e894a0a Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:23:32 -0400 Subject: [PATCH 06/17] fix: Substitute IFeatureService instead of nulling SDK key to prevent LaunchDarkly hangs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nulling the SDK key was insufficient — LdClient is still instantiated even in offline/TestData mode and may attempt outbound diagnostic connections. Replacing the IFeatureService registration with a no-op substitute prevents LdClient from ever being constructed, eliminating the ~138 s CI hang caused by dropped TCP SYN packets to LaunchDarkly endpoints. --- .../Platform/RelayPushRegistrationServiceTests.cs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index 45ca6c48c023..ef3bd93db94a 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -50,14 +50,12 @@ public RelayPushRegistrationServiceTests(ITestOutputHelper output) services.AddLogging(b => b.AddFakeLogging()); }); - // Null SDK key forces LaunchDarkly into offline mode (TestData.DataSource + NoEvents), - // eliminating all outbound network connections. Without this, LdClient tries to open - // a streaming connection to LaunchDarkly on the first request. In CI, outbound packets - // are dropped rather than refused, so the TCP handshake hangs for ~88 s (OS SYN-timeout) - // before the LdClient constructor can complete — which keeps the TestHost response-body - // pipe writer open, stalling the client's PipeReader for the same duration even though - // the server logged "Request finished 200" within ~1 s. - _cloudApi.Identity.UpdateConfiguration("globalSettings:launchDarkly:sdkKey", null); + // Substitute the SDK feature service so LaunchDarklyClientProvider is never instantiated + // and no outbound connections to LaunchDarkly are made. In CI, those connections hang + // because packets are dropped (TCP SYN-timeout ~2 min) rather than refused, stalling the + // TestHost response-body pipe writer even though the server logs "Request finished 200". + _cloudApi.Identity.SubstituteService( + service => { }); var cloudApiHttpClient = _cloudApi.CreateClient(); // Access the identity server's in-process handler directly so we can wrap it with From f118d0df468f75b2e6d76e5248e5f5058988d040 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:34:40 -0400 Subject: [PATCH 07/17] fix: Disable OpenTelemetry OTLP exporters in identity server test instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bitwarden SDK registers OTLP metric and trace exporters by default when not self-hosted. In CI those exporters attempt outbound TCP connections to a collector endpoint that drops packets rather than refusing them, causing a ~95 s SYN-timeout that stalls the TestHost response pipeline — the same failure mode as the LaunchDarkly hang. Setting OpenTelemetry:Enabled=false in the identity server's configuration suppresses both AddOtlpExporter() calls before server startup, eliminating the hang. --- .../Platform/RelayPushRegistrationServiceTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index ef3bd93db94a..c50a0cf57639 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -57,6 +57,12 @@ public RelayPushRegistrationServiceTests(ITestOutputHelper output) _cloudApi.Identity.SubstituteService( service => { }); + // Disable OpenTelemetry OTLP exporters. The SDK registers OTLP metric and trace exporters + // by default when not self-hosted. In CI those exporters attempt outbound TCP connections + // to a collector endpoint that drops packets (SYN-timeout ~95 s), stalling the TestHost + // pipeline in the same way as the LaunchDarkly connection above. + _cloudApi.Identity.UpdateConfiguration("OpenTelemetry:Enabled", "false"); + var cloudApiHttpClient = _cloudApi.CreateClient(); // Access the identity server's in-process handler directly so we can wrap it with // a timing handler. _cloudApi.CreateClient() above already triggered identity server From 78c936c889659cf0d50f2bdf056f1d704c6fef02 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:03:15 -0400 Subject: [PATCH 08/17] diag: Capture outbound HTTP calls and Activity spans during identity token request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add process-wide DiagnosticListener and ActivityListener subscriptions to MobileData_ShouldNotLogIssues so that CI logs reveal exactly which outbound TCP connection is causing the remaining hang. - DiagnosticAllObserver subscribes to AllListeners (which replays already- active listeners on subscribe, capturing HttpHandlerDiagnosticListener even after app startup). Every outbound HTTP→/HTTP←/HTTP✗ event is logged with a timestamp relative to test start. - ActivityListener covers all ActivitySource spans (url.full / http.url tags printed on start/stop), giving a timeline of identity server internals. - Thread-pool polling now shares the same Stopwatch as the other diagnostics. --- .../RelayPushRegistrationServiceTests.cs | 112 +++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index c50a0cf57639..22c675448691 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -178,6 +178,80 @@ private void DumpCollector(string label, FakeLogCollector collector) _output.WriteLine("---"); } + /// + /// Subscribes to and logs every outbound HTTP + /// request seen on HttpHandlerDiagnosticListener. Because + /// replays already-active listeners on subscribe, this captures HTTP calls made by the identity + /// TestHost even though it started before the subscription was established. + /// + private sealed class DiagnosticAllObserver(ITestOutputHelper output, Stopwatch sw) + : IObserver, IDisposable + { + private readonly List _subscriptions = []; + + public void OnNext(DiagnosticListener listener) + { + output.WriteLine($"[+{sw.Elapsed.TotalSeconds:F2}s][DiagListener] {listener.Name}"); + if (listener.Name is "HttpHandlerDiagnosticListener") + { + _subscriptions.Add(listener.Subscribe(new OutboundHttpObserver(output, sw))); + } + } + + public void OnError(Exception error) { } + public void OnCompleted() { } + + public void Dispose() + { + foreach (var s in _subscriptions) s.Dispose(); + } + } + + /// + /// Observes HttpHandlerDiagnosticListener events and logs each outbound HTTP + /// request, response, and exception with a timestamp relative to the test stopwatch. + /// + private sealed class OutboundHttpObserver(ITestOutputHelper output, Stopwatch sw) + : IObserver> + { + public void OnNext(KeyValuePair kv) + { + try + { + switch (kv.Key) + { + case "System.Net.Http.HttpRequestOut.Start": + { + var req = GetProperty(kv.Value, "Request"); + output.WriteLine($"[+{sw.Elapsed.TotalSeconds:F2}s][HTTP→] {req?.Method} {req?.RequestUri}"); + break; + } + case "System.Net.Http.HttpRequestOut.Stop": + { + var req = GetProperty(kv.Value, "Request"); + var res = GetProperty(kv.Value, "Response"); + output.WriteLine($"[+{sw.Elapsed.TotalSeconds:F2}s][HTTP←] {req?.Method} {req?.RequestUri} → {(int?)res?.StatusCode}"); + break; + } + case "System.Net.Http.Exception": + { + var req = GetProperty(kv.Value, "Request"); + var ex = GetProperty(kv.Value, "Exception"); + output.WriteLine($"[+{sw.Elapsed.TotalSeconds:F2}s][HTTP✗] {req?.Method} {req?.RequestUri}: {ex?.GetType().Name}: {ex?.Message}"); + break; + } + } + } + catch { /* never throw in a diagnostic observer */ } + } + + private static T? GetProperty(object? obj, string name) where T : class + => obj?.GetType().GetProperty(name)?.GetValue(obj) as T; + + public void OnError(Exception error) { } + public void OnCompleted() { } + } + [Fact] public async Task MobileData_ShouldNotLogIssues() { @@ -189,10 +263,46 @@ public async Task MobileData_ShouldNotLogIssues() ThreadPool.GetMaxThreads(out var maxWorkers, out var maxIo); + var sw = Stopwatch.StartNew(); + + // Subscribe to all active and future DiagnosticListeners. The replay-on-subscribe + // semantics mean we capture listeners that already exist (e.g. HttpHandlerDiagnosticListener, + // which was created during app startup). Because the identity TestHost runs in-process, + // outbound HttpClient calls made by the identity server during request processing appear here. + using var diagObserver = new DiagnosticAllObserver(_output, sw); + using var diagSub = DiagnosticListener.AllListeners.Subscribe(diagObserver); + + // Subscribe to all ActivitySource spans. Together with the outbound-HTTP events above, + // this pinpoints which operation within the identity server pipeline stalls. + using var activityListener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions options) => + ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = a => + { + var url = a.GetTagItem("url.full")?.ToString() + ?? a.GetTagItem("http.url")?.ToString() + ?? a.GetTagItem("server.address")?.ToString(); + _output.WriteLine( + $"[+{sw.Elapsed.TotalSeconds:F2}s][Activity+] {a.Source.Name}/{a.OperationName}" + + (url is not null ? $" {url}" : "")); + }, + ActivityStopped = a => + { + var url = a.GetTagItem("url.full")?.ToString() + ?? a.GetTagItem("http.url")?.ToString(); + _output.WriteLine( + $"[+{sw.Elapsed.TotalSeconds:F2}s][Activity-] {a.Source.Name}/{a.OperationName}" + + $" {a.Duration.TotalMilliseconds:F0}ms" + + (url is not null ? $" {url}" : "")); + } + }; + ActivitySource.AddActivityListener(activityListener); + using var pollCts = new CancellationTokenSource(); var pollTask = Task.Run(async () => { - var sw = Stopwatch.StartNew(); while (!pollCts.Token.IsCancellationRequested) { ThreadPool.GetAvailableThreads(out var w, out var io); From d57b144429f758f27ccdb114ce1811428113cbce Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:27:28 -0400 Subject: [PATCH 09/17] fix: Add UpdateHostConfiguration and use it to disable OTel OTLP in identity tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UpdateConfiguration() adds to IWebHostBuilder.ConfigureAppConfiguration, which is only visible in the DI IConfiguration (read by Startup.ConfigureServices). However, UseBitwardenSdk() registers AddMetrics() via IHostBuilder.ConfigureServices, which reads HostBuilderContext.Configuration — built exclusively from IHostBuilder. ConfigureAppConfiguration sources. So OpenTelemetry:Enabled=false set via UpdateConfiguration() was never seen by AddMetrics(), leaving OTLP exporters registered despite the override. Add UpdateHostConfiguration() / CreateHostBuilder() override to WebApplicationFactoryBase so callers can inject in-memory configuration at the IHostBuilder level. Wire the RelayPushRegistrationService test to use it for OpenTelemetry:Enabled=false. Root cause of the ~136 s hang: gRPC channel exponential-backoff reconnection (1 s → 2 s → 4 s → … → ~120 s max) when the OTLP collector endpoint on localhost:4317 refuses or drops connections in CI. --- .../RelayPushRegistrationServiceTests.cs | 14 +++++--- .../Factories/WebApplicationFactoryBase.cs | 34 +++++++++++++++++-- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index 22c675448691..904ce7c1d6f4 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -57,11 +57,15 @@ public RelayPushRegistrationServiceTests(ITestOutputHelper output) _cloudApi.Identity.SubstituteService( service => { }); - // Disable OpenTelemetry OTLP exporters. The SDK registers OTLP metric and trace exporters - // by default when not self-hosted. In CI those exporters attempt outbound TCP connections - // to a collector endpoint that drops packets (SYN-timeout ~95 s), stalling the TestHost - // pipeline in the same way as the LaunchDarkly connection above. - _cloudApi.Identity.UpdateConfiguration("OpenTelemetry:Enabled", "false"); + // Disable OpenTelemetry OTLP exporters. The Bitwarden SDK registers OTLP metric and trace + // exporters by default when not self-hosted. It does this from IHostBuilder.ConfigureServices, + // reading IHostBuilder.ConfigureAppConfiguration (HostBuilderContext.Configuration) — NOT + // the web-host configuration that UpdateConfiguration() adds to. UpdateHostConfiguration() + // adds an in-memory source directly to IHostBuilder.ConfigureAppConfiguration, which is what + // AddMetrics() actually reads when deciding whether to call tracing.AddOtlpExporter(). + // In CI the OTLP gRPC channel retries connecting to the unreachable endpoint with exponential + // backoff (1 s → 2 s → 4 s → … → ~120 s), stalling the TestHost pipeline for ~136 s. + _cloudApi.Identity.UpdateHostConfiguration("OpenTelemetry:Enabled", "false"); var cloudApiHttpClient = _cloudApi.CreateClient(); // Access the identity server's in-process handler directly so we can wrap it with diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index 2271611125db..e472e29bc6f4 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -14,6 +14,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -52,6 +53,7 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory protected readonly List> _configureTestServices = new(); private readonly List> _configureAppConfiguration = new(); + private readonly List> _configureHostAppConfiguration = new(); public void SubstituteService(Action mockService) where TService : class @@ -122,6 +124,36 @@ public void UpdateConfiguration(string key, string? value) }); } + /// + /// Overrides a host-level configuration setting. Unlike , + /// which adds to the web-host configuration read by Startup.ConfigureServices, this + /// method adds to the pipeline, which + /// is what services registered via IHostBuilder.ConfigureServices — such as the + /// Bitwarden SDK's UseBitwardenSdk() — read at service-registration time. + /// + /// This needs to be called BEFORE making any calls through the factory to take effect. + public void UpdateHostConfiguration(string key, string? value) + { + _configureHostAppConfiguration.Add(builder => + { + builder.AddInMemoryCollection(new Dictionary + { + { key, value }, + }); + }); + } + + protected override IHostBuilder? CreateHostBuilder() + { + var builder = base.CreateHostBuilder(); + foreach (var configure in _configureHostAppConfiguration) + { + var captured = configure; + builder?.ConfigureAppConfiguration((_, config) => captured(config)); + } + return builder; + } + /// /// Configure the web host to use a SQLite in memory database /// @@ -169,8 +201,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) .AddJsonFile("appsettings.json") .AddJsonFile("appsettings.Development.json"); - c.AddUserSecrets(typeof(Identity.Startup).Assembly, optional: true); - c.AddInMemoryCollection(config); }); From bc91016e87a600a2022f000587fe1ceba1020951 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:31:35 -0400 Subject: [PATCH 10/17] diag: Include db.statement snippet in EF Core Activity log lines When the ActivityListener sees an activity whose source name contains "EntityFrameworkCore", print a truncated (120-char) snippet of the db.statement tag on both Activity+ (start) and Activity- (stop) lines. The tag is guaranteed to be populated by stop time; it may also already be set as an initial tag at start time depending on the instrumentation version. Either way, the stop line will always show the SQL so we can identify exactly which query the identity server runs during the /connect/token flow. --- .../RelayPushRegistrationServiceTests.cs | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index 904ce7c1d6f4..375192a20d5d 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -124,6 +124,27 @@ await _sut.CreateOrUpdateRegistrationAsync( Assert.DoesNotContain(logs, l => l.Level >= LogLevel.Warning); } + /// + /// Returns a truncated snippet of the db.statement tag for EF Core activities, + /// or for all other activity sources. + /// + private static string? DbStatementSnippet(Activity a) + { + if (!a.Source.Name.Contains("EntityFrameworkCore")) + { + return null; + } + + var stmt = a.GetTagItem("db.statement")?.ToString(); + if (stmt is null) + { + return null; + } + + const int maxLen = 120; + return stmt.Length <= maxLen ? stmt : stmt[..maxLen] + "…"; + } + private void DumpLogs(string context) { DumpCollector($"RelayPushRegistrationService logs ({context})", _logCollector); @@ -288,18 +309,24 @@ public async Task MobileData_ShouldNotLogIssues() var url = a.GetTagItem("url.full")?.ToString() ?? a.GetTagItem("http.url")?.ToString() ?? a.GetTagItem("server.address")?.ToString(); + // db.statement may already be set as an initial tag on EF Core activities. + var dbSnippet = DbStatementSnippet(a); _output.WriteLine( $"[+{sw.Elapsed.TotalSeconds:F2}s][Activity+] {a.Source.Name}/{a.OperationName}" - + (url is not null ? $" {url}" : "")); + + (url is not null ? $" {url}" : "") + + (dbSnippet is not null ? $" sql={dbSnippet}" : "")); }, ActivityStopped = a => { var url = a.GetTagItem("url.full")?.ToString() ?? a.GetTagItem("http.url")?.ToString(); + // db.statement is guaranteed to be set by the time the activity stops. + var dbSnippet = DbStatementSnippet(a); _output.WriteLine( $"[+{sw.Elapsed.TotalSeconds:F2}s][Activity-] {a.Source.Name}/{a.OperationName}" + $" {a.Duration.TotalMilliseconds:F0}ms" - + (url is not null ? $" {url}" : "")); + + (url is not null ? $" {url}" : "") + + (dbSnippet is not null ? $" sql={dbSnippet}" : "")); } }; ActivitySource.AddActivityListener(activityListener); From 5e1e03a566107434f4b53389f2183f06c5d3d7ff Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:12:15 -0400 Subject: [PATCH 11/17] perf: Share factory startup across tests with IClassFixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tests were each creating a full ApiApplicationFactory (API + identity TestHost startup), costing ~40 s per test in CI for ~80 s total. Extracting the expensive server setup into RelayPushRegistrationServiceFixture and using IClassFixture means both servers start once for the class and are shared across all test methods, halving the overhead. Also removes the 10 s timeout that was on the identity HttpClient as a safety guard during the hang investigation — no longer needed now that OTel and LaunchDarkly are properly suppressed. --- .../RelayPushRegistrationServiceTests.cs | 118 +++++++++++------- 1 file changed, 70 insertions(+), 48 deletions(-) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index 375192a20d5d..b1ee533b53a1 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -1,4 +1,4 @@ -using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Factories; using Bit.Core.Enums; using Bit.Core.Platform.Installations; using Bit.Core.Platform.Push; @@ -16,35 +16,43 @@ namespace Bit.Api.IntegrationTest.Platform; -public class RelayPushRegistrationServiceTests +/// +/// Shared test fixture that starts the API and identity servers once for the entire +/// class. Starting both servers takes ~40 s +/// in CI; without this fixture each test method would pay that cost independently. +/// +public sealed class RelayPushRegistrationServiceFixture : IDisposable { - private readonly ApiApplicationFactory _cloudApi; - private readonly Guid _fakeInstallationId; - private readonly FakeLogCollector _logCollector; - private readonly RelayPushRegistrationService _sut; - private readonly ITestOutputHelper _output; + public ApiApplicationFactory CloudApi { get; } + public Guid FakeInstallationId { get; } - public RelayPushRegistrationServiceTests(ITestOutputHelper output) + /// The in-process TestHost handler for the identity server. + /// Tests wrap this in a per-test . + public HttpMessageHandler IdentityBaseHandler { get; } + + public HttpClient CloudApiHttpClient { get; } + public GlobalSettings GlobalSettings { get; } + + public RelayPushRegistrationServiceFixture() { - _output = output; - _cloudApi = new ApiApplicationFactory(); - _cloudApi.SubstituteService(service => { }); + CloudApi = new ApiApplicationFactory(); + CloudApi.SubstituteService(service => { }); - _fakeInstallationId = Guid.NewGuid(); + FakeInstallationId = Guid.NewGuid(); - _cloudApi.Identity.SubstituteService(service => + CloudApi.Identity.SubstituteService(service => { - service.GetByIdAsync(_fakeInstallationId) + service.GetByIdAsync(FakeInstallationId) .Returns(new Installation { - Id = _fakeInstallationId, + Id = FakeInstallationId, Key = "test_key", Enabled = true, }); }); // Replace the NullLoggerFactory so we can capture identity server logs for diagnostics. - _cloudApi.Identity.ConfigureServices(services => + CloudApi.Identity.ConfigureServices(services => { services.RemoveAll(); services.AddLogging(b => b.AddFakeLogging()); @@ -54,7 +62,7 @@ public RelayPushRegistrationServiceTests(ITestOutputHelper output) // and no outbound connections to LaunchDarkly are made. In CI, those connections hang // because packets are dropped (TCP SYN-timeout ~2 min) rather than refused, stalling the // TestHost response-body pipe writer even though the server logs "Request finished 200". - _cloudApi.Identity.SubstituteService( + CloudApi.Identity.SubstituteService( service => { }); // Disable OpenTelemetry OTLP exporters. The Bitwarden SDK registers OTLP metric and trace @@ -65,43 +73,57 @@ public RelayPushRegistrationServiceTests(ITestOutputHelper output) // AddMetrics() actually reads when deciding whether to call tracing.AddOtlpExporter(). // In CI the OTLP gRPC channel retries connecting to the unreachable endpoint with exponential // backoff (1 s → 2 s → 4 s → … → ~120 s), stalling the TestHost pipeline for ~136 s. - _cloudApi.Identity.UpdateHostConfiguration("OpenTelemetry:Enabled", "false"); - - var cloudApiHttpClient = _cloudApi.CreateClient(); - // Access the identity server's in-process handler directly so we can wrap it with - // a timing handler. _cloudApi.CreateClient() above already triggered identity server - // startup (ApiApplicationFactory wires the JWT backchannel to the identity TestServer), - // so Server is ready here without double-starting. - var identityBaseHandler = _cloudApi.Identity.Server.CreateHandler(); - var identityTimingHandler = new IdentityTimingHandler(output) { InnerHandler = identityBaseHandler }; - var cloudIdentityHttpClient = new HttpClient(identityTimingHandler) + CloudApi.Identity.UpdateHostConfiguration("OpenTelemetry:Enabled", "false"); + + CloudApiHttpClient = CloudApi.CreateClient(); + // Access the identity server's in-process handler directly so tests can wrap it with a + // timing handler. CloudApi.CreateClient() above already triggered identity server startup + // (ApiApplicationFactory wires the JWT backchannel to the identity TestServer), so the + // server is ready here without double-starting. + IdentityBaseHandler = CloudApi.Identity.Server.CreateHandler(); + + GlobalSettings = new GlobalSettings { - Timeout = TimeSpan.FromSeconds(10) + PushRelayBaseUri = "http://api.localhost" }; + GlobalSettings.Installation.IdentityUri = "http://identity.localhost"; + GlobalSettings.Installation.Id = FakeInstallationId; + GlobalSettings.Installation.Key = "test_key"; + } - var httpClientFactory = Substitute.For(); + public void Dispose() => CloudApi.Dispose(); +} - httpClientFactory.CreateClient("client") - .Returns(cloudApiHttpClient); +public class RelayPushRegistrationServiceTests : IClassFixture +{ + private readonly RelayPushRegistrationServiceFixture _fixture; + private readonly FakeLogCollector _logCollector; + private readonly RelayPushRegistrationService _sut; + private readonly ITestOutputHelper _output; - httpClientFactory.CreateClient("identity") - .Returns(cloudIdentityHttpClient); + public RelayPushRegistrationServiceTests( + RelayPushRegistrationServiceFixture fixture, + ITestOutputHelper output) + { + _fixture = fixture; + _output = output; - var globalSettings = new GlobalSettings + var identityTimingHandler = new IdentityTimingHandler(output) { - PushRelayBaseUri = "http://api.localhost" + InnerHandler = fixture.IdentityBaseHandler }; - globalSettings.Installation.IdentityUri = "http://identity.localhost"; - globalSettings.Installation.Id = _fakeInstallationId; - globalSettings.Installation.Key = "test_key"; + var cloudIdentityHttpClient = new HttpClient(identityTimingHandler); - var logger = new FakeLogger(); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient("client").Returns(fixture.CloudApiHttpClient); + httpClientFactory.CreateClient("identity").Returns(cloudIdentityHttpClient); + var logger = new FakeLogger(); _logCollector = logger.Collector; _sut = new RelayPushRegistrationService( httpClientFactory, - globalSettings, + fixture.GlobalSettings, logger ); } @@ -148,7 +170,7 @@ await _sut.CreateOrUpdateRegistrationAsync( private void DumpLogs(string context) { DumpCollector($"RelayPushRegistrationService logs ({context})", _logCollector); - DumpCollector($"Identity server logs ({context})", _cloudApi.Identity.GetService()); + DumpCollector($"Identity server logs ({context})", _fixture.CloudApi.Identity.GetService()); } /// @@ -164,7 +186,7 @@ private void DumpLogs(string context) /// /// /// - private sealed class IdentityTimingHandler(ITestOutputHelper output) : DelegatingHandler + internal sealed class IdentityTimingHandler(ITestOutputHelper output) : DelegatingHandler { protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) @@ -205,7 +227,7 @@ private void DumpCollector(string label, FakeLogCollector collector) /// /// Subscribes to and logs every outbound HTTP - /// request seen on HttpHandlerDiagnosticListener. Because + /// request seen on HttpHandlerDiagnosticListener. Because /// replays already-active listeners on subscribe, this captures HTTP calls made by the identity /// TestHost even though it started before the subscription was established. /// @@ -363,16 +385,16 @@ await _sut.CreateOrUpdateRegistrationAsync( // Mobile should also actually successfully make it to the cloud push registration service // with all of its data prefixed with the self host installation id. - var mockPushRegistrationService = _cloudApi.GetService(); + var mockPushRegistrationService = _fixture.CloudApi.GetService(); await mockPushRegistrationService .Received(1) .CreateOrUpdateRegistrationAsync( new PushRegistrationData("PushToken"), - deviceId: $"{_fakeInstallationId}_{deviceId}", - userId: $"{_fakeInstallationId}_{userId}", - identifier: $"{_fakeInstallationId}_{identifier}", + deviceId: $"{_fixture.FakeInstallationId}_{deviceId}", + userId: $"{_fixture.FakeInstallationId}_{userId}", + identifier: $"{_fixture.FakeInstallationId}_{identifier}", type: DeviceType.iOS, - Arg.Is>(v => v.Single() == $"{_fakeInstallationId}_{organizationId}"), + Arg.Is>(v => v.Single() == $"{_fixture.FakeInstallationId}_{organizationId}"), installationId ); } From efd4362b46528fd91779524efedb8c586752e8c4 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:40:25 -0400 Subject: [PATCH 12/17] fix: Create fresh HttpClient per test to allow BaseAddress assignment Sharing a single HttpClient instance across test instances via the fixture caused an InvalidOperationException: BaseIdentityClientService sets HttpClient.BaseAddress in its constructor, which HttpClient forbids after the first request has been sent. Store the raw HttpMessageHandler in the fixture instead of the HttpClient, and wrap it in a new HttpClient for each test constructor invocation so BaseAddress can be set freely. --- .../RelayPushRegistrationServiceTests.cs | 54 ++++++++++++------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index b1ee533b53a1..5da9a4e7c58b 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -26,11 +26,19 @@ public sealed class RelayPushRegistrationServiceFixture : IDisposable public ApiApplicationFactory CloudApi { get; } public Guid FakeInstallationId { get; } + /// The in-process TestHost handler for the API server. + /// + /// Tests create a fresh wrapping this handler per test instance. + /// A shared cannot be reused because + /// sets in its constructor, which is forbidden after the + /// first request has been sent. + /// + public HttpMessageHandler CloudApiHandler { get; } + /// The in-process TestHost handler for the identity server. /// Tests wrap this in a per-test . public HttpMessageHandler IdentityBaseHandler { get; } - public HttpClient CloudApiHttpClient { get; } public GlobalSettings GlobalSettings { get; } public RelayPushRegistrationServiceFixture() @@ -58,28 +66,32 @@ public RelayPushRegistrationServiceFixture() services.AddLogging(b => b.AddFakeLogging()); }); - // Substitute the SDK feature service so LaunchDarklyClientProvider is never instantiated - // and no outbound connections to LaunchDarkly are made. In CI, those connections hang - // because packets are dropped (TCP SYN-timeout ~2 min) rather than refused, stalling the - // TestHost response-body pipe writer even though the server logs "Request finished 200". + // Both the API and identity servers call UseBitwardenSdk() on IHostBuilder, which + // registers LaunchDarklyClientProvider (makes outbound TCP connections) and OTLP + // exporters (gRPC channel with exponential-backoff retries). In CI both endpoints are + // unreachable, causing startup / first-request delays of up to ~136 s each. + // Apply the same two suppressions to both servers. + + // Substitute the SDK feature service so LaunchDarklyClientProvider is never resolved + // and no outbound connections to LaunchDarkly are made. + CloudApi.SubstituteService(service => { }); CloudApi.Identity.SubstituteService( service => { }); - // Disable OpenTelemetry OTLP exporters. The Bitwarden SDK registers OTLP metric and trace - // exporters by default when not self-hosted. It does this from IHostBuilder.ConfigureServices, - // reading IHostBuilder.ConfigureAppConfiguration (HostBuilderContext.Configuration) — NOT - // the web-host configuration that UpdateConfiguration() adds to. UpdateHostConfiguration() - // adds an in-memory source directly to IHostBuilder.ConfigureAppConfiguration, which is what - // AddMetrics() actually reads when deciding whether to call tracing.AddOtlpExporter(). - // In CI the OTLP gRPC channel retries connecting to the unreachable endpoint with exponential - // backoff (1 s → 2 s → 4 s → … → ~120 s), stalling the TestHost pipeline for ~136 s. + // Disable OTLP exporters. UpdateHostConfiguration() adds to IHostBuilder.ConfigureAppConfiguration + // (HostBuilderContext.Configuration), which is what UseBitwardenSdk()'s AddMetrics() reads + // when deciding whether to call tracing.AddOtlpExporter(). UpdateConfiguration() would + // add to the web-host config pipeline instead, which AddMetrics() does NOT read. + CloudApi.UpdateHostConfiguration("OpenTelemetry:Enabled", "false"); CloudApi.Identity.UpdateHostConfiguration("OpenTelemetry:Enabled", "false"); - CloudApiHttpClient = CloudApi.CreateClient(); - // Access the identity server's in-process handler directly so tests can wrap it with a - // timing handler. CloudApi.CreateClient() above already triggered identity server startup - // (ApiApplicationFactory wires the JWT backchannel to the identity TestServer), so the - // server is ready here without double-starting. + // Trigger startup of both servers. CreateClient() starts the API TestHost, which in turn + // starts the identity TestHost via the JWT backchannel wired up in ApiApplicationFactory. + // Discard the returned HttpClient — tests must create their own fresh instances because + // BaseIdentityClientService sets HttpClient.BaseAddress in its constructor, which throws + // if the client has already sent a request. + CloudApi.CreateClient(); + CloudApiHandler = CloudApi.Server.CreateHandler(); IdentityBaseHandler = CloudApi.Identity.Server.CreateHandler(); GlobalSettings = new GlobalSettings @@ -108,6 +120,10 @@ public RelayPushRegistrationServiceTests( _fixture = fixture; _output = output; + // Fresh HttpClient instances per test: BaseIdentityClientService sets .BaseAddress in its + // constructor, which HttpClient forbids after the first request has been sent. Wrapping the + // shared handlers in new HttpClient instances each time avoids the InvalidOperationException. + var cloudApiHttpClient = new HttpClient(fixture.CloudApiHandler); var identityTimingHandler = new IdentityTimingHandler(output) { InnerHandler = fixture.IdentityBaseHandler @@ -115,7 +131,7 @@ public RelayPushRegistrationServiceTests( var cloudIdentityHttpClient = new HttpClient(identityTimingHandler); var httpClientFactory = Substitute.For(); - httpClientFactory.CreateClient("client").Returns(fixture.CloudApiHttpClient); + httpClientFactory.CreateClient("client").Returns(cloudApiHttpClient); httpClientFactory.CreateClient("identity").Returns(cloudIdentityHttpClient); var logger = new FakeLogger(); From ccf82dacfb822c79ed982859a0b022196493ffe6 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:25:45 -0400 Subject: [PATCH 13/17] fix: Don't substitute IFeatureService on the cloud API server --- .../Platform/RelayPushRegistrationServiceTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index 5da9a4e7c58b..47eaaca2b282 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -72,9 +72,12 @@ public RelayPushRegistrationServiceFixture() // unreachable, causing startup / first-request delays of up to ~136 s each. // Apply the same two suppressions to both servers. - // Substitute the SDK feature service so LaunchDarklyClientProvider is never resolved - // and no outbound connections to LaunchDarkly are made. - CloudApi.SubstituteService(service => { }); + // Substitute the SDK feature service on the identity server so LaunchDarklyClientProvider + // is never resolved and no outbound connections to LaunchDarkly are made. The API server + // does not need this substitution: the LaunchDarkly SDK key is blank in test appsettings, + // so its client runs in offline/no-connection mode. Substituting IFeatureService on the + // API server would break the push registration endpoint because NSubstitute returns false + // for all feature flag checks, causing the API to reject the relay's registration request. CloudApi.Identity.SubstituteService( service => { }); From 4b5c016884669ed1ac4e0c4686da70e44da353ce Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:01:46 -0400 Subject: [PATCH 14/17] fix: Extract CreateHostBuilder in Api so UpdateHostConfiguration takes effect WebApplicationFactory uses HostFactoryResolver to find a public static CreateHostBuilder method on the entry point class. Without it, CreateHostBuilder() returns null, and UpdateHostConfiguration calls are silently no-ops for the API server. This caused OTel to remain enabled despite the test fixture calling UpdateHostConfiguration("OpenTelemetry:Enabled", "false"), resulting in the same 136s OTLP hang that was the original flakiness root cause. --- src/Api/Program.cs | 13 ++++++++----- .../Platform/RelayPushRegistrationServiceTests.cs | 11 +++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/Api/Program.cs b/src/Api/Program.cs index baeaab9fdbb4..019e64930138 100644 --- a/src/Api/Program.cs +++ b/src/Api/Program.cs @@ -1,4 +1,4 @@ -using Bit.Core.Utilities; +using Bit.Core.Utilities; namespace Bit.Api; @@ -6,15 +6,18 @@ public class Program { public static void Main(string[] args) { - Host + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) + { + return Host .CreateDefaultBuilder(args) .UseBitwardenSdk() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); }) - .AddSerilogFileLogging() - .Build() - .Run(); + .AddSerilogFileLogging(); } } diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index 47eaaca2b282..fec2211e7876 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -126,12 +126,14 @@ public RelayPushRegistrationServiceTests( // Fresh HttpClient instances per test: BaseIdentityClientService sets .BaseAddress in its // constructor, which HttpClient forbids after the first request has been sent. Wrapping the // shared handlers in new HttpClient instances each time avoids the InvalidOperationException. - var cloudApiHttpClient = new HttpClient(fixture.CloudApiHandler); + // disposeHandler: false — the handlers are owned by the fixture; disposing the per-test + // HttpClient must not dispose the shared underlying handler. + var cloudApiHttpClient = new HttpClient(fixture.CloudApiHandler, disposeHandler: false); var identityTimingHandler = new IdentityTimingHandler(output) { InnerHandler = fixture.IdentityBaseHandler }; - var cloudIdentityHttpClient = new HttpClient(identityTimingHandler); + var cloudIdentityHttpClient = new HttpClient(identityTimingHandler, disposeHandler: false); var httpClientFactory = Substitute.For(); httpClientFactory.CreateClient("client").Returns(cloudApiHttpClient); @@ -227,6 +229,11 @@ protected override async Task SendAsync( throw; } } + + // The InnerHandler is owned by the fixture and shared across all test instances. + // DelegatingHandler.Dispose() would otherwise dispose it after the first test completes, + // breaking subsequent tests that wrap the same handler. + protected override void Dispose(bool disposing) { } } private void DumpCollector(string label, FakeLogCollector collector) From 0a9400f0af80efd81dd396fbb49cdb3a4f42c005 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:36:30 -0400 Subject: [PATCH 15/17] test: Disable OTel globally in WebApplicationFactoryBase; use Program entry point OTel OTLP exporters are now disabled for every test factory via WebApplicationFactoryBase.CreateHostBuilder(), so no individual test fixture needs to call UpdateHostConfiguration("OpenTelemetry:Enabled", "false"). ApiApplicationFactory and IdentityApplicationFactory are updated to use WebApplicationFactory instead of WebApplicationFactory. TEntryPoint is only used for assembly discovery so behaviour is identical, but Program is the correct modern entry-point type and aligns with HostFactoryResolver's expectation. The now-redundant UpdateHostConfiguration calls are removed from RelayPushRegistrationServiceFixture. --- .../Factories/ApiApplicationFactory.cs | 2 +- .../Platform/RelayPushRegistrationServiceTests.cs | 13 ------------- .../Factories/IdentityApplicationFactory.cs | 2 +- .../Factories/WebApplicationFactoryBase.cs | 10 ++++++++++ 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs index cbbb5e8cd787..52b5b1c1dd1a 100644 --- a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs +++ b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs @@ -12,7 +12,7 @@ namespace Bit.Api.IntegrationTest.Factories; -public class ApiApplicationFactory : WebApplicationFactoryBase +public class ApiApplicationFactory : WebApplicationFactoryBase { protected IdentityApplicationFactory _identityApplicationFactory; diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index fec2211e7876..7195f8dbd0f5 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -66,12 +66,6 @@ public RelayPushRegistrationServiceFixture() services.AddLogging(b => b.AddFakeLogging()); }); - // Both the API and identity servers call UseBitwardenSdk() on IHostBuilder, which - // registers LaunchDarklyClientProvider (makes outbound TCP connections) and OTLP - // exporters (gRPC channel with exponential-backoff retries). In CI both endpoints are - // unreachable, causing startup / first-request delays of up to ~136 s each. - // Apply the same two suppressions to both servers. - // Substitute the SDK feature service on the identity server so LaunchDarklyClientProvider // is never resolved and no outbound connections to LaunchDarkly are made. The API server // does not need this substitution: the LaunchDarkly SDK key is blank in test appsettings, @@ -81,13 +75,6 @@ public RelayPushRegistrationServiceFixture() CloudApi.Identity.SubstituteService( service => { }); - // Disable OTLP exporters. UpdateHostConfiguration() adds to IHostBuilder.ConfigureAppConfiguration - // (HostBuilderContext.Configuration), which is what UseBitwardenSdk()'s AddMetrics() reads - // when deciding whether to call tracing.AddOtlpExporter(). UpdateConfiguration() would - // add to the web-host config pipeline instead, which AddMetrics() does NOT read. - CloudApi.UpdateHostConfiguration("OpenTelemetry:Enabled", "false"); - CloudApi.Identity.UpdateHostConfiguration("OpenTelemetry:Enabled", "false"); - // Trigger startup of both servers. CreateClient() starts the API TestHost, which in turn // starts the identity TestHost via the JWT backchannel wired up in ApiApplicationFactory. // Discard the returned HttpClient — tests must create their own fresh instances because diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index 67745f70f996..9281458341b1 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -23,7 +23,7 @@ namespace Bit.IntegrationTestCommon.Factories; -public class IdentityApplicationFactory : WebApplicationFactoryBase +public class IdentityApplicationFactory : WebApplicationFactoryBase { public const string DefaultDeviceIdentifier = "92b9d953-b9b6-4eaf-9d3e-11d57144dfeb"; public const string DefaultUserEmail = "DefaultEmail@bitwarden.com"; diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index e472e29bc6f4..f133f727e041 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -146,6 +146,16 @@ public void UpdateHostConfiguration(string key, string? value) protected override IHostBuilder? CreateHostBuilder() { var builder = base.CreateHostBuilder(); + // Disable OTel in all test factories. UseBitwardenSdk() registers OTLP exporters that + // open gRPC channels with exponential-backoff retries; when the endpoint is unreachable + // (e.g. CI) the retry loop stalls response-body completion for ~136 s per request. + builder?.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + { "OpenTelemetry:Enabled", "false" }, + }); + }); foreach (var configure in _configureHostAppConfiguration) { var captured = configure; From 4e878f12b1de4a9db1f4e21400709b75a8131f84 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:27:23 -0400 Subject: [PATCH 16/17] test: Remove diagnostics from RelayPushRegistrationServiceTests --- .../RelayPushRegistrationServiceTests.cs | 244 +----------------- 1 file changed, 3 insertions(+), 241 deletions(-) diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index 7195f8dbd0f5..a65b1f730c35 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -5,14 +5,10 @@ using Bit.Core.Platform.PushRegistration; using Bit.Core.Platform.PushRegistration.Internal; using Bit.Core.Settings; -using System.Diagnostics; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using NSubstitute; using Xunit; -using Xunit.Abstractions; namespace Bit.Api.IntegrationTest.Platform; @@ -36,7 +32,7 @@ public sealed class RelayPushRegistrationServiceFixture : IDisposable public HttpMessageHandler CloudApiHandler { get; } /// The in-process TestHost handler for the identity server. - /// Tests wrap this in a per-test . + /// Tests wrap this in a fresh per test instance. public HttpMessageHandler IdentityBaseHandler { get; } public GlobalSettings GlobalSettings { get; } @@ -59,13 +55,6 @@ public RelayPushRegistrationServiceFixture() }); }); - // Replace the NullLoggerFactory so we can capture identity server logs for diagnostics. - CloudApi.Identity.ConfigureServices(services => - { - services.RemoveAll(); - services.AddLogging(b => b.AddFakeLogging()); - }); - // Substitute the SDK feature service on the identity server so LaunchDarklyClientProvider // is never resolved and no outbound connections to LaunchDarkly are made. The API server // does not need this substitution: the LaunchDarkly SDK key is blank in test appsettings, @@ -101,14 +90,10 @@ public class RelayPushRegistrationServiceTests : IClassFixture(); httpClientFactory.CreateClient("client").Returns(cloudApiHttpClient); @@ -154,164 +135,6 @@ await _sut.CreateOrUpdateRegistrationAsync( Assert.DoesNotContain(logs, l => l.Level >= LogLevel.Warning); } - /// - /// Returns a truncated snippet of the db.statement tag for EF Core activities, - /// or for all other activity sources. - /// - private static string? DbStatementSnippet(Activity a) - { - if (!a.Source.Name.Contains("EntityFrameworkCore")) - { - return null; - } - - var stmt = a.GetTagItem("db.statement")?.ToString(); - if (stmt is null) - { - return null; - } - - const int maxLen = 120; - return stmt.Length <= maxLen ? stmt : stmt[..maxLen] + "…"; - } - - private void DumpLogs(string context) - { - DumpCollector($"RelayPushRegistrationService logs ({context})", _logCollector); - DumpCollector($"Identity server logs ({context})", _fixture.CloudApi.Identity.GetService()); - } - - /// - /// Wraps the identity server's in-process handler to log the timing of each HTTP phase. - /// - /// When the identity server finishes writing the response, the server-side log shows - /// "Request finished … 200 … Xms". But the client can still hang reading the response - /// body from the TestHost pipe. This handler reveals which phase stalls: - /// - /// If "inner handler returned" never appears, the TestHost pipeline itself is stuck. - /// If "inner handler returned" appears but the test still hangs, HttpClient is stuck - /// buffering the response body (ResponseContentRead mode) from the pipe. - /// - /// - /// - internal sealed class IdentityTimingHandler(ITestOutputHelper output) : DelegatingHandler - { - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var sw = Stopwatch.StartNew(); - output.WriteLine($"[identity→] {request.Method} {request.RequestUri} (sending)"); - try - { - var response = await base.SendAsync(request, cancellationToken); - output.WriteLine( - $"[identity←] inner handler returned {(int)response.StatusCode} at {sw.ElapsedMilliseconds}ms " + - $"(headers ready; HttpClient will now read body)"); - return response; - } - catch (Exception ex) - { - output.WriteLine( - $"[identity✗] {ex.GetType().Name} after {sw.ElapsedMilliseconds}ms: {ex.Message}"); - throw; - } - } - - // The InnerHandler is owned by the fixture and shared across all test instances. - // DelegatingHandler.Dispose() would otherwise dispose it after the first test completes, - // breaking subsequent tests that wrap the same handler. - protected override void Dispose(bool disposing) { } - } - - private void DumpCollector(string label, FakeLogCollector collector) - { - var logs = collector.GetSnapshot(); - _output.WriteLine($"--- {label}: {logs.Count} entries ---"); - foreach (var log in logs) - { - _output.WriteLine($"[{log.Level}] {log.Message}"); - if (log.Exception is not null) - { - _output.WriteLine($" Exception: {log.Exception}"); - } - } - _output.WriteLine("---"); - } - - /// - /// Subscribes to and logs every outbound HTTP - /// request seen on HttpHandlerDiagnosticListener. Because - /// replays already-active listeners on subscribe, this captures HTTP calls made by the identity - /// TestHost even though it started before the subscription was established. - /// - private sealed class DiagnosticAllObserver(ITestOutputHelper output, Stopwatch sw) - : IObserver, IDisposable - { - private readonly List _subscriptions = []; - - public void OnNext(DiagnosticListener listener) - { - output.WriteLine($"[+{sw.Elapsed.TotalSeconds:F2}s][DiagListener] {listener.Name}"); - if (listener.Name is "HttpHandlerDiagnosticListener") - { - _subscriptions.Add(listener.Subscribe(new OutboundHttpObserver(output, sw))); - } - } - - public void OnError(Exception error) { } - public void OnCompleted() { } - - public void Dispose() - { - foreach (var s in _subscriptions) s.Dispose(); - } - } - - /// - /// Observes HttpHandlerDiagnosticListener events and logs each outbound HTTP - /// request, response, and exception with a timestamp relative to the test stopwatch. - /// - private sealed class OutboundHttpObserver(ITestOutputHelper output, Stopwatch sw) - : IObserver> - { - public void OnNext(KeyValuePair kv) - { - try - { - switch (kv.Key) - { - case "System.Net.Http.HttpRequestOut.Start": - { - var req = GetProperty(kv.Value, "Request"); - output.WriteLine($"[+{sw.Elapsed.TotalSeconds:F2}s][HTTP→] {req?.Method} {req?.RequestUri}"); - break; - } - case "System.Net.Http.HttpRequestOut.Stop": - { - var req = GetProperty(kv.Value, "Request"); - var res = GetProperty(kv.Value, "Response"); - output.WriteLine($"[+{sw.Elapsed.TotalSeconds:F2}s][HTTP←] {req?.Method} {req?.RequestUri} → {(int?)res?.StatusCode}"); - break; - } - case "System.Net.Http.Exception": - { - var req = GetProperty(kv.Value, "Request"); - var ex = GetProperty(kv.Value, "Exception"); - output.WriteLine($"[+{sw.Elapsed.TotalSeconds:F2}s][HTTP✗] {req?.Method} {req?.RequestUri}: {ex?.GetType().Name}: {ex?.Message}"); - break; - } - } - } - catch { /* never throw in a diagnostic observer */ } - } - - private static T? GetProperty(object? obj, string name) where T : class - => obj?.GetType().GetProperty(name)?.GetValue(obj) as T; - - public void OnError(Exception error) { } - public void OnCompleted() { } - } - [Fact] public async Task MobileData_ShouldNotLogIssues() { @@ -321,62 +144,6 @@ public async Task MobileData_ShouldNotLogIssues() var organizationId = Guid.NewGuid(); var installationId = Guid.NewGuid(); - ThreadPool.GetMaxThreads(out var maxWorkers, out var maxIo); - - var sw = Stopwatch.StartNew(); - - // Subscribe to all active and future DiagnosticListeners. The replay-on-subscribe - // semantics mean we capture listeners that already exist (e.g. HttpHandlerDiagnosticListener, - // which was created during app startup). Because the identity TestHost runs in-process, - // outbound HttpClient calls made by the identity server during request processing appear here. - using var diagObserver = new DiagnosticAllObserver(_output, sw); - using var diagSub = DiagnosticListener.AllListeners.Subscribe(diagObserver); - - // Subscribe to all ActivitySource spans. Together with the outbound-HTTP events above, - // this pinpoints which operation within the identity server pipeline stalls. - using var activityListener = new ActivityListener - { - ShouldListenTo = _ => true, - Sample = (ref ActivityCreationOptions options) => - ActivitySamplingResult.AllDataAndRecorded, - ActivityStarted = a => - { - var url = a.GetTagItem("url.full")?.ToString() - ?? a.GetTagItem("http.url")?.ToString() - ?? a.GetTagItem("server.address")?.ToString(); - // db.statement may already be set as an initial tag on EF Core activities. - var dbSnippet = DbStatementSnippet(a); - _output.WriteLine( - $"[+{sw.Elapsed.TotalSeconds:F2}s][Activity+] {a.Source.Name}/{a.OperationName}" - + (url is not null ? $" {url}" : "") - + (dbSnippet is not null ? $" sql={dbSnippet}" : "")); - }, - ActivityStopped = a => - { - var url = a.GetTagItem("url.full")?.ToString() - ?? a.GetTagItem("http.url")?.ToString(); - // db.statement is guaranteed to be set by the time the activity stops. - var dbSnippet = DbStatementSnippet(a); - _output.WriteLine( - $"[+{sw.Elapsed.TotalSeconds:F2}s][Activity-] {a.Source.Name}/{a.OperationName}" - + $" {a.Duration.TotalMilliseconds:F0}ms" - + (url is not null ? $" {url}" : "") - + (dbSnippet is not null ? $" sql={dbSnippet}" : "")); - } - }; - ActivitySource.AddActivityListener(activityListener); - - using var pollCts = new CancellationTokenSource(); - var pollTask = Task.Run(async () => - { - while (!pollCts.Token.IsCancellationRequested) - { - ThreadPool.GetAvailableThreads(out var w, out var io); - _output.WriteLine($"[+{sw.Elapsed.TotalSeconds:F1}s] ThreadPool: {w}/{maxWorkers} workers, {io}/{maxIo} IO available"); - await Task.Delay(TimeSpan.FromSeconds(5), pollCts.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - } - }, pollCts.Token); - await _sut.CreateOrUpdateRegistrationAsync( new PushRegistrationData("PushToken"), deviceId.ToString(), @@ -387,13 +154,8 @@ await _sut.CreateOrUpdateRegistrationAsync( installationId ); - await pollCts.CancelAsync(); - await pollTask; - var logs = _logCollector.GetSnapshot(); - DumpLogs("after CreateOrUpdateRegistrationAsync"); - Assert.DoesNotContain(logs, l => l.Level >= LogLevel.Warning); // Mobile should also actually successfully make it to the cloud push registration service From 91aee12bdf859b8d8e3a5ef2f74857b5d2c7459d Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:29:48 -0400 Subject: [PATCH 17/17] style: Apply dotnet format --- src/Api/Program.cs | 2 +- .../Platform/RelayPushRegistrationServiceTests.cs | 3 +-- .../Factories/WebApplicationFactoryBase.cs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Api/Program.cs b/src/Api/Program.cs index 019e64930138..51096693b544 100644 --- a/src/Api/Program.cs +++ b/src/Api/Program.cs @@ -1,4 +1,4 @@ -using Bit.Core.Utilities; +using Bit.Core.Utilities; namespace Bit.Api; diff --git a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs index a65b1f730c35..1daec8eda95c 100644 --- a/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs +++ b/test/Api.IntegrationTest/Platform/RelayPushRegistrationServiceTests.cs @@ -1,11 +1,10 @@ -using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Factories; using Bit.Core.Enums; using Bit.Core.Platform.Installations; using Bit.Core.Platform.Push; using Bit.Core.Platform.PushRegistration; using Bit.Core.Platform.PushRegistration.Internal; using Bit.Core.Settings; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using NSubstitute; using Xunit; diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index f133f727e041..d0612f7543fe 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -14,8 +14,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute;