qt+apprt: 1x1 sentinel default size + syncSurfaceSize on Show

Two coordinated changes to harden against the renderer producing
a "wrong-size first frame" that slips past drainVulkan's size
guard:

1) src/apprt/embedded.zig: change the default Surface.Options
   size from 800x600 to 1x1 sentinel. The previous default
   collided with real device-pixel widget sizes on DPR-fractional
   setups (e.g. 666 logical x 1.2 DPR = 800 device-pixel,
   matching libghostty's default exactly) — a wrong-size first
   frame at 800x600 would pass drainVulkan's expected-size check
   and get attached + stretched, with the custom shader's
   iResolution stuck at 800x600 producing wrong-scaled output.
   1x1 is small enough that no real widget will ever match, so
   drainVulkan always drops the first frame and waits for one
   produced after the host's ghostty_surface_set_size call.

2) qt/src/GhosttySurface.cpp: call syncSurfaceSize from the
   QEvent::Show handler. On brand-new tabs Qt fires resizeEvent
   right after Show and syncSurfaceSize runs from there, but on
   tab SWAP (the 2nd tab replacing the 1st in an already-laid-out
   tab area), the widget inherits the existing layout slot at
   the same size and Qt does NOT fire resizeEvent. Without
   this defensive call, libghostty stayed at its (now 1x1)
   default forever, the renderer kept producing 1x1 frames,
   drainVulkan kept dropping them, and the placeholder bg color
   showed indefinitely. Show-driven syncSurfaceSize ensures
   libghostty hears about the widget's real size on every
   show transition.

Status: 2nd-tab wrong-image bug still occasionally reproduces
("had to open ghastty twice to repro"), so there's a residual
race we haven't pinpointed. The 1x1 + Show-sync changes are
strict improvements regardless — they close the failure modes
we definitively understand. Further investigation needs runtime
instrumentation to identify exactly which frame is being
attached at the moment the bug fires.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
Nathan 2026-05-26 16:51:18 -05:00
parent b10d20a98a
commit 37aff5a2aa
2 changed files with 31 additions and 1 deletions

View File

@ -588,6 +588,26 @@ bool GhosttySurface::event(QEvent *e) {
// Clear the present-gate latch: subsequent frames go through
// the subsurface as normal.
m_hidden.store(false, std::memory_order_release);
// Defensive re-sync of the surface size to libghostty. On a
// brand-new tab Qt fires resizeEvent right after Show and
// syncSurfaceSize runs from there — but on a tab SWAP (the
// 2nd tab replaces the 1st in the tab area), the widget
// reuses the existing layout slot at the same size. Qt does
// NOT fire resizeEvent in that case, so syncSurfaceSize
// never runs, libghostty stays at its default 800×600 surface
// size, and the renderer's first frame goes out at 800×600.
// If the widget happens to ALSO be 800×600 (small windows,
// unlikely but possible), the wrong-size drop guard in
// drainVulkan misses, the wrong-size frame is attached,
// wp_viewport stretches it… and the custom shader's
// resolution uniform (set from libghostty's 800×600 surface
// size, not the widget's real size) makes the shader draw at
// the wrong scale → the iChannel0 texture renders at full
// image size instead of the configured background pattern.
// Calling syncSurfaceSize here ensures libghostty is told
// about the widget's actual size before the renderer's next
// frame, regardless of whether resizeEvent fires.
syncSurfaceSize();
// Re-attach the last-presented dmabuf immediately on Show.
// Without this, Hide had attached a NULL buffer (so the
// pane's old frame wouldn't ghost over the active tab) and

View File

@ -675,7 +675,17 @@ pub const Surface = struct {
.x = @floatCast(opts.scale_factor),
.y = @floatCast(opts.scale_factor),
},
.size = .{ .width = 800, .height = 600 },
// Initial surface size is a sentinel (1×1) until the host's
// first ghostty_surface_set_size call. The previous default
// (800×600) collided with real widget sizes on DPR-fractional
// setups (e.g. 666 logical × 1.2 DPR = 800 device-pixel),
// letting wrong-size first frames slip past the Qt apprt's
// wrong-size drop guard in drainVulkan. With 1×1 the renderer's
// first frame is always 1×1, drainVulkan always drops it as
// wrong-size, and the placeholder/real-frame swap waits for
// the first frame produced at the actual widget size after
// resize.
.size = .{ .width = 1, .height = 1 },
.cursor_pos = .{ .x = -1, .y = -1 },
};