From 2f30036aecabe7c2fb447ba0efd91dc7127113f4 Mon Sep 17 00:00:00 2001 From: Anthony Martin <38542602+anthony-c-martin@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:33:57 -0400 Subject: [PATCH 1/2] Implement pooled client factory --- .../PublicApis/Azure.Bicep.RpcClient.txt | 11 + .../PooledBicepClientFactoryTests.cs | 86 ++++++ .../PooledBicepClientFactory.cs | 278 ++++++++++++++++++ 3 files changed, 375 insertions(+) create mode 100644 src/Bicep.RpcClient.Tests/PooledBicepClientFactoryTests.cs create mode 100644 src/Bicep.RpcClient/PooledBicepClientFactory.cs diff --git a/src/Bicep.RpcClient.Tests/Files/PublicApis/Azure.Bicep.RpcClient.txt b/src/Bicep.RpcClient.Tests/Files/PublicApis/Azure.Bicep.RpcClient.txt index 53fcaa6b08a..9e0c9e246e5 100644 --- a/src/Bicep.RpcClient.Tests/Files/PublicApis/Azure.Bicep.RpcClient.txt +++ b/src/Bicep.RpcClient.Tests/Files/PublicApis/Azure.Bicep.RpcClient.txt @@ -53,6 +53,17 @@ namespace Bicep.RpcClient "ad.")] System.Threading.Tasks.Task InitializeFromPath(string bicepCliPath, System.Threading.CancellationToken cancellationToken = default); } + public class PooledBicepClientFactory : Bicep.RpcClient.IBicepClientFactory, System.IDisposable + { + public PooledBicepClientFactory(System.Net.Http.HttpClient? httpClient = null, System.TimeSpan? inactivityInterval = default) { } + public void Dispose() { } + [System.Obsolete("Use Initialize instead.")] + public System.Threading.Tasks.Task DownloadAndInitialize(Bicep.RpcClient.BicepClientConfiguration configuration, System.Threading.CancellationToken cancellationToken) { } + public System.Threading.Tasks.Task Initialize(Bicep.RpcClient.BicepClientConfiguration configuration, System.Threading.CancellationToken cancellationToken) { } + [System.Obsolete("Use Initialize with a BicepClientConfiguration that has ExistingCliPath set inste" + + "ad.")] + public System.Threading.Tasks.Task InitializeFromPath(string bicepCliPath, System.Threading.CancellationToken cancellationToken) { } + } } namespace Bicep.RpcClient.Models { diff --git a/src/Bicep.RpcClient.Tests/PooledBicepClientFactoryTests.cs b/src/Bicep.RpcClient.Tests/PooledBicepClientFactoryTests.cs new file mode 100644 index 00000000000..c631f902c23 --- /dev/null +++ b/src/Bicep.RpcClient.Tests/PooledBicepClientFactoryTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using Bicep.Core.UnitTests.Utils; +using Bicep.RpcClient.Models; +using FluentAssertions; + +namespace Bicep.RpcClient.Tests; + +[TestClass] +public class PooledBicepClientFactoryTests +{ + public TestContext TestContext { get; set; } = null!; + + [TestCleanup] + public void Cleanup() + { + if (Directory.Exists(TestContext.TestResultsDirectory)) + { + Directory.Delete(TestContext.TestResultsDirectory, true); + } + } + + [TestMethod] + public async Task Disposing_one_wrapper_does_not_dispose_shared_client() + { + using var factory = new PooledBicepClientFactory(); + var config = new BicepClientConfiguration { ExistingCliPath = GetCliPath() }; + + var wrapperA = await factory.Initialize(config, TestContext.CancellationTokenSource.Token); + var wrapperB = await factory.Initialize(config, TestContext.CancellationTokenSource.Token); + + wrapperA.Dispose(); + + var version = await wrapperB.GetVersion(TestContext.CancellationTokenSource.Token); + + version.Should().MatchRegex(@"^\d+\.\d+\.\d+(-.+)?$"); + + wrapperB.Dispose(); + } + + [TestMethod] + public async Task Initialize_throws_after_factory_disposed() + { + var factory = new PooledBicepClientFactory(); + factory.Dispose(); + + await FluentActions.Invoking(() => + factory.Initialize(new BicepClientConfiguration { ExistingCliPath = GetCliPath() }, TestContext.CancellationTokenSource.Token)) + .Should().ThrowAsync(); + } + + [TestMethod] + public async Task Concurrent_requests_succeed_with_multiple_wrappers() + { + using var factory = new PooledBicepClientFactory(); + var config = new BicepClientConfiguration { ExistingCliPath = GetCliPath() }; + + var wrappers = await Task.WhenAll(Enumerable.Range(0, 4) + .Select(_ => factory.Initialize(config, TestContext.CancellationTokenSource.Token))); + + try + { + var bicepFile = FileHelper.SaveResultFile(TestContext, "main.bicep", "param location string"); + + var results = await Task.WhenAll(wrappers.Select(wrapper => + wrapper.Compile(new CompileRequest(bicepFile), TestContext.CancellationTokenSource.Token))); + + results.Should().OnlyContain(result => result.Success); + } + finally + { + foreach (var wrapper in wrappers) + { + wrapper.Dispose(); + } + } + } + + private static string GetCliPath() + { + var cliName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "bicep.exe" : "bicep"; + return Path.GetFullPath(Path.Combine(typeof(PooledBicepClientFactoryTests).Assembly.Location, $"../{cliName}")); + } +} diff --git a/src/Bicep.RpcClient/PooledBicepClientFactory.cs b/src/Bicep.RpcClient/PooledBicepClientFactory.cs new file mode 100644 index 00000000000..7c2e040ed19 --- /dev/null +++ b/src/Bicep.RpcClient/PooledBicepClientFactory.cs @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Concurrent; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Bicep.RpcClient.Models; + +namespace Bicep.RpcClient; + +public class PooledBicepClientFactory : IBicepClientFactory, IDisposable +{ + private readonly object lockObj = new(); + private static readonly TimeSpan TimerPollInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan DefaultClientInactivityInterval = TimeSpan.FromSeconds(30); + private readonly TimeSpan clientInactivityInterval; + private readonly BicepClientFactory innerFactory; + private readonly ConcurrentDictionary clientPool = new(); + private readonly CancellationTokenSource disposedCts = new(); + + public PooledBicepClientFactory(HttpClient? httpClient = null, TimeSpan? inactivityInterval = null) + { + if (inactivityInterval is { } interval && interval < DefaultClientInactivityInterval) + { + throw new ArgumentOutOfRangeException(nameof(inactivityInterval), $"The {nameof(inactivityInterval)} must be at least {DefaultClientInactivityInterval.TotalSeconds:0} seconds."); + } + + clientInactivityInterval = inactivityInterval ?? DefaultClientInactivityInterval; + innerFactory = new(httpClient); + _ = Task.Run(TimerLoop); + } + + /// + public Task Initialize(BicepClientConfiguration configuration, CancellationToken cancellationToken) + { + BicepClientConfiguration.Validate(configuration); + + ClientPoolEntry poolEntry; + lock (lockObj) + { + if (disposedCts.IsCancellationRequested) + { + throw new ObjectDisposedException(nameof(PooledBicepClientFactory)); + } + + poolEntry = clientPool.GetOrAdd(configuration, config => new ClientPoolEntry(innerFactory, config)); + } + + return Task.FromResult(poolEntry.GetPooledClient()); + } + + [Obsolete($"Use {nameof(Initialize)} with a {nameof(BicepClientConfiguration)} that has {nameof(BicepClientConfiguration.ExistingCliPath)} set instead.")] + public Task InitializeFromPath(string bicepCliPath, CancellationToken cancellationToken) + => Initialize(new() { ExistingCliPath = bicepCliPath }, cancellationToken); + + [Obsolete($"Use {nameof(Initialize)} instead.")] + public Task DownloadAndInitialize(BicepClientConfiguration configuration, CancellationToken cancellationToken) + => Initialize(configuration, cancellationToken); + + private async Task TimerLoop() + { + while (!disposedCts.Token.IsCancellationRequested) + { + try + { + await Task.Delay(TimerPollInterval, disposedCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (disposedCts.Token.IsCancellationRequested) + { + break; + } + + foreach (var poolEntry in clientPool.Values) + { + poolEntry.CloseUnderlyingClientIfIdle(clientInactivityInterval); + } + } + } + + public void Dispose() + { + ClientPoolEntry[] poolEntries; + lock (lockObj) + { + disposedCts.Cancel(); + poolEntries = [.. clientPool.Values]; + clientPool.Clear(); + } + + foreach (var poolEntry in poolEntries) + { + poolEntry.Dispose(); + } + } + + private class ClientPoolEntry(BicepClientFactory innerFactory, BicepClientConfiguration configuration) : IDisposable + { + private readonly SemaphoreSlim acquireClientSemaphore = new(1, 1); + private readonly object lockObj = new(); + private IBicepClient? activeClient; + private DateTime lastUsed = DateTime.MinValue; + private readonly CancellationTokenSource disposedCts = new(); + private int activeRequests; + + public void Dispose() + { + disposedCts.Cancel(); + CloseUnderlyingClient(() => true); + } + + public PooledBicepClient GetPooledClient() => new(this); + + public void CloseUnderlyingClient(Func shouldClose) + { + IBicepClient? clientToDispose = null; + lock (lockObj) + { + if (!shouldClose()) + { + return; + } + + lastUsed = DateTime.MinValue; + clientToDispose = activeClient; + activeClient = null; + } + + clientToDispose?.Dispose(); + } + + public void CloseUnderlyingClientIfIdle(TimeSpan inactivityInterval) + => CloseUnderlyingClient(() => + activeClient is {} && + activeRequests == 0 && + lastUsed + inactivityInterval < DateTime.UtcNow); + + private IBicepClient? TryGetActiveClient() + { + lock (lockObj) + { + return activeClient; + } + } + + private IBicepClient UseCreatedOrExistingClient(IBicepClient createdClient) + { + lock (lockObj) + { + if (disposedCts.IsCancellationRequested) + { + createdClient.Dispose(); + disposedCts.Token.ThrowIfCancellationRequested(); + } + + if (activeClient is null) + { + activeClient = createdClient; + return activeClient; + } + + return activeClient; + } + } + + private async Task GetOrCreateUnderlyingClient(CancellationToken cancellationToken) + { + if (TryGetActiveClient() is { } existingClient) + { + return existingClient; + } + + using var acquireCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, disposedCts.Token); + await acquireClientSemaphore.WaitAsync(acquireCts.Token).ConfigureAwait(false); + try + { + if (TryGetActiveClient() is { } existingClientAfterWait) + { + return existingClientAfterWait; + } + + acquireCts.Token.ThrowIfCancellationRequested(); + + var createdClient = await innerFactory.Initialize(configuration, acquireCts.Token).ConfigureAwait(false); + var clientToUse = UseCreatedOrExistingClient(createdClient); + + if (!ReferenceEquals(clientToUse, createdClient)) + { + createdClient.Dispose(); + } + + return clientToUse; + } + finally + { + acquireClientSemaphore.Release(); + } + } + + public async Task MakeRequest(Func> func, CancellationToken cancellationToken) + { + using var requestCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, disposedCts.Token); + + try + { + lock (lockObj) + { + activeRequests++; + lastUsed = DateTime.UtcNow; + } + + var client = await GetOrCreateUnderlyingClient(requestCts.Token).ConfigureAwait(false); + + return await func(client, requestCts.Token).ConfigureAwait(false); + } + finally + { + lock (lockObj) + { + activeRequests--; + lastUsed = DateTime.UtcNow; + } + } + } + } + + private class PooledBicepClient(ClientPoolEntry poolEntry) : IBicepClient + { + private readonly CancellationTokenSource disposedCts = new(); + + public Task Compile(CompileRequest request, CancellationToken cancellationToken = default) + => MakeRequest((client, ct) => client.Compile(request, ct), cancellationToken); + + public Task CompileParams(CompileParamsRequest request, CancellationToken cancellationToken = default) + => MakeRequest((client, ct) => client.CompileParams(request, ct), cancellationToken); + + public Task Format(FormatRequest request, CancellationToken cancellationToken = default) + => MakeRequest((client, ct) => client.Format(request, ct), cancellationToken); + + public Task GetDeploymentGraph(GetDeploymentGraphRequest request, CancellationToken cancellationToken = default) + => MakeRequest((client, ct) => client.GetDeploymentGraph(request, ct), cancellationToken); + + public Task GetFileReferences(GetFileReferencesRequest request, CancellationToken cancellationToken = default) + => MakeRequest((client, ct) => client.GetFileReferences(request, ct), cancellationToken); + + public Task GetMetadata(GetMetadataRequest request, CancellationToken cancellationToken = default) + => MakeRequest((client, ct) => client.GetMetadata(request, ct), cancellationToken); + + public Task GetSnapshot(GetSnapshotRequest request, CancellationToken cancellationToken = default) + => MakeRequest((client, ct) => client.GetSnapshot(request, ct), cancellationToken); + + public Task GetVersion(CancellationToken cancellationToken = default) + => MakeRequest((client, ct) => client.GetVersion(ct), cancellationToken); + + public async Task MakeRequest(Func> func, CancellationToken cancellationToken) + { + // simulate disposal without actually releasing underlying resource, since the pool manages this lifecycle + try + { + disposedCts.Token.ThrowIfCancellationRequested(); + + using var requestCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, disposedCts.Token); + + return await poolEntry.MakeRequest(func, requestCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (disposedCts.IsCancellationRequested) + { + throw new ObjectDisposedException(nameof(PooledBicepClient)); + } + } + + public void Dispose() + { + disposedCts.Cancel(); + } + } +} \ No newline at end of file From 566b438138f4400a404810be3317fca951d20cdc Mon Sep 17 00:00:00 2001 From: Anthony Martin <38542602+anthony-c-martin@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:28:43 -0400 Subject: [PATCH 2/2] Expand test coverage --- src/Bicep.RpcClient.Tests/BicepClientTests.cs | 127 +++++++ .../BicepClientUnitTests.cs | 167 +++++++++ .../JsonRpcClientTests.cs | 328 ++++++++++++++++++ .../PooledBicepClientFactoryTests.cs | 297 ++++++++++++++++ src/Bicep.RpcClient/BicepClient.cs | 22 +- src/Bicep.RpcClient/BicepClientFactory.cs | 3 + src/Bicep.RpcClient/JsonRpc/IJsonRpcClient.cs | 15 + src/Bicep.RpcClient/JsonRpc/JsonRpcClient.cs | 2 +- .../PooledBicepClientFactory.cs | 37 +- 9 files changed, 983 insertions(+), 15 deletions(-) create mode 100644 src/Bicep.RpcClient.Tests/BicepClientUnitTests.cs create mode 100644 src/Bicep.RpcClient.Tests/JsonRpcClientTests.cs create mode 100644 src/Bicep.RpcClient/JsonRpc/IJsonRpcClient.cs diff --git a/src/Bicep.RpcClient.Tests/BicepClientTests.cs b/src/Bicep.RpcClient.Tests/BicepClientTests.cs index 433febf64e0..69090fb8d56 100644 --- a/src/Bicep.RpcClient.Tests/BicepClientTests.cs +++ b/src/Bicep.RpcClient.Tests/BicepClientTests.cs @@ -93,6 +93,87 @@ public async Task Download_fetches_and_installs_bicep_cli(string name, Architect installedContent.Should().BeEquivalentTo(randomBytes); } + [TestMethod] + public async Task Download_uses_specified_BicepVersion_without_querying_latest() + { + var outputDir = FileHelper.GetUniqueTestOutputPath(TestContext); + + MockHttpMessageHandler mockHandler = new(); + // Any request other than the pinned-version artifact (e.g. releases/latest) should fail the test. + mockHandler.Fallback.Throw(new InvalidOperationException("Unexpected request - the latest version should not be queried when BicepVersion is set.")); + + var randomBytes = Guid.NewGuid().ToByteArray(); + mockHandler.When(HttpMethod.Get, "https://downloads.bicep.azure.com/v9.8.7/bicep-linux-x64") + .Respond(_ => new(System.Net.HttpStatusCode.OK) { Content = new ByteArrayContent(randomBytes) }); + + var clientFactory = new BicepClientFactory(new(mockHandler)); + + var bicepCliPath = await clientFactory.Download(new() + { + InstallBasePath = outputDir, + OsPlatform = OSPlatform.Linux, + Architecture = Architecture.X64, + BicepVersion = "9.8.7", + }, TestContext.CancellationTokenSource.Token); + + bicepCliPath.Should().Be(Path.Combine(outputDir, "v9.8.7", "bicep")); + (await File.ReadAllBytesAsync(bicepCliPath)).Should().BeEquivalentTo(randomBytes); + } + + [TestMethod] + public async Task Download_skips_download_when_cli_is_already_installed() + { + var outputDir = FileHelper.GetUniqueTestOutputPath(TestContext); + var existingPath = Path.Combine(outputDir, "v9.8.7", "bicep"); + Directory.CreateDirectory(Path.GetDirectoryName(existingPath)!); + await File.WriteAllTextAsync(existingPath, "already-installed"); + + MockHttpMessageHandler mockHandler = new(); + // No download should occur, so any HTTP request fails the test. + mockHandler.Fallback.Throw(new InvalidOperationException("Unexpected request - the CLI is already installed.")); + + var clientFactory = new BicepClientFactory(new(mockHandler)); + + var bicepCliPath = await clientFactory.Download(new() + { + InstallBasePath = outputDir, + OsPlatform = OSPlatform.Linux, + Architecture = Architecture.X64, + BicepVersion = "9.8.7", + }, TestContext.CancellationTokenSource.Token); + + bicepCliPath.Should().Be(existingPath); + (await File.ReadAllTextAsync(bicepCliPath)).Should().Be("already-installed"); + } + + [TestMethod] + public async Task Download_falls_back_to_obsolete_InstallPath_when_InstallBasePath_is_not_set() + { + var outputDir = FileHelper.GetUniqueTestOutputPath(TestContext); + + MockHttpMessageHandler mockHandler = new(); + var randomBytes = Guid.NewGuid().ToByteArray(); + mockHandler.When(HttpMethod.Get, "https://downloads.bicep.azure.com/v9.8.7/bicep-linux-x64") + .Respond(_ => new(System.Net.HttpStatusCode.OK) { Content = new ByteArrayContent(randomBytes) }); + + var clientFactory = new BicepClientFactory(new(mockHandler)); + +#pragma warning disable CS0618 // Type or member is obsolete + var configuration = new BicepClientConfiguration + { + InstallPath = outputDir, + OsPlatform = OSPlatform.Linux, + Architecture = Architecture.X64, + BicepVersion = "9.8.7", + }; +#pragma warning restore CS0618 // Type or member is obsolete + + var bicepCliPath = await clientFactory.Download(configuration, TestContext.CancellationTokenSource.Token); + + bicepCliPath.Should().Be(Path.Combine(outputDir, "v9.8.7", "bicep")); + (await File.ReadAllBytesAsync(bicepCliPath)).Should().BeEquivalentTo(randomBytes); + } + [TestMethod] public async Task Initialize_validates_version_number_format() { @@ -345,4 +426,50 @@ public async Task GetMetadataResponse_runs_successfully() result.Exports[0].Description.Should().Be("A foo object"); } + + [TestMethod] + public async Task GetDeploymentGraph_runs_successfully() + { + var bicepFile = FileHelper.SaveResultFile(TestContext, "main.bicep", """ + resource storageAccount 'Microsoft.Storage/storageAccounts@2021-02-01' = { + name: 'myStgAct' + location: 'westus' + kind: 'StorageV2' + sku: { + name: 'Standard_LRS' + } + } + + resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2021-02-01' = { + parent: storageAccount + name: 'default' + } + """); + + var result = await Bicep.GetDeploymentGraph(new(bicepFile)); + + result.Nodes.Should().Contain(node => node.Name == "storageAccount"); + result.Nodes.Should().Contain(node => node.Name == "blobService"); + result.Edges.Should().Contain(edge => edge.Source == "blobService" && edge.Target == "storageAccount"); + } + + [TestMethod] + public async Task GetFileReferences_runs_successfully() + { + var outputPath = FileHelper.SaveResultFiles(TestContext, [ + new("main.bicep", """ + module mod 'mod.bicep' = { + name: 'mod' + } + """), + new("mod.bicep", """ + param unused string + """), + ]); + + var result = await Bicep.GetFileReferences(new(Path.Combine(outputPath, "main.bicep"))); + + result.FilePaths.Should().Contain(path => path.EndsWith("main.bicep")); + result.FilePaths.Should().Contain(path => path.EndsWith("mod.bicep")); + } } diff --git a/src/Bicep.RpcClient.Tests/BicepClientUnitTests.cs b/src/Bicep.RpcClient.Tests/BicepClientUnitTests.cs new file mode 100644 index 00000000000..46b2618ff44 --- /dev/null +++ b/src/Bicep.RpcClient.Tests/BicepClientUnitTests.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using Bicep.RpcClient.JsonRpc; +using Bicep.RpcClient.Models; +using FluentAssertions; + +namespace Bicep.RpcClient.Tests; + +[TestClass] +public class BicepClientUnitTests +{ + public TestContext TestContext { get; set; } = null!; + + private CancellationToken Token => TestContext.CancellationTokenSource.Token; + + [TestMethod] + public async Task GetVersion_caches_result_and_does_not_re_issue_request() + { + var rpc = new FakeJsonRpcClient(); + rpc.SetResponse("bicep/version", new VersionResponse("1.2.3")); + using var client = new BicepClient(rpc); + + (await client.GetVersion(Token)).Should().Be("1.2.3"); + (await client.GetVersion(Token)).Should().Be("1.2.3"); + + rpc.CallCount("bicep/version").Should().Be(1); + } + + [TestMethod] + public async Task Format_throws_when_cli_version_is_below_minimum() + { + var rpc = new FakeJsonRpcClient(); + rpc.SetResponse("bicep/version", new VersionResponse("0.37.0")); + using var client = new BicepClient(rpc); + + await FluentActions.Invoking(() => client.Format(new("main.bicep"), Token)) + .Should().ThrowAsync() + .WithMessage("*requires Bicep CLI version '0.37.1' or later*0.37.0*"); + + rpc.CallCount("bicep/format").Should().Be(0); + } + + [TestMethod] + public async Task Format_succeeds_when_cli_version_meets_minimum() + { + var rpc = new FakeJsonRpcClient(); + rpc.SetResponse("bicep/version", new VersionResponse("0.37.1")); + rpc.SetResponse("bicep/format", new FormatResponse("formatted")); + using var client = new BicepClient(rpc); + + var result = await client.Format(new("main.bicep"), Token); + + result.Contents.Should().Be("formatted"); + rpc.CallCount("bicep/format").Should().Be(1); + } + + [TestMethod] + public async Task GetSnapshot_throws_when_cli_version_is_below_minimum() + { + var rpc = new FakeJsonRpcClient(); + rpc.SetResponse("bicep/version", new VersionResponse("0.36.0")); + using var client = new BicepClient(rpc); + + await FluentActions.Invoking(() => client.GetSnapshot( + new("main.bicepparam", new(null, null, null, null, null), null), Token)) + .Should().ThrowAsync() + .WithMessage("*requires Bicep CLI version '0.36.1' or later*0.36.0*"); + + rpc.CallCount("bicep/getSnapshot").Should().Be(0); + } + + [TestMethod] + public async Task GetSnapshot_succeeds_when_cli_version_meets_minimum() + { + var rpc = new FakeJsonRpcClient(); + rpc.SetResponse("bicep/version", new VersionResponse("0.36.1")); + rpc.SetResponse("bicep/getSnapshot", new GetSnapshotResponse("snapshot-contents")); + using var client = new BicepClient(rpc); + + var result = await client.GetSnapshot( + new("main.bicepparam", new(null, null, null, null, null), null), Token); + + result.Snapshot.Should().Be("snapshot-contents"); + rpc.CallCount("bicep/getSnapshot").Should().Be(1); + } + + [TestMethod] + public async Task Compile_forwards_request_to_the_expected_method() + { + var rpc = new FakeJsonRpcClient(); + rpc.SetResponse("bicep/compile", new CompileResponse(true, [], "{}")); + using var client = new BicepClient(rpc); + + var result = await client.Compile(new("main.bicep"), Token); + + result.Success.Should().BeTrue(); + rpc.CallCount("bicep/compile").Should().Be(1); + } + + [TestMethod] + public async Task GetDeploymentGraph_forwards_request_to_the_expected_method() + { + var rpc = new FakeJsonRpcClient(); + rpc.SetResponse("bicep/getDeploymentGraph", new GetDeploymentGraphResponse([], [])); + using var client = new BicepClient(rpc); + + var result = await client.GetDeploymentGraph(new("main.bicep"), Token); + + result.Nodes.Should().BeEmpty(); + rpc.CallCount("bicep/getDeploymentGraph").Should().Be(1); + } + + [TestMethod] + public async Task GetFileReferences_forwards_request_to_the_expected_method() + { + var rpc = new FakeJsonRpcClient(); + rpc.SetResponse("bicep/getFileReferences", new GetFileReferencesResponse(["main.bicep"])); + using var client = new BicepClient(rpc); + + var result = await client.GetFileReferences(new("main.bicep"), Token); + + result.FilePaths.Should().Contain("main.bicep"); + rpc.CallCount("bicep/getFileReferences").Should().Be(1); + } + + [TestMethod] + public void Dispose_disposes_the_underlying_rpc_client() + { + var rpc = new FakeJsonRpcClient(); + var client = new BicepClient(rpc); + + client.Dispose(); + + rpc.IsDisposed.Should().BeTrue(); + } + + private sealed class FakeJsonRpcClient : IJsonRpcClient + { + private readonly ConcurrentDictionary responsesByMethod = new(); + private readonly ConcurrentDictionary callCountsByMethod = new(); + + public bool IsDisposed { get; private set; } + + public void SetResponse(string method, TResponse response) + => responsesByMethod[method] = response!; + + public int CallCount(string method) => callCountsByMethod.TryGetValue(method, out var count) ? count : 0; + + public Task SendRequest(string method, TRequest request, CancellationToken cancellationToken) + { + callCountsByMethod.AddOrUpdate(method, 1, (_, count) => count + 1); + + if (!responsesByMethod.TryGetValue(method, out var response)) + { + throw new InvalidOperationException($"No response configured for method '{method}'."); + } + + return Task.FromResult((TResponse)response); + } + + public Task Listen(Action onComplete, CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() => IsDisposed = true; + } +} diff --git a/src/Bicep.RpcClient.Tests/JsonRpcClientTests.cs b/src/Bicep.RpcClient.Tests/JsonRpcClientTests.cs new file mode 100644 index 00000000000..02cb3121809 --- /dev/null +++ b/src/Bicep.RpcClient.Tests/JsonRpcClientTests.cs @@ -0,0 +1,328 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.IO.Pipelines; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Bicep.RpcClient.JsonRpc; +using FluentAssertions; + +namespace Bicep.RpcClient.Tests; + +[TestClass] +public class JsonRpcClientTests +{ + public TestContext TestContext { get; set; } = null!; + + private record TestParams(string Name); + + private record TestResult(string Value); + + private CancellationToken Token => TestContext.CancellationTokenSource.Token; + + [TestMethod] + public async Task SendRequest_writes_a_well_formed_framed_request() + { + using var harness = new TestHarness(); + harness.StartListening(); + + var requestTask = harness.Client.SendRequest("test/method", new("foo"), Token); + + var body = await harness.ReadRequestBodyAsync(Token); + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + root.GetProperty("jsonrpc").GetString().Should().Be("2.0"); + root.GetProperty("method").GetString().Should().Be("test/method"); + root.GetProperty("params").GetProperty("name").GetString().Should().Be("foo"); + root.GetProperty("id").GetInt32().Should().Be(1); + + await harness.WriteResultAsync(root.GetProperty("id").GetInt32(), """{"value":"bar"}"""); + + (await requestTask).Value.Should().Be("bar"); + } + + [TestMethod] + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Observing the request result; not an issue in test code.")] + public async Task SendRequest_throws_with_error_message_on_error_response() + { + using var harness = new TestHarness(); + harness.StartListening(); + + var requestTask = harness.Client.SendRequest("test/method", new("foo"), Token); + + var id = await harness.ReadRequestIdAsync(Token); + await harness.WriteErrorAsync(id, -32000, "boom"); + + await FluentActions.Invoking(() => requestTask) + .Should().ThrowAsync().WithMessage("boom"); + } + + [TestMethod] + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Observing the request result; not an issue in test code.")] + public async Task SendRequest_throws_when_response_has_neither_result_nor_error() + { + using var harness = new TestHarness(); + harness.StartListening(); + + var requestTask = harness.Client.SendRequest("test/method", new("foo"), Token); + + var id = await harness.ReadRequestIdAsync(Token); + await harness.WriteEmptyResponseAsync(id); + + await FluentActions.Invoking(() => requestTask) + .Should().ThrowAsync(); + } + + [TestMethod] + public async Task SendRequest_correlates_out_of_order_responses_by_id() + { + using var harness = new TestHarness(); + harness.StartListening(); + + var firstTask = harness.Client.SendRequest("test/method", new("first"), Token); + var firstId = await harness.ReadRequestIdAsync(Token); + + var secondTask = harness.Client.SendRequest("test/method", new("second"), Token); + var secondId = await harness.ReadRequestIdAsync(Token); + + // Respond to the second request before the first to verify id-based correlation. + await harness.WriteResultAsync(secondId, """{"value":"second-result"}"""); + await harness.WriteResultAsync(firstId, """{"value":"first-result"}"""); + + (await secondTask).Value.Should().Be("second-result"); + (await firstTask).Value.Should().Be("first-result"); + } + + [TestMethod] + public async Task SendRequest_reassembles_a_response_split_across_multiple_reads() + { + using var harness = new TestHarness(); + harness.StartListening(); + + var requestTask = harness.Client.SendRequest("test/method", new("foo"), Token); + var id = await harness.ReadRequestIdAsync(Token); + + var json = "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"result\":{\"value\":\"chunked\"}}"; + var frame = Encoding.UTF8.GetBytes($"Content-Length: {Encoding.UTF8.GetByteCount(json)}\r\n\r\n{json}"); + + // Deliver the framed response one byte at a time, flushing between each, to exercise the partial-read loop. + foreach (var b in frame) + { + await harness.WriteRawBytesAsync([b]); + } + + (await requestTask).Value.Should().Be("chunked"); + } + + [TestMethod] + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Observing the listen loop result; not an issue in test code.")] + public async Task Listen_invokes_onComplete_and_completes_on_clean_disconnect() + { + using var harness = new TestHarness(); + var onCompleteCalled = false; + var listenTask = harness.Client.Listen(() => onCompleteCalled = true, Token); + + await harness.CompleteResponseAsync(); + + await listenTask; + onCompleteCalled.Should().BeTrue(); + } + + [TestMethod] + public async Task Listen_invokes_onComplete_on_cancellation() + { + using var harness = new TestHarness(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(Token); + var onComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + // The outer Task.Run may itself observe cancellation, so assert via the onComplete callback rather than the returned task. + _ = harness.Client.Listen(() => onComplete.TrySetResult(true), cts.Token); + + // Give the listen loop a moment to start and park inside a pending read before cancelling, + // so cancellation unwinds through the loop body and its finally block. + await Task.Delay(50); + await cts.CancelAsync(); + + (await Task.WhenAny(onComplete.Task, Task.Delay(TimeSpan.FromSeconds(5)))).Should().Be(onComplete.Task); + onComplete.Task.IsCompletedSuccessfully.Should().BeTrue(); + } + + [TestMethod] + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Observing the listen loop result; not an issue in test code.")] + public async Task Listen_faults_when_Content_Length_header_is_missing() + { + using var harness = new TestHarness(); + var listenTask = harness.Client.Listen(() => { }, Token); + + await harness.WriteRawBytesAsync(Encoding.UTF8.GetBytes("Header: value\r\n\r\n")); + + await FluentActions.Invoking(() => listenTask) + .Should().ThrowAsync().WithMessage("*Content-Length*"); + } + + [TestMethod] + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Observing the listen loop result; not an issue in test code.")] + public async Task Listen_faults_when_header_has_no_colon() + { + using var harness = new TestHarness(); + var listenTask = harness.Client.Listen(() => { }, Token); + + await harness.WriteRawBytesAsync(Encoding.UTF8.GetBytes("NoColonHere\r\n\r\n")); + + await FluentActions.Invoking(() => listenTask) + .Should().ThrowAsync().WithMessage("*Colon*"); + } + + [TestMethod] + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Observing the listen loop result; not an issue in test code.")] + public async Task Listen_faults_when_header_line_is_not_crlf_terminated() + { + using var harness = new TestHarness(); + var listenTask = harness.Client.Listen(() => { }, Token); + + await harness.WriteRawBytesAsync(Encoding.UTF8.GetBytes("Content-Length: 5\nbody!")); + + await FluentActions.Invoking(() => listenTask) + .Should().ThrowAsync().WithMessage(@"*\r\n*"); + } + + [TestMethod] + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Observing the listen loop result; not an issue in test code.")] + public async Task Listen_faults_with_EndOfStream_when_headers_are_truncated() + { + using var harness = new TestHarness(); + var listenTask = harness.Client.Listen(() => { }, Token); + + await harness.WriteRawBytesAsync(Encoding.UTF8.GetBytes("Content-Length: 5")); + await harness.CompleteResponseAsync(); + + await FluentActions.Invoking(() => listenTask) + .Should().ThrowAsync(); + } + + [TestMethod] + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Observing the listen loop result; not an issue in test code.")] + public async Task Listen_faults_with_EndOfStream_when_body_is_shorter_than_content_length() + { + using var harness = new TestHarness(); + var listenTask = harness.Client.Listen(() => { }, Token); + + await harness.WriteRawBytesAsync(Encoding.UTF8.GetBytes("Content-Length: 100\r\n\r\n{}")); + await harness.CompleteResponseAsync(); + + await FluentActions.Invoking(() => listenTask) + .Should().ThrowAsync(); + } + + private sealed class TestHarness : IDisposable + { + // requestPipe carries client -> server bytes; responsePipe carries server -> client bytes. + private readonly Pipe requestPipe = new(); + private readonly Pipe responsePipe = new(); + + public TestHarness() + { + Client = new JsonRpcClient(responsePipe.Reader, requestPipe.Writer); + } + + public JsonRpcClient Client { get; } + + private Task? listenTask; + + public void StartListening() => listenTask = Client.Listen(() => { }, CancellationToken.None); + + public async Task ReadRequestBodyAsync(CancellationToken cancellationToken) + { + while (true) + { + var result = await requestPipe.Reader.ReadAsync(cancellationToken); + var buffer = result.Buffer; + if (TryParseFrame(buffer, out var body, out var consumed)) + { + requestPipe.Reader.AdvanceTo(consumed); + return body; + } + + requestPipe.Reader.AdvanceTo(buffer.Start, buffer.End); + if (result.IsCompleted) + { + throw new EndOfStreamException(); + } + } + } + + public async Task ReadRequestIdAsync(CancellationToken cancellationToken) + { + var body = await ReadRequestBodyAsync(cancellationToken); + using var document = JsonDocument.Parse(body); + return document.RootElement.GetProperty("id").GetInt32(); + } + + public Task WriteResultAsync(int id, string resultJson) + => WriteRawResponseAsync("{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"result\":" + resultJson + "}"); + + public Task WriteErrorAsync(int id, int code, string message) + => WriteRawResponseAsync(JsonSerializer.Serialize(new { jsonrpc = "2.0", id, error = new { code, message } })); + + public Task WriteEmptyResponseAsync(int id) + => WriteRawResponseAsync(JsonSerializer.Serialize(new { jsonrpc = "2.0", id })); + + public async Task WriteRawResponseAsync(string json) + { + var frame = $"Content-Length: {Encoding.UTF8.GetByteCount(json)}\r\n\r\n{json}"; + await WriteRawBytesAsync(Encoding.UTF8.GetBytes(frame)); + } + + public async Task WriteRawBytesAsync(byte[] bytes) + { + await responsePipe.Writer.WriteAsync(bytes); + await responsePipe.Writer.FlushAsync(); + } + + public async Task CompleteResponseAsync() + { + await responsePipe.Writer.FlushAsync(); + await responsePipe.Writer.CompleteAsync(); + } + + private static bool TryParseFrame(ReadOnlySequence buffer, out string body, out SequencePosition consumed) + { + body = string.Empty; + consumed = buffer.Start; + + var bytes = buffer.ToArray(); + var text = Encoding.UTF8.GetString(bytes); + var headerEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); + if (headerEnd < 0) + { + return false; + } + + var headerText = text.Substring(0, headerEnd); + var match = Regex.Match(headerText, @"Content-Length:\s*(\d+)", RegexOptions.IgnoreCase); + if (!match.Success) + { + return false; + } + + var contentLength = int.Parse(match.Groups[1].Value); + var bodyStart = headerEnd + 4; // ASCII headers, so char offset == byte offset. + if (bytes.Length - bodyStart < contentLength) + { + return false; + } + + body = Encoding.UTF8.GetString(bytes, bodyStart, contentLength); + consumed = buffer.GetPosition(bodyStart + contentLength); + return true; + } + + public void Dispose() + { + Client.Dispose(); + _ = listenTask; + } + } +} diff --git a/src/Bicep.RpcClient.Tests/PooledBicepClientFactoryTests.cs b/src/Bicep.RpcClient.Tests/PooledBicepClientFactoryTests.cs index c631f902c23..68f5677888f 100644 --- a/src/Bicep.RpcClient.Tests/PooledBicepClientFactoryTests.cs +++ b/src/Bicep.RpcClient.Tests/PooledBicepClientFactoryTests.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using Bicep.Core.UnitTests.Utils; using Bicep.RpcClient.Models; @@ -78,9 +81,303 @@ public async Task Concurrent_requests_succeed_with_multiple_wrappers() } } + [TestMethod] + public void Constructor_throws_for_non_positive_inactivity_interval() + { + FluentActions.Invoking(() => new PooledBicepClientFactory(inactivityInterval: TimeSpan.Zero)) + .Should().Throw(); + + FluentActions.Invoking(() => new PooledBicepClientFactory(inactivityInterval: TimeSpan.FromSeconds(-1))) + .Should().Throw(); + } + + [TestMethod] + public void Constructor_accepts_positive_inactivity_interval() + { + FluentActions.Invoking(() => + { + using var factory = new PooledBicepClientFactory(inactivityInterval: TimeSpan.FromSeconds(1)); + }).Should().NotThrow(); + } + + [TestMethod] + public async Task Equal_configurations_reuse_a_single_underlying_client() + { + var inner = new FakeBicepClientFactory(); + using var factory = CreatePooledFactory(inner); + + // Two distinct-but-equal configuration instances must resolve to the same pool entry (record equality). + var wrapperA = await factory.Initialize(new BicepClientConfiguration(), Token); + var wrapperB = await factory.Initialize(new BicepClientConfiguration(), Token); + + await wrapperA.GetVersion(Token); + await wrapperB.GetVersion(Token); + + inner.InitializeCallCount.Should().Be(1); + inner.CreatedClients.Should().HaveCount(1); + + wrapperA.Dispose(); + wrapperB.Dispose(); + } + + [TestMethod] + public async Task Different_configurations_create_separate_clients() + { + var inner = new FakeBicepClientFactory(); + using var factory = CreatePooledFactory(inner); + + var wrapperA = await factory.Initialize(new BicepClientConfiguration(), Token); + var wrapperB = await factory.Initialize(new BicepClientConfiguration { BicepVersion = "1.2.3" }, Token); + + await wrapperA.GetVersion(Token); + await wrapperB.GetVersion(Token); + + inner.InitializeCallCount.Should().Be(2); + inner.CreatedClients.Should().HaveCount(2); + + wrapperA.Dispose(); + wrapperB.Dispose(); + } + + [TestMethod] + public async Task Idle_client_is_closed_and_recreated_on_next_request() + { + var inner = new FakeBicepClientFactory(); + using var factory = CreatePooledFactory(inner, inactivityInterval: TimeSpan.FromMilliseconds(50), pollInterval: TimeSpan.FromMilliseconds(20)); + var wrapper = await factory.Initialize(new BicepClientConfiguration(), Token); + + await wrapper.GetVersion(Token); + var firstClient = inner.CreatedClients.Single(); + + await WaitUntilAsync(() => firstClient.IsDisposed, TimeSpan.FromSeconds(5)); + firstClient.IsDisposed.Should().BeTrue(); + + // A subsequent request should transparently spin up a fresh underlying client. + await wrapper.GetVersion(Token); + inner.InitializeCallCount.Should().Be(2); + inner.CreatedClients.Should().HaveCount(2); + + wrapper.Dispose(); + } + + [TestMethod] + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Test gating; not an issue in test code.")] + public async Task Active_request_prevents_idle_eviction() + { + var requestStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseRequest = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var inner = new FakeBicepClientFactory(clientFactory: () => new FakeBicepClient(async _ => + { + requestStarted.TrySetResult(true); + await releaseRequest.Task; + })); + using var factory = CreatePooledFactory(inner, inactivityInterval: TimeSpan.FromMilliseconds(50), pollInterval: TimeSpan.FromMilliseconds(20)); + var wrapper = await factory.Initialize(new BicepClientConfiguration(), Token); + + var requestTask = wrapper.GetVersion(Token); + await requestStarted.Task; + + // Wait well past the inactivity interval; the in-flight request must keep the client alive. + await Task.Delay(TimeSpan.FromMilliseconds(250)); + inner.CreatedClients.Single().IsDisposed.Should().BeFalse(); + + releaseRequest.SetResult(true); + (await requestTask).Should().Be(FakeBicepClient.Version); + + wrapper.Dispose(); + } + + [TestMethod] + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Test gating; not an issue in test code.")] + public async Task Concurrent_first_requests_create_a_single_client() + { + var releaseInitialize = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var inner = new FakeBicepClientFactory(onInitialize: async _ => await releaseInitialize.Task); + using var factory = CreatePooledFactory(inner); + var config = new BicepClientConfiguration(); + + var wrappers = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => factory.Initialize(config, Token))); + var requests = wrappers.Select(wrapper => wrapper.GetVersion(Token)).ToArray(); + + // Give the concurrent requests time to converge on the single-client acquisition path. + await Task.Delay(TimeSpan.FromMilliseconds(50)); + releaseInitialize.SetResult(true); + await Task.WhenAll(requests); + + inner.InitializeCallCount.Should().Be(1); + inner.CreatedClients.Should().HaveCount(1); + + foreach (var wrapper in wrappers) + { + wrapper.Dispose(); + } + } + + [TestMethod] + public async Task Request_on_disposed_wrapper_throws_object_disposed() + { + var inner = new FakeBicepClientFactory(); + using var factory = CreatePooledFactory(inner); + var wrapper = await factory.Initialize(new BicepClientConfiguration(), Token); + + wrapper.Dispose(); + + await FluentActions.Invoking(() => wrapper.GetVersion(Token)) + .Should().ThrowAsync(); + } + + [TestMethod] + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Test gating; not an issue in test code.")] + public async Task Disposing_one_wrapper_does_not_affect_in_flight_request_on_another() + { + var releaseRequest = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var requestStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var inner = new FakeBicepClientFactory(clientFactory: () => new FakeBicepClient(async _ => + { + requestStarted.TrySetResult(true); + await releaseRequest.Task; + })); + using var factory = CreatePooledFactory(inner); + var config = new BicepClientConfiguration(); + + var wrapperA = await factory.Initialize(config, Token); + var wrapperB = await factory.Initialize(config, Token); + + var requestTask = wrapperB.GetVersion(Token); + await requestStarted.Task; + + wrapperA.Dispose(); + + releaseRequest.SetResult(true); + (await requestTask).Should().Be(FakeBicepClient.Version); + + wrapperB.Dispose(); + } + + [TestMethod] + public async Task Request_after_factory_disposed_throws_object_disposed() + { + var inner = new FakeBicepClientFactory(); + var factory = CreatePooledFactory(inner); + var wrapper = await factory.Initialize(new BicepClientConfiguration(), Token); + + factory.Dispose(); + + await FluentActions.Invoking(() => wrapper.GetVersion(Token)) + .Should().ThrowAsync(); + } + + [TestMethod] + public async Task Request_with_canceled_token_throws() + { + var inner = new FakeBicepClientFactory(); + using var factory = CreatePooledFactory(inner); + var wrapper = await factory.Initialize(new BicepClientConfiguration(), Token); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await FluentActions.Invoking(() => wrapper.GetVersion(cts.Token)) + .Should().ThrowAsync(); + + wrapper.Dispose(); + } + + private CancellationToken Token => TestContext.CancellationTokenSource.Token; + + private static PooledBicepClientFactory CreatePooledFactory(FakeBicepClientFactory inner, TimeSpan? inactivityInterval = null, TimeSpan? pollInterval = null) + => new(inner, inactivityInterval ?? TimeSpan.FromMinutes(5), pollInterval ?? TimeSpan.FromMilliseconds(20)); + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var stopwatch = Stopwatch.StartNew(); + while (!condition()) + { + if (stopwatch.Elapsed > timeout) + { + throw new TimeoutException("The expected condition was not met within the timeout."); + } + + await Task.Delay(10); + } + } + private static string GetCliPath() { var cliName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "bicep.exe" : "bicep"; return Path.GetFullPath(Path.Combine(typeof(PooledBicepClientFactoryTests).Assembly.Location, $"../{cliName}")); } + + private sealed class FakeBicepClientFactory( + Func? clientFactory = null, + Func? onInitialize = null) : IBicepClientFactory + { + private int initializeCallCount; + + public int InitializeCallCount => Volatile.Read(ref initializeCallCount); + + public ConcurrentQueue CreatedClients { get; } = new(); + + public async Task Initialize(BicepClientConfiguration configuration, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref initializeCallCount); + + if (onInitialize is { }) + { + await onInitialize(cancellationToken); + } + + var client = (clientFactory ?? (() => new FakeBicepClient()))(); + CreatedClients.Enqueue(client); + return client; + } + + public Task InitializeFromPath(string bicepCliPath, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task DownloadAndInitialize(BicepClientConfiguration configuration, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } + + private sealed class FakeBicepClient(Func? onRequest = null) : IBicepClient + { + public const string Version = "1.2.3"; + + private int disposeCount; + + public bool IsDisposed => Volatile.Read(ref disposeCount) > 0; + + public async Task GetVersion(CancellationToken cancellationToken = default) + { + if (onRequest is { }) + { + await onRequest(cancellationToken); + } + + return Version; + } + + public Task Compile(CompileRequest request, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task CompileParams(CompileParamsRequest request, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task Format(FormatRequest request, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task GetDeploymentGraph(GetDeploymentGraphRequest request, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task GetFileReferences(GetFileReferencesRequest request, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task GetMetadata(GetMetadataRequest request, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task GetSnapshot(GetSnapshotRequest request, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void Dispose() => Interlocked.Increment(ref disposeCount); + } } diff --git a/src/Bicep.RpcClient/BicepClient.cs b/src/Bicep.RpcClient/BicepClient.cs index 0bedbe093f2..6b99e8631ee 100644 --- a/src/Bicep.RpcClient/BicepClient.cs +++ b/src/Bicep.RpcClient/BicepClient.cs @@ -16,13 +16,13 @@ namespace Bicep.RpcClient; internal class BicepClient : IBicepClient { - private readonly Process cliProcess; - private readonly JsonRpcClient jsonRpcClient; + private readonly Process? cliProcess; + private readonly IJsonRpcClient jsonRpcClient; private readonly Task backgroundTask; private readonly CancellationTokenSource onDisposeCts; private string? cachedVersion; - private BicepClient(Action onComplete, Process cliProcess, JsonRpcClient jsonRpcClient) + private BicepClient(Action onComplete, Process cliProcess, IJsonRpcClient jsonRpcClient) { this.cliProcess = cliProcess; this.jsonRpcClient = jsonRpcClient; @@ -30,6 +30,17 @@ private BicepClient(Action onComplete, Process cliProcess, JsonRpcClient jsonRpc this.backgroundTask = jsonRpcClient.Listen(onComplete: onComplete, onDisposeCts.Token); } + /// + /// Test-only constructor that injects a JSON-RPC client without starting a CLI process. + /// + internal BicepClient(IJsonRpcClient jsonRpcClient) + { + this.cliProcess = null; + this.jsonRpcClient = jsonRpcClient; + this.onDisposeCts = new CancellationTokenSource(); + this.backgroundTask = jsonRpcClient.Listen(onComplete: () => { }, onDisposeCts.Token); + } + /// /// Initializes the Bicep CLI by starting the process and establishing a JSON-RPC connection using a named pipe transport. /// @@ -173,7 +184,10 @@ public void Dispose() { onDisposeCts.Cancel(); jsonRpcClient.Dispose(); - TryKillProcess(cliProcess); + if (cliProcess is { }) + { + TryKillProcess(cliProcess); + } } private static void TryKillProcess(Process process) diff --git a/src/Bicep.RpcClient/BicepClientFactory.cs b/src/Bicep.RpcClient/BicepClientFactory.cs index fa8aba99cb6..df0ecccc034 100644 --- a/src/Bicep.RpcClient/BicepClientFactory.cs +++ b/src/Bicep.RpcClient/BicepClientFactory.cs @@ -13,6 +13,9 @@ namespace Bicep.RpcClient; +/// +/// A factory that creates and manages Bicep clients, handling the download and installation of the Bicep CLI if necessary. +/// public class BicepClientFactory(HttpClient? httpClient = null) : IBicepClientFactory { private static readonly string DefaultInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".bicep", "bin"); diff --git a/src/Bicep.RpcClient/JsonRpc/IJsonRpcClient.cs b/src/Bicep.RpcClient/JsonRpc/IJsonRpcClient.cs new file mode 100644 index 00000000000..d693cdd131b --- /dev/null +++ b/src/Bicep.RpcClient/JsonRpc/IJsonRpcClient.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Bicep.RpcClient.JsonRpc; + +internal interface IJsonRpcClient : IDisposable +{ + Task SendRequest(string method, TRequest request, CancellationToken cancellationToken); + + Task Listen(Action onComplete, CancellationToken cancellationToken); +} diff --git a/src/Bicep.RpcClient/JsonRpc/JsonRpcClient.cs b/src/Bicep.RpcClient/JsonRpc/JsonRpcClient.cs index 8e08f80550e..d8f029313f5 100644 --- a/src/Bicep.RpcClient/JsonRpc/JsonRpcClient.cs +++ b/src/Bicep.RpcClient/JsonRpc/JsonRpcClient.cs @@ -17,7 +17,7 @@ namespace Bicep.RpcClient.JsonRpc; -internal class JsonRpcClient(PipeReader reader, PipeWriter writer) : IDisposable +internal class JsonRpcClient(PipeReader reader, PipeWriter writer) : IJsonRpcClient { private record JsonRpcRequest( string Jsonrpc, diff --git a/src/Bicep.RpcClient/PooledBicepClientFactory.cs b/src/Bicep.RpcClient/PooledBicepClientFactory.cs index 7c2e040ed19..6988da4d1bd 100644 --- a/src/Bicep.RpcClient/PooledBicepClientFactory.cs +++ b/src/Bicep.RpcClient/PooledBicepClientFactory.cs @@ -10,26 +10,41 @@ namespace Bicep.RpcClient; +/// +/// A factory that manages a pool of Bicep clients, allowing for reuse of clients and automatic disposal of idle clients after a specified inactivity interval. +/// public class PooledBicepClientFactory : IBicepClientFactory, IDisposable { private readonly object lockObj = new(); - private static readonly TimeSpan TimerPollInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan DefaultTimerPollInterval = TimeSpan.FromSeconds(5); private static readonly TimeSpan DefaultClientInactivityInterval = TimeSpan.FromSeconds(30); + private readonly TimeSpan timerPollInterval; private readonly TimeSpan clientInactivityInterval; - private readonly BicepClientFactory innerFactory; + private readonly IBicepClientFactory innerFactory; private readonly ConcurrentDictionary clientPool = new(); private readonly CancellationTokenSource disposedCts = new(); public PooledBicepClientFactory(HttpClient? httpClient = null, TimeSpan? inactivityInterval = null) + : this(new BicepClientFactory(httpClient), ValidateInactivityInterval(inactivityInterval), DefaultTimerPollInterval) { - if (inactivityInterval is { } interval && interval < DefaultClientInactivityInterval) + } + + internal PooledBicepClientFactory(IBicepClientFactory innerFactory, TimeSpan inactivityInterval, TimeSpan timerPollInterval) + { + this.innerFactory = innerFactory; + this.clientInactivityInterval = inactivityInterval; + this.timerPollInterval = timerPollInterval; + _ = Task.Run(TimerLoop); + } + + private static TimeSpan ValidateInactivityInterval(TimeSpan? inactivityInterval) + { + if (inactivityInterval is { } interval && interval <= TimeSpan.Zero) { - throw new ArgumentOutOfRangeException(nameof(inactivityInterval), $"The {nameof(inactivityInterval)} must be at least {DefaultClientInactivityInterval.TotalSeconds:0} seconds."); + throw new ArgumentOutOfRangeException(nameof(inactivityInterval), $"The {nameof(inactivityInterval)} must be greater than zero."); } - clientInactivityInterval = inactivityInterval ?? DefaultClientInactivityInterval; - innerFactory = new(httpClient); - _ = Task.Run(TimerLoop); + return inactivityInterval ?? DefaultClientInactivityInterval; } /// @@ -65,7 +80,7 @@ private async Task TimerLoop() { try { - await Task.Delay(TimerPollInterval, disposedCts.Token).ConfigureAwait(false); + await Task.Delay(timerPollInterval, disposedCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (disposedCts.Token.IsCancellationRequested) { @@ -95,7 +110,7 @@ public void Dispose() } } - private class ClientPoolEntry(BicepClientFactory innerFactory, BicepClientConfiguration configuration) : IDisposable + private class ClientPoolEntry(IBicepClientFactory innerFactory, BicepClientConfiguration configuration) : IDisposable { private readonly SemaphoreSlim acquireClientSemaphore = new(1, 1); private readonly object lockObj = new(); @@ -104,6 +119,8 @@ private class ClientPoolEntry(BicepClientFactory innerFactory, BicepClientConfig private readonly CancellationTokenSource disposedCts = new(); private int activeRequests; + public bool IsDisposed => disposedCts.IsCancellationRequested; + public void Dispose() { disposedCts.Cancel(); @@ -264,7 +281,7 @@ public async Task MakeRequest(Func