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
2 changes: 1 addition & 1 deletion .github/workflows/build-native.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion native/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 15 additions & 1 deletion native/include/deview.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 */
Expand Down
5 changes: 5 additions & 0 deletions native/src/deview.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion native/swift/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions native/swift/Sources/Deview/Exports.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
61 changes: 61 additions & 0 deletions src/DiffEngineViewer.Tests/ViewerProgramTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,67 @@ public async Task AnAttachedViewerPersistsNothing()
await Assert.That(project.StagedFiles()).IsEmpty();
}

/// <summary>
/// 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.
/// </summary>
[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();
}

/// <summary>
/// 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.
/// </summary>
[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);
}

/// <summary>
/// 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.
/// </summary>
[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();
}

/// <summary>
/// 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
Expand Down
18 changes: 11 additions & 7 deletions src/DiffEngineViewer/ILoopHooks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
/// For a head whose toolkit can hold the loop's thread where the loop cannot see it. The loop in
/// <see cref="ViewerProgram" /> runs one frame per <see cref="IViewerWindow.Present" />, 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.
/// <para>
/// 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 <see cref="SessionEnding" />, 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.
/// </para>
/// </summary>
interface ILoopHooks
Expand All @@ -24,7 +27,8 @@ interface ILoopHooks
/// <summary>
/// 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.
/// </summary>
Action? SessionEnding { set; }
}
5 changes: 4 additions & 1 deletion src/DiffEngineViewer/Native/Deview.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public const int ExpectedVersion = 12;
public const int ExpectedVersion = 13;

[LibraryImport(Library, EntryPoint = "deview_version")]
public static partial int Version();
Expand Down Expand Up @@ -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<void> lost);
}
51 changes: 50 additions & 1 deletion src/DiffEngineViewer/Native/NativeViewerWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,16 @@
/// The <see cref="IViewerWindow" /> 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.
/// </summary>
sealed class NativeViewerWindow : IViewerWindow
sealed class NativeViewerWindow :
IViewerWindow,
ILoopHooks
{
/// <summary>
/// What <see cref="DisplayLost" /> runs. Static, since the shim calls back through a plain
/// function pointer, which carries no instance to find it on.
/// </summary>
static Action? sessionEnding;

readonly ScreenPayload payload = new();
bool disposed;

Expand Down Expand Up @@ -160,6 +168,47 @@ public bool SetClipboard(string text)
public void Focus() =>
Deview.Focus();

/// <summary>
/// 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 <see cref="ILoopHooks" />.
/// </summary>
public Func<Screen>? Frame
{
set
{
}
}

/// <summary>
/// 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.
/// </summary>
public unsafe Action? SessionEnding
{
set
{
sessionEnding = value;
Deview.OnDisplayLost(value is null ? null : &DisplayLost);
}
}

/// <summary>
/// Nothing may be thrown back into the shim, which would end the process before Xlib had said
/// why.
/// </summary>
[UnmanagedCallersOnly]
static void DisplayLost()
{
try
{
sessionEnding?.Invoke();
}
catch (Exception exception)
{
Console.Error.WriteLine($"Failed while the session was ending: {exception}");
}
}

/// <summary>
/// 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.
Expand Down
49 changes: 47 additions & 2 deletions src/DiffEngineViewer/ViewerProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -418,6 +424,45 @@ static Screen ModalFrame(SessionHost host, IViewerWindow window, OwnerLink? link
return screens.Get(state);
}

/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// </summary>
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);
}
};
}

/// <summary>
/// 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
Expand Down
6 changes: 3 additions & 3 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
-->
<PackageVersion Include="SkiaSharp" Version="4.153.1" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.153.1" />
<PackageVersion Include="PlatformHeads" Version="0.5.0" />
<PackageVersion Include="PlatformHeads.Core" Version="0.5.0" />
<PackageVersion Include="PlatformHeads.WindowsForms" Version="0.5.0" />
<PackageVersion Include="PlatformHeads" Version="0.6.0" />
<PackageVersion Include="PlatformHeads.Core" Version="0.6.0" />
<PackageVersion Include="PlatformHeads.WindowsForms" Version="0.6.0" />
<PackageVersion Include="Polyfill" Version="11.4.5" />
<PackageVersion Include="ProjectDefaults" Version="1.0.184" />
<PackageVersion Include="Squash" Version="2.1.0" />
Expand Down
Loading