Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ apart.
quotes, braces and newlines, and the `inline` body carries an `InlinePatchFile` payload verbatim.
- Compiles for every DiffEngine target, so the socket calls carry `#if` branches for the
frameworks with no cancellation overloads. `ViewerProtocolTests` runs on all of them.
- `ViewerServer`'s accept loop awaits with `ConfigureAwait(false)`, the one place that matters
in a repo that otherwise leaves it off. The Windows viewer starts listening on its UI thread,
and resuming there left every connection waiting on the render loop to pump.
`AnOwnerAnswersWhileTheThreadThatStartedItIsBusy` pins it.

**Native shim (`native/`), used by the Mac and Linux heads only:**
- `raylib` and `imgui` are fetched by CMake (`FetchContent`), pinned by tag in
Expand Down
53 changes: 53 additions & 0 deletions src/DiffEngine.Tests/ViewerProtocolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,59 @@ public async Task AnOwnerAnswersAClient()
await Wait(listening);
}

/// <summary>
/// The Windows viewer starts listening on its UI thread, which carries a WinForms context by
/// then, and that thread is the render loop: it pumps between frames, and not at all while an
/// accept on it waits up to ten seconds on InlineApplier's mutex. The accept loop resumed on
/// it, so every connection went unanswered for as long as the render thread was busy - a
/// tray's listing, an attached viewer's poll, the next failing snapshot.
/// <para>
/// A context that is never pumped is that thread at its worst, and the owner still answers.
/// </para>
/// </summary>
[Test]
public async Task AnOwnerAnswersWhileTheThreadThatStartedItIsBusy()
{
await Assert.That(ViewerServer.TryBind(0, out var bound)).IsTrue();
using var server = bound!;
using var cancel = new CancelSource();
Task listening;
var previous = SynchronizationContext.Current;
// Only around the call, and with nothing awaited inside it: a continuation of this test
// posted to a context nobody pumps would never run
SynchronizationContext.SetSynchronizationContext(new UnpumpedContext());
try
{
listening = server.Listen(_ => ViewerResponse.Success($"heard {_.Verb}"), cancel.Token);
}
finally
{
SynchronizationContext.SetSynchronizationContext(previous);
}

var sent = ViewerClient.TrySend(new(ViewerVerb.List), out var response, server.Port, underLoad);

await Assert.That(sent).IsTrue();
await Assert.That(response!.Message).IsEqualTo("heard List");

await cancel.CancelAsync();
await Wait(listening);
}

/// <summary>
/// The one thread of a context whose owner is too busy to pump it, so whatever is posted to it
/// waits for good.
/// </summary>
sealed class UnpumpedContext : SynchronizationContext
{
public override void Post(SendOrPostCallback callback, object? state)
{
}

public override void Send(SendOrPostCallback callback, object? state) =>
throw new NotSupportedException("A thread that is not pumping cannot be sent to.");
}

/// <summary>
/// Connections are handled concurrently, so one slow exchange does not stop the next from
/// being answered. Accepting an inline snapshot legitimately takes seconds, and a client
Expand Down
11 changes: 8 additions & 3 deletions src/DiffEngine/Protocol/ViewerServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ public async Task Listen(Func<ViewerMessage, ViewerResponse> handle, Cancel canc
TcpClient client;
try
{
client = await Accept(cancel);
// Off whatever thread started listening, here and in Accept. The Windows viewer
// starts on its UI thread, which has a WinForms context by then, and resuming there
// waited for the render loop to pump: every connection went unanswered for as long
// as that thread was busy, which an accept holding InlineApplier's mutex makes up
// to ten seconds. Both awaits, because the first Accept runs on the caller's thread.
client = await Accept(cancel).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Expand Down Expand Up @@ -111,12 +116,12 @@ internal static bool IsStop(SocketException exception, Cancel cancel) =>
async Task<TcpClient> Accept(Cancel cancel)
{
#if NET6_0_OR_GREATER
return await listener.AcceptTcpClientAsync(cancel);
return await listener.AcceptTcpClientAsync(cancel).ConfigureAwait(false);
#else
// No token overload here, so cancellation arrives as the registered Stop, which faults
// this await with one of the exceptions the caller already treats as "stop serving".
cancel.ThrowIfCancellationRequested();
return await listener.AcceptTcpClientAsync();
return await listener.AcceptTcpClientAsync().ConfigureAwait(false);
#endif
}

Expand Down
Loading