diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml
index 452a4172..7ae30124 100644
--- a/.github/workflows/build-native.yml
+++ b/.github/workflows/build-native.yml
@@ -35,7 +35,7 @@ concurrency:
jobs:
native:
- uses: SimonCropp/PlatformHeads/.github/workflows/build-native.yml@0.5.0
+ uses: SimonCropp/PlatformHeads/.github/workflows/build-native.yml@0.6.0
permissions:
contents: write
pull-requests: write
diff --git a/claude.md b/claude.md
index c9adfc5a..3095a8e1 100644
--- a/claude.md
+++ b/claude.md
@@ -641,7 +641,7 @@ the comment there about not caching "nothing staged" asks for.
this folder, so a recursive clone on every checkout would serve a path almost nobody takes.
- Building it needs CMake 3.24+, a C++17 compiler and network access. Contributors do not need
any of that, because the binaries are committed.
-- `native/src/deview.cpp` is a renderer for the `Screen` model, not an ImGui binding: eleven exports
+- `native/src/deview.cpp` is a renderer for the `Screen` model, not an ImGui binding: twelve exports
taking one flat blittable frame description. The ABI is `native/include/deview.h`; bump
`DEVIEW_VERSION` whenever the structs change **or a field changes meaning**. The managed side
refuses a library whose version is not an exact match, so a bump and a binaries rebuild land
@@ -652,6 +652,19 @@ the comment there about not caching "nothing staged" asks for.
see its window (hidden or minimised on Linux; ordered out, miniaturised or occluded on macOS;
minimised on Windows, through `ViewerInput.Unseen`). `ViewerProgram.Loop` keeps its own `hidden`
and the head's `unseen` apart and slows `OwnerLink`, `TrackedWatch` and `DocumentWatch` on either.
+ Version 13 is `deview_on_display_lost`, which the Linux shim hands to PlatformHeads'
+ `ph::OnDisplayLost`, and the Swift one exports and never calls.
+- A logoff stages an owned queue on every platform, as a clean exit does, since none reaches the
+ loop's own shutdown. `ViewerProgram.SessionEnding` does it, once, touching no window, and is
+ heard three ways: on Windows through `HeadForm.SessionEnding`, which saves the placement after
+ it; on Linux as the X server goes, through `deview_on_display_lost`, which `NativeViewerWindow`
+ takes as `ILoopHooks.SessionEnding` and from inside which Xlib then calls `exit(1)`; and through
+ SIGTERM and SIGHUP, which `ViewerProgram` holds PlatformHeads.Core's `SessionEndSignals` for,
+ since .NET ends the process on either with no `finally` run. The signals do not save the
+ placement, because they come on a thread of their own. A display lost inside a mutation cannot
+ mark the session closing, and the queue is staged anyway. Seen working in the `ubuntu:24.04`
+ container: a viewer launched with an inline payload under Xvfb, then Xvfb killed or the viewer
+ sent SIGTERM, leaves the trio in the project's `obj/VerifyInline`.
- Built binaries are **committed** to `src/DiffEngineViewer.{Linux,Mac}/runtimes/{rid}/native/`, so a plain
`dotnet build` produces a shippable package and contributors never need CMake. Regenerate them
with the `build-native` GitHub workflow, which opens a PR.
diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt
index 46ad5659..f3d82781 100644
--- a/native/CMakeLists.txt
+++ b/native/CMakeLists.txt
@@ -15,7 +15,7 @@ include(FetchContent)
FetchContent_Declare(
platformheads
GIT_REPOSITORY https://github.com/SimonCropp/PlatformHeads.git
- GIT_TAG 0.5.0
+ GIT_TAG 0.6.0
GIT_SHALLOW ON)
FetchContent_MakeAvailable(platformheads)
include(${platformheads_SOURCE_DIR}/cmake/PlatformHeads.cmake)
diff --git a/native/include/deview.h b/native/include/deview.h
index a9c6a0b7..5dd72502 100644
--- a/native/include/deview.h
+++ b/native/include/deview.h
@@ -434,8 +434,13 @@ typedef struct DeviewPlacement {
* DeviewInput also reports a window nobody can see, at its end, in the same bump because
* they shipped together. And panX and panY are kept inside what either pane can show, where
* they were kept inside what the dragged one can.
+ * 13: deview_on_display_lost. No struct moved: a new entry point, which a library from before
+ * does not have, and which the managed side calls on every native window.
*/
-#define DEVIEW_VERSION 12
+#define DEVIEW_VERSION 13
+
+/* What deview_on_display_lost calls. */
+typedef void (*DeviewDisplayLost)(void);
/*
* The Swift implementation imports this header for the struct layouts, because Swift does not
@@ -511,6 +516,15 @@ DEVIEW_API int32_t deview_get_placement(DeviewPlacement* placement);
DEVIEW_API void deview_shutdown(void);
+/*
+ * Has lost called once as the connection to the X server is lost, which is how a logoff ends the
+ * Linux viewer: Xlib ends the process with exit(1) as it returns, so it is the one chance to stage
+ * an owned queue. Called on the thread inside deview_present or deview_poll_input, from inside that
+ * call. NULL stops it. macOS never calls it: a logout's quit there is the app's own, which reaches
+ * the clean exit.
+ */
+DEVIEW_API void deview_on_display_lost(DeviewDisplayLost lost);
+
DEVIEW_API int32_t deview_version(void);
#endif /* DEVIEW_TYPES_ONLY */
diff --git a/native/src/deview.cpp b/native/src/deview.cpp
index 354d501d..a39ccb0f 100644
--- a/native/src/deview.cpp
+++ b/native/src/deview.cpp
@@ -2753,6 +2753,11 @@ int32_t deview_version(void)
return DEVIEW_VERSION;
}
+void deview_on_display_lost(DeviewDisplayLost lost)
+{
+ ph::OnDisplayLost(lost);
+}
+
int32_t deview_init(
int32_t width,
int32_t height,
diff --git a/native/swift/Package.swift b/native/swift/Package.swift
index f3c56918..37d9eaea 100644
--- a/native/swift/Package.swift
+++ b/native/swift/Package.swift
@@ -12,7 +12,7 @@ let package = Package(
dependencies: [
// What every AppKit head does underneath its drawing, shared with BuildMonitor. The sixth
// pin of PlatformHeads, moved with the other five.
- .package(url: "https://github.com/SimonCropp/PlatformHeads", exact: "0.5.0")
+ .package(url: "https://github.com/SimonCropp/PlatformHeads", exact: "0.6.0")
],
targets: [
// Exists only to import the ABI. Swift does not guarantee struct layout, so the structs
diff --git a/native/swift/Sources/Deview/Exports.swift b/native/swift/Sources/Deview/Exports.swift
index a3e54d84..f9672137 100644
--- a/native/swift/Sources/Deview/Exports.swift
+++ b/native/swift/Sources/Deview/Exports.swift
@@ -148,6 +148,12 @@ public func deviewShutdown() {
}
}
+/// Never called here: a logout's quit is the app's own (TerminateGuard), which reaches the clean
+/// exit. Only the Linux shim can lose its display out from under it.
+@_cdecl("deview_on_display_lost")
+public func deviewOnDisplayLost(_ lost: DeviewDisplayLost?) {
+}
+
/// Renders into a bitmap of this side's own making rather than asking the view for one, with
/// everything that varies pinned, so a committed baseline matches on any display: see `Capture`.
@_cdecl("deview_capture")
diff --git a/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so b/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so
index acbc5f95..9d587b74 100644
Binary files a/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so and b/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so differ
diff --git a/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so b/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so
index c8b206f9..00ae9685 100644
Binary files a/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so and b/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so differ
diff --git a/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib
index 4d340f03..e684b356 100644
Binary files a/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib and b/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib differ
diff --git a/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib
index 4d340f03..e684b356 100644
Binary files a/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib and b/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib differ
diff --git a/src/DiffEngineViewer.Tests/ViewerProgramTests.cs b/src/DiffEngineViewer.Tests/ViewerProgramTests.cs
index ad723e94..8be17709 100644
--- a/src/DiffEngineViewer.Tests/ViewerProgramTests.cs
+++ b/src/DiffEngineViewer.Tests/ViewerProgramTests.cs
@@ -35,6 +35,67 @@ public async Task AnAttachedViewerPersistsNothing()
await Assert.That(project.StagedFiles()).IsEmpty();
}
+ ///
+ /// A logoff never reaches the loop's own shutdown, so the queue is staged as the session ends,
+ /// and nothing that arrives after is taken into a process about to end.
+ ///
+ [Test]
+ public async Task TheSessionEndingStagesTheQueue()
+ {
+ using var project = new TempProject();
+ var source = project.Source("SampleTests.cs");
+ var host = new SessionHost(Fixtures.Inline(Fixtures.Patch(source: source, framework: "net10.0")));
+
+ ViewerProgram.SessionEnding(host, link: null)();
+
+ await Assert.That(project.StagedFiles().Count(_ => _.EndsWith(".inlinepatch"))).IsEqualTo(1);
+ await Assert.That(host.State.Closing).IsTrue();
+ }
+
+ ///
+ /// On Linux the X server going is heard from inside a call into the window, which can be
+ /// inside a mutation, and a mutation cannot mutate again. The queue is staged anyway.
+ ///
+ [Test]
+ public async Task TheSessionEndingStagesTheQueueFromInsideAMutation()
+ {
+ using var project = new TempProject();
+ var source = project.Source("SampleTests.cs");
+ var host = new SessionHost(Fixtures.Inline(Fixtures.Patch(source: source, framework: "net10.0")));
+ var ending = ViewerProgram.SessionEnding(host, link: null);
+
+ host.Mutate(_ =>
+ {
+ ending();
+ return _;
+ });
+
+ await Assert.That(project.StagedFiles().Count(_ => _.EndsWith(".inlinepatch"))).IsEqualTo(1);
+ }
+
+ ///
+ /// A session ends with the X server going, SIGTERM and SIGHUP close together, each heard on its
+ /// own, and the queue is staged for the first of them only.
+ ///
+ [Test]
+ public async Task TheSessionEndingStagesTheQueueOnce()
+ {
+ using var project = new TempProject();
+ var source = project.Source("SampleTests.cs");
+ var host = new SessionHost(Fixtures.Inline(Fixtures.Patch(source: source, framework: "net10.0")));
+ var ending = ViewerProgram.SessionEnding(host, link: null);
+
+ ending();
+ foreach (var file in project.StagedFiles())
+ {
+ File.Delete(file);
+ }
+
+ ending();
+
+ await Assert.That(project.StagedFiles()).IsEmpty();
+ }
+
///
/// The port is bound before the window is asked for, so whoever launched the viewer was told
/// what it sent had been taken. A window that could not open - no display, a native library
diff --git a/src/DiffEngineViewer/ILoopHooks.cs b/src/DiffEngineViewer/ILoopHooks.cs
index 62f5b102..23d89795 100644
--- a/src/DiffEngineViewer/ILoopHooks.cs
+++ b/src/DiffEngineViewer/ILoopHooks.cs
@@ -2,13 +2,16 @@
/// For a head whose toolkit can hold the loop's thread where the loop cannot see it. The loop in
/// runs one frame per , and on
/// Windows a present pumps messages - which is where user32 runs its own modal loops, for dragging
-/// a scrollbar thumb and for moving or sizing the window, and where a logoff ends the session. The
-/// loop's next step never comes in either case, so the head is handed those steps to run itself.
+/// a scrollbar thumb and for moving or sizing the window, and where a logoff ends the session. On
+/// Linux a present reads the X server's events, and a logoff ends the process from inside it as the
+/// server goes. The loop's next step never comes in any of these, so the head is handed those steps
+/// to run itself.
///
-/// Optional, and only WinForms takes it. The native heads pump their own events. The Linux one has
-/// no modal loops to be caught in. AppKit has: a menu, which is deliberate, and a window being
-/// resized, which still draws the rows sliced for its old size until the mouse comes up. Handing
-/// that head a frame from inside the loop would take a callback in the C ABI.
+/// Optional. WinForms takes both; the native heads only , which the
+/// macOS one never calls, since a logout's quit there is the app's own. The Linux one has no modal
+/// loops to be caught in. AppKit has: a menu, which is deliberate, and a window being resized,
+/// which still draws the rows sliced for its old size until the mouse comes up. Handing that head
+/// a frame from inside the loop would take a callback in the C ABI.
///
///
interface ILoopHooks
@@ -24,7 +27,8 @@ interface ILoopHooks
///
/// The session is ending: stop taking arrivals and stage what the queue holds, before
/// returning. Windows may end the process as soon as a window has answered the message that
- /// says so, which is before the loop would get as far as its own shutdown.
+ /// says so, and Xlib ends it as soon as its handler for the lost server returns, both before
+ /// the loop would get as far as its own shutdown.
///
Action? SessionEnding { set; }
}
diff --git a/src/DiffEngineViewer/Native/Deview.cs b/src/DiffEngineViewer/Native/Deview.cs
index 03245720..35eca393 100644
--- a/src/DiffEngineViewer/Native/Deview.cs
+++ b/src/DiffEngineViewer/Native/Deview.cs
@@ -18,7 +18,7 @@ public static void Resolve() =>
/// Must match DEVIEW_VERSION in native/include/deview.h. Bumped whenever the structs change,
/// so a stale native library is reported rather than read as garbage.
///
- public const int ExpectedVersion = 12;
+ public const int ExpectedVersion = 13;
[LibraryImport(Library, EntryPoint = "deview_version")]
public static partial int Version();
@@ -59,4 +59,7 @@ public static partial int Init(
[LibraryImport(Library, EntryPoint = "deview_shutdown")]
public static partial void Shutdown();
+
+ [LibraryImport(Library, EntryPoint = "deview_on_display_lost")]
+ public static partial void OnDisplayLost(delegate* unmanaged lost);
}
diff --git a/src/DiffEngineViewer/Native/NativeViewerWindow.cs b/src/DiffEngineViewer/Native/NativeViewerWindow.cs
index b4bb841f..cbd28c95 100644
--- a/src/DiffEngineViewer/Native/NativeViewerWindow.cs
+++ b/src/DiffEngineViewer/Native/NativeViewerWindow.cs
@@ -3,8 +3,16 @@
/// The backed by the native shim. The only type in the app that
/// touches it, so everything else stays testable on a machine with no GPU.
///
-sealed class NativeViewerWindow : IViewerWindow
+sealed class NativeViewerWindow :
+ IViewerWindow,
+ ILoopHooks
{
+ ///
+ /// What runs. Static, since the shim calls back through a plain
+ /// function pointer, which carries no instance to find it on.
+ ///
+ static Action? sessionEnding;
+
readonly ScreenPayload payload = new();
bool disposed;
@@ -160,6 +168,47 @@ public bool SetClipboard(string text)
public void Focus() =>
Deview.Focus();
+ ///
+ /// Nothing to run: the Linux shim has no modal loop to be caught in, and the macOS one has no
+ /// callback to run a frame from inside AppKit's. See .
+ ///
+ public Func? Frame
+ {
+ set
+ {
+ }
+ }
+
+ ///
+ /// Heard as the X server goes, which on Linux is how a logoff ends the process, from inside
+ /// whichever call was reading its events. The macOS shim never calls it.
+ ///
+ public unsafe Action? SessionEnding
+ {
+ set
+ {
+ sessionEnding = value;
+ Deview.OnDisplayLost(value is null ? null : &DisplayLost);
+ }
+ }
+
+ ///
+ /// Nothing may be thrown back into the shim, which would end the process before Xlib had said
+ /// why.
+ ///
+ [UnmanagedCallersOnly]
+ static void DisplayLost()
+ {
+ try
+ {
+ sessionEnding?.Invoke();
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine($"Failed while the session was ending: {exception}");
+ }
+ }
+
///
/// Explicit rather than a cast, because the shim reports only the keys a window can produce
/// and the two enums deliberately do not line up.
diff --git a/src/DiffEngineViewer/ViewerProgram.cs b/src/DiffEngineViewer/ViewerProgram.cs
index b7dfdf78..957d6e89 100644
--- a/src/DiffEngineViewer/ViewerProgram.cs
+++ b/src/DiffEngineViewer/ViewerProgram.cs
@@ -308,17 +308,23 @@ internal static int Run(
// One for the loop and for a head's modal loop, which draw the same window on one thread.
var screens = new ScreenCache(ScreenBuilder.Build);
+ var sessionEnding = SessionEnding(host, link);
if (window is ILoopHooks hooks)
{
hooks.Frame = () => ModalFrame(host, window, link, preferences, screens);
hooks.SessionEnding = () =>
{
- host.Mutate(_ => _ with {Closing = true});
- PersistOwned(host.State, link);
+ sessionEnding();
+ // Last, since it asks the window, and on Linux the window's display is what has
+ // just gone: the queue is staged by then whatever the asking does
Remember(window, preferences);
};
}
+ // What ends a macOS or Linux session's processes, which no window hears. Not the placement:
+ // these come on a thread of their own, and the window is the loop's.
+ using var signals = new SessionEndSignals(sessionEnding, _ => Console.Error.WriteLine($"Failed while the session was ending: {_}"));
+
// Whichever of the two produces them; a process either owns the queue or displays one.
var windowCommands = link?.Windows ?? new();
// What an idle loop waits on: set by every change of state, and by every window command,
@@ -418,6 +424,45 @@ static Screen ModalFrame(SessionHost host, IViewerWindow window, OwnerLink? link
return screens.Get(state);
}
+ ///
+ /// The session is ending: stop taking arrivals and stage what the queue holds. A logoff never
+ /// reaches the loop's own shutdown, which is what stages it otherwise, so an owning viewer's
+ /// snapshots went with the process.
+ ///
+ /// Once, however many of the ways a session ends are heard, and a second waits for the first
+ /// to finish rather than returning to a process that is about to end. Touches no window, so it
+ /// can run on a signal's thread, or inside a call into the window, which is where Linux hears
+ /// the X server going. That call may itself be inside a mutation, which cannot mutate again:
+ /// the queue is still staged, and nothing more arrives in a process that is about to end.
+ ///
+ ///
+ internal static Action SessionEnding(SessionHost host, OwnerLink? link)
+ {
+ var gate = new Lock();
+ var ended = false;
+ return () =>
+ {
+ lock (gate)
+ {
+ if (ended)
+ {
+ return;
+ }
+
+ ended = true;
+ try
+ {
+ host.Mutate(_ => _ with {Closing = true});
+ }
+ catch (InvalidOperationException)
+ {
+ }
+
+ PersistOwned(host.State, link);
+ }
+ };
+ }
+
///
/// An owning viewer's queue lives in this process's memory, so exiting with entries still
/// pending used to discard them silently. Staged instead, so accept tooling still finds them
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 1aaf58e4..9e55fbc9 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -19,9 +19,9 @@
-->
-
-
-
+
+
+