From 1af3b0e0e1b3ecddd0f42bc9cff175e6e619bea4 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:56:34 +0200 Subject: [PATCH 1/2] Bind GTK capture proof to the popup render frame Observe the serial and frame counter when GTK snapshots fresh alpha-zero proof content, instead of accepting a shared after-paint event. Preserve stale-proof rejection and the existing mapped-surface and compositor-feedback checks. Keep empty menu proof wrappers at least one pixel so GTK snapshots them. Exercise real GtkPopover and GtkPopoverMenu surfaces through an isolated Wayland proxy, including menu restoration and an exact child execution check. Fold the unused counter helper into the drag paint callback. The paintable has its own module; the wrapper stays in view/mod.rs because its observation and refresh state belong to that existing owner. The native regressions fail before their respective repairs and pass afterward. Full local validation and both GTK gates pass. Related to #398; the separate layout-change toast is unresolved. --- src/toolbar_gtk/view/capture_proof.rs | 96 ++++++ src/toolbar_gtk/view/capture_suppression.rs | 17 +- .../capture_suppression/tests/presentation.rs | 280 ++++++++++++++++++ src/toolbar_gtk/view/mod.rs | 44 ++- tools/test-fixtures/gtk_popup_wayland.py | 277 +++++++++++++++++ 5 files changed, 690 insertions(+), 24 deletions(-) create mode 100644 src/toolbar_gtk/view/capture_proof.rs create mode 100644 src/toolbar_gtk/view/capture_suppression/tests/presentation.rs create mode 100644 tools/test-fixtures/gtk_popup_wayland.py diff --git a/src/toolbar_gtk/view/capture_proof.rs b/src/toolbar_gtk/view/capture_proof.rs new file mode 100644 index 000000000..43c793c04 --- /dev/null +++ b/src/toolbar_gtk/view/capture_proof.rs @@ -0,0 +1,96 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use gtk4::gdk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +#[derive(Clone, Copy)] +pub(super) struct ProofRender { + pub serial: u8, + pub frame_counter: i64, +} + +pub(super) type ProofObservation = Rc>>; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct CaptureProof { + pub(super) texture: RefCell>, + pub(super) picture: glib::WeakRef, + pub(super) observed: RefCell>, + pub(super) serial: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for CaptureProof { + const NAME: &'static str = "WayscriberCaptureProof"; + type Type = super::CaptureProof; + type Interfaces = (gdk::Paintable,); + } + + impl ObjectImpl for CaptureProof {} + + impl gdk::subclass::prelude::PaintableImpl for CaptureProof { + fn snapshot(&self, snapshot: &gdk::Snapshot, width: f64, height: f64) { + let texture = self.texture.borrow(); + let Some(texture) = texture.as_ref() else { + return; + }; + + texture.snapshot(snapshot, width, height); + + if let Some(picture) = self.picture.upgrade() + && let Some(clock) = picture.frame_clock() + && let Some(observed) = self.observed.borrow().as_ref() + { + let serial = self.serial.get(); + let frame = clock.frame_counter(); + observed.set(Some(ProofRender { + serial, + frame_counter: frame, + })); + } + } + + fn flags(&self) -> gdk::PaintableFlags { + gdk::PaintableFlags::STATIC_CONTENTS | gdk::PaintableFlags::STATIC_SIZE + } + + fn intrinsic_width(&self) -> i32 { + 1 + } + + fn intrinsic_height(&self) -> i32 { + 1 + } + + fn intrinsic_aspect_ratio(&self) -> f64 { + 1.0 + } + } +} + +glib::wrapper! { + pub struct CaptureProof(ObjectSubclass) @implements gdk::Paintable; +} + +impl CaptureProof { + pub(super) fn new( + texture: &gdk::MemoryTexture, + picture: >k4::Picture, + observed: ProofObservation, + serial: u8, + ) -> Self { + let proof: Self = glib::Object::new(); + proof.imp().texture.replace(Some(texture.clone())); + proof.imp().picture.set(Some(picture)); + proof.imp().observed.replace(Some(observed)); + proof.imp().serial.set(serial); + + proof + } +} diff --git a/src/toolbar_gtk/view/capture_suppression.rs b/src/toolbar_gtk/view/capture_suppression.rs index a0cf020d4..104255b63 100644 --- a/src/toolbar_gtk/view/capture_suppression.rs +++ b/src/toolbar_gtk/view/capture_suppression.rs @@ -7,10 +7,7 @@ use std::time::{Duration, Instant}; use gtk4::prelude::*; -use super::{ - CaptureProofTarget, CaptureSurfaceContent, after_next_surface_paint_counter, - widget_native_is_mapped, -}; +use super::{CaptureProofTarget, CaptureSurfaceContent, widget_native_is_mapped}; use crate::toolbar_gtk::css::CAPTURE_TRANSPARENT_CLASS; use native_popovers::NativePopoverCapture; @@ -398,8 +395,6 @@ async fn wait_for_surface_presentation( "GTK capture suppression generation {generation} found no frame clock for mapped {name}" ) })?; - let rendered_counter = Rc::new(Cell::new(None)); - let callback_counter = Rc::clone(&rendered_counter); log::info!( "capture.preflight id={generation} component=gtk surface={name} phase=proof-requested sequence={ordinal}/{target_count} elapsed_ms={}", @@ -411,11 +406,10 @@ async fn wait_for_surface_presentation( // without a new Wayland buffer or presentation event. Give this target a // fresh but still alpha-zero texture before arming its dedicated frame. target.refresh_transparent_proof(); - after_next_surface_paint_counter(widget, move |counter| { - callback_counter.set(counter); - }); + // A shared frame clock can paint the parent while the popup is frozen. + // Bind this proof to the fresh paintable's actual native snapshot. - while rendered_counter.get().is_none() { + while target.content.proof_frame().is_none() { reject_withdrawn_target(generation, target, started)?; if proof_started.elapsed() >= CAPTURE_PAINT_TIMEOUT || Instant::now() >= deadline { return Err(format!( @@ -425,7 +419,7 @@ async fn wait_for_surface_presentation( gtk4::glib::timeout_future(CAPTURE_PAINT_POLL_INTERVAL).await; } - let Some(frame_counter) = rendered_counter.get() else { + let Some(frame_counter) = target.content.proof_frame() else { return Err(format!( "GTK capture suppression generation {generation} lost the dedicated transparent render from {name}" )); @@ -493,6 +487,7 @@ fn reject_withdrawn_target( #[cfg(test)] mod tests { + mod presentation; mod process; use super::*; diff --git a/src/toolbar_gtk/view/capture_suppression/tests/presentation.rs b/src/toolbar_gtk/view/capture_suppression/tests/presentation.rs new file mode 100644 index 000000000..889561824 --- /dev/null +++ b/src/toolbar_gtk/view/capture_suppression/tests/presentation.rs @@ -0,0 +1,280 @@ +use super::*; +use std::future::{Future, poll_fn}; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::task::Poll; + +const CHILD_ENV: &str = "WAYSCRIBER_GTK_POPUP_PRESENTATION_CHILD"; +const TEST_GENERATION: u64 = 1; +const FIXTURE_WAIT: Duration = Duration::from_secs(2); + +#[test] +fn popup_capture_waits_for_its_own_render_after_a_shared_clock_paint() { + let name = concat!( + module_path!(), + "::popup_capture_waits_for_its_own_render_after_a_shared_clock_paint" + ); + if !run_in_private_display(name) { + return; + } + + gtk4::init().expect("private GTK display"); + let (window, anchor) = popup_parent(); + let content = CaptureSurfaceContent::new(>k4::Label::new(Some("Popup content"))); + let popover = gtk4::Popover::new(); + popover.set_autohide(false); + popover.set_has_arrow(false); + popover.set_child(Some(content.widget())); + popover.set_parent(&anchor); + window.present(); + + gtk4::glib::MainContext::default().block_on(async { + wait_for_native_mapping(window.upcast_ref()).await; + require_gl_renderer(&window); + + popover.popup(); + wait_for_native_mapping(popover.upcast_ref()).await; + wait_for_initial_popup_callback().await; + + control("arm"); + content.set_transparent(true); + let target = CaptureProofTarget::new_withdrawable("canvas", &popover, &content); + assert_popup_proof(&window, target).await; + }); + + content.set_transparent(false); + popover.popdown(); + popover.unparent(); + window.destroy(); + eprintln!("EXECUTED: GTK native popup presentation regression"); +} + +#[test] +fn menu_capture_presents_its_empty_proof_and_restores_the_menu() { + let name = concat!( + module_path!(), + "::menu_capture_presents_its_empty_proof_and_restores_the_menu" + ); + if !run_in_private_display(name) { + return; + } + + gtk4::init().expect("private GTK display"); + let (window, anchor) = popup_parent(); + let model = gtk4::gio::Menu::new(); + model.append(Some("Copy"), Some("clipboard.copy")); + let menu = gtk4::PopoverMenu::from_model(Some(&model)); + menu.set_autohide(false); + menu.set_has_arrow(false); + menu.set_parent(&anchor); + let original_child = menu.child().expect("menu child"); + let original_model = menu.menu_model(); + let capture = TooltipCapture::new(); + window.present(); + + gtk4::glib::MainContext::default().block_on(async { + wait_for_native_mapping(window.upcast_ref()).await; + require_gl_renderer(&window); + + menu.popup(); + wait_for_native_mapping(menu.upcast_ref()).await; + wait_for_initial_popup_callback().await; + + control("arm"); + capture.set_suppressed(true); + capture.install_tree(window.upcast_ref()); + let mut targets = capture.capture_popover_targets(); + assert_eq!(targets.len(), 1, "mapped native menu must be enrolled"); + let target = targets.pop().unwrap(); + assert_eq!(target.content.content_opacity(), None); + assert_eq!(menu.child().as_ref(), Some(&original_child)); + + assert_popup_proof(&window, target).await; + capture.mark_capture_popovers_proven(); + assert!(capture.pending_capture_popover_targets().is_empty()); + }); + + capture.set_suppressed(false); + assert_eq!(menu.menu_model(), original_model); + assert_eq!(menu.child().as_ref(), Some(&original_child)); + menu.popdown(); + menu.unparent(); + window.destroy(); + eprintln!("EXECUTED: GTK native menu presentation assertions"); +} + +fn run_in_private_display(test_name: &str) -> bool { + if std::env::var_os(CHILD_ENV).is_some() { + return true; + } + if std::env::var_os("WAYSCRIBER_REQUIRE_GTK_TESTS").is_none() { + eprintln!("skipping native popup presentation test: run tools/test-gtk-widgets.sh"); + return false; + } + + let test_name = test_name + .strip_prefix(concat!(env!("CARGO_CRATE_NAME"), "::")) + .expect("test name contains the crate prefix"); + let fixture = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tools/test-fixtures/gtk_popup_wayland.py" + ); + let output = std::process::Command::new("python3") + .arg(fixture) + .arg(std::env::current_exe().expect("test binary")) + .arg(test_name) + .env(CHILD_ENV, "1") + .output() + .expect("run private Wayland popup fixture"); + std::io::stdout().write_all(&output.stdout).unwrap(); + std::io::stderr().write_all(&output.stderr).unwrap(); + + assert!( + output.status.success(), + "native popup presentation test failed" + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("test result: ok. 1 passed; 0 failed;"), + "private fixture must execute exactly one passing test" + ); + + false +} + +fn popup_parent() -> (gtk4::Window, gtk4::Button) { + let window = gtk4::Window::new(); + window.set_default_size(320, 200); + let anchor = gtk4::Button::with_label("Canvas"); + window.set_child(Some(&anchor)); + + (window, anchor) +} + +fn require_gl_renderer(window: >k4::Window) { + assert!( + window + .renderer() + .expect("window renderer") + .is::() + ); +} + +async fn wait_for_native_mapping(widget: >k4::Widget) { + let deadline = Instant::now() + FIXTURE_WAIT; + loop { + if widget_native_is_mapped(widget) && widget.width() > 0 && widget.height() > 0 { + return; + } + + assert!( + Instant::now() < deadline, + "native surface did not map with nonzero bounds" + ); + gtk4::glib::timeout_future(CAPTURE_PAINT_POLL_INTERVAL).await; + } +} + +async fn wait_for_initial_popup_callback() { + let deadline = Instant::now() + FIXTURE_WAIT; + while control("status").popup_callbacks_delivered == 0 { + assert!( + Instant::now() < deadline, + "initial popup frame did not complete" + ); + + gtk4::glib::timeout_future(CAPTURE_PAINT_POLL_INTERVAL).await; + } +} + +async fn assert_popup_proof(window: >k4::Window, target: CaptureProofTarget) { + let popup = target.widget.clone(); + let clock = popup.frame_clock().expect("popup frame clock"); + assert_eq!(window.frame_clock().as_ref(), Some(&clock)); + wait_for_held_popup_callback().await; + + let held_commits = control("status").popup_commits; + let shared_paint = Rc::new(Cell::new(None)); + let paint_observation = Rc::clone(&shared_paint); + let handler = clock.connect_after_paint(move |clock| { + paint_observation.set(Some(clock.frame_counter())); + }); + let proof = wait_for_presented_transparency(TEST_GENERATION, vec![target]); + let mut proof = std::pin::pin!(proof); + + // Arm the real capture wait before asking the shared clock to paint. + // A separately spawned task could start after that paint and miss the race. + assert!( + poll_fn(|cx| Poll::Ready(proof.as_mut().poll(cx))) + .await + .is_pending() + ); + clock.request_phase(gtk4::gdk::FrameClockPhase::PAINT); + + let deadline = Instant::now() + Duration::from_millis(250); + while shared_paint.get().is_none() { + assert!(Instant::now() < deadline, "shared clock did not paint"); + gtk4::glib::timeout_future(CAPTURE_PAINT_POLL_INTERVAL).await; + } + clock.disconnect(handler); + + assert!( + poll_fn(|cx| Poll::Ready(proof.as_mut().poll(cx))) + .await + .is_pending(), + "a parent paint cannot admit the popup" + ); + let status = control("status"); + assert!(status.held); + assert_eq!(status.popup_commits, held_commits); + + control("release"); + proof.await.expect( + "the fresh popup render must complete capture after the previous callback is released", + ); + + let status = control("status"); + assert!(status.popup_commits > held_commits); + assert!(status.popup_callbacks_delivered >= 2); + assert!(widget_native_is_mapped(&popup)); +} + +async fn wait_for_held_popup_callback() { + let deadline = Instant::now() + FIXTURE_WAIT; + while !control("status").held { + assert!( + Instant::now() < deadline, + "popup never submitted the held callback" + ); + + gtk4::glib::timeout_future(CAPTURE_PAINT_POLL_INTERVAL).await; + } +} + +#[derive(Debug, serde::Deserialize)] +struct ProxyStatus { + held: bool, + popup_commits: u64, + popup_callbacks_delivered: u64, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +enum ProxyResponse { + Status(ProxyStatus), + Error { error: String }, +} + +fn control(command: &str) -> ProxyStatus { + let path = std::env::var_os("WAYSCRIBER_GTK_WAYLAND_CONTROL").expect("private proxy control"); + let mut socket = UnixStream::connect(path).expect("connect proxy control"); + socket.set_read_timeout(Some(FIXTURE_WAIT)).unwrap(); + + writeln!(socket, "{command}").unwrap(); + let mut response = String::new(); + BufReader::new(socket).read_line(&mut response).unwrap(); + + match serde_json::from_str(&response).expect("proxy response") { + ProxyResponse::Status(status) => status, + ProxyResponse::Error { error } => panic!("proxy error: {error}"), + } +} diff --git a/src/toolbar_gtk/view/mod.rs b/src/toolbar_gtk/view/mod.rs index 8eaa1a1d1..169a32262 100644 --- a/src/toolbar_gtk/view/mod.rs +++ b/src/toolbar_gtk/view/mod.rs @@ -1,6 +1,7 @@ //! GTK toolbar windows: owns the top strip, the shared //! stylesheet, and output pinning. +mod capture_proof; mod capture_suppression; mod drag; mod sections; @@ -141,6 +142,7 @@ pub(super) struct CaptureSurfaceContent { root: gtk4::Overlay, proof: gtk4::Picture, proof_serial: std::rc::Rc>, + proof_observation: capture_proof::ProofObservation, } impl CaptureSurfaceContent { @@ -151,7 +153,11 @@ impl CaptureSurfaceContent { let bytes = gtk4::glib::Bytes::from_static(&[0xff, 0x00, 0xff, 0x00]); let texture = gtk4::gdk::MemoryTexture::new(1, 1, gtk4::gdk::MemoryFormat::R8g8b8a8, &bytes, 4); - let proof = gtk4::Picture::for_paintable(&texture); + let proof_observation = std::rc::Rc::new(std::cell::Cell::new(None)); + let proof = gtk4::Picture::new(); + let paintable = + capture_proof::CaptureProof::new(&texture, &proof, proof_observation.clone(), 0); + proof.set_paintable(Some(&paintable)); proof.set_content_fit(gtk4::ContentFit::Fill); proof.set_halign(gtk4::Align::Fill); proof.set_valign(gtk4::Align::Fill); @@ -162,12 +168,16 @@ impl CaptureSurfaceContent { let root = gtk4::Overlay::new(); root.add_css_class(CAPTURE_SURFACE_CONTENT_CLASS); + // GTK-owned menus have no ordinary child in this wrapper. Keep their + // proof area nonzero so GTK actually snapshots the alpha-zero texture. + root.set_size_request(1, 1); root.add_overlay(&proof); Self { root, proof, proof_serial: std::rc::Rc::new(std::cell::Cell::new(0)), + proof_observation, } } @@ -225,10 +235,24 @@ impl CaptureSurfaceContent { let bytes = gtk4::glib::Bytes::from_owned(bytes); let texture = gtk4::gdk::MemoryTexture::new(1, 1, gtk4::gdk::MemoryFormat::R8g8b8a8, &bytes, 4); - self.proof.set_paintable(Some(&texture)); + self.proof_observation.set(None); + let paintable = capture_proof::CaptureProof::new( + &texture, + &self.proof, + self.proof_observation.clone(), + serial, + ); + self.proof.set_paintable(Some(&paintable)); self.root.queue_draw(); } + fn proof_frame(&self) -> Option { + self.proof_observation + .get() + .filter(|render| render.serial == self.proof_serial.get()) + .map(|render| render.frame_counter) + } + fn content_opacity(&self) -> Option { self.root.child().map(|content| content.opacity()) } @@ -394,32 +418,26 @@ pub(super) fn after_next_surface_paint(widget: &W, callback: F) where W: IsA, F: FnOnce() + 'static, -{ - after_next_surface_paint_counter(widget, move |_| callback()); -} - -fn after_next_surface_paint_counter(widget: &W, callback: F) -where - W: IsA, - F: FnOnce(Option) + 'static, { let Some(frame_clock) = widget.frame_clock() else { - callback(None); + callback(); return; }; + let callback = std::rc::Rc::new(std::cell::RefCell::new(Some(callback))); let handler = std::rc::Rc::new(std::cell::RefCell::new(None)); let callback_slot = callback.clone(); let handler_slot = handler.clone(); let callback_clock = frame_clock.clone(); - let handler_id = frame_clock.connect_after_paint(move |clock| { + let handler_id = frame_clock.connect_after_paint(move |_| { if let Some(handler_id) = handler_slot.borrow_mut().take() { callback_clock.disconnect(handler_id); } if let Some(callback) = callback_slot.borrow_mut().take() { - callback(Some(clock.frame_counter())); + callback(); } }); + *handler.borrow_mut() = Some(handler_id); widget.queue_draw(); frame_clock.request_phase(gtk4::gdk::FrameClockPhase::PAINT); diff --git a/tools/test-fixtures/gtk_popup_wayland.py b/tools/test-fixtures/gtk_popup_wayland.py new file mode 100644 index 000000000..e559ff5f0 --- /dev/null +++ b/tools/test-fixtures/gtk_popup_wayland.py @@ -0,0 +1,277 @@ +"""Run a real GTK popup behind a private Weston with one frame event withheld. + +All protocol messages and SCM_RIGHTS descriptors pass through unchanged, except +the selected popup callback and its delete_id event. The XML schemas provide +opcodes and new-object types; this fixture neither renders nor invents feedback. +""" + +import array +import json +import os +from pathlib import Path +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +import xml.etree.ElementTree as ET + + +def schemas(): + interfaces = {} + for package, relative in (("wayland-client", "wayland.xml"), + ("wayland-protocols", "stable/xdg-shell/xdg-shell.xml")): + directory = subprocess.check_output( + ["pkg-config", "--variable=pkgdatadir", package], text=True + ).strip() + for interface in ET.parse(Path(directory) / relative).getroot().findall("interface"): + interfaces[interface.attrib["name"]] = { + direction: interface.findall(tag) + for direction, tag in (("request", "request"), ("event", "event")) + } + return interfaces + + +def arguments(message, payload): + values = {} + offset = 0 + for argument in message.findall("arg"): + kind = argument.attrib["type"] + if kind == "fd": + continue + value = struct.unpack_from("=I", payload, offset)[0] + offset += 4 + if kind in ("string", "array"): + raw = payload[offset:offset + value] + offset += (value + 3) & ~3 + value = raw.rstrip(b"\0").decode() if kind == "string" else raw + elif kind == "new_id" and "interface" not in argument.attrib: + # A dynamic new_id carries its interface name, version, then ID. + interface = payload[offset:offset + value].rstrip(b"\0").decode() + offset += (value + 3) & ~3 + version, value = struct.unpack_from("=II", payload, offset) + offset += 8 + values["interface"] = interface + values["version"] = version + values[argument.attrib["name"]] = value + return values + + +def receive(sock): + data = bytearray() + fds = array.array("i") + size = 8 + while len(data) < size: + chunk, ancillary, flags, _ = sock.recvmsg( + size - len(data), socket.CMSG_SPACE(256 * fds.itemsize) + ) + if not chunk: + for fd in fds: + os.close(fd) + return None + if flags & socket.MSG_CTRUNC: + raise RuntimeError("truncated Wayland descriptors") + data.extend(chunk) + for level, kind, raw in ancillary: + if level == socket.SOL_SOCKET and kind == socket.SCM_RIGHTS: + fds.frombytes(raw) + if len(data) == 8: + size = struct.unpack_from("=I", data, 4)[0] >> 16 + if size < 8 or size % 4: + raise RuntimeError("invalid Wayland message length") + return bytes(data), fds + + +def send(sock, packet): + data, fds = packet + try: + ancillary = [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fds)] if fds else [] + sent = sock.sendmsg([data], ancillary) + sock.sendall(data[sent:]) + finally: + for fd in fds: + os.close(fd) + + +class Connection: + def __init__(self, client, upstream, interfaces): + self.client = client + self.server = socket.socket(socket.AF_UNIX) + self.server.connect(upstream) + self.interfaces = interfaces + self.objects = {1: "wl_display"} + self.xdg_surfaces = {} + self.popups = set() + self.callbacks = {} + self.lock = threading.RLock() + self.armed = False + self.selected = None + self.held = [] + self.commits = 0 + self.delivered = 0 + self.error = None + + def start(self): + for source, destination, direction in ( + (self.client, self.server, "request"), (self.server, self.client, "event") + ): + threading.Thread(target=self.forward, args=(source, destination, direction), + daemon=True).start() + + def inspect(self, packet, direction): + data, _ = packet + object_id, header = struct.unpack_from("=II", data) + interface = self.objects.get(object_id) + if interface not in self.interfaces: + return False + message = self.interfaces[interface][direction][header & 0xffff] + values = arguments(message, data[8:]) + for argument in message.findall("arg"): + if argument.attrib["type"] == "new_id": + self.objects[values[argument.attrib["name"]]] = argument.attrib.get( + "interface", values.get("interface") + ) + name = message.attrib["name"] + + if direction == "request": + if interface == "xdg_wm_base" and name == "get_xdg_surface": + self.xdg_surfaces[values["id"]] = values["surface"] + elif interface == "xdg_surface" and name == "get_popup": + self.popups.add(self.xdg_surfaces[object_id]) + elif interface == "wl_surface" and name == "frame": + self.callbacks[values["callback"]] = object_id + if self.armed and object_id in self.popups: + self.selected = values["callback"] + self.armed = False + elif interface == "wl_surface" and name == "commit" and object_id in self.popups: + self.commits += 1 + return False + + if interface == "wl_callback" and name == "done": + if object_id == self.selected: + return True + if self.callbacks.pop(object_id, None) in self.popups: + self.delivered += 1 + elif interface == "wl_display" and name == "delete_id": + if values["id"] == self.selected: + return True + self.objects.pop(values["id"], None) + return False + + def forward(self, source, destination, direction): + try: + while (packet := receive(source)) is not None: + with self.lock: + if self.inspect(packet, direction): + self.held.append(packet) + else: + send(destination, packet) + except Exception as error: + with self.lock: + self.error = f"{type(error).__name__}: {error}" + + def control(self, command): + with self.lock: + if self.error: + return {"error": self.error} + if command == "arm": + self.armed = True + self.commits = 0 + self.delivered = 0 + elif command == "release": + if not self.held: + return {"error": "no popup callback held"} + self.selected = None + for packet in self.held: + self.inspect(packet, "event") + send(self.client, packet) + self.held.clear() + elif command != "status": + return {"error": "unknown control command"} + return {"held": bool(self.held), "popup_commits": self.commits, + "popup_callbacks_delivered": self.delivered} + + +def listener(path): + sock = socket.socket(socket.AF_UNIX) + sock.bind(str(path)) + sock.listen() + return sock + + +def run_fixture(directory, executable, test_name): + environment = dict(os.environ, XDG_RUNTIME_DIR=directory, WAYLAND_DISPLAY="upstream", + GDK_BACKEND="wayland", GSK_RENDERER="gl", LIBGL_ALWAYS_SOFTWARE="1", + GTK_A11Y="test", GDK_DEBUG="no-portals") + environment.pop("DISPLAY", None) + environment.pop("WAYLAND_SOCKET", None) + environment.pop("DBUS_SESSION_BUS_ADDRESS", None) + with open(Path(directory) / "weston.log", "w+") as log: + weston = subprocess.Popen( + ["weston", "--backend=headless-backend.so", "--renderer=pixman", "--no-config", + "--socket=upstream", "--idle-time=0"], env=environment, stdout=log, stderr=log + ) + try: + upstream = str(Path(directory) / "upstream") + deadline = time.monotonic() + 10 + while not Path(upstream).is_socket(): + if weston.poll() is not None or time.monotonic() >= deadline: + log.seek(0) + raise RuntimeError(log.read()) + time.sleep(0.01) + interfaces = schemas() + proxy = listener(Path(directory) / "proxy") + control = listener(Path(directory) / "control") + connections = [] + + def accept_clients(): + while True: + client, _ = proxy.accept() + connection = Connection(client, upstream, interfaces) + connections.append(connection) + connection.start() + + def accept_control(): + while True: + client, _ = control.accept() + with client, client.makefile("r") as reader: + command = reader.readline().strip() + popup_connections = [connection for connection in connections + if connection.popups] + response = popup_connections[-1].control(command) if popup_connections else { + "error": "no GTK popup connection" + } + client.sendall((json.dumps(response) + "\n").encode()) + + threading.Thread(target=accept_clients, daemon=True).start() + threading.Thread(target=accept_control, daemon=True).start() + environment.update(WAYLAND_DISPLAY="proxy", G_DEBUG="fatal-criticals", + WAYSCRIBER_GTK_WAYLAND_CONTROL=str(Path(directory) / "control")) + child = subprocess.Popen( + ["dbus-run-session", "--", executable, test_name, "--exact", "--test-threads=1", + "--nocapture"], env=environment, start_new_session=True + ) + try: + return child.wait(timeout=20) + finally: + # Includes the private bus and any services it activated. + try: + os.killpg(child.pid, signal.SIGKILL) + except ProcessLookupError: + pass + child.wait() + finally: + weston.terminate() + try: + weston.wait(timeout=3) + except subprocess.TimeoutExpired: + weston.kill() + weston.wait() + + +if __name__ == "__main__": + with tempfile.TemporaryDirectory(prefix="wayscriber-popup-test-") as directory: + raise SystemExit(run_fixture(directory, *sys.argv[1:])) From 44be5def44b3651754468642bb22a42bb0eb7d25 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:56:51 +0200 Subject: [PATCH 2/2] Require native popup coverage in both GTK gates Require execution markers for the native popup race and empty menu proof. Install explicit Python and Mesa dependencies in the widget CI profile, and document pkg-config and ordinary display-backed test behavior. Add parity fixtures for shell and C# admission failures, private runtime cleanup, and process failure propagation. Both actual GTK gates pass 86 tests with four required markers; the full local gate passes, including all four parity cases. --- CONTRIBUTING.md | 18 +- tools/csharp-tests/GtkGateParityTests.cs | 163 +++++++++++++++++++ tools/csharp-tests/includes.cs | 1 + tools/csharp/Commands/DevelopmentCommands.cs | 10 +- tools/test-gtk-widgets.sh | 2 + 5 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 tools/csharp-tests/GtkGateParityTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db6e3bec2..6694629f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -240,9 +240,15 @@ features, alongside source, packaging, retained release-contract, and C# tool checks when .NET is installed. CI runs the equivalent C# command and additionally checks dynamic and static gtk4-layer-shell linkage and uploads its code-health report. -GTK widget coverage runs separately with `./tools/test-gtk-widgets.sh` (Weston and -`dbus-run-session` required). It creates a private headless display and requires -GTK initialization; an unavailable display fails this check. Successful widget -bodies print `EXECUTED` markers. Ordinary tests without a display report an -optional skip. Neither route proves layer-shell focus or screen capture behavior -on a user's compositor. +GTK widget coverage runs separately with `./tools/test-gtk-widgets.sh` (Weston, +`dbus-run-session`, Python 3, `pkg-config`, Mesa software OpenGL, and Wayland +protocol XML required). It creates a private headless display and requires GTK +initialization; an unavailable display fails this check. The native popup tests +use another private Weston with software OpenGL and a protocol proxy to hold one +popup frame callback while its shared clock paints, then require capture to finish +after the popup's fresh render is acknowledged. They cover a plain `GtkPopover` +and `GtkPopoverMenu`'s empty proof overlay and menu restoration. Successful bodies +print `EXECUTED` markers. Ordinary widget tests without a display report an +optional skip; when a display is available, their GTK assertions run. The native +popup tests require the dedicated GTK gate. Neither route proves layer-shell +focus or screen capture behavior on a user's compositor. diff --git a/tools/csharp-tests/GtkGateParityTests.cs b/tools/csharp-tests/GtkGateParityTests.cs new file mode 100644 index 000000000..ed06e3d1a --- /dev/null +++ b/tools/csharp-tests/GtkGateParityTests.cs @@ -0,0 +1,163 @@ +using System.Net.Sockets; +using System.Runtime.Versioning; +using Xunit; + +namespace Wayscriber.Tools.Tests; + +public sealed class GtkGateParityTests +{ + private const string PopupMarker = "EXECUTED: GTK native popup presentation regression"; + private const string MenuMarker = "EXECUTED: GTK native menu presentation assertions"; + private const string FixtureSocket = "WAYSCRIBER_FIXTURE_SOCKET"; + private const string FixtureOutput = "WAYSCRIBER_FIXTURE_OUTPUT"; + private const string FixtureExit = "WAYSCRIBER_FIXTURE_EXIT"; + private const string FixtureRuntime = "WAYSCRIBER_FIXTURE_RUNTIME"; + private const string ShellProgram = "/usr/bin/bash"; + + [Theory] + [InlineData( null, ExitCodes.Success )] + [InlineData( PopupMarker, ExitCodes.Success )] + [InlineData( MenuMarker, ExitCodes.Success )] + [InlineData( null, ExitCodes.Failure )] + public async Task BothGatesRequireNativeAssertionsAndCleanUpTheirPrivateDisplay( string? omittedMarker, int childExitCode ) + { + if ( !OperatingSystem.IsLinux( ) ) + { + return; + } + + using var fixture = new GtkGateFixture( omittedMarker, childExitCode ); + var csharp = await fixture.RunCsharp( ); + var csharpRuntime = File.ReadAllText( fixture.RuntimeLog ); + Assert.False( Directory.Exists( csharpRuntime ), "C# gate must clean up its private runtime" ); + + var shell = await fixture.RunShell( ); + var shellRuntime = File.ReadAllText( fixture.RuntimeLog ); + Assert.False( Directory.Exists( shellRuntime ), "shell gate must clean up its private runtime" ); + Assert.NotEqual( csharpRuntime, shellRuntime ); + + var expected = omittedMarker is null && childExitCode == ExitCodes.Success ? ExitCodes.Success : ExitCodes.Failure; + Assert.Equal( expected, csharp.ExitCode ); + Assert.Equal( expected, shell.ExitCode ); + if ( omittedMarker is not null ) + { + Assert.Contains( omittedMarker, csharp.StandardError, StringComparison.Ordinal ); + } + } + + [SupportedOSPlatform( "linux" )] + private sealed class GtkGateFixture : IProcessRunner, IDisposable + { + private readonly TemporaryDirectory _directory = new( "wayscriber-gtk-gate-parity" ); + private readonly Socket _socket = new( AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified ); + private readonly ProcessRunner _runner = new( TextWriter.Null, TextWriter.Null ); + private readonly Dictionary _environment; + private readonly string _bin; + + public GtkGateFixture( string? omittedMarker, int childExitCode ) + { + _bin = Path.Combine( _directory.Path, "bin" ); + Directory.CreateDirectory( _bin ); + var tools = Path.Combine( _directory.Path, "tools" ); + Directory.CreateDirectory( tools ); + File.Copy( Path.Combine( FindRepository( ), "tools/test-gtk-widgets.sh" ), Path.Combine( tools, "test-gtk-widgets.sh" ) ); + + var socket = Path.Combine( _directory.Path, "socket" ); + _socket.Bind( new UnixDomainSocketEndPoint( socket ) ); + var output = Path.Combine( _directory.Path, "output" ); + string[] markers = + [ + "EXECUTED: GTK focus and slider assertions", + "EXECUTED: GTK widget contract assertions", + PopupMarker, + MenuMarker, + ]; + File.WriteAllLines( output, markers.Where( marker => marker != omittedMarker ) ); + RuntimeLog = Path.Combine( _directory.Path, "runtime" ); + _environment = new Dictionary + { + [EnvironmentVariables.Path] = _bin + Path.PathSeparator + Environment.GetEnvironmentVariable( EnvironmentVariables.Path ), + [EnvironmentVariables.Display] = ":fixture-display-must-be-removed", + [FixtureSocket] = socket, + [FixtureOutput] = output, + [FixtureExit] = childExitCode.ToString( System.Globalization.CultureInfo.InvariantCulture ), + [FixtureRuntime] = RuntimeLog, + }; + + // These stand-ins only test gate admission and cleanup. Real GTK + // presentation remains owned by the Rust tests on private Weston. + WriteExecutable( "weston", $$""" +#!/usr/bin/bash +ln -s "${{{FixtureSocket}}:?}" "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" +exec /usr/bin/sleep 30 +""" ); + WriteExecutable( "dbus-run-session", $$""" +#!/usr/bin/bash +set -euo pipefail +[[ -z "${DISPLAY:-}" ]] +[[ "$GDK_BACKEND" == wayland ]] +[[ "$WAYSCRIBER_REQUIRE_GTK_TESTS" == 1 ]] +[[ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]] +printf '%s' "$XDG_RUNTIME_DIR" > "${{{FixtureRuntime}}:?}" +cat "${{{FixtureOutput}}:?}" +exit "${{{FixtureExit}}:?}" +""" ); + } + + public string RuntimeLog { get; } + + public async Task RunCsharp( ) + { + using var output = new StringWriter( ); + using var error = new StringWriter( ); + var context = new ToolContext( _directory.Path, output, error, this, CancellationToken.None ); + var command = DevelopmentCommands.Commands.Single( item => item.Name == CommandNames.GtkWidgets ); + + try + { + var result = await command.Handler( context, [] ); + return new ProcessResult( result, output.ToString( ), error.ToString( ) ); + } + catch ( ToolException failure ) + { + return new ProcessResult( failure.ExitCode, output.ToString( ), failure.Message ); + } + } + + public Task RunShell( ) => _runner.RunAsync( new ProcessRequest( + ShellProgram, [Path.Combine( _directory.Path, "tools/test-gtk-widgets.sh" )], _directory.Path, + _environment, CaptureOutput: true, Trace: false, AllowedExitCodes: new HashSet { ExitCodes.Success, ExitCodes.Failure } ), + CancellationToken.None ); + + public Task RunAsync( ProcessRequest request, CancellationToken cancellationToken ) + { + var environment = new Dictionary( _environment ); + foreach ( var pair in request.Environment! ) + { + environment[pair.Key] = pair.Value; + } + + return _runner.RunAsync( request with { FileName = Path.Combine( _bin, request.FileName ), Environment = environment }, + cancellationToken ); + } + + public void Dispose( ) + { + _socket.Dispose( ); + _directory.Dispose( ); + } + + private void WriteExecutable( string name, string content ) + { + var path = Path.Combine( _bin, name ); + File.WriteAllText( path, content ); + File.SetUnixFileMode( path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute ); + } + } + + private static string FindRepository( ) + { + var directory = AppContext.GetData( "EntryPointFileDirectoryPath" ) as string ?? Environment.CurrentDirectory; + return Path.GetFullPath( Path.Combine( directory, ".." ) ); + } +} diff --git a/tools/csharp-tests/includes.cs b/tools/csharp-tests/includes.cs index c20c71723..360acde99 100644 --- a/tools/csharp-tests/includes.cs +++ b/tools/csharp-tests/includes.cs @@ -1,4 +1,5 @@ #:include CoreTests.cs +#:include GtkGateParityTests.cs #:include RepositoryContractTests.cs #:include ReleaseParityRegressionTests.cs #:include ReleaseRegressionTests.cs diff --git a/tools/csharp/Commands/DevelopmentCommands.cs b/tools/csharp/Commands/DevelopmentCommands.cs index a1149fd57..d614de18c 100644 --- a/tools/csharp/Commands/DevelopmentCommands.cs +++ b/tools/csharp/Commands/DevelopmentCommands.cs @@ -161,6 +161,7 @@ private static async Task RunIsolatedRenderTests( ToolContext context, string fe private static async Task GtkWidgets( ToolContext context, string[] args ) { new Arguments( args ).RequireEmpty( "ci gtk-widgets" ); + using var runtime = new TemporaryDirectory( "wayscriber-gtk-tests" ); const string display = "wayscriber-widget-tests"; var environment = new Dictionary @@ -172,6 +173,7 @@ private static async Task GtkWidgets( ToolContext context, string[] args ) [EnvironmentVariables.GtkAccessibility] = "test", [EnvironmentVariables.WayscriberRequireGtkTests] = EnvironmentVariables.Enabled, }; + using var westonCancellation = CancellationTokenSource.CreateLinkedTokenSource( context.CancellationToken ); var weston = context.Processes.RunAsync( new ProcessRequest( Programs.Weston, ["--backend=headless-backend.so", "--renderer=pixman", "--no-config", $"--socket={display}", "--idle-time=0", $"--log={Path.Combine( runtime.Path, "weston.log" )}"], @@ -192,19 +194,23 @@ private static async Task GtkWidgets( ToolContext context, string[] args ) { throw new ToolException( "Weston did not create its Wayland socket." ); } + var result = await context.Run( Programs.DbusRunSession, [CommandLineOptions.EndOfOptions, Programs.Cargo, CommandNames.Test, CommandLineOptions.Locked, "-p", RepositoryNames.MainPackage, CommandLineOptions.AllFeatures, "--lib", "toolbar_gtk::", CommandLineOptions.EndOfOptions, CommandLineOptions.SingleTestThread, "--nocapture"], environment: environment, capture: true ); await context.Output.WriteAsync( result.StandardOutput ); await context.Error.WriteAsync( result.StandardError ); + var combinedOutput = result.StandardOutput + result.StandardError; - foreach ( var marker in new[] { "EXECUTED: GTK focus and slider assertions", "EXECUTED: GTK widget contract assertions" } ) + foreach ( var marker in new[] { "EXECUTED: GTK focus and slider assertions", "EXECUTED: GTK widget contract assertions", + "EXECUTED: GTK native popup presentation regression", "EXECUTED: GTK native menu presentation assertions" } ) { if ( !combinedOutput.Contains( marker, StringComparison.Ordinal ) ) { throw new ToolException( $"GTK test output lacks marker: {marker}" ); } } + return ExitCodes.Success; } finally @@ -229,7 +235,7 @@ private static async Task InstallDependencies( ToolContext context, string[ { "checks" => common.Concat( ["clang", "cmake", "libxkbcommon-x11-dev", "libegl1-mesa-dev", "libgles2-mesa-dev", "libdbus-1-dev", "libinput-dev", "libudev-dev", "libpixman-1-dev", "libxcb-randr0-dev"] ) .Concat( repositoryPackages ), - "widgets" => common.Concat( ["weston", "dbus-x11", "fonts-dejavu-core", "clang", "cmake", "libxkbcommon-x11-dev", "libegl1-mesa-dev", "libgles2-mesa-dev", "libdbus-1-dev", "libinput-dev", "libudev-dev", "libpixman-1-dev", "libxcb-randr0-dev"] ), + "widgets" => common.Concat( ["weston", "dbus-x11", "python3", "libgl1-mesa-dri", "fonts-dejavu-core", "clang", "cmake", "libxkbcommon-x11-dev", "libegl1-mesa-dev", "libgles2-mesa-dev", "libdbus-1-dev", "libinput-dev", "libudev-dev", "libpixman-1-dev", "libxcb-randr0-dev"] ), "package" => common.Concat( ["rpm"] ), "repositories" => repositoryPackages, _ => null, diff --git a/tools/test-gtk-widgets.sh b/tools/test-gtk-widgets.sh index 577449f5f..80e68966b 100755 --- a/tools/test-gtk-widgets.sh +++ b/tools/test-gtk-widgets.sh @@ -36,3 +36,5 @@ dbus-run-session -- cargo test --locked -p wayscriber --all-features --lib \ toolbar_gtk:: -- --test-threads=1 --nocapture 2>&1 | tee "$GTK_TEST_RUNTIME/tests.log" grep -Fq 'EXECUTED: GTK focus and slider assertions' "$GTK_TEST_RUNTIME/tests.log" grep -Fq 'EXECUTED: GTK widget contract assertions' "$GTK_TEST_RUNTIME/tests.log" +grep -Fq 'EXECUTED: GTK native popup presentation regression' "$GTK_TEST_RUNTIME/tests.log" +grep -Fq 'EXECUTED: GTK native menu presentation assertions' "$GTK_TEST_RUNTIME/tests.log"