fix(linux): recover the wayland video plane after the compositor stops acking frames

Switching workspaces on KDE or a wlroots compositor hides the toplevel
without any GTK visibility change, and the compositor stops answering
the video plane's frame callbacks. Once the ack watchdog spent its miss
budget it stopped dead: the giveup destroyed the only outstanding
wl_callback, mpv's edge-triggered redraw latch was already saturated by
a render that bailed on frame_pending(), and no visibility change ever
comes - so video stayed frozen on the last buffer until a resize or
fullscreen toggle, while audio kept playing.

Back off to a slow re-present timer instead of stopping. A present only
happens when mpv actually has a new frame, so a hidden playing plane
settles at one commit per ~1.5 s and recovers within a second of being
shown again; a paused hidden plane goes dormant and is revived by mpv's
next update edge.

close #2067
This commit is contained in:
edde746
2026-08-22 15:31:34 +02:00
parent a9a7498991
commit 6f64ca2952
2 changed files with 75 additions and 16 deletions
+47 -8
View File
@@ -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<WaylandVideoSurface*>(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_);
}
+28 -8
View File
@@ -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.