Building GNOME Apps with Rust, Part 6: Fetching Feeds
This is Part 6 of a series taking a GNOME app from an empty directory to GNOME Circle. Part 5 wired the sidebar to a real Feed GObject and a feed-selected signal - selecting a row updates the content pane and prints a line to the terminal. This is the post where that line stops being a placeholder and becomes a real network fetch. The click that still does nothing real Select This Week in GNOME, then select Hacker News. The placeholder swaps in the new name and URL - everything the app knows about that feed, and none of what's actually in it. Every Feed in the sidebar has a uri pointing at a real RSS or Atom document on the actual internet, and nothing in the codebase has ever asked one of them what's there. The obvious fix - fetch the URL when feed-selected fires - is one line of code and the wrong line of code. GTK doesn't have a spare thread lying around to do that on, and the thread it does have is busy. By the end of this post, selecting Hacker News in the sidebar prints real headlines to the terminal, fetched over the network, without the window so much as flickering. We'll build a Tokio runtime alongside GTK's own, give Feed somewhere to put what it fetches, and draw a hard line neither executor is allowed to cross. The freeze Wire the obvious fix into the feed-selected handler first, so you can watch it fail: obj.connect_closure( "feed-selected", false, glib::closure_local!(move |_window: &super::GazetteWindow, feed: Feed| { // Don't do this. std:๐งต:sleep(std::time::Duration::from_secs(2)); eprintln!("fetched (fake): {}", feed.name()); }), ); std:๐งต:sleep stands in for "network request in flight." Run the app and click a feed. For two seconds the window stops responding to anything - no hover states, no resize, no cursor blink, nothing repaints. Click a different feed while the first is still "fetching" and the click doesn't register until the sleep ends; it queues up invisibly and the window only notices you clicked at all once the first one finishes. If you have GTK Inspector open (GTK_DEBUG=interactive , or Ctrl+Shift+I) while you try this, its own live views stall for the same two seconds - Inspector needs the app's main loop to be responsive to answer its own queries, so a frozen loop is invisible to Inspector too. Worth knowing the next time you're not sure whether your code froze or something else did. Revert the handler to the logging-only version from Part 5 before continuing. The freeze was the demonstration, not a step you keep. One main loop, nothing else runs until you give it back GTK schedules everything through a single glib::MainContext , running cooperatively on a single thread: one job finishes before the next one starts. When you clicked a feed a moment ago, the redraw that should have shown the hover state, the cursor update, the eventual repaint once the click registered - all of it queued behind your feed-selected handler, waiting for std:๐งต:sleep to let go of the only thread any of it can run on. That's a deliberate tradeoff. Exactly one thread ever touches a widget, so there's no locking, no data races, no Mutex . The price is that the one thread has to keep coming back. Block it - a sleep, a synchronous file read, a blocking network call, anything - and every other queued job waits behind you. This is also why "just spawn a thread and do it there" isn't a fix, only a different failure. Almost every GTK/GObject type is !Send : the bindings won't even let you move a Feed or a widget handle across a thread boundary - it's a compile error, not a runtime one. GObject's own machinery assumes single-threaded access and doesn't defend against concurrent calls from a second thread. A background thread can compute a result, but it can't hand that result to a widget directly. Something still has to get the result back onto the one thread allowed to touch GTK state. Two executors, one job each The pattern that resolves this has a name because the shape recurs in every GTK app that talks to a network: two executors, each with a rule it never breaks. The GLib main loop owns every widget, every GObject, every signal, and it must never block. Tokio owns anything that can block - the network request here, file I/O or database queries later. It runs on its own thread pool, and it never touches a widget or a GObject directly. That's its rule. The two only meet at one seam: a future running on the GLib main context can .await a handle to work Tokio is doing, and when that work finishes, execution resumes back on the main context with the result now sitting there as a plain value. Nothing crosses except that value - no Feed , no gtk::Label , nothing GTK owns ever travels to a Tokio thread, and no raw socket or blocking read ever runs on the GLib thread. The rest of this post is standing up both executors and building that one seam. Standing up the second executor Add the dependencies: tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "time"] } feed-rs = "2.4.0" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } rt-multi-thread gives the runtime its worker threads; net and time give it the I/O and timer drivers reqwest - and any future tokio::time::sleep - actually needs. That matters more than it looks like it should: reqwest also depends on tokio and pulls in net /time itself, and Cargo unifies features across the whole dependency graph. Leave them off tokio 's own line and the project still compiles today, by accident of what else happens to be in Cargo.toml . It breaks the day reqwest changes its feature list, or the day someone copies this dependency line into a project with no reqwest in it. reqwest with default-features = false and rustls-tls pulls in a pure-Rust TLS stack instead of linking against the system's OpenSSL - one less thing the Flatpak manifest needs to account for. The 0.12 pin is deliberate too: 0.13 renamed that feature to rustls and made it the default backend, which would leave the line above redundant rather than wrong, but 0.12 is what this post was written and tested against, and mixing versions is a worse trap than an old pin. The runtime itself is built once, in main() , before the GTK application exists: // The second executor. Built before the GTK application and dropped // after it exits, so it outlives every fetch that borrows its handle - // the GLib main loop is the other executor, and it never blocks // waiting on this one. let tokio_runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .thread_name("gazette-fetch") .enable_all() .build() .expect("failed to build Tokio runtime"); let app = GazetteApplication::new( "io.github.fromthearchitect.gazette", &gio::ApplicationFlags::empty(), tokio_runtime.handle().clone(), ); let exit_code = app.run(); // tokio_runtime would drop here anyway at end of scope; the explicit // drop just documents the ordering. Runtime::drop blocks this thread // until every worker stops and abandons whatever tasks haven't // finished - abrupt, not a cooperative cancel - which is still better // than a fetch outliving the window that would have consumed it. drop(tokio_runtime); exit_code app.run() blocks until the GTK application quits - that's the GLib main loop, occupying this thread for the entire life of the app. The Tokio runtime doesn't need this thread; Builder::new_multi_thread spins up its own worker threads (two of them here, named for easy identification in a debugger) and hands back a Runtime you only need to keep alive, not sit inside. tokio_runtime.handle().clone() is the thing that actually travels: a cheap, cloneable reference that can schedule work onto the runtime from any thread, including the GLib main thread. One trap worth flagging on its own: forgetting .enable_all() fails silently until the first real request. A Builder without it produces a runtime with no I/O or timer driver - a different gap from the missing Cargo features above, this one is about whether a correctly-compiled runtime instance turns its drivers on. It builds fine, main() runs fine, the window opens fine, and the first time a spawned task tries to do the thing a runtime is for - reqwest::get or tokio::time::sleep - it panics with a message about no I/O driver running. The bug is invisible until the exact code path that needs the missing driver executes, which is exactly the fetch path this post is building. That Handle needs a home the rest of the app can reach. GazetteApplication already owns everything else global to a running instance, so it owns this too - the same OnceCell shape Part 5 used for feeds: OnceCell on the window: set once, read forever after. #[derive(Debug, Default)] pub struct GazetteApplication { // The second executor. Built once in main(), before the GTK // application runs, and outlives every fetch that borrows it. pub tokio: OnceCell , } impl GazetteApplication { pub fn new( application_id: &str, flags: &gio::ApplicationFlags, tokio: tokio::runtime::Handle, ) -> Self { let app: Self = glib::Object::builder() .property("application-id", application_id) .property("flags", flags) .property("resource-base-path", "/io/github/fromthearchitect/gazette") .build(); app.imp() .tokio .set(tokio) .expect("tokio handle set once at construction"); app } /// The shared Tokio runtime handle - the executor every network fetch /// runs on. Available from any GazetteWindow via its application(). pub fn tokio_handle(&self) -> tokio::runtime::Handle { self.imp().tokio.get().expect("tokio handle set").clone() } } build() returns a fully constructed object. If GazetteApplication ever grows a constructed() that reaches for tokio_handle() , it will find the cell empty - constructed() runs during build() , before the line that sets tokio ever executes. Today's code escapes that trap by timing rather than by ordering: nothing that needs the handle runs until GTK calls activate , and activate doesn't fire until app.run() , long after new has returned with
Comments
No comments yet. Start the discussion.