From 0dc50eef5abcaa34b0eb48e6b2f4c5edbc96a065 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sun, 23 Aug 2026 09:06:37 +1000 Subject: [PATCH] Do not close an attached window because the owner answered badly OwnerLink treated an error reply the same as a refused connection, and ViewerServer turns any exception in the owner's listing handler into an error reply. So one transient throw over there closed this window with "The queue owner is no longer running.", losing the queue it was displaying - while the owner was still running and still holding it. Only a connection that could not be made means gone now. An error reply is shown as the message it carries, and the next pass asks again. --- .../AttachedViewerTests.cs | 27 +++++++++++++++++++ src/DiffEngineViewer/Ipc/OwnerLink.cs | 16 +++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/DiffEngineViewer.Tests/AttachedViewerTests.cs b/src/DiffEngineViewer.Tests/AttachedViewerTests.cs index bb8f4314..d09181f6 100644 --- a/src/DiffEngineViewer.Tests/AttachedViewerTests.cs +++ b/src/DiffEngineViewer.Tests/AttachedViewerTests.cs @@ -151,6 +151,33 @@ public async Task ABusyOwnerDoesNotReadAsDead() _ = listening; } + /// + /// An owner that answers is an owner, whatever it answered. + /// + /// ViewerServer turns any exception in the listing handler into an error reply, and this side + /// read one of those as the owner having gone: the window closed with "The queue owner is no + /// longer running." over a single transient throw, taking the queue it was displaying with it. + /// + /// + [Test] + public async Task AnErrorReplyIsNotADeadOwner() + { + await Assert.That(ViewerServer.TryBind(0, out var bound)).IsTrue(); + using var server = bound!; + using var cancel = new CancelSource(); + var listening = server.Listen( + _ => ViewerResponse.Error("The listing could not be built"), + cancel.Token); + var host = new SessionHost(SessionState.Start(ViewerMode.Inline, Fixtures.Columns, Fixtures.Rows)); + + await Assert.That(new OwnerLink(host, server.Port).Pump()).IsTrue(); + + await Assert.That(host.State.Message).IsEqualTo("The listing could not be built"); + await Assert.That(host.State.Exit).IsFalse(); + await cancel.CancelAsync(); + _ = listening; + } + /// /// The owner has no window of its own, so raising, hiding and closing come back on a listing /// rather than being pushed at a port this process does not hold. diff --git a/src/DiffEngineViewer/Ipc/OwnerLink.cs b/src/DiffEngineViewer/Ipc/OwnerLink.cs index 1d800e2c..695466af 100644 --- a/src/DiffEngineViewer/Ipc/OwnerLink.cs +++ b/src/DiffEngineViewer/Ipc/OwnerLink.cs @@ -61,12 +61,24 @@ public bool Pump(out bool sent) message = Send(command); } - if (!ViewerClient.TrySend(new(ViewerVerb.ListFull), out var response, port, Wait) || - !response.Ok) + if (!ViewerClient.TrySend(new(ViewerVerb.ListFull), out var response, port, Wait)) { return false; } + if (!response.Ok) + { + // An owner that answers is an owner. ViewerServer turns any exception in the listing + // handler into an error reply, so reading one as death closed this window over a + // single transient throw and lost the queue it was displaying. Said instead, and asked + // again on the next pass. + host.Mutate(_ => _ with + { + Message = response.Message ?? "The queue owner refused the listing." + }); + return true; + } + var pending = InlineQueue.From(ViewerListing.Pending(response.Items)); var changes = ReadChanges(response); host.Mutate(_ => ViewerSession.Sync(_, pending, changes, message));