Skip to content

Wayland: event loop spins at 100% CPU polling with a zero timeout; no frames, no input #4668

Description

@virtualritz

Description

Edited twice on 2026-08-19. The original report claimed a ptrace attach
recovered it and that resume from suspend was the trigger; neither holds, and both are
retracted below. The second edit adds the important part: it reproduces with a forty
line eframe app
, whose full source is included, wedged in the same session with an
identical syscall cycle.

Summary

An eframe/winit app on Wayland stops dispatching. The window keeps its last frame, no
input ever reaches the app again, and the main thread pins one of my 16 cores until
something outside the process changes. Three occurrences so far.

While wedged, the loop calls epoll_pwait with a zero timeout about 9,400 times per
second, gets zero events, re-arms its registrations, repeats. Nothing is ever ready; the
poller never blocks and the loop never dispatches. Hence no paint, no input.

Versions

crate version
winit 0.30.13
calloop 0.13.0
polling 3.11.0
wayland-client 0.31.15
smithay-client-toolkit 0.19.2
eframe 0.36.1
egui 0.35.0
wgpu 29.0.4

Ubuntu kernel 7.0.0-22-generic (PREEMPT_DYNAMIC), GNOME Shell (--mode=ubuntu) on
Wayland. Hybrid graphics: AMD Radeon 680M drives the display, NVIDIA RTX 2050 alongside
(driver 580.173.02). Debug build, wgpu backend.

The Loop

strace -c, 10 s, while wedged:

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 39.85    0.466938           2    188915           epoll_ctl
 21.00    0.246064           2     94458           timerfd_settime
 19.82    0.232295           2     94457     94457 read
 19.33    0.226567           2     94457           epoll_pwait

Every iteration is identical:

timerfd_settime(6, 0, {it_interval={0,0}, it_value={0,0}}) = 0          # disarm the timer
epoll_ctl(4, EPOLL_CTL_MOD, 6, {EPOLLIN|EPOLLPRI|EPOLLERR|EPOLLHUP|EPOLLONESHOT}) = 0
epoll_pwait(4, [], 1024, 0, NULL, 1024)                   = 0          # timeout 0, no events
read(5, 0x7ffe8fadfc70, 8)                                = -1 EAGAIN  # notifier empty
epoll_ctl(4, EPOLL_CTL_MOD, 5, {EPOLLIN|EPOLLPRI|EPOLLERR|EPOLLHUP|EPOLLONESHOT}) = 0

fd 4 is the epoll, fd 6 the timerfd, fd 5 an eventfd; the 8 byte read is polling's
wakeup notifier and it is empty every time (24,554 EAGAIN in 24,555 calls over a
separate 5 s sample).

A zero timeout forever is what a ControlFlow::WaitUntil deadline stuck in the past looks
like from underneath: deadline - now saturates to zero on every pass, so polling polls
instead of waiting, and nothing advances the deadline because that needs a dispatch which
never happens. I cannot tell from here which deadline it is, or what keeps it in the past.

The Compositor Misbehaves At The Same Moments

gnome-shell logs a known mutter assertion, and the two largest clusters in the whole
boot land on my first two wedges:

gnome-shell[8114]: meta_window_set_stack_position_no_sync:
                   assertion 'window->stack_position >= 0' failed
when assertions that minute app
01:23 8 first wedge begins
11:38-11:39 9 second wedge begins
11:45-11:47 9 still wedged, then recovers
rest of boot 1 to 5, scattered normal

That is GNOME/mutter#1647, open and Critical, reported for years with triggers filed
against several unrelated apps (fullscreen mpv, Evolution, Nautilus, Telegram Desktop).
Telegram runs here and its D-Bus IdleMonitor calls appear minutes before both clusters,
so I suspect my compositor's stacking state is corrupted by something other than my app.

Not filing this as "mutter's fault, never mind". The compositor is the trigger; the bug I
report is the response. A client that loses the compositor's cooperation should idle or
complain, not burn a core indefinitely and silently stop accepting input.

Incidents

# began ended how
1 ~01:23, about 8 min after resume from a 7 h s2idle suspend ~10:10, unclear; coincided with a gdb attach that did not reproduce as a cure
2 ~11:38, ordinary session, no suspend ~11:47, spontaneously, no attach, no interaction
3 same day, same shape not instrumented

Incident 1 ran about 9 hours: ps showed CPUTIME 08:58:20 over ELAPSED 18:40:12, and
the main thread held 3,203,667 of the process's 3,230,074 jiffies.

Two retractions follow. Incident 2 survived five gdb attach and detach cycles and
kept spinning, so ptrace does not recover this. Incident 2 had no suspend, so suspend is
one route in, not the cause.

While wedged: state=R, utime rising every sample. Recovered: state=S, counters static.

What The Protocol Trace Shows

With WAYLAND_DEBUG=1 on the probe, the mild form is periodic and unmistakable. Before
each stall the client commits and asks for a callback:

[4220513.488]  -> wl_surface#27.frame(new id wl_callback#122)
[4220513.670]  -> wp_fifo_v1#87.set_barrier()
[4220513.673]  -> wp_fifo_v1#87.wait_barrier()
[4220513.675]  -> wl_surface#27.commit()

Then silence, ended by exactly one event, after which the app resumes in the same
millisecond:

[4243046.668] wl_callback#122.done(61602155)
[  301.0s] logic=1317 ui=1316

Every stall in the session ends that way:

stall ended by
101.7s to 120.6s (18.9s) wl_callback#122.done
120.6s to 143.2s (22.6s) wl_callback#122.done
143.2s to 177.7s (34.5s) wl_callback#122.done
177.7s to 188.3s (10.6s) wl_callback#122.done
188.3s to 210.9s (22.6s) wl_callback#122.done
210.9s to 233.4s (22.5s) wl_callback#122.done

The loop is alive during the stalls. Mid gap the client receives and answers a compositor
ping, and receives pointer input:

[4242578.492] xdg_wm_base#14.ping(61601687)
[4242578.502] wl_pointer#23.motion(61601687, 11.22656250, 303.56640625)
[4242578.544]  -> xdg_wm_base#14.pong(61601687)
[4242585.910] wl_pointer#107.leave(64440, wl_surface#27)

So input is delivered and dispatched; it is simply never processed, because no egui pass
runs until the frame callback arrives. From the outside that is "the window is drawn and
clicks do nothing", which is exactly what I see.

wp_fifo_v1 is in use here (set_barrier, wait_barrier), which may matter.

Reproduced With A Minimal App

I left a forty line eframe probe running beside the real application. Both wedged in the
same session, minutes apart, and the loop is the same in both:

probe my app
iterations per 10 s 91,613 94,458
epoll_pwait timeout 0 0
eventfd read failures 91,612 of 91,612 94,457 of 94,457
fds 4 epoll, 6 timerfd, 5 eventfd same

One difference worth noting: the probe crawls rather than stopping dead. It managed one
pass per 85 s or so while wedged, where my application produced none at all. Whatever
starves them is not perfectly total.

The probe counts logic and ui separately, because eframe 0.36 runs logic without an
egui pass while it considers the window hidden. Both counters stall together here, so that
path does not appear to be the explanation.

//! Minimal eframe app for the winit/Wayland zero-timeout spin.
//!
//! Counts `logic` and `ui` passes separately. eframe 0.36 runs `logic` but no egui
//! pass while it considers the window hidden, so if a wedge shows logic climbing
//! while ui stalls, the hidden-window path is implicated; if both stall, the loop
//! never reaches the app at all.
//!
//! It also requests a timed repaint every pass, the way a worker-polling app does.
use eframe::{App, Frame};

struct Probe {
    logic_passes: u64,
    ui_passes: u64,
    started: std::time::Instant,
    last_report: std::time::Instant,
}

impl App for Probe {
    fn logic(&mut self, ctx: &egui::Context, _frame: &mut Frame) {
        self.logic_passes += 1;
        if self.last_report.elapsed() >= std::time::Duration::from_secs(1) {
            eprintln!(
                "[{:>7.1}s] logic={} ui={}",
                self.started.elapsed().as_secs_f32(),
                self.logic_passes,
                self.ui_passes,
            );
            self.last_report = std::time::Instant::now();
        }
        // The pattern an app uses to poll background workers.
        ctx.request_repaint_after(std::time::Duration::from_millis(100));
    }

    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut Frame) {
        self.ui_passes += 1;
        ui.heading("winit zero-timeout spin probe");
        ui.label(format!("logic passes: {}", self.logic_passes));
        ui.label(format!("ui passes: {}", self.ui_passes));
        ui.label("SIGSTOP this process, wait, then SIGCONT.");
    }
}

fn main() -> eframe::Result<()> {
    eframe::run_native(
        "winit-spin-repro",
        eframe::NativeOptions::default(),
        Box::new(|_cc| {
            Ok(Box::new(Probe {
                logic_passes: 0,
                ui_passes: 0,
                started: std::time::Instant::now(),
                last_report: std::time::Instant::now(),
            }))
        }),
    )
}

A stall alone does not do it. I SIGSTOPped this probe for 120 s and SIGCONTed it: the
log shows the expected 120.5 s gap, then it resumed within a second and stayed healthy. So
a clock jump by itself is not sufficient, which argues against my own guess above being
the whole story.

Ruled Out

  • GPU fault or device lost: no NVRM: Xid, no drm errors, no GPU call on the stack.
  • Compositor restart: the socket peer is still gnome-shell pid 8114, started before the
    app; ss shows ESTAB.
  • Unread input backing up: every socket shows RecvQ 0.
  • A pathological timeout from the app: every request_repaint_after is 16 to 100 ms plus
    a bounded debounce remainder. Nothing near Duration::MAX, nothing derived from a
    pre-suspend Instant.

Questions

  1. While waiting for a wl_surface.frame callback, should the backend block in the
    poller rather than poll with a zero timeout? The waiting itself looks correct; burning
    a core for the duration does not.
  2. Is a compositor that withholds frame callbacks for tens of seconds, or hours, something
    winit can reasonably detect and report, rather than presenting as a frozen app?
  3. Does wp_fifo_v1 change anything here? The barrier is set and waited on immediately
    before each stall.

Reproduction

No procedure yet, only a specimen that eventually wedges: run the probe above and wait.
Four occurrences across two processes so far. SIGSTOP/SIGCONT is ruled out as a
trigger, as noted above.

A second probe is now running with WAYLAND_DEBUG=1, so the next wedge will show which
protocol message stops arriving; the wedges so far were not logged that way. I am also
willing to provoke mutter#1647 deliberately to see whether the assertion alone is enough.

Happy to run anything against a live wedged process.

Debugging output

No WAYLAND_DEBUG=1 output. The app was launched without it and the wedges were not
anticipated; it is enabled from now on. What I have instead:

Stack while spinning (main thread, in the timer re-arm step):

#6  rustix::backend::event::syscalls::epoll_mod   (linux_raw/event/syscalls.rs:260)
#7  rustix::event::epoll::modify                  (event/epoll.rs:161)
#8  polling::epoll::Poller::modify                (polling/src/epoll.rs:138)
#9  polling::epoll::Poller::wait_deadline         (polling/src/epoll.rs:199)
#10 polling::Poller::wait_impl                    (polling/src/lib.rs:754)
#12 calloop::sys::Poll::poll                      (calloop/src/sys.rs:233)
#13 calloop::loop_logic::EventLoop::dispatch_events<winit::..::wayland::state::WinitState>
#15 winit::..::wayland::event_loop::EventLoop::loop_dispatch          (mod.rs:585)
#16 winit::..::wayland::event_loop::EventLoop::poll_events_with_timeout (mod.rs:289)
#17 winit::..::wayland::event_loop::EventLoop::pump_events            (mod.rs:226)
#22 eframe::native::run::run_and_return

No application frame appears on it; update() is never called again.

After recovery, five stacks one second apart are identical and parked rather than
spinning:

#1  rustix::backend::event::syscalls::epoll_wait
#2  rustix::event::epoll::wait                    (event/epoll.rs:213)
#3  polling::epoll::Poller::wait_deadline         (polling/src/epoll.rs:219)
#4  polling::Poller::wait_impl                    (polling/src/lib.rs:754)

Not the "windows are not shown unless you draw" case: the window drew and presented
normally for about 8 minutes after resume, with three timestamped autosaves in the app's
own log, before it stopped.

Window isn't shown unless you draw

  • I understand that windows aren't shown on Wayland unless I draw and present to them.

Winit version

0.30.13 (full matrix in the description)

Metadata

Metadata

Assignees

No one assigned

    Labels

    B - bugDang, that shouldn't have happenedDS - waylandAffects the Wayland backend, or generally free Unix platforms

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions