diff --git a/linux/runner/mpv/wayland_video_surface.cc b/linux/runner/mpv/wayland_video_surface.cc index 6ce537c97..01ebcd317 100644 --- a/linux/runner/mpv/wayland_video_surface.cc +++ b/linux/runner/mpv/wayland_video_surface.cc @@ -1002,10 +1002,15 @@ void WaylandVideoSurface::ArmFrameAckWatchdog() { // directly is what makes the next present happen at all. self->ClearFrameCallback(); if (++self->consecutive_frame_acks_missed_ > kMaxConsecutiveFrameAckMisses) { - g_warning( - "MPV video plane: compositor is not acknowledging frames (%d misses); " - "stopping re-present attempts until a frame or visibility change", - self->consecutive_frame_acks_missed_); + // Warn once per stall: the slow timer keeps this branch cycling + // until a real acknowledgement resets the count. + if (self->consecutive_frame_acks_missed_ == kMaxConsecutiveFrameAckMisses + 1) { + g_warning( + "MPV video plane: compositor is not acknowledging frames (%d misses); " + "backing off to re-presenting every %d ms", + self->consecutive_frame_acks_missed_, kStalledRepresentIntervalMs); + } + self->ArmStalledRepresentTimer(); return G_SOURCE_REMOVE; } g_message("MPV video plane: frame not acknowledged within %d ms; re-presenting", kFrameAckTimeoutMs); @@ -1022,6 +1027,34 @@ void WaylandVideoSurface::CancelFrameAckWatchdog() { } } +void WaylandVideoSurface::ArmStalledRepresentTimer() { + if (stalled_represent_source_ != 0) return; + stalled_represent_source_ = g_timeout_add( + kStalledRepresentIntervalMs, + +[](gpointer data) -> gboolean { + auto* self = static_cast(data); + self->stalled_represent_source_ = 0; + // A pending frame means a present happened since this timer was armed; + // its own watchdog owns the wait now and lands back here on a miss. + if (!self->visible_ || self->frame_pending_) return G_SOURCE_REMOVE; + // on_frame_, not on_forced_render_: the plugin's handler presents only + // when mpv actually has a new frame or a refresh is owed, so a paused + // hidden plane stops here instead of re-committing the same buffer + // forever. Nothing is lost by going dormant - the render that consumed + // the latch guarantees mpv's next frame arrives as an update edge. + if (self->on_frame_) self->on_frame_(); + return G_SOURCE_REMOVE; + }, + this); +} + +void WaylandVideoSurface::CancelStalledRepresentTimer() { + if (stalled_represent_source_ != 0) { + g_source_remove(stalled_represent_source_); + stalled_represent_source_ = 0; + } +} + void WaylandVideoSurface::ClearFrameCallback() { CancelFrameAckWatchdog(); if (frame_callback_ != nullptr) { @@ -1043,20 +1076,23 @@ void WaylandVideoSurface::HandleFrameDone(void* data, wl_callback* callback, uin } self->frame_pending_ = false; self->consecutive_frame_acks_missed_ = 0; - // A real acknowledgement is the watchdog's success case; it has no more - // work to do (this is a static handler, so the call goes through `self`). + // A real acknowledgement is the watchdog's success case; neither it nor the + // stalled-plane backoff has more work to do (this is a static handler, so + // the calls go through `self`). self->CancelFrameAckWatchdog(); + self->CancelStalledRepresentTimer(); // Rendering resumes from here, not from mpv: its redraw latch is still set // from the update we declined to serve, so it will not notify again. if (self->on_frame_) self->on_frame_(); } void WaylandVideoSurface::Destroy() { - // Unconditionally, ahead of everything: both timeout closures capture + // Unconditionally, ahead of everything: all three timeout closures capture // `this`, and the transition watchdog is only cancelled below when a // transition is actually staged. CancelTransitionWatchdog(); CancelFrameAckWatchdog(); + CancelStalledRepresentTimer(); if (egl_surface_ != EGL_NO_SURFACE) { if (eglGetCurrentSurface(EGL_DRAW) == egl_surface_ || eglGetCurrentSurface(EGL_READ) == egl_surface_) { eglMakeCurrent(egl_display_, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); @@ -1236,9 +1272,12 @@ void WaylandVideoSurface::DetachBuffer() { // on its own: a subsurface has no visibility of its own, so what "hidden" // means here is "carrying no buffer", and the content stays up until the // compositor is told to drop it. The pending frame callback goes too - it - // would otherwise fire against a surface with nothing to present. + // would otherwise fire against a surface with nothing to present - and so + // does the stalled-plane backoff, which exists to revive exactly that + // callback. if (surface_ == nullptr) return; ClearFrameCallback(); + CancelStalledRepresentTimer(); wl_surface_attach(surface_, nullptr, 0, 0); wl_surface_commit(surface_); } diff --git a/linux/runner/mpv/wayland_video_surface.h b/linux/runner/mpv/wayland_video_surface.h index 4eac9ea9e..fe72591b6 100644 --- a/linux/runner/mpv/wayland_video_surface.h +++ b/linux/runner/mpv/wayland_video_surface.h @@ -285,14 +285,14 @@ class WaylandVideoSurface { // Bounds the frame-acknowledgement wait. A compositor is entitled to stop // acknowledging frames for an occluded or minimized surface - wlroots - // lineage compositors (Hyprland) do exactly that - and frame_pending_ is the - // only latch between Present() and the frame callback. Without a bound, one - // missed wl_callback freezes the plane on its last buffer for good: every - // later render bails on frame_pending(), and nothing else clears it. The - // watchdog withdraws the dead callback and asks for a fresh present, which - // re-arms the callback; a compositor that keeps ignoring the surface (still - // minimized) hits the miss budget and stops being poked until a real - // acknowledgement or a new frame turns up. + // lineage compositors (Hyprland) and KWin do exactly that - and + // frame_pending_ is the only latch between Present() and the frame callback. + // Without a bound, one missed wl_callback freezes the plane on its last + // buffer for good: every later render bails on frame_pending(), and nothing + // else clears it. The watchdog withdraws the dead callback and asks for a + // fresh present, which re-arms the callback; a compositor that keeps + // ignoring the surface (still hidden) hits the miss budget and backs off to + // the slow re-present timer below. static constexpr int kFrameAckTimeoutMs = 500; static constexpr int kMaxConsecutiveFrameAckMisses = 5; void ArmFrameAckWatchdog(); @@ -300,6 +300,26 @@ class WaylandVideoSurface { guint frame_ack_source_ = 0; int consecutive_frame_acks_missed_ = 0; + // Keeps a stalled plane recoverable after the miss budget is spent. Stopping + // outright would leave no wake-up at all (issue #2067): the giveup just + // destroyed the only outstanding wl_callback, so no acknowledgement can ever + // arrive; mpv's redraw latch is typically already saturated - the render its + // update scheduled bailed on frame_pending() without consuming it, and + // OnMpvRenderUpdate only schedules on the latch's false->true edge - so mpv + // never notifies again; and a workspace switch is invisible to GTK, so no + // visibility change comes either. The timer re-runs the frame callback at a + // pace the compositor cannot mind: a present only actually happens when mpv + // has produced a new frame (or a refresh is owed), and each one re-arms the + // normal watchdog, so a hidden playing plane settles at one present per + // timeout-plus-interval and recovers within one interval of being shown + // again. A paused hidden plane goes dormant instead - its last render + // consumed the latch, so the next mpv frame reaches the plugin as a fresh + // update edge. + static constexpr int kStalledRepresentIntervalMs = 1000; + void ArmStalledRepresentTimer(); + void CancelStalledRepresentTimer(); + guint stalled_represent_source_ = 0; + // Creates the preferred-description query. The returned description is ready // immediately per the protocol, so get_information follows on ready, and the // accumulated fields are committed when the info burst ends with done.