Skip to content

Commit 68818f2

Browse files
committed
Merge branch 'main' into fix-file-purger-kills-tray
2 parents 7e56383 + dbf8a0e commit 68818f2

6 files changed

Lines changed: 284 additions & 22 deletions

File tree

claude.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
1212
# Build (from repo root). Also packs: ProjectDefaults sets GeneratePackageOnBuild in Release.
1313
dotnet build src --configuration Release
1414

15-
# Run all tests
16-
dotnet test --project src/DiffEngine.Tests --configuration Release
17-
dotnet test --project src/DiffEngineTray.Tests --configuration Release
18-
dotnet test --project src/DiffEngineViewer.Tests --configuration Release
15+
# Run all tests, which is what CI runs
16+
dotnet test --solution src/DiffEngine.slnx --configuration Release --no-build --no-restore
1917

20-
# Run a single test project with filter
21-
dotnet test --project src/DiffEngine.Tests --configuration Release --filter "FullyQualifiedName~ClassName"
18+
# Run one test project
19+
dotnet test --project src/DiffEngine.Tests/DiffEngine.Tests.csproj --configuration Release --no-build --no-restore
2220

23-
# Run a specific test
24-
dotnet test --project src/DiffEngine.Tests --configuration Release --filter "FullyQualifiedName=DiffEngine.Tests.ClassName.TestMethod"
21+
# Run one class, then one test
22+
dotnet test --project src/DiffEngine.Tests/DiffEngine.Tests.csproj --configuration Release --no-build --no-restore -- --treenode-filter "/*/*/ClassName/*"
23+
dotnet test --project src/DiffEngine.Tests/DiffEngine.Tests.csproj --configuration Release --no-build --no-restore -- --treenode-filter "/*/*/ClassName/MethodName"
24+
25+
# Or run the test project directly, which is the fastest loop and takes the same filter
26+
src/DiffEngine.Tests/bin/Debug/net10.0/DiffEngine.Tests.exe --treenode-filter "/*/*/ClassName/*"
2527
```
2628

27-
**SDK Requirements:** .NET 10 SDK (see `src/global.json`). The project uses preview/prerelease SDK features.
29+
**Test runner:** TUnit runs on Microsoft.Testing.Platform rather than VSTest, which changes two things about the commands above. Filters are treenode paths given after `--`, as `/Assembly/Namespace/Class/Test` with `*` for any segment; VSTest's `--filter "FullyQualifiedName~ClassName"` matches nothing and exits 5, so a filtered run that reports no failures may have run no tests. And `--nologo` makes any run report "Zero tests ran" and exit 5, whatever else is on the command line, so leave it off.
30+
31+
**SDK Requirements:** .NET 10 SDK (see `global.json`, at the repository root rather than under `src`). It also carries `"test": { "runner": "Microsoft.Testing.Platform" }`, which is what puts `dotnet test` on the runner described above. The project uses preview/prerelease SDK features.
2832

2933
**Target Frameworks:**
3034
- DiffEngine library: net462, net472, net48, net6.0, net7.0, net8.0, net9.0, net10.0 (Windows also includes .NET Framework targets)

src/DiffEngine.Tests/GlobalUsings.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
global using EmptyFiles;
22
global using System.Collections.Concurrent;
33
global using System.Diagnostics;
4+
global using System.Net;
5+
global using System.Net.Sockets;
46
global using System.Reflection;
57
global using System.Text;
68
global using Polyfills;

src/DiffEngine.Tests/InlineApplierTests.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,19 @@ public async Task ContentIsNeverObservedHalfWritten()
205205

206206
using var cancellation = new CancelSource();
207207
var seen = new ConcurrentDictionary<long, byte>();
208+
// Signalled from inside the delegate, so the apply cannot run and finish before the
209+
// reader is looking. Without it a reader that starts late observes nothing and the
210+
// assertion below passes on an empty set, which is a pass that checked nothing
211+
var reading = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
212+
// No token on Task.Run. It cancels the scheduling rather than the delegate, so a pool
213+
// that had not yet picked this up when the cancel lands leaves the task Canceled and
214+
// `await reader` throwing TaskCanceledException - which is what made this fail on CI,
215+
// on all three OSes, while passing on any machine with a spare core. The loop already
216+
// exits on the token, which is the only cancellation this ever wanted
208217
var reader = Task.Run(
209218
() =>
210219
{
220+
reading.SetResult(true);
211221
while (!cancellation.IsCancellationRequested)
212222
{
213223
try
@@ -224,7 +234,9 @@ public async Task ContentIsNeverObservedHalfWritten()
224234
// The swap is in flight. Not an observation of the content
225235
}
226236
}
227-
}, cancellation.Token);
237+
});
238+
239+
await reading.Task;
228240

229241
// Long enough that the two whole files differ in length, which is what makes a
230242
// half written one tell itself apart
@@ -236,6 +248,10 @@ public async Task ContentIsNeverObservedHalfWritten()
236248
var after = new FileInfo(path).Length;
237249
await Assert.That(after).IsNotEqualTo(before);
238250

251+
// That the reader looked at all. Everything below is a statement about what it saw,
252+
// and an empty set satisfies all of it
253+
await Assert.That(seen.Keys).IsNotEmpty();
254+
239255
var partial = seen.Keys.Where(_ => _ != before && _ != after).ToList();
240256
await Assert.That(partial).IsEmpty();
241257
}

src/DiffEngine.Tests/ViewerProtocolTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,48 @@ public async Task ASecondBindIsRefused()
673673
await Assert.That(second).IsNull();
674674
}
675675

676+
/// <summary>
677+
/// An owner that accepts the connection and then says nothing. There used to be no bound on
678+
/// this at all: SendTimeout and ReceiveTimeout apply only to synchronous calls, and the token
679+
/// the async path was handed is the caller's, which is default from DiffRunner.AddInlineAsync.
680+
/// A failing test waited for the owner for the rest of its life.
681+
/// </summary>
682+
[Test]
683+
public async Task AnUnresponsiveOwnerTimesOutRatherThanHanging()
684+
{
685+
// Stop rather than Dispose: TcpListener is only IDisposable on the modern frameworks, and
686+
// this test compiles for net48 too
687+
var listener = new TcpListener(IPAddress.Loopback, 0);
688+
listener.Start();
689+
try
690+
{
691+
var port = ((IPEndPoint) listener.LocalEndpoint).Port;
692+
693+
// Accepted and then held, which is what a viewer inside the applier mutex looks like.
694+
// Kept in scope so the connection is not collected and closed under the client
695+
var accepted = listener.AcceptTcpClientAsync();
696+
697+
var watch = Stopwatch.StartNew();
698+
var sent = await ViewerClient.TrySendAsync(
699+
new(ViewerVerb.List),
700+
default,
701+
port,
702+
TimeSpan.FromSeconds(1));
703+
watch.Stop();
704+
705+
await Assert.That(sent).IsFalse();
706+
await Assert.That(watch.Elapsed).IsLessThan(TimeSpan.FromSeconds(15));
707+
708+
if (accepted.Status == TaskStatus.RanToCompletion)
709+
{
710+
accepted.Result.Close();
711+
}
712+
}
713+
finally
714+
{
715+
listener.Stop();
716+
}
717+
}
676718
[Test]
677719
public async Task AnAbsentOwnerIsNotAnError()
678720
{

src/DiffEngine/Protocol/ViewerClient.cs

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ public static int Port
3636

3737
static readonly TimeSpan timeout = TimeSpan.FromSeconds(3);
3838

39+
/// <summary>
40+
/// The deadline for the async exchange. Longer than the synchronous one because the owner
41+
/// answers on its listener thread, so a connection can sit behind an accept that is itself
42+
/// waiting up to ten seconds on <see cref="InlineApplier"/>'s cross process mutex. Shorter
43+
/// than forever because there was no bound at all: SendTimeout and ReceiveTimeout apply only
44+
/// to synchronous calls, and the token every async call was given is the caller's, which is
45+
/// default from DiffRunner.AddInlineAsync - Verify passes none. An owner that accepted the
46+
/// connection and then stopped answering hung the failing test for good.
47+
/// </summary>
48+
static readonly TimeSpan asyncTimeout = TimeSpan.FromSeconds(30);
49+
3950
/// <summary>
4051
/// For callers on a clock or an interactive path, such as the tray's scan timer and its menu.
4152
/// The exchange is loopback to a local process, so anything slower than this is a wedged owner
@@ -95,40 +106,71 @@ public static bool TrySend(
95106
/// Fully async, including the read. A blocking read here would tie up a thread pool thread for
96107
/// the whole exchange, and a parallel test run calling this once per failing snapshot would
97108
/// starve the pool on a small machine.
109+
/// <para>
110+
/// <paramref name="port"/> and <paramref name="wait"/> override <see cref="Port"/> and
111+
/// <see cref="asyncTimeout"/> for a single call, as they do on the synchronous overload. Tests
112+
/// pass their own ephemeral port rather than mutating anything static, so they can run in
113+
/// parallel.
114+
/// </para>
98115
/// </summary>
99-
public static async Task<bool> TrySendAsync(ViewerMessage message, Cancel cancel)
116+
public static async Task<bool> TrySendAsync(
117+
ViewerMessage message,
118+
Cancel cancel,
119+
int? port = null,
120+
TimeSpan? wait = null)
100121
{
122+
var endpointPort = port ?? Port;
123+
var timeToWait = wait ?? asyncTimeout;
124+
using var deadline = CancelSource.CreateLinkedTokenSource(cancel);
125+
deadline.CancelAfter(timeToWait);
126+
var token = deadline.Token;
101127
try
102128
{
103129
using var client = new TcpClient();
130+
// Closing the socket is the only thing that unblocks every framework: the pre-net7
131+
// ReadToEndAsync takes no token at all, and net462 has no cancellable connect or
132+
// write either. Registered after the client and so disposed before it, which is what
133+
// stops the callback firing on a disposed object
134+
using var abort = token.Register(() => Abort(client));
104135
#if NET6_0_OR_GREATER
105-
await client.ConnectAsync(IPAddress.Loopback, Port, cancel);
136+
await client.ConnectAsync(IPAddress.Loopback, endpointPort, token);
106137
#else
107-
cancel.ThrowIfCancellationRequested();
108-
using (cancel.Register(client.Close))
109-
{
110-
await client.ConnectAsync(IPAddress.Loopback, Port);
111-
}
138+
token.ThrowIfCancellationRequested();
139+
await client.ConnectAsync(IPAddress.Loopback, endpointPort);
112140
#endif
113-
Configure(client, timeout);
141+
Configure(client, timeToWait);
114142
var stream = client.GetStream();
115143
var bytes = Encoding.UTF8.GetBytes(message.Build());
116144
#if NET6_0_OR_GREATER
117-
await stream.WriteAsync(bytes, cancel);
145+
await stream.WriteAsync(bytes, token);
118146
#else
119-
await stream.WriteAsync(bytes, 0, bytes.Length, cancel);
147+
await stream.WriteAsync(bytes, 0, bytes.Length, token);
120148
#endif
121-
await stream.FlushAsync(cancel);
149+
await stream.FlushAsync(token);
122150
HalfClose(client);
123151
using var reader = new StreamReader(stream, Encoding.UTF8);
124152
#if NET7_0_OR_GREATER
125-
var text = await reader.ReadToEndAsync(cancel);
153+
var text = await reader.ReadToEndAsync(token);
126154
#else
127155
var text = await reader.ReadToEndAsync();
128156
#endif
129157
return ViewerResponse.TryParse(text, out var response) &&
130158
response.Ok;
131159
}
160+
// The deadline, rather than the caller cancelling. Whatever the abort surfaced as - a
161+
// cancellation, a closed socket, a torn down stream - the owner is present but not
162+
// answering. Reported as absence because that is the recoverable answer: the caller
163+
// launches a viewer or stages the patch, rather than waiting on a process that has
164+
// stopped listening. Logged so the two are still tellable apart afterwards
165+
catch (Exception exception)
166+
when (!cancel.IsCancellationRequested && token.IsCancellationRequested)
167+
{
168+
// Trace rather than Logging, because this file is linked into the viewer too
169+
Trace.WriteLine(
170+
$"Timed out after {timeToWait} waiting for the inline queue owner on port {endpointPort}. " +
171+
$"Verb: {message.Verb}. The owner is present but unresponsive. {exception.GetType().Name}");
172+
return false;
173+
}
132174
// Cancellation is the caller's business; a missing owner is not.
133175
catch (Exception exception)
134176
when (exception is not OperationCanceledException && Ignorable(exception))
@@ -137,6 +179,21 @@ public static async Task<bool> TrySendAsync(ViewerMessage message, Cancel cancel
137179
}
138180
}
139181

182+
/// <summary>
183+
/// Unblocks whatever the exchange is waiting on. Swallowing here rather than letting it out:
184+
/// this runs on the timer that fired the deadline, where a throw has nowhere to go.
185+
/// </summary>
186+
static void Abort(TcpClient client)
187+
{
188+
try
189+
{
190+
client.Close();
191+
}
192+
catch (Exception exception)
193+
when (Ignorable(exception))
194+
{
195+
}
196+
}
140197
static void Configure(TcpClient client, TimeSpan wait)
141198
{
142199
client.SendTimeout = (int) wait.TotalMilliseconds;

0 commit comments

Comments
 (0)