Skip to content

Commit 31c859d

Browse files
authored
Always use system TLS defaults (#706)
* Always use system TLS defaults * Add extra tests to confirm SChannel cache is clean * Fix test to run on Windows * MemoryFixture workaround * Generate unique certificates per test to fix SChannel session-cache collisions on net48 With SslProtocols.None on .NET Framework 4.8, Windows SChannel uses a per-process TLS session cache keyed on certificate + hostname. Reusing the same static certs (Octopus, TentacleListening, TentaclePolling) across tests in the same process causes SChannel to incorrectly reuse session entries when connecting to localhost, producing AuthenticationExceptions that cause ~202 test failures on net48. Fix by generating fresh unique certificates per test in: - LatestClientAndLatestServiceBuilder (Listening/Polling/PollingOverWebSocket factories) - SecureClientFixture (SetUp + SecureClientClearsPoolWhenAllConnectionsCorrupt) - ClientServerLifecycleTests (ListeningConfiguration/PollingConfiguration/ListeningThenPollingConfiguration) Tests that explicitly call WithCertificates(...) are unaffected. * Extend unique-cert-per-test fix to backwards compat builders and bad-cert tests Add ServiceThumbprint property to ClientAndService so bad-certificate tests can reference the service's actual cert thumbprint rather than the client's configured trusted thumbprint (which differs in WithClientTrustingTheWrongCertificate tests). Also fixes DiscoveryClientFixture and backwards compatibility builders. * Scope per-test unique certificates to net48 The unique-cert-per-test fix (added to work around SChannel session-cache collisions under SslProtocols.None on net48) was applied to all target frameworks. On net80 this defeats SChannel TLS session resumption, so every handshake becomes a slow full handshake. With a short receive timeout that bleeds into the handshake (ssl.ReadTimeout), this made ReceiveResponseTimeoutTests.WhenRpcExecutionIsWithinReceiveResponseTimeout_ButSubsequentDataIsDelayed fail consistently on net80 Windows. Move all of the conditional logic into a single TestCertificates helper that generates fresh certs per test only on net48, and otherwise returns the shared static certs (Octopus/TentacleListening/TentaclePolling) used on main. This restores fast resumed handshakes on net80/Linux while keeping the net48 collision fix intact. * Extend unique-cert-per-test fix to client-only builder The client-only path (LatestClientBuilder.ForServiceConnectionType, used by CreateClientOnlyTestCaseBuilder) still used the shared static Octopus cert on net48. Under SslProtocols.None this intermittently triggers the same SChannel session-cache collision as before, surfacing as an AuthenticationException ('a call to SSPI failed ... they do not possess a common algorithm'). That is classified as UnknownError rather than IsNetworkError, causing ExceptionReturnedByHalibutProxyExtensionMethodFixture.BecauseTheListeningTentacleIsNotResponding to flake on net48 Windows. Generate a fresh per-test client certificate on net48 (Polling/Listening) and dispose its TmpDirectory with the client. PollingOverWebSocket keeps the Ssl cert (bound via netsh). No-op on net80/Linux. * Trust each polling client's own certificate WhenPollingMultipleClientsWithOneService builds two client-only polling clients plus one service that polls both. The service builder trusted a single static thumbprint and dialled every listening client expecting it, which only worked while all client-only builders shared the static Octopus cert. Generating a unique client cert per test on net48 broke that contract, failing RequestsShouldBeTakenFromAnyClient on every net48 build. Carry each client's own certificate thumbprint from the client-only builder through to the service builder so the polling service dials each listening client with that client's thumbprint: - expose LatestClient.ClientThumbprint (via IClient) - WithListeningClient/WithListeningClients now take (uri, thumbprint) Identical behaviour on net80/Linux (every client resolves to the shared Octopus thumbprint); on net48 the service trusts each unique client cert. * Make listening-client trust thumbprint optional The previous change had the combined builder dial each listening client with that client's actual certificate thumbprint. That overrode the service's configured serviceTrustsThumbprint, which the wrong-certificate test helpers deliberately set to a non-matching value. As a result the polling service accepted connections it should have rejected, breaking the bad-certificate negative tests on every platform: - BadCertificatesTests.FailWhenClientPresentsWrongCertificateToPollingService - ConnectionObserverFixture.ObserveUnauthorizedPollingWebSocketConnections Make the per-client thumbprint optional. The combined builder passes none, so the polling loop falls back to serviceTrustsThumbprint (its previous, known- good behaviour). Only the standalone multi-client test, which has two clients with distinct certs and no wrong-certificate setup, passes explicit per-client thumbprints via WithListeningClients. * Extract ICertAndThumbprint, remove SchannelProbe binary CertAndThumbprint now implements ICertAndThumbprint, allowing builders to hold either static certs or disposable per-test certs behind a common interface. All builders replace their TmpDirectory? nullable field with a DisposableCollection, making lifetime management uniform regardless of whether per-test certs are generated. CertificateGenerator is removed in favour of TempDisposableCertAndThumbprint.CreateSelfSigned, which manages its own temp directory and registers with the caller's DisposableCollection. The SchannelProbe compat binary and SchannelSessionCacheFixture are deleted. They proved that process isolation avoids SChannel session- cache collisions; with unique per-test certs that problem is solved in-process and the out-of-process probe is no longer needed. * Remove redundant comments from certificate setup methods The rationale for using separate runtimes and distinct certificates is already documented in detail at the test method level. These helper method comments were duplicating that explanation, so remove them to reduce clutter. * Fix net48 build * empty commit to force build
1 parent fe55a78 commit 31c859d

34 files changed

Lines changed: 404 additions & 231 deletions

source/Halibut.Tests.DotMemory/MemoryFixture.cs

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,26 @@ public void TcpClientsAreDisposedCorrectly()
5959
.WriteTo.NUnitOutput()
6060
.CreateLogger();
6161

62+
// Two separate HalibutRuntime instances are used to avoid an SChannel session cache
63+
// collision on Windows (.NET Framework / SslProtocols.None). SChannel's TLS session
64+
// cache is per-process and keyed on certificate + host. If a single runtime acts as
65+
// both a TLS server (accepting inbound connections) and a TLS client (making outbound
66+
// polling connections) using the same certificate, SChannel can incorrectly reuse a
67+
// server-side session for a client-side handshake, causing SSPI/TLS failures.
68+
//
69+
// - server: pure TLS server — accepts inbound connections from listening tentacles.
70+
// Uses Certificates.Octopus.
71+
// - pollingServer: pure TLS client — only makes outbound polling connections to tentacles.
72+
// Must use a DIFFERENT certificate (Certificates.TentacleListening) so
73+
// that SChannel never sees the same cert in both server and client roles
74+
// within this process.
6275
HalibutRuntime? server = null;
76+
HalibutRuntime? pollingServer = null;
6377

6478
try
6579
{
6680
server = RunServer(Certificates.Octopus, out var port);
81+
pollingServer = RunPollingServer(Certificates.TentacleListening);
6782

6883
var expectedTcpClientCount = 1; //server listen = 1 tcpclient
6984
//valid requests
@@ -75,13 +90,15 @@ public void TcpClientsAreDisposedCorrectly()
7590
for (var i = 0; i < NumberOfClients; i++)
7691
{
7792
expectedTcpClientCount++; // each time the server polls, it keeps a tcpclient (as we dont have support to say StopPolling)
78-
RunPollingClient(server, Certificates.TentaclePolling, Certificates.TentaclePollingPublicThumbprint).GetAwaiter().GetResult();
93+
RunPollingClient(pollingServer, Certificates.TentaclePolling, Certificates.TentaclePollingPublicThumbprint).GetAwaiter().GetResult();
7994
}
8095

8196
#if SUPPORTS_WEB_SOCKET_CLIENT
97+
//setup polling websocket
98+
AddSslCertToLocalStoreAndRegisterFor("0.0.0.0:8434");
8299
for (var i = 0; i < NumberOfClients; i++)
83100
{
84-
RunWebSocketPollingClient(server, Certificates.TentaclePolling, Certificates.TentaclePollingPublicThumbprint, Certificates.OctopusPublicThumbprint).GetAwaiter().GetResult();
101+
RunWebSocketPollingClient(pollingServer, Certificates.TentaclePolling, Certificates.TentaclePollingPublicThumbprint, Certificates.TentacleListeningPublicThumbprint).GetAwaiter().GetResult();
85102
}
86103
#endif
87104

@@ -106,6 +123,7 @@ public void TcpClientsAreDisposedCorrectly()
106123
finally
107124
{
108125
server?.DisposeAsync().GetAwaiter().GetResult();
126+
pollingServer?.DisposeAsync().GetAwaiter().GetResult();
109127
}
110128
}
111129

@@ -142,16 +160,24 @@ static HalibutRuntime RunServer(X509Certificate2 serverCertificate, out int port
142160
.WithLogFactory(new TestContextLogFactory("client", LogLevel.Info))
143161
.Build();
144162

145-
//set up listening
146163
server.Trust(Certificates.TentacleListeningPublicThumbprint);
147164
port = server.Listen();
148165

149-
//setup polling websocket
150-
AddSslCertToLocalStoreAndRegisterFor("0.0.0.0:8434");
151-
152166
return server;
153167
}
154168

169+
static HalibutRuntime RunPollingServer(X509Certificate2 serverCertificate)
170+
{
171+
var services = new DelegateServiceFactory();
172+
services.Register<ICalculatorService, IAsyncCalculatorService>(() => new AsyncCalculatorService());
173+
174+
return new HalibutRuntimeBuilder()
175+
.WithServerCertificate(serverCertificate)
176+
.WithServiceFactory(services)
177+
.WithLogFactory(new TestContextLogFactory("polling-server", LogLevel.Info))
178+
.Build();
179+
}
180+
155181
static async Task RunListeningClient(X509Certificate2 clientCertificate, int port, string remoteThumbprint, bool expectSuccess = true)
156182
{
157183
await using (var runtime = new HalibutRuntimeBuilder().WithServerCertificate(clientCertificate).Build())
@@ -169,9 +195,13 @@ static async Task RunPollingClient(HalibutRuntime server, X509Certificate2 clien
169195
.Build())
170196
{
171197
runtime.Listen(new IPEndPoint(IPAddress.IPv6Any, 8433));
172-
runtime.Trust(Certificates.OctopusPublicThumbprint);
198+
// Trust the thumbprint of pollingServer's certificate (TentacleListening), which is
199+
// the cert pollingServer presents when it dials in to establish the polling connection.
200+
runtime.Trust(Certificates.TentacleListeningPublicThumbprint);
173201

174202
//setup polling
203+
// The remote thumbprint here is this runtime's own certificate (TentaclePolling),
204+
// which pollingServer verifies when it connects to port 8433.
175205
var serverEndpoint = new ServiceEndPoint(new Uri("https://localhost:8433"), Certificates.TentaclePollingPublicThumbprint, runtime.TimeoutsAndLimits)
176206
{
177207
TcpClientConnectTimeout = TimeSpan.FromSeconds(5)

source/Halibut.Tests/BadCertificatesTests.cs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public async Task SucceedsWhenPollingServicePresentsWrongCertificate_ButServiceI
3030
var clientTrustProvider = new DefaultTrustProvider();
3131
var unauthorizedThumbprint = "";
3232
var firstCall = true;
33+
var serviceThumbprint = "";
3334

3435
var unauthorizedClientHasConnected = new TaskCompletionSource<bool>();
3536
CancellationToken.Register(() => unauthorizedClientHasConnected.TrySetCanceled()); // backup to fail the test in case it never connects
@@ -43,7 +44,7 @@ public async Task SucceedsWhenPollingServicePresentsWrongCertificate_ButServiceI
4344
{
4445
if (firstCall)
4546
{
46-
clientTrustProvider.IsTrusted(CertAndThumbprint.TentaclePolling.Thumbprint).Should().BeFalse();
47+
clientTrustProvider.IsTrusted(serviceThumbprint).Should().BeFalse();
4748
firstCall = false;
4849
}
4950

@@ -53,6 +54,8 @@ public async Task SucceedsWhenPollingServicePresentsWrongCertificate_ButServiceI
5354
})
5455
.Build(CancellationToken))
5556
{
57+
serviceThumbprint = clientAndBuilder.ServiceThumbprint;
58+
5659
// Act
5760
var clientCountingService = clientAndBuilder.CreateAsyncClient<ICountingService, IAsyncClientCountingService>();
5861
await clientCountingService.IncrementAsync();
@@ -62,8 +65,8 @@ public async Task SucceedsWhenPollingServicePresentsWrongCertificate_ButServiceI
6265
// Assert
6366
countingService.CurrentValue().Should().Be(1);
6467

65-
clientTrustProvider.IsTrusted(CertAndThumbprint.TentaclePolling.Thumbprint).Should().BeTrue();
66-
unauthorizedThumbprint.Should().Be(CertAndThumbprint.TentaclePolling.Thumbprint);
68+
clientTrustProvider.IsTrusted(serviceThumbprint).Should().BeTrue();
69+
unauthorizedThumbprint.Should().Be(serviceThumbprint);
6770
}
6871
}
6972

@@ -93,6 +96,8 @@ public async Task FailWhenPollingServicePresentsWrongCertificate_ButServiceIsCon
9396
})
9497
.Build(CancellationToken))
9598
{
99+
var serviceThumbprint = clientAndBuilder.ServiceThumbprint;
100+
96101
using var cts = new CancellationTokenSource();
97102
var clientCountingService = clientAndBuilder.CreateAsyncClient<ICountingService, IAsyncClientCountingServiceWithOptions>(point =>
98103
{
@@ -105,7 +110,7 @@ public async Task FailWhenPollingServicePresentsWrongCertificate_ButServiceIsCon
105110
// Interestingly the message exchange error is logged to a non polling looking URL, perhaps because it has not been identified?
106111
Wait.UntilActionSucceeds(() => {
107112
AllLogs(serviceLoggers).Select(l => l.FormattedMessage).ToArray()
108-
.Should().Contain(s => s.Contains("and attempted a message exchange, but it presented a client certificate with the thumbprint '4098EC3A2FC2B92B97339D3831BA230CC1DD590F' which is not in the list of thumbprints that we trust"));
113+
.Should().Contain(s => s.Contains($"and attempted a message exchange, but it presented a client certificate with the thumbprint '{serviceThumbprint}' which is not in the list of thumbprints that we trust"));
109114
},
110115
TimeSpan.FromSeconds(10),
111116
Logger,
@@ -124,7 +129,7 @@ public async Task FailWhenPollingServicePresentsWrongCertificate_ButServiceIsCon
124129
// Assert
125130
countingService.CurrentValue().Should().Be(0, "With a bad certificate the request never should have been made");
126131

127-
unauthorizedThumbprint.Should().Be(CertAndThumbprint.TentaclePolling.Thumbprint);
132+
unauthorizedThumbprint.Should().Be(serviceThumbprint);
128133
}
129134
}
130135

@@ -195,8 +200,8 @@ public async Task FailWhenClientPresentsWrongCertificateToListeningService(Clien
195200

196201
serviceLoggers[serviceLoggers.Keys.First(x => x != nameof(MessageSerializer))].GetLogs().Should()
197202
.Contain(log => log.FormattedMessage
198-
.Contains("and attempted a message exchange, but it presented a client certificate with the thumbprint " +
199-
"'76225C0717A16C1D0BA4A7FFA76519D286D8A248' which is not in the list of thumbprints that we trust"));
203+
.Contains("and attempted a message exchange, but it presented a client certificate with the thumbprint")
204+
&& log.FormattedMessage.Contains("which is not in the list of thumbprints that we trust"));
200205
}
201206
}
202207

@@ -254,11 +259,13 @@ public async Task FailWhenListeningServicePresentsWrongCertificate(ClientAndServ
254259
.WithCountingService(countingService)
255260
.Build(CancellationToken))
256261
{
262+
var serviceThumbprint = clientAndBuilder.ServiceThumbprint;
263+
257264
var clientCountingService = clientAndBuilder.CreateAsyncClient<ICountingService, IAsyncClientCountingService>();
258265
(await AssertionExtensions.Should(() => clientCountingService.IncrementAsync()).ThrowAsync<HalibutClientException>())
259266
.And.Message.Should().Contain("" +
260267
"We expected the server to present a certificate with the thumbprint 'EC32122053C6BFF582F8246F5697633D06F0F97F'. " +
261-
"Instead, it presented a certificate with a thumbprint of '36F35047CE8B000CF4C671819A2DD1AFCDE3403D'");
268+
$"Instead, it presented a certificate with a thumbprint of '{serviceThumbprint}'");
262269
countingService.CurrentValue().Should().Be(0, "With a bad certificate the request never should have been made");
263270
}
264271
}
@@ -275,6 +282,8 @@ public async Task FailWhenPollingServicePresentsWrongCertificate(ClientAndServic
275282
.RecordingClientLogs(out var serviceLoggers)
276283
.Build(CancellationToken))
277284
{
285+
var serviceThumbprint = clientAndBuilder.ServiceThumbprint;
286+
278287
using var cts = new CancellationTokenSource();
279288
var clientCountingService = clientAndBuilder.CreateAsyncClient<ICountingService, IAsyncClientCountingServiceWithOptions>(point =>
280289
{
@@ -285,7 +294,7 @@ public async Task FailWhenPollingServicePresentsWrongCertificate(ClientAndServic
285294

286295
// Interestingly the message exchange error is logged to a non polling looking URL, perhaps because it has not been identified?
287296
Wait.UntilActionSucceeds(() => { AllLogs(serviceLoggers).Select(l => l.FormattedMessage).ToArray()
288-
.Should().Contain(s => s.Contains("and attempted a message exchange, but it presented a client certificate with the thumbprint '4098EC3A2FC2B92B97339D3831BA230CC1DD590F' which is not in the list of thumbprints that we trust")); },
297+
.Should().Contain(s => s.Contains($"and attempted a message exchange, but it presented a client certificate with the thumbprint '{serviceThumbprint}' which is not in the list of thumbprints that we trust")); },
289298
TimeSpan.FromSeconds(10),
290299
Logger,
291300
CancellationToken);

source/Halibut.Tests/ClientServerLifecycleTests.cs

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,33 @@ namespace Halibut.Tests
2525
{
2626
public class ClientServerLifecycleTests : BaseTest
2727
{
28+
DisposableCollection disposables = null!;
29+
ICertAndThumbprint serverCert = null!;
30+
ICertAndThumbprint listenerCert = null!;
31+
ICertAndThumbprint pollerCert = null!;
32+
33+
[SetUp]
34+
public void SetUpCerts()
35+
{
36+
disposables = new DisposableCollection();
37+
serverCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, disposedBy: disposables);
38+
listenerCert = TestCertificates.CertFor(CertAndThumbprint.TentacleListening, disposedBy: disposables);
39+
pollerCert = TestCertificates.CertFor(CertAndThumbprint.TentaclePolling, disposedBy: disposables);
40+
}
41+
42+
[TearDown]
43+
public void TearDownCerts()
44+
{
45+
disposables?.Dispose();
46+
}
47+
2848
[Test]
2949
public async Task ListeningConfiguration()
3050
{
3151
await using var server = RunServer(out var serverPort);
3252

3353
await using var runtime = CreateRuntimeForListener();
34-
var client = CreateClient(runtime, serverPort);
54+
var client = CreateClient(runtime, serverPort, serverCert);
3555
var result = await client.AddAsync(2, 2);
3656
result.Should().Be(4);
3757
}
@@ -56,7 +76,7 @@ public async Task ListeningThenPollingConfiguration()
5676
HalibutRuntime CreateRuntimeForListener()
5777
{
5878
var runtime = new HalibutRuntimeBuilder()
59-
.WithServerCertificate(Certificates.TentacleListening)
79+
.WithServerCertificate(listenerCert.Certificate2)
6080
.WithLogFactory(new TestLogFactory(HalibutLog))
6181
.Build();
6282
return runtime;
@@ -65,15 +85,15 @@ HalibutRuntime CreateRuntimeForListener()
6585
HalibutRuntime CreateRuntimeForPoller(HalibutRuntime serverRuntime, out IAsyncClientCalculatorService client)
6686
{
6787
var runtime = new HalibutRuntimeBuilder()
68-
.WithServerCertificate(Certificates.TentaclePolling)
88+
.WithServerCertificate(pollerCert.Certificate2)
6989
.WithLogFactory(new TestLogFactory(HalibutLog))
7090
.Build();
7191
var port = runtime.Listen();
72-
runtime.Trust(Certificates.OctopusPublicThumbprint);
92+
runtime.Trust(serverCert.Thumbprint);
7393

7494
var pollEndpoint = new ServiceEndPoint(
7595
baseUri: new Uri($"https://localhost:{port}/"),
76-
remoteThumbprint: Certificates.TentaclePollingPublicThumbprint,
96+
remoteThumbprint: pollerCert.Thumbprint,
7797
halibutTimeoutsAndLimits: runtime.TimeoutsAndLimits
7898
)
7999
{
@@ -83,19 +103,19 @@ HalibutRuntime CreateRuntimeForPoller(HalibutRuntime serverRuntime, out IAsyncCl
83103
serverRuntime.Poll(pollingUri, pollEndpoint, CancellationToken);
84104
var clientEndpoint = new ServiceEndPoint(
85105
baseUri: pollingUri,
86-
remoteThumbprint: Certificates.OctopusPublicThumbprint,
106+
remoteThumbprint: serverCert.Thumbprint,
87107
halibutTimeoutsAndLimits: runtime.TimeoutsAndLimits
88108
);
89109
client = runtime.CreateAsyncClient<ICalculatorService, IAsyncClientCalculatorService>(clientEndpoint);
90110

91111
return runtime;
92112
}
93113

94-
static IAsyncClientCalculatorService CreateClient(HalibutRuntime runtime, int port)
114+
static IAsyncClientCalculatorService CreateClient(HalibutRuntime runtime, int port, ICertAndThumbprint serverCertAndThumbprint)
95115
{
96116
var endpoint = new ServiceEndPoint(
97117
baseUri: $"https://localhost:{port}",
98-
remoteThumbprint: Certificates.OctopusPublicThumbprint,
118+
remoteThumbprint: serverCertAndThumbprint.Thumbprint,
99119
halibutTimeoutsAndLimits: runtime.TimeoutsAndLimits
100120
);
101121
var client = runtime
@@ -115,13 +135,13 @@ HalibutRuntime RunServer(out int port)
115135
var services = CreateServiceFactory();
116136

117137
var runtime = new HalibutRuntimeBuilder()
118-
.WithServerCertificate(Certificates.Octopus)
138+
.WithServerCertificate(serverCert.Certificate2)
119139
.WithServiceFactory(services)
120140
.WithLogFactory(new TestLogFactory(HalibutLog))
121141
.Build();
122142

123-
runtime.Trust(Certificates.TentacleListeningPublicThumbprint);
124-
runtime.Trust(Certificates.TentaclePollingPublicThumbprint);
143+
runtime.Trust(listenerCert.Thumbprint);
144+
runtime.Trust(pollerCert.Thumbprint);
125145
port = runtime.Listen();
126146

127147
return runtime;

source/Halibut.Tests/Support/BackwardsCompatibility/HalibutTestBinaryPath.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,33 @@ public class HalibutTestBinaryPath
99
public string BinPath(string version)
1010
{
1111
var onDiskVersion = version.Replace(".", "_");
12+
var projectName = $"Halibut.TestUtils.CompatBinary.v{onDiskVersion}";
13+
return ResolveProjectBinPath(projectName);
14+
}
15+
16+
public string SchannelProbeBinPath()
17+
{
18+
return ResolveProjectBinPath("Halibut.TestUtils.CompatBinary.SchannelProbe");
19+
}
20+
21+
string ResolveProjectBinPath(string projectName)
22+
{
1223
var assemblyDir = new DirectoryInfo(Path.GetDirectoryName(typeof(HalibutTestBinaryRunner).Assembly.Location)!);
1324
var upAt = assemblyDir.Parent!.Parent!.Parent!.Parent!;
14-
var projectName = $"Halibut.TestUtils.CompatBinary.v{onDiskVersion}";
1525
var executable = Path.Combine(upAt.FullName, projectName, assemblyDir.Parent.Parent.Name, assemblyDir.Parent.Name, assemblyDir.Name, projectName);
1626
executable = AddExeForWindows(executable);
1727

1828
if (!File.Exists(executable))
1929
{
20-
throw new Exception("Could not executable at path:\n" +
30+
throw new Exception("Could not find executable at path:\n" +
2131
executable + "\n" +
2232
$"Did you forget to update the csproj to depend on {projectName}\n" +
2333
"If testing a previously untested version of Halibut a new project may be required.");
2434
}
2535

2636
return executable;
2737
}
28-
38+
2939
string AddExeForWindows(string path)
3040
{
3141
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return path + ".exe";

0 commit comments

Comments
 (0)