Skip to content

feat(windows): DX12 swap chain resize + Target double-free fix#158

Merged
deblasis merged 5 commits intowindowsfrom
feat/winui3-shell-dx12-fixes
Apr 6, 2026
Merged

feat(windows): DX12 swap chain resize + Target double-free fix#158
deblasis merged 5 commits intowindowsfrom
feat/winui3-shell-dx12-fixes

Conversation

@deblasis
Copy link
Copy Markdown
Owner

@deblasis deblasis commented Apr 6, 2026

Important

Stacked PR. Order:

  1. feat(windows): scaffold WinUI 3 app shell consuming libghostty #156 feat/winui3-shell -> windows
  2. fix(windows): WinUI 3 shell focus, input topology, and action plumbing #157 feat/winui3-shell-followup -> feat/winui3-shell
  3. this PR feat/winui3-shell-dx12-fixes -> feat/winui3-shell-followup

Two bugs from the followup backlog. They surfaced together but had separate root causes.

Target.deinit double-free

Target.resource is set in beginFrame to one of DirectX12.back_buffers[i] without an AddRef -- it is a borrowed reference owned by the API. Target.deinit was Releasing it anyway. The previous no-op setTargetSize masked this because the back buffers were never released, so the dangling pointer was never used. As soon as ResizeBuffers releases a back buffer, the next frame.resize hits Target.deinit on the dangling pointer and the process dies silently (no Zig panic, no managed exception).

Fix: document Target.resource as borrowed and stop releasing it in deinit.

DX12 swap chain resize

setTargetSize only cached the requested dimensions. The back buffers stayed at the initial size for the lifetime of the surface and DXGI composition stretched the small buffer onto the larger SwapChainPanel, which is why the terminal looked smaller and blurry.

setTargetSize runs synchronously from ghostty_surface_set_size on the C# UI thread, so it cannot touch GPU state. It now stores the desired dims as atomics. beginFrame runs on the renderer thread (the only thread allowed near back buffers, fences, and RTVs) and compares desired vs applied at the top -- if they differ it calls a new resizeSwapChain:

  • drain the GPU via waitForGpu
  • release the three back buffers
  • IDXGISwapChain1::ResizeBuffers(frame_count, w, h, UNKNOWN, 0)
  • re-acquire back buffers and recreate RTVs at the same descriptor slots
  • reset per-frame fence values

UNKNOWN format and 0 flags preserve creation values. Device-removed HRESULTs route through the existing handleDeviceRemoved path. Guarded against zero/no-op sizes so the C# 30ms debounce drops identical calls cheaply.

Debug-only unhandled-exception logger

The crash here was masked by the C# host silently exiting on an unhandled managed exception with no diagnostic. Added a Debug-only hook on the three exception entry points (UI dispatcher, AppDomain, task scheduler) that writes the exception to stderr. Release builds leave this off so WER can capture a real crash dump.

Test plan

  • zig build -Dapp-runtime=none clean
  • dotnet build windows/Ghostty/Ghostty.sln clean
  • just run-win, drag-resize aggressively across many sizes, run dir, close window. No crash, no DX12 debug-layer warnings, terminal grid fills the panel pixel-perfect.

@deblasis deblasis marked this pull request as ready for review April 6, 2026 15:29
@deblasis deblasis force-pushed the feat/winui3-shell-followup branch from 10d735b to 35d666f Compare April 6, 2026 16:02
@deblasis deblasis force-pushed the feat/winui3-shell-dx12-fixes branch from 6029d75 to 6ee61e7 Compare April 6, 2026 16:03
@deblasis deblasis deleted the branch windows April 6, 2026 16:05
@deblasis deblasis closed this Apr 6, 2026
@deblasis deblasis reopened this Apr 6, 2026
@deblasis deblasis changed the base branch from feat/winui3-shell-followup to windows April 6, 2026 16:09
deblasis added 4 commits April 6, 2026 18:09
Target.resource is set in beginFrame to one of the API's back_buffers
slots without an AddRef -- it is a borrowed reference whose lifetime
is owned by DirectX12.back_buffers. Target.deinit was Releasing it
anyway, which was a latent double-free that the previous no-op
setTargetSize masked because the back buffers were never actually
released. As soon as ResizeBuffers releases them on resize, the next
frame.resize call hits Target.deinit on the dangling pointer and the
process dies silently.
setTargetSize previously only cached the requested dimensions and the
swap chain back buffers stayed at their initial size for the lifetime
of the surface. DXGI composition stretched the small back buffer onto
the larger SwapChainPanel, which is why the terminal looked smaller
and blurry inside the window.

Resize on the renderer thread, not the apprt thread. setTargetSize
runs synchronously from ghostty_surface_set_size on the C# UI thread
and must not touch GPU state. It now stores the desired size in
atomics and beginFrame compares them to applied_width/height at the
top -- if they differ it drains the GPU, releases back buffers, calls
IDXGISwapChain1::ResizeBuffers, re-acquires buffers and recreates RTVs
at the same descriptor slots, then resets per-frame fence values.
Device-removed HRESULTs route through the existing handleDeviceRemoved
path.
A managed exception on the UI thread silently exits the process with
a non-descriptive code, leaving nothing to debug from. Hook the three
unhandled-exception entry points (UI dispatcher, AppDomain, task
scheduler) and write the exception to stderr. Debug-only -- in
Release we want WER to capture a real crash dump instead.
- Pack desired_width/desired_height into a single u64 atomic so a
  drag-resize cannot tear and briefly resize the swap chain to a
  width/height pair from two different setTargetSize calls.
- resizeSwapChain now returns error.NoDevice / error.NoSwapChain on
  the missing-handle paths instead of silently returning. The previous
  silent return left applied_width/height stale, so beginFrame would
  loop on the same desired size every frame with nothing logged.
- Drop the unconditional log.info("presentLastTarget"...) that fired
  every idle frame.
- Document the IDXGISwapChain3 -> IDXGISwapChain1 ptrCast as a COM
  v-table prefix reinterpret (cheaper than QueryInterface for a hot
  path).
@deblasis deblasis force-pushed the feat/winui3-shell-dx12-fixes branch from 6ee61e7 to d8ce97a Compare April 6, 2026 16:28
Three changes that together remove the visible white frame at the
leading edge of an interactive drag-resize:

- Replace the WNDCLASS hbrBackground brush on the WinUI 3 host HWND
  with a dark solid brush. DWM paints uncovered window pixels with
  the class brush before WinUI 3 extends its XAML content into the
  new area, and the default brush is white.
- Set RootGrid Background to the same dark color so the XAML layer
  matches the Win32 layer underneath the SwapChainPanel.
- Drop the 30 ms resize debounce in TerminalControl. It was a
  workaround for the old DX12 renderer that recreated the swap chain
  on every set_size; now that setTargetSize records desired
  dimensions atomically and beginFrame applies them via
  ResizeBuffers, every pixel of drag costs only an atomic store and
  matches the macOS apprt's zero-debounce path.
@deblasis deblasis merged commit 60fcf8f into windows Apr 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant