winit: Consolidate poll_events() and run_forever() into get_event(Timeout)

EDIT: THIS ISSUE HAS BEEN SUPERSEDED by #459.

In #219, I examined some unfortunate event handling complexities on MacOS X. That examination led me to view and re-implement both poll_events() and run_forever() as special cases of a more general API, get_event(Timeout) -> Option<Event>.

I propose:

  1. Refactoring each platform to expose this get_event(Timeout) interface,
  2. Refactoring poll_events() and run_forever() to be implemented in terms of get_event(Timeout), and
  3. Deprecating poll_events() and run_forever() in favor of get_event(Timeout).

This is a user-facing API change, so I’m accompanying this proposal with a larger than usual explanation.

Receive-with-time-constraint is the fundamental operation

poll_events() allows the user to communicate “I want all the events that are ready, but I don’t want you to wait”. Let’s call that get_event(Timeout::Now), and make the loop explicit:

// current
    events_loop.poll_events(|event| {
        match event {
            Event::WindowEvent { event: WindowEvent::Resized(w, h), .. } => {
                println!("The window was resized to {}x{}", w, h);
            },
            _ => ()
        }
    });
    
// proposed
    while let Some(event) = events_loop.get_event(Timeout::Now) {
        match event {
            Event::WindowEvent { event: WindowEvent::Resized(w, h), .. }) => {
                println!("The window was resized to {}x{}", w, h);
            },
            _ => (),
        }
    }

run_forever() allows the user to communicate “I want all the events until I tell you to stop”, which… is kind of weird, isn’t it? At any rate, it’s the user’s intention to block indefinitely and eventually break out of the loop. Let’s call that get_event(Timeout::Forever), and again push down the loop:

// current
    events_loop.run_forever(|event| {
        match event {
            winit::Event::WindowEvent { event: winit::WindowEvent::Closed, .. } => {
                winit::ControlFlow::Break
            },
            _ => winit::ControlFlow::Continue,
        }
    });

// proposed
    loop {
        match events_loop.get_event(Timeout::Forever) {
            winit::Event::WindowEvent { event: winit::WindowEvent::Closed, .. } => {
                break;
            },
            _ => ()
        }
    }

Some users have additional needs, like “I want all the events between now and when I need to update my state/draw the next frame”. pistoncore-glutin_window does this today by unconditionally spawning a thread that wakes up the event loop after a fixed period of time. We should be able to support it efficiently on every platform without needing extra threads, and with a timeout-centric API, we can:

// current
    let events_loop_proxy = self.events_loop.create_proxy();
    thread::spawn(move || {
        thread::sleep(timeout);
        events_loop_proxy.wakeup().ok(); // wakeup can fail only if the event loop went away
    });
    {
        let ref mut events = self.events;
        self.events_loop.run_forever(|ev| {
            events.push_back(ev);
            glutin::ControlFlow::Break
        });
    }
    let event = self.events.pop_front();

// proposed
    let event = events_loop.get_event(Timeout::Deadline(timeout));

Timeouts are sufficient to justify a new API

Consider a game that wants to update and render at 60 fps. If the game is running faster than 60 fps, it should sleep and wait until it’s time to start the next frame; if it’s running slower, it should busy-wait.

This use case presently requires a calling run_forever(callback) to allow the thread to sleep, plus a second thread keeping track of time and calling Proxy::wakeup() as needed. That’s wasteful and unnecessarily complex.

winit should support timeouts pattern directly. Additionally, this is a common requirement; every event loop has some way to block until a specified time, or some idiomaitc way to synthesize a timeout. winit should use this where appropriate.

Simplifies the API

winit puts user code at the bottom of the stack, and does not force the user to organize their code in terms of callbacks… except for event handling. Changing the API so that the user calls get_event(Timeout) in a loop adds consistency, and it lets the user define and manage their event loop with normal Rust statements instead of a callback that returns a ControlFlow enum.

This also makes EventsLoop look more like a futures::stream, or a generator, or many other things that users might already know. “I want to get a message from you” requires less explanation than having two different functions that take two different callbacks.

Reduces duplication

poll_events() and run_forever() have considerable overlap, and in the case of the MacOS platform, considerable code duplication. Not only can get_event(Timeout) provide more functionality, in the case of the MacOS platform, refactoring into this caused a net reduction in code.

Low risk

It’s very easy to implement poll_events() and run_forever() in terms of get_event(Timeout), which means there’s very little risk in deciding to move that direction.

winit can continue to provide the same poll_events() + run_forever() API to the user while refactoring platforms into get_event(Timeout) behind the scenes. Once they’re all ready, winit can update the examples, deprecate poll_events() and run_forever(), and ultimately remove at some later point.

Next steps

I think this covers everything:

pub enum Timeout {
    Now,
    Forever,
    Deadline(std::time::Duration),
}

impl EventsLoop {
    pub fn get_event(Timeout) -> Option<Event> {
        // …
    }
}

A change like this requires buy-in, and this isn’t my project, so I don’t know what all would need to happen. I’m happy to start by soliciting feedback, so: what does everyone think?

About this issue

  • Original URL
  • State: closed
  • Created 7 years ago
  • Reactions: 12
  • Comments: 32 (11 by maintainers)

Commits related to this issue

Most upvoted comments

It’s amazing that the maintainers don’t seem to care.

They care very much so - I believe most of the points in this issue are being addressed in #638 in @Osspial’s heroic effort to rewrite the event loop, though I’m not familiar enough with the PR to know for sure if everything in this issue has been addressed. That PR is just about ready to go - feel free to have a look, test it and share any constructive input you might have. The maintainers are a group of collaborators working on this in their limited, free time and for many, winit is just one of many open source crates to which they contribute, each with their own slew of “showstopper” issues to address.

There are any number of reasons: porting existing code which is not written in a callback style, to building libraries on top of winit to name a couple. If two libraries make the decision to control the event loop then those libraries are forever incompatible.

Inconvenience is also an important factor - I may not even care about the resizing issue on macos, as for many applications it doesn’t matter if the loop blocks for that period of time, and yet to implement the basic primitive of “wait with timeout” the universal operation support by synchronous APIs, I have to:

  • Launch a second thread
  • Setup a concurrent queue between the two threads
  • Pass a reference to my WindowProxy to the other thread
  • Each time I receive a normal event on the main thread, send a message across the queue to the second thread, with a value indicating how long I am willing to wait before being woken up
  • Each time I receive a wake-up event on the main thread, send a “confirm” message across the queue
  • On the second thread, loop while performing a wait-with-timeout on the concurrent queue, using the timeout from the last message
  • Each time the second thread doesn’t get a message in the time, send a wake-up message to the WindowProxy, and then wait indefinitely for a “confirm” message

Even then, there’s still a race condition between the “wake up” and the next event coming in - I’m not really sure how you avoid that if you’re forced (by winit and not the platform) to use multiple threads!

The stack swapping feature is a nice-to-have workaround for a relatively minor platform-specific bug: replacing winit’s old API with the current one because of this bug was a terrible mistake.

I wonder why these issues aren’t getting solved. Winit is basically useless as it is now. You can choose between using 100% CPU (poll_events) or not rendering anything at all (loop_forever). If this isn’t going to be solved, please update Winit with a warning telling people not to use it.

What you describe is more or less winit’s old API. We transitioned to callbacks because it solves the fact that MacOS uses a resize callback, and also in the longer term because of Android and Emscripten.

This also makes EventsLoop look more like a futures::stream

It’s the opposite, the futures library is based on callbacks.

So, if I’m understanding things correctly, combining what’s been outlined here with the problems we have with Windows, then doesn’t this mean that our current design is at odds with most platforms?

Unfortunately, using the X connection fd like this does not work reliably, because OpenGL/vulkan implementations will do IO on that fd internally, causing unrelated events to be read early and read-readiness notifications to be missed.

If two libraries make the decision to control the event loop then those libraries are forever incompatible.

winit already controls the event loop. It provides no wait handles with which to link to other event loops, which means you can either a) let winit block inside the windowing system or b) block yourself somewhere outside the windowing system. If your goal is to service events promptly, you must use run_forever().

I opened this issue to propose poll-with-timeout as a more useful common denominator: this would let you block inside the windowing system and regain control again after a fixed period. This doesn’t change the fact that winit is still blocking in its own event loop; there’s no way to tell winit to e.g. wait on a windowing event and wait on a socket becoming readable.

IMO, the main issue here is control over the main thread. Right now, users call winit and winit returns back to the user. This isn’t how MacOS works; you’re not supposed to constantly bail out of the platform event loop, and there are situations (like resizing) where the platform’s event loop takes over anyway. It’s also not how the web is designed to work, and it’s much more difficult to paper over that.

Poll-with-timeout is a more useful common denominator, but it’s not the lowest common denominator. The common interface that all platforms can share is to have the user surrender their thread to winit, and have winit call back into the user’s code.

Inverting control flow would absolutely be a giant breaking change, but that doesn’t avoid the question: which is the right model? Should the user call winit, or should winit call the user? Does the main thread belong to the user, or does it belong to the platform? @tomaka?

If the user should call winit, then we should merge coroutines support for MacOS, since MacOS’s design is at odds with winit’s design. We should also give up on having winit’s web platform work the same as the other platforms, since… it can’t.

If winit should call the user, then we should figure out what that API should look like, and we should figure out how to get there from here. It’s worth noting that I used coroutines to merge two incompatible event loop models in #237; we could do the same thing to provide a user-calls-winit compatibility layer on top of a new winit-calls-user API.

One option is to totally invert the control flow:

struct MyApp();

impl winit::App for MyApp {
  fn create_windows(&mut self, window_builder: &mut winit::WindowBuilder) {
    // use window_builder
  }
  
  fn on_event(&mut self, event: winit::Event) {
    match event {
      WindowEvent::Closed => {
        // tell winit to shut down
      }
      WindowEvent::KeyboardInput { ... } => {
        // change state and re-paint
      }
      // …
    }
  }
}

fn main() {
  winit::Main(MyApp());
}

The user’s fn main() would only exist to call into winit::Main(), which might be a Rust event loop that runs forever (most platforms), a native event loop that runs forever (MacOS), or a function that sets up callbacks and returns (web). winit need only guarantee that each platform would somehow call back into the user’s code after initialization.

@Boscop I’m trying to make a program that draws only after events (user interaction, fs/network input, timers above 1 second). I expect to make a loop that waits until there is an event, then processes all pending events, then draws.

The first thing I try is poll_events. It handles all pending events and then yields to rendering, but I still need something to wait for the next event before I repeat the loop. The next thing to try is loop_forever. It waits for events and handles them, but it doesn’t return when there are no more events. It’s hard to understand how someone could have decided that there shall be one function that processes events without waiting and then returns, and one function that waits and processes events but doesn’t return, but no function that waits for events, processes them and then returns. I can’t draw from within the infinite loop, because that causes only one event to be processed for each frame, making the program unresponsive. There is no ‘idle’ event I can use to detect when it’s appropriate to draw.