From 4a85777123b52e467a1cbd2f19d7fe5dd68702b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 23:33:41 +0000 Subject: [PATCH 01/16] Upgrade to .NET 10, update all dependencies, add xUnit v3 tests, fix bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## .NET and dependencies - Target framework: net9.0 → net10.0 - All NuGet packages updated to latest stable versions (Azure.Core 1.51.1, Azure.Identity 1.19.0, Microsoft.Azure.Relay 3.1.1, Microsoft.Extensions.* 10.0.x, Serilog 4.3.1/10.0.0/6.1.1, IdentityModel 8.16.0, etc.) - GitHub Actions: checkout/setup-dotnet updated to v4, dotnet-version 10.0.x ## Bug fixes - CancellationTokenSource leak: _cts is now a local `using var` inside Run(), so it is always disposed — previously the IDisposable was never called because Create() discarded the instance. - TcpClient leak: ClientProxyConnection.Run() now disposes the TcpClient via `using var _ = tcpClient`. - Orphaned pump: after Task.WhenAny one pump finished but the other was left running indefinitely. Now CancelAsync() is called immediately after WhenAny and both pumps are awaited, ensuring clean shutdown. - Console.WriteLine for errors replaced with Log.Error/Log.Debug (Serilog). - Case-sensitivity bug on Linux: ConfigurationLocator.DefaultsFile was "appsettings.json" but the file on disk was "appSettings.json" (capital S). Renamed to appsettings.json — would have crashed on the Ubuntu CI runner. - Program.cs: unnecessary else after throwing if removed; cts.Cancel() → cts.CancelAsync(); exception type InvalidOperationException instead of Exception. - ConfigurationLocator.OverlayFile changed from static field (computed once at class load) to a computed property, consistent with OverlayFilePath. ## Testability / modernisation - New IHybridConnectionProvider interface and HybridConnectionClientProvider implementation decouple the proxy from HybridConnectionClient, enabling testing without live Azure credentials. - Internal overload ClientProxy.Create(IHybridConnectionProvider, TcpListener) allows tests to supply their own started listener and inspect the actual port. - InternalsVisibleTo attribute added to main csproj for the test project. - Unused using directives removed; file-scoped namespaces; `[]` instead of Array.Empty(); expression-bodied Proxy.ListenAddress; BCL keyword aliases. ## Tests (new project: HybridConnectionClientProxy.Tests, xUnit v3) - ProxySettingsTests: Proxy.ListenAddress parsing (null, empty, invalid, IPv4, IPv6, any). - ConfigurationLocatorTests: DefaultsFile constant, OverlayFileName/Path/File. - ClientProxyIntegrationTests (6 tests): full end-to-end proxy pipeline over loopback TCP using TcpHybridConnectionProvider (no Azure needed): client→backend, backend→client, bidirectional, multiple sequential connections, graceful client disconnect, cancellation/shutdown. https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- .github/workflows/dotnet.yml | 6 +- .../ClientProxyIntegrationTests.cs | 248 ++++++++++++++++++ .../ConfigurationLocatorTests.cs | 45 ++++ .../HybridConnectionClientProxy.Tests.csproj | 28 ++ .../Settings/ProxySettingsTests.cs | 71 +++++ .../TcpHybridConnectionProvider.cs | 28 ++ HybridConnectionClientProxy.csproj | 39 +-- HybridConnectionClientProxy.sln | 8 +- appSettings.json => appsettings.json | 0 src/ClientProxy.cs | 120 ++++----- src/ClientProxyConnection.cs | 110 +++----- src/ConfigurationLocator.cs | 63 ++--- src/HybridConnectionClientProvider.cs | 19 ++ src/IHybridConnectionProvider.cs | 9 + src/Program.cs | 63 ++--- src/Settings/AppSettings.cs | 15 +- src/Settings/Proxy.cs | 35 +-- 17 files changed, 645 insertions(+), 262 deletions(-) create mode 100644 HybridConnectionClientProxy.Tests/ClientProxyIntegrationTests.cs create mode 100644 HybridConnectionClientProxy.Tests/ConfigurationLocatorTests.cs create mode 100644 HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj create mode 100644 HybridConnectionClientProxy.Tests/Settings/ProxySettingsTests.cs create mode 100644 HybridConnectionClientProxy.Tests/TcpHybridConnectionProvider.cs rename appSettings.json => appsettings.json (100%) create mode 100644 src/HybridConnectionClientProvider.cs create mode 100644 src/IHybridConnectionProvider.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index e0c0049..a623634 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -15,11 +15,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore - name: Build diff --git a/HybridConnectionClientProxy.Tests/ClientProxyIntegrationTests.cs b/HybridConnectionClientProxy.Tests/ClientProxyIntegrationTests.cs new file mode 100644 index 0000000..45f971d --- /dev/null +++ b/HybridConnectionClientProxy.Tests/ClientProxyIntegrationTests.cs @@ -0,0 +1,248 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace HybridConnectionClientProxy.Tests; + +/// +/// End-to-end integration tests for the proxy pipeline using loopback TCP connections. +/// +/// Topology: +/// [Test client] --TCP--> [ClientProxy listener] --[TcpHybridConnectionProvider]--> [Backend server] +/// +/// No Azure credentials are required; the TcpHybridConnectionProvider forwards each connection +/// to a local TCP server that plays the role of the on-premises backend. +/// +public class ClientProxyIntegrationTests +{ + private const int TestTimeoutMs = 10_000; + + /// + /// Binds to port 0, starts the listener, and returns it together with the OS-assigned port. + /// + private static (TcpListener listener, int port) StartListener() + { + var listener = new TcpListener( IPAddress.Loopback, 0 ); + listener.Start(); + var port = ( (IPEndPoint) listener.LocalEndpoint ).Port; + return (listener, port); + } + + [Fact] + public async Task Proxy_ClientToBackend_DataForwardedCorrectly() + { + using var cts = new CancellationTokenSource( TestTimeoutMs ); + + // Backend — simulates on-premises server + var (backendListener, backendPort) = StartListener(); + + // Proxy — listens for client connections, forwards via mock provider to backend + var (proxyListener, proxyPort) = StartListener(); + var mockProvider = new TcpHybridConnectionProvider( backendPort ); + var proxyTask = ClientProxy.Create( mockProvider, proxyListener, cts.Token ); + + // Connect client to proxy + using var clientTcp = new TcpClient(); + await clientTcp.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + var clientStream = clientTcp.GetStream(); + + // Accept the corresponding backend connection + using var backendTcp = await backendListener.AcceptTcpClientAsync( cts.Token ); + var backendStream = backendTcp.GetStream(); + + // Send data client → proxy → backend + var sent = "hello from client"u8.ToArray(); + await clientStream.WriteAsync( sent, cts.Token ); + await clientStream.FlushAsync( cts.Token ); + + var received = new byte[sent.Length]; + await ReadExactAsync( backendStream, received, cts.Token ); + + Assert.Equal( sent, received ); + + await cts.CancelAsync(); + backendListener.Stop(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task Proxy_BackendToClient_DataForwardedCorrectly() + { + using var cts = new CancellationTokenSource( TestTimeoutMs ); + + var (backendListener, backendPort) = StartListener(); + var (proxyListener, proxyPort) = StartListener(); + + var mockProvider = new TcpHybridConnectionProvider( backendPort ); + var proxyTask = ClientProxy.Create( mockProvider, proxyListener, cts.Token ); + + using var clientTcp = new TcpClient(); + await clientTcp.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + var clientStream = clientTcp.GetStream(); + + using var backendTcp = await backendListener.AcceptTcpClientAsync( cts.Token ); + var backendStream = backendTcp.GetStream(); + + // Send data backend → proxy → client + var sent = "hello from backend"u8.ToArray(); + await backendStream.WriteAsync( sent, cts.Token ); + await backendStream.FlushAsync( cts.Token ); + + var received = new byte[sent.Length]; + await ReadExactAsync( clientStream, received, cts.Token ); + + Assert.Equal( sent, received ); + + await cts.CancelAsync(); + backendListener.Stop(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task Proxy_Bidirectional_BothDirectionsWork() + { + using var cts = new CancellationTokenSource( TestTimeoutMs ); + + var (backendListener, backendPort) = StartListener(); + var (proxyListener, proxyPort) = StartListener(); + + var mockProvider = new TcpHybridConnectionProvider( backendPort ); + var proxyTask = ClientProxy.Create( mockProvider, proxyListener, cts.Token ); + + using var clientTcp = new TcpClient(); + await clientTcp.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + var clientStream = clientTcp.GetStream(); + + using var backendTcp = await backendListener.AcceptTcpClientAsync( cts.Token ); + var backendStream = backendTcp.GetStream(); + + // Client → Backend + var request = "REQUEST"u8.ToArray(); + await clientStream.WriteAsync( request, cts.Token ); + await clientStream.FlushAsync( cts.Token ); + + var requestReceived = new byte[request.Length]; + await ReadExactAsync( backendStream, requestReceived, cts.Token ); + Assert.Equal( request, requestReceived ); + + // Backend → Client + var response = "RESPONSE"u8.ToArray(); + await backendStream.WriteAsync( response, cts.Token ); + await backendStream.FlushAsync( cts.Token ); + + var responseReceived = new byte[response.Length]; + await ReadExactAsync( clientStream, responseReceived, cts.Token ); + Assert.Equal( response, responseReceived ); + + await cts.CancelAsync(); + backendListener.Stop(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task Proxy_MultipleSequentialConnections_AllForwardedCorrectly() + { + using var cts = new CancellationTokenSource( TestTimeoutMs ); + + var (backendListener, backendPort) = StartListener(); + var (proxyListener, proxyPort) = StartListener(); + + var mockProvider = new TcpHybridConnectionProvider( backendPort ); + var proxyTask = ClientProxy.Create( mockProvider, proxyListener, cts.Token ); + + for( int i = 0; i < 3; i++ ) + { + using var clientTcp = new TcpClient(); + await clientTcp.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + var clientStream = clientTcp.GetStream(); + + using var backendTcp = await backendListener.AcceptTcpClientAsync( cts.Token ); + var backendStream = backendTcp.GetStream(); + + var message = Encoding.UTF8.GetBytes( $"message-{i}" ); + await clientStream.WriteAsync( message, cts.Token ); + await clientStream.FlushAsync( cts.Token ); + + var buffer = new byte[message.Length]; + await ReadExactAsync( backendStream, buffer, cts.Token ); + Assert.Equal( message, buffer ); + } + + await cts.CancelAsync(); + backendListener.Stop(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task Proxy_WhenClientDisconnects_ConnectionCleanedUpGracefully() + { + using var cts = new CancellationTokenSource( TestTimeoutMs ); + + var (backendListener, backendPort) = StartListener(); + var (proxyListener, proxyPort) = StartListener(); + + var mockProvider = new TcpHybridConnectionProvider( backendPort ); + var proxyTask = ClientProxy.Create( mockProvider, proxyListener, cts.Token ); + + var clientTcp = new TcpClient(); + await clientTcp.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + + using var backendTcp = await backendListener.AcceptTcpClientAsync( cts.Token ); + var backendStream = backendTcp.GetStream(); + + // Close client — backend should observe EOF without throwing + clientTcp.Close(); + + // Backend should receive EOF (ReadAsync returns 0) without hanging + var readTask = backendStream.ReadAsync( new byte[1], cts.Token ).AsTask(); + var completed = await Task.WhenAny( readTask, Task.Delay( 3_000, cts.Token ) ); + Assert.Equal( readTask, completed ); + Assert.Equal( 0, await readTask ); + + await cts.CancelAsync(); + backendListener.Stop(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task Proxy_WhenCancelled_StopsAcceptingNewConnections() + { + using var cts = new CancellationTokenSource( TestTimeoutMs ); + + var (proxyListener, proxyPort) = StartListener(); + + // Backend is never used here — we just want to confirm the proxy stops + var (backendListener, backendPort) = StartListener(); + var mockProvider = new TcpHybridConnectionProvider( backendPort ); + var proxyTask = ClientProxy.Create( mockProvider, proxyListener, cts.Token ); + + // Cancel immediately + await cts.CancelAsync(); + proxyListener.Stop(); + backendListener.Stop(); + + // Proxy task should complete cleanly (not throw) + await proxyTask; + } + + /// + /// Reads exactly .Length bytes, retrying partial reads as needed. + /// This mirrors real network behaviour where data may arrive in multiple TCP segments. + /// + private static async Task ReadExactAsync( Stream stream, byte[] buffer, CancellationToken ct ) + { + int totalRead = 0; + while( totalRead < buffer.Length ) + { + int n = await stream.ReadAsync( buffer.AsMemory( totalRead ), ct ); + if( n == 0 ) + throw new EndOfStreamException( $"Stream ended after {totalRead}/{buffer.Length} bytes." ); + totalRead += n; + } + } +} diff --git a/HybridConnectionClientProxy.Tests/ConfigurationLocatorTests.cs b/HybridConnectionClientProxy.Tests/ConfigurationLocatorTests.cs new file mode 100644 index 0000000..09255c0 --- /dev/null +++ b/HybridConnectionClientProxy.Tests/ConfigurationLocatorTests.cs @@ -0,0 +1,45 @@ +namespace HybridConnectionClientProxy.Tests; + +public class ConfigurationLocatorTests +{ + [Fact] + public void DefaultsFile_IsLowercaseAppsettingsJson() + { + // Verifies the case-safe lowercase filename convention (capital S caused failures on Linux) + Assert.Equal( "appsettings.json", ConfigurationLocator.DefaultsFile ); + } + + [Fact] + public void OverlayFileName_ContainsMachineName() + { + var fileName = ConfigurationLocator.OverlayFileName; + Assert.Contains( Environment.MachineName, fileName ); + } + + [Fact] + public void OverlayFileName_HasJsonExtension() + { + Assert.EndsWith( ".json", ConfigurationLocator.OverlayFileName ); + } + + [Fact] + public void OverlayFileName_StartsWithAppsettingsDot() + { + Assert.StartsWith( "appsettings.", ConfigurationLocator.OverlayFileName ); + } + + [Fact] + public void OverlayFilePath_EndsWithConfig() + { + var path = ConfigurationLocator.OverlayFilePath; + var leaf = Path.GetFileName( path ); + Assert.Equal( "config", leaf ); + } + + [Fact] + public void OverlayFile_CombinesOverlayFilePathAndOverlayFileName() + { + var expected = Path.Combine( ConfigurationLocator.OverlayFilePath, ConfigurationLocator.OverlayFileName ); + Assert.Equal( expected, ConfigurationLocator.OverlayFile ); + } +} diff --git a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj new file mode 100644 index 0000000..f9d9748 --- /dev/null +++ b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/HybridConnectionClientProxy.Tests/Settings/ProxySettingsTests.cs b/HybridConnectionClientProxy.Tests/Settings/ProxySettingsTests.cs new file mode 100644 index 0000000..1cd9920 --- /dev/null +++ b/HybridConnectionClientProxy.Tests/Settings/ProxySettingsTests.cs @@ -0,0 +1,71 @@ +using System.Net; + +using HybridConnectionClientProxy.Settings; + +namespace HybridConnectionClientProxy.Tests.Settings; + +public class ProxySettingsTests +{ + [Fact] + public void ListenAddress_WhenListenIPAddressIsNull_ReturnsLoopback() + { + var proxy = new Proxy { ListenIPAddress = null }; + Assert.Equal( IPAddress.Loopback, proxy.ListenAddress ); + } + + [Fact] + public void ListenAddress_WhenListenIPAddressIsEmpty_ReturnsLoopback() + { + var proxy = new Proxy { ListenIPAddress = string.Empty }; + Assert.Equal( IPAddress.Loopback, proxy.ListenAddress ); + } + + [Fact] + public void ListenAddress_WhenListenIPAddressIsInvalid_ReturnsLoopback() + { + var proxy = new Proxy { ListenIPAddress = "not-an-ip" }; + Assert.Equal( IPAddress.Loopback, proxy.ListenAddress ); + } + + [Fact] + public void ListenAddress_WhenListenIPAddressIsLoopback_ReturnsLoopback() + { + var proxy = new Proxy { ListenIPAddress = "127.0.0.1" }; + Assert.Equal( IPAddress.Loopback, proxy.ListenAddress ); + } + + [Fact] + public void ListenAddress_WhenListenIPAddressIsAny_ReturnsAny() + { + var proxy = new Proxy { ListenIPAddress = "0.0.0.0" }; + Assert.Equal( IPAddress.Any, proxy.ListenAddress ); + } + + [Fact] + public void ListenAddress_WhenListenIPAddressIsIPv6Loopback_ReturnsIPv6Loopback() + { + var proxy = new Proxy { ListenIPAddress = "::1" }; + Assert.Equal( IPAddress.IPv6Loopback, proxy.ListenAddress ); + } + + [Fact] + public void ListenAddress_WhenListenIPAddressIsArbitraryIPv4_ReturnsCorrectAddress() + { + var proxy = new Proxy { ListenIPAddress = "192.168.1.100" }; + Assert.Equal( IPAddress.Parse( "192.168.1.100" ), proxy.ListenAddress ); + } + + [Fact] + public void Proxy_DefaultListenPort_IsZero() + { + var proxy = new Proxy(); + Assert.Equal( 0, proxy.ListenPort ); + } + + [Fact] + public void Proxy_DefaultProxies_IsEmptyArray() + { + var settings = new AppSettings(); + Assert.Empty( settings.Proxies ); + } +} diff --git a/HybridConnectionClientProxy.Tests/TcpHybridConnectionProvider.cs b/HybridConnectionClientProxy.Tests/TcpHybridConnectionProvider.cs new file mode 100644 index 0000000..1c8fd60 --- /dev/null +++ b/HybridConnectionClientProxy.Tests/TcpHybridConnectionProvider.cs @@ -0,0 +1,28 @@ +using System.Net; +using System.Net.Sockets; + +using HybridConnectionClientProxy; + +namespace HybridConnectionClientProxy.Tests; + +/// +/// Test double for that connects to a local TCP server +/// instead of Azure Service Bus. This lets integration tests verify the full proxy pipeline +/// without requiring live Azure credentials. +/// +internal sealed class TcpHybridConnectionProvider : IHybridConnectionProvider +{ + private readonly int _targetPort; + + public TcpHybridConnectionProvider( int targetPort ) + { + _targetPort = targetPort; + } + + public async Task CreateConnectionAsync( CancellationToken cancellationToken = default ) + { + var client = new TcpClient(); + await client.ConnectAsync( IPAddress.Loopback, _targetPort, cancellationToken ); + return client.GetStream(); + } +} diff --git a/HybridConnectionClientProxy.csproj b/HybridConnectionClientProxy.csproj index 10e10e1..91dc6df 100644 --- a/HybridConnectionClientProxy.csproj +++ b/HybridConnectionClientProxy.csproj @@ -1,37 +1,44 @@ - + Exe - net9.0 + net10.0 enable enable eb380b7a-0bcd-4399-84ef-0d994ab63de3 + + + + <_Parameter1>HybridConnectionClientProxy.Tests + + + - - - - - - - - - - - - + + + + + + + + + + + + - + - + PreserveNewest diff --git a/HybridConnectionClientProxy.sln b/HybridConnectionClientProxy.sln index 0212d68..bf8167b 100644 --- a/HybridConnectionClientProxy.sln +++ b/HybridConnectionClientProxy.sln @@ -1,10 +1,12 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.8.34330.188 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HybridConnectionClientProxy", "HybridConnectionClientProxy.csproj", "{5129B416-0657-4D5E-AEEB-186E536E6DCC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HybridConnectionClientProxy.Tests", "HybridConnectionClientProxy.Tests\HybridConnectionClientProxy.Tests.csproj", "{A2D4F6B8-C0E2-4A6C-8E0F-2B4D6C8A0E2C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {5129B416-0657-4D5E-AEEB-186E536E6DCC}.Debug|Any CPU.Build.0 = Debug|Any CPU {5129B416-0657-4D5E-AEEB-186E536E6DCC}.Release|Any CPU.ActiveCfg = Release|Any CPU {5129B416-0657-4D5E-AEEB-186E536E6DCC}.Release|Any CPU.Build.0 = Release|Any CPU + {A2D4F6B8-C0E2-4A6C-8E0F-2B4D6C8A0E2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2D4F6B8-C0E2-4A6C-8E0F-2B4D6C8A0E2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2D4F6B8-C0E2-4A6C-8E0F-2B4D6C8A0E2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2D4F6B8-C0E2-4A6C-8E0F-2B4D6C8A0E2C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/appSettings.json b/appsettings.json similarity index 100% rename from appSettings.json rename to appsettings.json diff --git a/src/ClientProxy.cs b/src/ClientProxy.cs index 0382390..a4cea44 100644 --- a/src/ClientProxy.cs +++ b/src/ClientProxy.cs @@ -1,83 +1,75 @@ -using System.Net; +using System.Net; using System.Net.Sockets; -using Microsoft.Azure.Relay; +using Serilog; -namespace HybridConnectionClientProxy -{ - public class ClientProxy - : IDisposable - { - private bool disposedValue; +namespace HybridConnectionClientProxy; - protected CancellationTokenSource? _cts; +public class ClientProxy +{ + private ClientProxy() { } - protected ClientProxy() - { - } + private async Task Run( + IHybridConnectionProvider hycoProvider, + TcpListener tcpListener, + bool ownsListener, + CancellationToken cancellation ) + { + // CancellationTokenSource is created and disposed within Run, so no leak + using var cts = CancellationTokenSource.CreateLinkedTokenSource( cancellation ); - protected async Task Run( String hycoConnectionString, IPAddress listenAddress, int listenPort, CancellationToken cancellation = default ) - { - _cts = CancellationTokenSource.CreateLinkedTokenSource( cancellation ); - using var tcpListener = new TcpListener( listenAddress, listenPort ); - var hycoClient = new HybridConnectionClient( hycoConnectionString ); + if( ownsListener ) tcpListener.Start(); - try - { - while( !_cts.IsCancellationRequested ) - { - var tcpClient = await tcpListener.AcceptTcpClientAsync( _cts.Token ); - var _ = ClientProxyConnection.Create( tcpClient, hycoClient, _cts.Token ); - } - } - catch( OperationCanceledException ) - { - // quiet - } - catch( Exception ex ) + try + { + while( !cts.IsCancellationRequested ) { - Console.WriteLine( ex.ToString() ); + var tcpClient = await tcpListener.AcceptTcpClientAsync( cts.Token ); + _ = ClientProxyConnection.Create( tcpClient, hycoProvider, cts.Token ); } } - - public static Task Create( String hycoConnectionString, IPAddress listenAddress, int listenPort, CancellationToken cancellation = default ) + catch( OperationCanceledException ) { - var clientProxy = new ClientProxy(); - return clientProxy.Run( hycoConnectionString, listenAddress, listenPort, cancellation ); + // quiet — expected on shutdown } - - protected virtual void Dispose( bool disposing ) + catch( Exception ex ) { - if( !disposedValue ) - { - if( disposing ) - { - // TODO: dispose managed state (managed objects) - if( _cts != null && _cts.Token.CanBeCanceled ) - { - _cts.Cancel(); - } - } - - // TODO: free unmanaged resources (unmanaged objects) and override finalizer - // TODO: set large fields to null - disposedValue = true; - } + Log.Error( ex, "ClientProxy encountered an unexpected error" ); } - - // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources - // ~ClientProxy() - // { - // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - // Dispose(disposing: false); - // } - - public void Dispose() + finally { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose( disposing: true ); - GC.SuppressFinalize( this ); + if( ownsListener ) + tcpListener.Stop(); } } + + /// + /// Creates and starts a proxy that listens on : + /// and forwards each accepted connection through the given Azure Hybrid Connection string. + /// + public static Task Create( + string hycoConnectionString, + IPAddress listenAddress, + int listenPort, + CancellationToken cancellation = default ) + { + var proxy = new ClientProxy(); + var provider = new HybridConnectionClientProvider( hycoConnectionString ); + var listener = new TcpListener( listenAddress, listenPort ); + return proxy.Run( provider, listener, ownsListener: true, cancellation ); + } + + /// + /// Internal overload used by tests: caller owns the lifecycle + /// (Start/Stop), allowing the port to be inspected before passing it in. + /// + internal static Task Create( + IHybridConnectionProvider hycoProvider, + TcpListener tcpListener, + CancellationToken cancellation = default ) + { + var proxy = new ClientProxy(); + return proxy.Run( hycoProvider, tcpListener, ownsListener: false, cancellation ); + } } diff --git a/src/ClientProxyConnection.cs b/src/ClientProxyConnection.cs index 2b6ed52..2b31133 100644 --- a/src/ClientProxyConnection.cs +++ b/src/ClientProxyConnection.cs @@ -1,82 +1,56 @@ -using System.Net.Sockets; +using System.Net.Sockets; -using Microsoft.Azure.Relay; +using Serilog; -namespace HybridConnectionClientProxy +namespace HybridConnectionClientProxy; + +internal class ClientProxyConnection { - public class ClientProxyConnection - : IDisposable - { - private bool disposedValue; - protected CancellationTokenSource? _cts; + private ClientProxyConnection() { } - protected ClientProxyConnection() - { - } + private async Task Run( TcpClient tcpClient, IHybridConnectionProvider hycoProvider, CancellationToken cancellation ) + { + // Both the CancellationTokenSource and the TcpClient are disposed here, + // eliminating the leaks present in the original design where Create() discarded the instance. + using var cts = CancellationTokenSource.CreateLinkedTokenSource( cancellation ); + using var _ = tcpClient; - protected async Task Run( TcpClient tcpClient, HybridConnectionClient hycoClient, CancellationToken cancellation ) + try { - try - { - _cts = CancellationTokenSource.CreateLinkedTokenSource( cancellation ); - using var tcpStream = tcpClient.GetStream(); - using var hycoStream = await hycoClient.CreateConnectionAsync(); - - var sendPump = tcpStream.CopyToAsync( hycoStream, _cts.Token ); - var receivePump = hycoStream.CopyToAsync( tcpStream, _cts.Token ); - await Task.WhenAny( sendPump, receivePump ); - await Task.WhenAll( - tcpStream.FlushAsync(), - hycoStream.FlushAsync() - ); - } - catch( OperationCanceledException ) - { - // quiet - } - catch( Exception ex ) - { - Console.WriteLine( ex.ToString() ); - } + using var tcpStream = tcpClient.GetStream(); + using var hycoStream = await hycoProvider.CreateConnectionAsync( cts.Token ); + + var sendPump = tcpStream.CopyToAsync( hycoStream, cts.Token ); + var receivePump = hycoStream.CopyToAsync( tcpStream, cts.Token ); + + // Wait for whichever side closes first, then cancel the other pump + // so it is not left running indefinitely (original bug: orphaned task). + await Task.WhenAny( sendPump, receivePump ); + await cts.CancelAsync(); + + // Await both pumps so exceptions are observed and resources released cleanly. + // Errors here are diagnostic only — the connection is already closing. + try { await sendPump; } + catch( Exception ex ) when( ex is not OperationCanceledException ) + { Log.Debug( ex, "Send pump closed with error" ); } + + try { await receivePump; } + catch( Exception ex ) when( ex is not OperationCanceledException ) + { Log.Debug( ex, "Receive pump closed with error" ); } } - - public static Task Create( TcpClient tcpClient, HybridConnectionClient hycoClient, CancellationToken cancellation ) + catch( OperationCanceledException ) { - var connection = new ClientProxyConnection(); - return connection.Run( tcpClient, hycoClient, cancellation ); + // quiet — expected on shutdown or when the remote side closes first } - - protected virtual void Dispose( bool disposing ) + catch( Exception ex ) { - if( !disposedValue ) - { - if( disposing ) - { - // TODO: dispose managed state (managed objects) - if( _cts != null && _cts.Token.CanBeCanceled ) - { - _cts.Cancel(); - } - } - - // TODO: free unmanaged resources (unmanaged objects) and override finalizer - // TODO: set large fields to null - disposedValue = true; - } + Log.Error( ex, "ClientProxyConnection encountered an unexpected error" ); } + } - // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources - // ~ClientProxyConnection() - // { - // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - // Dispose(disposing: false); - // } - - public void Dispose() - { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose( disposing: true ); - GC.SuppressFinalize( this ); - } + internal static Task Create( TcpClient tcpClient, IHybridConnectionProvider hycoProvider, CancellationToken cancellation ) + { + var connection = new ClientProxyConnection(); + return connection.Run( tcpClient, hycoProvider, cancellation ); } } diff --git a/src/ConfigurationLocator.cs b/src/ConfigurationLocator.cs index 7f9c340..3f6c325 100644 --- a/src/ConfigurationLocator.cs +++ b/src/ConfigurationLocator.cs @@ -1,50 +1,37 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +namespace HybridConnectionClientProxy; -namespace HybridConnectionClientProxy +internal static class ConfigurationLocator { - internal class ConfigurationLocator - { - public const String DefaultsFile - = "appsettings.json"; + /// + /// Lowercase convention matches ASP.NET Core and is case-safe on Linux. + /// Previously "appSettings.json" (capital S) would silently fail on case-sensitive file systems. + /// + public const string DefaultsFile = "appsettings.json"; - public static String OverlayFileName - => $"appsettings.{Environment.MachineName}.json"; + public static string OverlayFileName + => $"appsettings.{Environment.MachineName}.json"; - public static String OverlayFilePath + public static string OverlayFilePath + { + get { - get - { - var segments = Environment.CurrentDirectory.Split( Path.DirectorySeparatorChar ); - Int32 binpos = segments.Length - 1; + var segments = Environment.CurrentDirectory.Split( Path.DirectorySeparatorChar ); + int binpos = segments.Length - 1; - for( int i = segments.Length - 1; i >= 0; i-- ) + for( int i = segments.Length - 1; i >= 0; i-- ) + { + if( StringComparer.InvariantCultureIgnoreCase.Equals( segments[i], "bin" ) ) { - if( StringComparer.InvariantCultureIgnoreCase.Equals( segments[i], "bin" ) ) - { - binpos = i; - break; - } + binpos = i; + break; } - - var parentpath = String.Join( Path.DirectorySeparatorChar, segments.Take( binpos ) ); - // var parentpath = string.Join( Path.DirectorySeparatorChar, Enumerable.Repeat( "..", segments.Length - binpos ) ); - - return - Path.Combine( - parentpath, - "config" - ); } - } - public static String OverlayFile - = Path.Combine( - OverlayFilePath, - OverlayFileName - ); + var parentPath = string.Join( Path.DirectorySeparatorChar, segments.Take( binpos ) ); + return Path.Combine( parentPath, "config" ); + } } + + public static string OverlayFile + => Path.Combine( OverlayFilePath, OverlayFileName ); } diff --git a/src/HybridConnectionClientProvider.cs b/src/HybridConnectionClientProvider.cs new file mode 100644 index 0000000..3770dd7 --- /dev/null +++ b/src/HybridConnectionClientProvider.cs @@ -0,0 +1,19 @@ +using Microsoft.Azure.Relay; + +namespace HybridConnectionClientProxy; + +/// +/// Production implementation of backed by . +/// +internal sealed class HybridConnectionClientProvider : IHybridConnectionProvider +{ + private readonly HybridConnectionClient _client; + + public HybridConnectionClientProvider( string connectionString ) + { + _client = new HybridConnectionClient( connectionString ); + } + + public async Task CreateConnectionAsync( CancellationToken cancellationToken = default ) + => await _client.CreateConnectionAsync(); +} diff --git a/src/IHybridConnectionProvider.cs b/src/IHybridConnectionProvider.cs new file mode 100644 index 0000000..cb5d42c --- /dev/null +++ b/src/IHybridConnectionProvider.cs @@ -0,0 +1,9 @@ +namespace HybridConnectionClientProxy; + +/// +/// Abstracts the creation of a Hybrid Connection stream, enabling unit testing without live Azure credentials. +/// +internal interface IHybridConnectionProvider +{ + Task CreateConnectionAsync( CancellationToken cancellationToken = default ); +} diff --git a/src/Program.cs b/src/Program.cs index 6f2d17d..2f9180c 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -1,13 +1,12 @@ -using HybridConnectionClientProxy; +using HybridConnectionClientProxy; using HybridConnectionClientProxy.Settings; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.FileProviders; using Serilog; -using Serilog.Events; -// Bootstrap logger +// Bootstrap logger — replaced after full configuration is loaded Log.Logger = new LoggerConfiguration() .Enrich.FromLogContext() .WriteTo.Console() @@ -17,9 +16,9 @@ Log.Information( "Reading Configuration" ); var configurationManager = new ConfigurationManager(); configurationManager.AddJsonFile( ConfigurationLocator.DefaultsFile ); -configurationManager.AddJsonFile( ConfigurationLocator.OverlayFile, true ); +configurationManager.AddJsonFile( ConfigurationLocator.OverlayFile, optional: true ); #if DEBUG -configurationManager.AddUserSecrets( typeof(Program).Assembly, true ); +configurationManager.AddUserSecrets( typeof( Program ).Assembly, optional: true ); #endif configurationManager.AddEnvironmentVariables(); configurationManager.AddCommandLine( args ); @@ -28,7 +27,7 @@ .ReadFrom.Configuration( configurationManager ) .CreateLogger(); -Log.Information( "Started in {workingDirectory}", Environment.CurrentDirectory ); +Log.Information( "Started in {WorkingDirectory}", Environment.CurrentDirectory ); var files = configurationManager.GetFileProvider(); Log.Debug( @@ -44,7 +43,7 @@ foreach( var entry in configurationManager.AsEnumerable().OrderBy( pair => pair.Key ) ) { - Log.Verbose( "{config} = {value}", entry.Key, entry.Value ); + Log.Verbose( "{Config} = {Value}", entry.Key, entry.Value ); } var configurationSection = configurationManager.GetRequiredSection( AppSettings.Section ); @@ -52,36 +51,30 @@ ConfigurationBinder.Bind( configurationSection, appSettings ); Log.Information( "Starting" ); + +if( appSettings.Proxies.Length == 0 ) + throw new InvalidOperationException( "You must specify at least one proxy in the configuration." ); + var cts = new CancellationTokenSource(); var proxyTasks = new List(); -if( appSettings.Proxies == null || appSettings.Proxies.Length == 0 ) -{ - throw new Exception( "You must specify at least one proxy in the configuration." ); -} -else + +foreach( var proxy in appSettings.Proxies ) { - foreach( var proxy in appSettings.Proxies ) - { - if( proxy.HybridConnectionString == null ) - { - throw new Exception( "Every proxy in configuration must have a HybridConnectionString." ); - } - - if( proxy.ListenPort == 0 ) - { - throw new Exception( "Every proxy in configuration must have a ListenPort." ); - } - - Log.Debug( "Adding proxy {proxyName}", proxy.Name ); - proxyTasks.Add( - ClientProxy.Create( - proxy.HybridConnectionString, - proxy.ListenAddress, - proxy.ListenPort, - cts.Token - ) - ); - } + if( proxy.HybridConnectionString is null ) + throw new InvalidOperationException( "Every proxy in configuration must have a HybridConnectionString." ); + + if( proxy.ListenPort == 0 ) + throw new InvalidOperationException( "Every proxy in configuration must have a ListenPort." ); + + Log.Debug( "Adding proxy {ProxyName}", proxy.Name ); + proxyTasks.Add( + ClientProxy.Create( + proxy.HybridConnectionString, + proxy.ListenAddress, + proxy.ListenPort, + cts.Token + ) + ); } Log.Information( "Running" ); @@ -89,7 +82,7 @@ Console.ReadKey( true ); Log.Information( "Stopping" ); -cts.Cancel(); +await cts.CancelAsync(); await Task.WhenAll( proxyTasks ); Log.Information( "Stopped" ); diff --git a/src/Settings/AppSettings.cs b/src/Settings/AppSettings.cs index dde696b..5de0b69 100644 --- a/src/Settings/AppSettings.cs +++ b/src/Settings/AppSettings.cs @@ -1,15 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +namespace HybridConnectionClientProxy.Settings; -namespace HybridConnectionClientProxy.Settings +public class AppSettings { - public class AppSettings - { - public const String Section = "AppSettings"; + public const string Section = "AppSettings"; - public Proxy[]? Proxies { get; set; } = Array.Empty(); - } + public Proxy[] Proxies { get; set; } = []; } diff --git a/src/Settings/Proxy.cs b/src/Settings/Proxy.cs index 885ca75..e3ee3b7 100644 --- a/src/Settings/Proxy.cs +++ b/src/Settings/Proxy.cs @@ -1,31 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Text; -using System.Threading.Tasks; -namespace HybridConnectionClientProxy.Settings -{ - public class Proxy - { - public String? Name { get; set; } // Purely decorative - public String? HybridConnectionString { get; set; } - public String? ListenIPAddress { get; set; } - public int ListenPort { get; set; } +namespace HybridConnectionClientProxy.Settings; - public IPAddress ListenAddress - { - get - { - IPAddress? ip; - if( !IPAddress.TryParse( ListenIPAddress, out ip ) ) - { - ip = IPAddress.Loopback; - } +public class Proxy +{ + public string? Name { get; set; } // Purely decorative + public string? HybridConnectionString { get; set; } + public string? ListenIPAddress { get; set; } + public int ListenPort { get; set; } - return ip; - } - } - } + public IPAddress ListenAddress + => IPAddress.TryParse( ListenIPAddress, out var ip ) ? ip : IPAddress.Loopback; } From c85ccad27cb756d59c097a4efedfc646700eaec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 23:44:49 +0000 Subject: [PATCH 02/16] Fix test build: add Xunit implicit using to test project xUnit v3 does not inject a global 'using Xunit;' automatically. Adding to the test csproj makes [Fact] and [Theory] available without requiring an explicit using in every test file. https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- .../HybridConnectionClientProxy.Tests.csproj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj index f9d9748..06e92d0 100644 --- a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj +++ b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj @@ -8,6 +8,11 @@ true + + + + + From 89e5d82ce6666d7c1c73cb258cb10eae5dfdba38 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 23:56:15 +0000 Subject: [PATCH 03/16] Fix build failure and address Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build fix: - Add GlobalUsings.cs with 'global using Xunit;' — the previous approach of in the csproj was not being honoured by the SDK and the generated GlobalUsings.g.cs did not include the Xunit namespace. - Remove the now-redundant from the test csproj. Copilot review fixes: - HybridConnectionClientProvider: pass cancellationToken through to _client.CreateConnectionAsync() so cancellation is honoured during in-flight Azure Relay connection setup. - appsettings.json: add empty AppSettings section so GetRequiredSection succeeds on a clean run; without it the app threw a cryptic InvalidOperationException before the friendly validation message. - Program.cs: use 'using var cts' so CancellationTokenSource is disposed promptly when the application exits. https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- HybridConnectionClientProxy.Tests/GlobalUsings.cs | 1 + .../HybridConnectionClientProxy.Tests.csproj | 5 ----- appsettings.json | 3 +++ src/HybridConnectionClientProvider.cs | 2 +- src/Program.cs | 2 +- 5 files changed, 6 insertions(+), 7 deletions(-) create mode 100644 HybridConnectionClientProxy.Tests/GlobalUsings.cs diff --git a/HybridConnectionClientProxy.Tests/GlobalUsings.cs b/HybridConnectionClientProxy.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/HybridConnectionClientProxy.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj index 06e92d0..f9d9748 100644 --- a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj +++ b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj @@ -8,11 +8,6 @@ true - - - - - diff --git a/appsettings.json b/appsettings.json index d996b03..971d840 100644 --- a/appsettings.json +++ b/appsettings.json @@ -27,5 +27,8 @@ "Properties": { "Application": "HybridConnectionClientProxy" } + }, + "AppSettings": { + "Proxies": [] } } \ No newline at end of file diff --git a/src/HybridConnectionClientProvider.cs b/src/HybridConnectionClientProvider.cs index 3770dd7..6a787e4 100644 --- a/src/HybridConnectionClientProvider.cs +++ b/src/HybridConnectionClientProvider.cs @@ -15,5 +15,5 @@ public HybridConnectionClientProvider( string connectionString ) } public async Task CreateConnectionAsync( CancellationToken cancellationToken = default ) - => await _client.CreateConnectionAsync(); + => await _client.CreateConnectionAsync( cancellationToken ); } diff --git a/src/Program.cs b/src/Program.cs index 2f9180c..f2ca621 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -55,7 +55,7 @@ if( appSettings.Proxies.Length == 0 ) throw new InvalidOperationException( "You must specify at least one proxy in the configuration." ); -var cts = new CancellationTokenSource(); +using var cts = new CancellationTokenSource(); var proxyTasks = new List(); foreach( var proxy in appSettings.Proxies ) From 01404f363350b2009c96bab29590cc2cd3b1f8d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 00:08:15 +0000 Subject: [PATCH 04/16] Switch test project from xunit.v3 to xunit v2 (2.9.3) xunit.v3 3.2.2 now depends on xunit.v3.mtp-v1 (Microsoft Testing Platform), which uses a completely different runner model from VSTest. The MTP integration requires EnableMicrosoftTestingPlatformRunner and does not provide compile-time assembly references through the standard dotnet test / VSTest pipeline, causing 'The type or namespace Xunit could not be found' at compile time. xunit v2 (2.9.3) is the latest stable release that works with the standard dotnet test workflow. The test API ([Fact], [Theory], Assert.*) is identical, so no test code changes are needed. GlobalUsings.cs (global using Xunit;) now resolves correctly because the xunit v2 assembly is a standard compile reference. https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- .../HybridConnectionClientProxy.Tests.csproj | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj index f9d9748..31829da 100644 --- a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj +++ b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj @@ -10,8 +10,13 @@ - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all From 9183b35b79301f8e56d6a3056819c7c3ed28facc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 00:26:01 +0000 Subject: [PATCH 05/16] Downgrade Microsoft.NET.Test.Sdk; explicit usings; fix CI test step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Microsoft.NET.Test.Sdk 18.x changed how compile-time assembly references are handled as part of its Microsoft Testing Platform (MTP) integration. This broke Xunit namespace resolution for both xunit.v2 and xunit.v3 — the package DLLs were not being added as compile references. Downgrading to 17.12.0 restores the standard VSTest / dotnet-test behaviour. Replace GlobalUsings.cs (global using Xunit;) with explicit 'using Xunit;' in each test file. The global-using file approach depended on the package correctly injecting the namespace, which was unreliable across SDK versions. Explicit usings are unconditional and don't rely on any package-level magic. CI: change 'dotnet test --no-build' to 'dotnet test --no-restore'. The --no-build flag skips the test-project build entirely, so if the Build step produced no test binary (e.g. due to a project-discovery issue), the Test step had nothing to run and failed silently. --no-restore keeps the packages from being downloaded twice while still ensuring the binary is always built. https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- .github/workflows/dotnet.yml | 2 +- .../ClientProxyIntegrationTests.cs | 2 ++ .../ConfigurationLocatorTests.cs | 2 ++ HybridConnectionClientProxy.Tests/GlobalUsings.cs | 1 - .../HybridConnectionClientProxy.Tests.csproj | 9 +++++---- .../Settings/ProxySettingsTests.cs | 1 + 6 files changed, 11 insertions(+), 6 deletions(-) delete mode 100644 HybridConnectionClientProxy.Tests/GlobalUsings.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index a623634..147496e 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -25,4 +25,4 @@ jobs: - name: Build run: dotnet build --no-restore - name: Test - run: dotnet test --no-build --verbosity normal + run: dotnet test --no-restore --verbosity normal diff --git a/HybridConnectionClientProxy.Tests/ClientProxyIntegrationTests.cs b/HybridConnectionClientProxy.Tests/ClientProxyIntegrationTests.cs index 45f971d..6e05b86 100644 --- a/HybridConnectionClientProxy.Tests/ClientProxyIntegrationTests.cs +++ b/HybridConnectionClientProxy.Tests/ClientProxyIntegrationTests.cs @@ -2,6 +2,8 @@ using System.Net.Sockets; using System.Text; +using Xunit; + namespace HybridConnectionClientProxy.Tests; /// diff --git a/HybridConnectionClientProxy.Tests/ConfigurationLocatorTests.cs b/HybridConnectionClientProxy.Tests/ConfigurationLocatorTests.cs index 09255c0..ea6ccc6 100644 --- a/HybridConnectionClientProxy.Tests/ConfigurationLocatorTests.cs +++ b/HybridConnectionClientProxy.Tests/ConfigurationLocatorTests.cs @@ -1,3 +1,5 @@ +using Xunit; + namespace HybridConnectionClientProxy.Tests; public class ConfigurationLocatorTests diff --git a/HybridConnectionClientProxy.Tests/GlobalUsings.cs b/HybridConnectionClientProxy.Tests/GlobalUsings.cs deleted file mode 100644 index c802f44..0000000 --- a/HybridConnectionClientProxy.Tests/GlobalUsings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; diff --git a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj index 31829da..eef1347 100644 --- a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj +++ b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj @@ -9,12 +9,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/HybridConnectionClientProxy.Tests/Settings/ProxySettingsTests.cs b/HybridConnectionClientProxy.Tests/Settings/ProxySettingsTests.cs index 1cd9920..c9bc494 100644 --- a/HybridConnectionClientProxy.Tests/Settings/ProxySettingsTests.cs +++ b/HybridConnectionClientProxy.Tests/Settings/ProxySettingsTests.cs @@ -1,6 +1,7 @@ using System.Net; using HybridConnectionClientProxy.Settings; +using Xunit; namespace HybridConnectionClientProxy.Tests.Settings; From 3f546d5cf8cb0f0f1ec89872caf28b9cc99ce837 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 11:39:41 +0000 Subject: [PATCH 06/16] Restructure: move projects into own folders; replace .sln with .slnx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of all xunit build failures: HybridConnectionClientProxy.csproj was at the repo root, so its default SDK glob (implicit **/*.cs) swept up every .cs file in the entire repository — including all the test files. The test files reference [Fact] from xunit, which is not a dependency of the main project, causing 'Xunit namespace not found' in the *main* project build regardless of how the test project was configured. Fix: each project now lives in its own subfolder so each project's implicit glob only covers its own source files. Layout after this commit: HybridConnectionClientProxy/ <- main project (was at root) HybridConnectionClientProxy.csproj appsettings.json config/ src/ HybridConnectionClientProxy.Tests/ HybridConnectionClientProxy.Tests.csproj src/ <- test sources (were at project root) ClientProxyIntegrationTests.cs ConfigurationLocatorTests.cs TcpHybridConnectionProvider.cs Settings/ProxySettingsTests.cs HybridConnectionClientProxy.slnx <- replaces HybridConnectionClientProxy.sln Other changes: - HybridConnectionClientProxy.sln removed; replaced by HybridConnectionClientProxy.slnx (Visual Studio XML solution format, much simpler than the old .sln format) - ProjectReference in test project updated to new relative path - .github/workflows reference in main csproj updated (../.github/...) - CI workflow: all dotnet commands now specify HybridConnectionClientProxy.slnx explicitly so there is no ambiguity about which project/solution to use https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- .github/workflows/dotnet.yml | 6 ++-- .../HybridConnectionClientProxy.Tests.csproj | 2 +- .../{ => src}/ClientProxyIntegrationTests.cs | 0 .../{ => src}/ConfigurationLocatorTests.cs | 0 .../{ => src}/Settings/ProxySettingsTests.cs | 0 .../{ => src}/TcpHybridConnectionProvider.cs | 0 HybridConnectionClientProxy.sln | 31 ------------------- HybridConnectionClientProxy.slnx | 4 +++ .../HybridConnectionClientProxy.csproj | 4 +-- .../appsettings.json | 0 .../config}/appsettings.template.json | 0 .../src}/ClientProxy.cs | 0 .../src}/ClientProxyConnection.cs | 0 .../src}/ConfigurationLocator.cs | 0 .../src}/HybridConnectionClientProvider.cs | 0 .../src}/IHybridConnectionProvider.cs | 0 .../src}/Program.cs | 0 .../src}/Settings/AppSettings.cs | 0 .../src}/Settings/Proxy.cs | 0 19 files changed, 10 insertions(+), 37 deletions(-) rename HybridConnectionClientProxy.Tests/{ => src}/ClientProxyIntegrationTests.cs (100%) rename HybridConnectionClientProxy.Tests/{ => src}/ConfigurationLocatorTests.cs (100%) rename HybridConnectionClientProxy.Tests/{ => src}/Settings/ProxySettingsTests.cs (100%) rename HybridConnectionClientProxy.Tests/{ => src}/TcpHybridConnectionProvider.cs (100%) delete mode 100644 HybridConnectionClientProxy.sln create mode 100644 HybridConnectionClientProxy.slnx rename HybridConnectionClientProxy.csproj => HybridConnectionClientProxy/HybridConnectionClientProxy.csproj (95%) rename appsettings.json => HybridConnectionClientProxy/appsettings.json (100%) rename {config => HybridConnectionClientProxy/config}/appsettings.template.json (100%) rename {src => HybridConnectionClientProxy/src}/ClientProxy.cs (100%) rename {src => HybridConnectionClientProxy/src}/ClientProxyConnection.cs (100%) rename {src => HybridConnectionClientProxy/src}/ConfigurationLocator.cs (100%) rename {src => HybridConnectionClientProxy/src}/HybridConnectionClientProvider.cs (100%) rename {src => HybridConnectionClientProxy/src}/IHybridConnectionProvider.cs (100%) rename {src => HybridConnectionClientProxy/src}/Program.cs (100%) rename {src => HybridConnectionClientProxy/src}/Settings/AppSettings.cs (100%) rename {src => HybridConnectionClientProxy/src}/Settings/Proxy.cs (100%) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 147496e..726c173 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -21,8 +21,8 @@ jobs: with: dotnet-version: 10.0.x - name: Restore dependencies - run: dotnet restore + run: dotnet restore HybridConnectionClientProxy.slnx - name: Build - run: dotnet build --no-restore + run: dotnet build HybridConnectionClientProxy.slnx --no-restore - name: Test - run: dotnet test --no-restore --verbosity normal + run: dotnet test HybridConnectionClientProxy.slnx --no-restore --verbosity normal diff --git a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj index eef1347..ac26404 100644 --- a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj +++ b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj @@ -28,7 +28,7 @@ - + diff --git a/HybridConnectionClientProxy.Tests/ClientProxyIntegrationTests.cs b/HybridConnectionClientProxy.Tests/src/ClientProxyIntegrationTests.cs similarity index 100% rename from HybridConnectionClientProxy.Tests/ClientProxyIntegrationTests.cs rename to HybridConnectionClientProxy.Tests/src/ClientProxyIntegrationTests.cs diff --git a/HybridConnectionClientProxy.Tests/ConfigurationLocatorTests.cs b/HybridConnectionClientProxy.Tests/src/ConfigurationLocatorTests.cs similarity index 100% rename from HybridConnectionClientProxy.Tests/ConfigurationLocatorTests.cs rename to HybridConnectionClientProxy.Tests/src/ConfigurationLocatorTests.cs diff --git a/HybridConnectionClientProxy.Tests/Settings/ProxySettingsTests.cs b/HybridConnectionClientProxy.Tests/src/Settings/ProxySettingsTests.cs similarity index 100% rename from HybridConnectionClientProxy.Tests/Settings/ProxySettingsTests.cs rename to HybridConnectionClientProxy.Tests/src/Settings/ProxySettingsTests.cs diff --git a/HybridConnectionClientProxy.Tests/TcpHybridConnectionProvider.cs b/HybridConnectionClientProxy.Tests/src/TcpHybridConnectionProvider.cs similarity index 100% rename from HybridConnectionClientProxy.Tests/TcpHybridConnectionProvider.cs rename to HybridConnectionClientProxy.Tests/src/TcpHybridConnectionProvider.cs diff --git a/HybridConnectionClientProxy.sln b/HybridConnectionClientProxy.sln deleted file mode 100644 index bf8167b..0000000 --- a/HybridConnectionClientProxy.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.8.34330.188 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HybridConnectionClientProxy", "HybridConnectionClientProxy.csproj", "{5129B416-0657-4D5E-AEEB-186E536E6DCC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HybridConnectionClientProxy.Tests", "HybridConnectionClientProxy.Tests\HybridConnectionClientProxy.Tests.csproj", "{A2D4F6B8-C0E2-4A6C-8E0F-2B4D6C8A0E2C}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {5129B416-0657-4D5E-AEEB-186E536E6DCC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5129B416-0657-4D5E-AEEB-186E536E6DCC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5129B416-0657-4D5E-AEEB-186E536E6DCC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5129B416-0657-4D5E-AEEB-186E536E6DCC}.Release|Any CPU.Build.0 = Release|Any CPU - {A2D4F6B8-C0E2-4A6C-8E0F-2B4D6C8A0E2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A2D4F6B8-C0E2-4A6C-8E0F-2B4D6C8A0E2C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A2D4F6B8-C0E2-4A6C-8E0F-2B4D6C8A0E2C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A2D4F6B8-C0E2-4A6C-8E0F-2B4D6C8A0E2C}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {26499249-4A8E-4641-93E2-B6803FF3DAA4} - EndGlobalSection -EndGlobal diff --git a/HybridConnectionClientProxy.slnx b/HybridConnectionClientProxy.slnx new file mode 100644 index 0000000..42abd8c --- /dev/null +++ b/HybridConnectionClientProxy.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/HybridConnectionClientProxy.csproj b/HybridConnectionClientProxy/HybridConnectionClientProxy.csproj similarity index 95% rename from HybridConnectionClientProxy.csproj rename to HybridConnectionClientProxy/HybridConnectionClientProxy.csproj index 91dc6df..9f472a5 100644 --- a/HybridConnectionClientProxy.csproj +++ b/HybridConnectionClientProxy/HybridConnectionClientProxy.csproj @@ -16,7 +16,7 @@ - + @@ -44,7 +44,7 @@ - + diff --git a/appsettings.json b/HybridConnectionClientProxy/appsettings.json similarity index 100% rename from appsettings.json rename to HybridConnectionClientProxy/appsettings.json diff --git a/config/appsettings.template.json b/HybridConnectionClientProxy/config/appsettings.template.json similarity index 100% rename from config/appsettings.template.json rename to HybridConnectionClientProxy/config/appsettings.template.json diff --git a/src/ClientProxy.cs b/HybridConnectionClientProxy/src/ClientProxy.cs similarity index 100% rename from src/ClientProxy.cs rename to HybridConnectionClientProxy/src/ClientProxy.cs diff --git a/src/ClientProxyConnection.cs b/HybridConnectionClientProxy/src/ClientProxyConnection.cs similarity index 100% rename from src/ClientProxyConnection.cs rename to HybridConnectionClientProxy/src/ClientProxyConnection.cs diff --git a/src/ConfigurationLocator.cs b/HybridConnectionClientProxy/src/ConfigurationLocator.cs similarity index 100% rename from src/ConfigurationLocator.cs rename to HybridConnectionClientProxy/src/ConfigurationLocator.cs diff --git a/src/HybridConnectionClientProvider.cs b/HybridConnectionClientProxy/src/HybridConnectionClientProvider.cs similarity index 100% rename from src/HybridConnectionClientProvider.cs rename to HybridConnectionClientProxy/src/HybridConnectionClientProvider.cs diff --git a/src/IHybridConnectionProvider.cs b/HybridConnectionClientProxy/src/IHybridConnectionProvider.cs similarity index 100% rename from src/IHybridConnectionProvider.cs rename to HybridConnectionClientProxy/src/IHybridConnectionProvider.cs diff --git a/src/Program.cs b/HybridConnectionClientProxy/src/Program.cs similarity index 100% rename from src/Program.cs rename to HybridConnectionClientProxy/src/Program.cs diff --git a/src/Settings/AppSettings.cs b/HybridConnectionClientProxy/src/Settings/AppSettings.cs similarity index 100% rename from src/Settings/AppSettings.cs rename to HybridConnectionClientProxy/src/Settings/AppSettings.cs diff --git a/src/Settings/Proxy.cs b/HybridConnectionClientProxy/src/Settings/Proxy.cs similarity index 100% rename from src/Settings/Proxy.cs rename to HybridConnectionClientProxy/src/Settings/Proxy.cs From ac53a5bb5e2057ed011b9b593abaa8e31e91a397 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 11:56:25 +0000 Subject: [PATCH 07/16] Fix Azure Relay API call; restore xunit.v3 HybridConnectionClient.CreateConnectionAsync takes an optional IDictionary of custom headers, not a CancellationToken. The Copilot suggestion to forward the token was incorrect. Reverted to the parameterless call (matches original behaviour). Restore xunit.v3 3.2.2 + xunit.runner.visualstudio 3.1.5. Every previous 'Xunit namespace not found' error was caused by the test .cs files being compiled as part of the main project (root-level csproj glob). Now that each project lives in its own folder that issue is gone and xunit.v3 can be used directly as intended. https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- .../HybridConnectionClientProxy.Tests.csproj | 10 ++-------- .../src/HybridConnectionClientProvider.cs | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj index ac26404..3b88604 100644 --- a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj +++ b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj @@ -9,15 +9,9 @@ - - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/HybridConnectionClientProxy/src/HybridConnectionClientProvider.cs b/HybridConnectionClientProxy/src/HybridConnectionClientProvider.cs index 6a787e4..3770dd7 100644 --- a/HybridConnectionClientProxy/src/HybridConnectionClientProvider.cs +++ b/HybridConnectionClientProxy/src/HybridConnectionClientProvider.cs @@ -15,5 +15,5 @@ public HybridConnectionClientProvider( string connectionString ) } public async Task CreateConnectionAsync( CancellationToken cancellationToken = default ) - => await _client.CreateConnectionAsync( cancellationToken ); + => await _client.CreateConnectionAsync(); } From b9276f390189cc0d6d887df0aeb449e91a63b382 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 11:58:29 +0000 Subject: [PATCH 08/16] Upgrade Microsoft.NET.Test.Sdk to 18.3.0 Previously downgraded to 17.12.0 while debugging the xunit build failures. Those failures were caused by the project structure (root-level csproj glob picking up test files), not by this package. Now that CI is green, restore the latest version. https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- .../HybridConnectionClientProxy.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj index 3b88604..da3aca4 100644 --- a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj +++ b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj @@ -9,7 +9,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive From 7671d719cea410bddfe9f38591e698c2ac8616c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 15:54:22 +0000 Subject: [PATCH 09/16] Fix client disconnect causing receive pump to hang HybridConnectionStream.ReadAsync does not reliably unblock when its CancellationToken is cancelled. After signalling cancellation, explicitly close both the TCP stream and the hybrid connection stream so that any blocked ReadAsync call throws immediately (SocketException 995) rather than hanging until the Azure Relay times the connection out. https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- .../src/ClientProxyConnection.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/HybridConnectionClientProxy/src/ClientProxyConnection.cs b/HybridConnectionClientProxy/src/ClientProxyConnection.cs index 2b31133..1a26ee1 100644 --- a/HybridConnectionClientProxy/src/ClientProxyConnection.cs +++ b/HybridConnectionClientProxy/src/ClientProxyConnection.cs @@ -28,8 +28,14 @@ private async Task Run( TcpClient tcpClient, IHybridConnectionProvider hycoProvi await Task.WhenAny( sendPump, receivePump ); await cts.CancelAsync(); + // HybridConnectionStream.ReadAsync does not reliably unblock when its + // CancellationToken is cancelled, so explicitly close both streams to + // force any blocked reads to throw immediately rather than hanging. + try { tcpStream.Close(); } catch { } + try { hycoStream.Close(); } catch { } + // Await both pumps so exceptions are observed and resources released cleanly. - // Errors here are diagnostic only — the connection is already closing. + // Errors here are diagnostic only - the connection is already closing. try { await sendPump; } catch( Exception ex ) when( ex is not OperationCanceledException ) { Log.Debug( ex, "Send pump closed with error" ); } @@ -40,7 +46,7 @@ private async Task Run( TcpClient tcpClient, IHybridConnectionProvider hycoProvi } catch( OperationCanceledException ) { - // quiet — expected on shutdown or when the remote side closes first + // quiet - expected on shutdown or when the remote side closes first } catch( Exception ex ) { From 3184f634da638d660079d22257253a4aae962535 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 16:06:04 +0000 Subject: [PATCH 10/16] Suppress expected teardown exceptions after intentional stream close After WhenAny returns and both streams are explicitly closed, any exception from the pumps is caused by the close itself (RelayException wrapping SocketException 995) and is always expected. Silently catch both rather than logging misleading "Receive pump closed with error" messages. https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- .../src/ClientProxyConnection.cs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/HybridConnectionClientProxy/src/ClientProxyConnection.cs b/HybridConnectionClientProxy/src/ClientProxyConnection.cs index 1a26ee1..e5e0580 100644 --- a/HybridConnectionClientProxy/src/ClientProxyConnection.cs +++ b/HybridConnectionClientProxy/src/ClientProxyConnection.cs @@ -34,15 +34,10 @@ private async Task Run( TcpClient tcpClient, IHybridConnectionProvider hycoProvi try { tcpStream.Close(); } catch { } try { hycoStream.Close(); } catch { } - // Await both pumps so exceptions are observed and resources released cleanly. - // Errors here are diagnostic only - the connection is already closing. - try { await sendPump; } - catch( Exception ex ) when( ex is not OperationCanceledException ) - { Log.Debug( ex, "Send pump closed with error" ); } - - try { await receivePump; } - catch( Exception ex ) when( ex is not OperationCanceledException ) - { Log.Debug( ex, "Receive pump closed with error" ); } + // Await both pumps to ensure exceptions are observed and resources released. + // Any exception here is expected - both streams have been explicitly closed. + try { await sendPump; } catch { } + try { await receivePump; } catch { } } catch( OperationCanceledException ) { From f3de2bf36db6d07bf0d39e35e86919d68d672215 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 16:23:28 +0000 Subject: [PATCH 11/16] Add Azure integration tests with credential-based skipping - Write APPSETTINGS secret to appsettings.Test.json before build in CI - Copy that file to test output via conditional CopyToOutputDirectory - AzureIntegrationTests reads the connection string via System.Text.Json and calls Skip.If when the file is absent (fork PRs, local runs) - Three tests: connection established, disconnect cleanup, data flow https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- .github/workflows/dotnet.yml | 3 + .../HybridConnectionClientProxy.Tests.csproj | 6 + .../src/AzureIntegrationTests.cs | 147 ++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 726c173..cf1cf97 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -20,6 +20,9 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x + - name: Write test appsettings + if: ${{ secrets.APPSETTINGS != '' }} + run: echo '${{ secrets.APPSETTINGS }}' > HybridConnectionClientProxy.Tests/appsettings.Test.json - name: Restore dependencies run: dotnet restore HybridConnectionClientProxy.slnx - name: Build diff --git a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj index da3aca4..c211636 100644 --- a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj +++ b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj @@ -25,4 +25,10 @@ + + + PreserveNewest + + + diff --git a/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs b/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs new file mode 100644 index 0000000..e788c9d --- /dev/null +++ b/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +using Xunit; + +namespace HybridConnectionClientProxy.Tests; + +/// +/// Integration tests that run against a real Azure Hybrid Connection endpoint. +/// +/// These tests require appsettings.Test.json to be present alongside the test +/// assembly, containing valid Azure Relay credentials in the same format as +/// appsettings.json. When the file is absent (fork PRs, local runs without +/// credentials) every test is skipped automatically. +/// +/// The endpoint referenced by the first proxy in the config must have a listener +/// registered and running for data-flow assertions to pass. +/// +public class AzureIntegrationTests +{ + private const int TimeoutMs = 30_000; + + // ------------------------------------------------------------------------- + // Tests + // ------------------------------------------------------------------------- + + [Fact] + public async Task AzureProxy_WhenClientConnects_ConnectionEstablishedWithoutError() + { + var connStr = GetConnectionString(); + Skip.If( connStr is null, "No Azure credentials in appsettings.Test.json" ); + + using var cts = new CancellationTokenSource( TimeoutMs ); + + var provider = new HybridConnectionClientProvider( connStr! ); + var (proxyListener, proxyPort) = StartListener(); + var proxyTask = ClientProxy.Create( provider, proxyListener, cts.Token ); + + using var client = new TcpClient(); + await client.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + + // Give the relay a moment to confirm the connection is live + await Task.Delay( 1_000, cts.Token ); + + await cts.CancelAsync(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task AzureProxy_WhenClientDisconnects_CleansUpPromptly() + { + var connStr = GetConnectionString(); + Skip.If( connStr is null, "No Azure credentials in appsettings.Test.json" ); + + using var cts = new CancellationTokenSource( TimeoutMs ); + + var provider = new HybridConnectionClientProvider( connStr! ); + var (proxyListener, proxyPort) = StartListener(); + var proxyTask = ClientProxy.Create( provider, proxyListener, cts.Token ); + + var client = new TcpClient(); + await client.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + await Task.Delay( 500, cts.Token ); // let the relay connection settle + + // Disconnect the client — this is the scenario that used to hang + client.Close(); + + // The proxy should clean up within a few seconds; if it hangs the test + // times out and fails, confirming the regression is present. + await Task.Delay( 3_000, cts.Token ); + + await cts.CancelAsync(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task AzureProxy_DataFlow_ClientToBackend() + { + var connStr = GetConnectionString(); + Skip.If( connStr is null, "No Azure credentials in appsettings.Test.json" ); + + using var cts = new CancellationTokenSource( TimeoutMs ); + + var provider = new HybridConnectionClientProvider( connStr! ); + var (proxyListener, proxyPort) = StartListener(); + _ = ClientProxy.Create( provider, proxyListener, cts.Token ); + + using var client = new TcpClient(); + await client.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + var stream = client.GetStream(); + + // Send a recognisable payload — the backend is expected to echo it back + var sent = Encoding.UTF8.GetBytes( "PING" ); + await stream.WriteAsync( sent, cts.Token ); + await stream.FlushAsync( cts.Token ); + + var received = new byte[4]; + await ReadExactAsync( stream, received, cts.Token ); + Assert.Equal( sent, received ); + + await cts.CancelAsync(); + proxyListener.Stop(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static string? GetConnectionString() + { + var path = Path.Combine( AppContext.BaseDirectory, "appsettings.Test.json" ); + if( !File.Exists( path ) ) return null; + + try + { + using var doc = JsonDocument.Parse( File.ReadAllText( path ) ); + var proxies = doc.RootElement + .GetProperty( "AppSettings" ) + .GetProperty( "Proxies" ); + if( proxies.GetArrayLength() == 0 ) return null; + return proxies[0].GetProperty( "HybridConnectionString" ).GetString(); + } + catch { return null; } + } + + private static (TcpListener listener, int port) StartListener() + { + var listener = new TcpListener( IPAddress.Loopback, 0 ); + listener.Start(); + return (listener, ((IPEndPoint) listener.LocalEndpoint).Port); + } + + private static async Task ReadExactAsync( Stream stream, byte[] buffer, CancellationToken ct ) + { + int total = 0; + while( total < buffer.Length ) + { + int n = await stream.ReadAsync( buffer.AsMemory( total ), ct ); + if( n == 0 ) throw new EndOfStreamException( $"Stream ended after {total}/{buffer.Length} bytes." ); + total += n; + } + } +} From a170d979a04b314dbf326b9facf85cf0a7da2176 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 16:26:08 +0000 Subject: [PATCH 12/16] Use SMTP EHLO/250 handshake for Azure data-flow integration test The backend behind the relay is smtp4dev, so replace the generic PING/echo test with a proper SMTP exchange: read the 220 greeting, send EHLO, assert a 250 response, then QUIT cleanly. ReadSmtpResponseAsync handles multi-line responses (NNN- continuation). https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- .../src/AzureIntegrationTests.cs | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs b/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs index e788c9d..4f9bde0 100644 --- a/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs +++ b/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs @@ -78,7 +78,7 @@ public async Task AzureProxy_WhenClientDisconnects_CleansUpPromptly() } [Fact] - public async Task AzureProxy_DataFlow_ClientToBackend() + public async Task AzureProxy_SmtpEhlo_ServerResponds250() { var connStr = GetConnectionString(); Skip.If( connStr is null, "No Azure credentials in appsettings.Test.json" ); @@ -91,16 +91,21 @@ public async Task AzureProxy_DataFlow_ClientToBackend() using var client = new TcpClient(); await client.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); - var stream = client.GetStream(); + using var reader = new StreamReader( client.GetStream(), Encoding.ASCII, leaveOpen: true ); + using var writer = new StreamWriter( client.GetStream(), Encoding.ASCII, leaveOpen: true ) { NewLine = "\r\n", AutoFlush = true }; - // Send a recognisable payload — the backend is expected to echo it back - var sent = Encoding.UTF8.GetBytes( "PING" ); - await stream.WriteAsync( sent, cts.Token ); - await stream.FlushAsync( cts.Token ); + // Server must send a 220 greeting + var greeting = await ReadSmtpResponseAsync( reader, cts.Token ); + Assert.StartsWith( "220", greeting ); - var received = new byte[4]; - await ReadExactAsync( stream, received, cts.Token ); - Assert.Equal( sent, received ); + // Send EHLO and expect 250 + await writer.WriteLineAsync( "EHLO test" ); + var response = await ReadSmtpResponseAsync( reader, cts.Token ); + Assert.StartsWith( "250", response ); + + // Quit cleanly + await writer.WriteLineAsync( "QUIT" ); + await ReadSmtpResponseAsync( reader, cts.Token ); await cts.CancelAsync(); proxyListener.Stop(); @@ -134,14 +139,20 @@ private static (TcpListener listener, int port) StartListener() return (listener, ((IPEndPoint) listener.LocalEndpoint).Port); } - private static async Task ReadExactAsync( Stream stream, byte[] buffer, CancellationToken ct ) + /// + /// Reads a (possibly multi-line) SMTP response and returns the final line. + /// Multi-line responses use "NNN-text" for continuation lines and "NNN text" for the last. + /// + private static async Task ReadSmtpResponseAsync( StreamReader reader, CancellationToken ct ) { - int total = 0; - while( total < buffer.Length ) + string? line; + do { - int n = await stream.ReadAsync( buffer.AsMemory( total ), ct ); - if( n == 0 ) throw new EndOfStreamException( $"Stream ended after {total}/{buffer.Length} bytes." ); - total += n; + line = await reader.ReadLineAsync( ct ); + if( line is null ) throw new EndOfStreamException( "SMTP server closed the connection." ); } + while( line.Length >= 4 && line[3] == '-' ); // continuation line + + return line; } } From 44d87f56a8cbd84f71ba4a26435863aa60cd8d20 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 16:27:38 +0000 Subject: [PATCH 13/16] Trigger CI with APPSETTINGS secret https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF From cb47e22be711bf39bf4b73a4286616a643527438 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 16:29:44 +0000 Subject: [PATCH 14/16] Fix workflow: secrets context not valid in step if condition Pass the secret as an env var and guard the write with a shell if [ -n "$APPSETTINGS" ] test instead. https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- .github/workflows/dotnet.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index cf1cf97..60bae6c 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -21,8 +21,12 @@ jobs: with: dotnet-version: 10.0.x - name: Write test appsettings - if: ${{ secrets.APPSETTINGS != '' }} - run: echo '${{ secrets.APPSETTINGS }}' > HybridConnectionClientProxy.Tests/appsettings.Test.json + env: + APPSETTINGS: ${{ secrets.APPSETTINGS }} + run: | + if [ -n "$APPSETTINGS" ]; then + echo "$APPSETTINGS" > HybridConnectionClientProxy.Tests/appsettings.Test.json + fi - name: Restore dependencies run: dotnet restore HybridConnectionClientProxy.slnx - name: Build From d806100a353202cb36c56bea26ca78ed39c1ae48 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 16:32:53 +0000 Subject: [PATCH 15/16] Fix: use Assert.Skip() instead of Skip.If() for xunit.v3 Skip is not in the Xunit namespace in xunit.v3 3.2.2; Assert.Skip() is the correct API from xunit.v3.assert. https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF --- .../src/AzureIntegrationTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs b/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs index 4f9bde0..ab907cb 100644 --- a/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs +++ b/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs @@ -30,7 +30,7 @@ public class AzureIntegrationTests public async Task AzureProxy_WhenClientConnects_ConnectionEstablishedWithoutError() { var connStr = GetConnectionString(); - Skip.If( connStr is null, "No Azure credentials in appsettings.Test.json" ); + if( connStr is null ) Assert.Skip( "No Azure credentials in appsettings.Test.json" ); using var cts = new CancellationTokenSource( TimeoutMs ); @@ -53,7 +53,7 @@ public async Task AzureProxy_WhenClientConnects_ConnectionEstablishedWithoutErro public async Task AzureProxy_WhenClientDisconnects_CleansUpPromptly() { var connStr = GetConnectionString(); - Skip.If( connStr is null, "No Azure credentials in appsettings.Test.json" ); + if( connStr is null ) Assert.Skip( "No Azure credentials in appsettings.Test.json" ); using var cts = new CancellationTokenSource( TimeoutMs ); @@ -81,7 +81,7 @@ public async Task AzureProxy_WhenClientDisconnects_CleansUpPromptly() public async Task AzureProxy_SmtpEhlo_ServerResponds250() { var connStr = GetConnectionString(); - Skip.If( connStr is null, "No Azure credentials in appsettings.Test.json" ); + if( connStr is null ) Assert.Skip( "No Azure credentials in appsettings.Test.json" ); using var cts = new CancellationTokenSource( TimeoutMs ); From b76765914e7bf5b1996ae21bcf3a2399f1bd5b42 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 21:01:29 +0000 Subject: [PATCH 16/16] Trigger CI re-run (SMTP server was down) https://claude.ai/code/session_014Jr8HbUyCyqUmUguYgHVfF