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 @@ --> - - - + + +