diff --git a/src/DiffEngineViewer.Windows.Tests/GlobalUsings.cs b/src/DiffEngineViewer.Windows.Tests/GlobalUsings.cs
index 0b4fd261..bc3662c4 100644
--- a/src/DiffEngineViewer.Windows.Tests/GlobalUsings.cs
+++ b/src/DiffEngineViewer.Windows.Tests/GlobalUsings.cs
@@ -1,2 +1,2 @@
global using System.Buffers.Binary;
-global using System.IO.Compression;
\ No newline at end of file
+global using System.IO.Compression;global using System.Reflection;
diff --git a/src/DiffEngineViewer.Windows.Tests/ViewerFormClosingTests.cs b/src/DiffEngineViewer.Windows.Tests/ViewerFormClosingTests.cs
new file mode 100644
index 00000000..df610c12
--- /dev/null
+++ b/src/DiffEngineViewer.Windows.Tests/ViewerFormClosingTests.cs
@@ -0,0 +1,53 @@
+///
+/// Who gets to refuse a close.
+///
+/// The form cancels a user close because whether closing means hide or exit is ViewerProgram's
+/// rule, not the form's. It used to cancel every close, including the one Windows sends when the
+/// session is ending — and WinForms answers WM_QUERYENDSESSION with !e.Cancel, so the viewer
+/// reported itself as preventing shutdown.
+///
+///
+/// Driven through OnFormClosing by reflection, because CloseReason is set by the message that
+/// started the close and there is no way to ask a form to close as though Windows had.
+///
+///
+[NotInParallel]
+[TUnit.Core.Executors.STAThreadExecutor]
+public class ViewerFormClosingTests
+{
+ [Test]
+ [Arguments(CloseReason.UserClosing, true)]
+ [Arguments(CloseReason.None, true)]
+ [Arguments(CloseReason.WindowsShutDown, false)]
+ [Arguments(CloseReason.TaskManagerClosing, false)]
+ public async Task Cancels(CloseReason reason, bool expected)
+ {
+ using var form = new ViewerForm("title", 800, 600);
+ var args = new FormClosingEventArgs(reason, false);
+
+ Raise(form, args);
+
+ await Assert.That(args.Cancel).IsEqualTo(expected);
+ }
+
+ ///
+ /// And CloseForReal still wins, whatever the reason, since that is the loop answering its own
+ /// question.
+ ///
+ [Test]
+ public async Task CloseForRealIsNeverCancelled()
+ {
+ using var form = new ViewerForm("title", 800, 600);
+ form.CloseForReal();
+ var args = new FormClosingEventArgs(CloseReason.UserClosing, false);
+
+ Raise(form, args);
+
+ await Assert.That(args.Cancel).IsFalse();
+ }
+
+ static void Raise(ViewerForm form, FormClosingEventArgs args) =>
+ typeof(ViewerForm)
+ .GetMethod("OnFormClosing", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .Invoke(form, [args]);
+}
diff --git a/src/DiffEngineViewer.Windows/ViewerForm.cs b/src/DiffEngineViewer.Windows/ViewerForm.cs
index b497800e..fe3a3456 100644
--- a/src/DiffEngineViewer.Windows/ViewerForm.cs
+++ b/src/DiffEngineViewer.Windows/ViewerForm.cs
@@ -312,9 +312,16 @@ public void CloseForReal()
protected override void OnFormClosing(FormClosingEventArgs e)
{
- // Always cancelled, because whether closing means hide or exit is ViewerProgram's rule and
- // it needs a tray check to decide. CloseForReal is how the answer comes back.
- if (!closingForReal)
+ // Cancelled for a close the user asked for, because whether that means hide or exit is
+ // ViewerProgram's rule and it needs a tray check to decide. CloseForReal is how the answer
+ // comes back.
+ //
+ // Never for a close the session is ending: WinForms answers WM_QUERYENDSESSION with
+ // !e.Cancel, so refusing made Windows report the viewer as preventing shutdown, and with a
+ // tray running the loop only hid the window - leaving the process blocking until the user
+ // chose "Shut down anyway". Letting it through is safe because the loop watches for a
+ // disposed form and returns, which runs the same shutdown it would have run anyway.
+ if (!closingForReal && !EndsTheSession(e.CloseReason))
{
closeRequested = true;
e.Cancel = true;
@@ -323,6 +330,13 @@ protected override void OnFormClosing(FormClosingEventArgs e)
base.OnFormClosing(e);
}
+ ///
+ /// The process is going away whatever this form says. Task Manager's End Task is here with
+ /// shutdown because refusing it buys the same nothing: the user has already decided.
+ ///
+ internal static bool EndsTheSession(CloseReason reason) =>
+ reason is CloseReason.WindowsShutDown or CloseReason.TaskManagerClosing;
+
///
/// ProcessCmdKey rather than OnKeyDown, because Tab and Escape are consumed by focus
/// navigation and the default button before a key handler would ever see them.