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::thread::sleep(std::time::Duration::from_secs(2));
        eprintln!("fetched (fake): {}", feed.name());
    }),
);

std::thread::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::thread::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<Widget>. 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<gio::ListStore> 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<tokio::runtime::Handle>,
}
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 tokio set. Any window can reach the runtime through window.application(), downcast to GazetteApplication, and call tokio_handle().


Fetching, off the main thread

The fetch itself lives in a new file, src/fetch.rs, deliberately separate from feed.rs:

use std::fmt;

/// A single parsed entry from a feed. Plain data — not a GObject. It's
/// built on the Tokio runtime and only crosses onto the GLib main context
/// as a value, via `Feed::set_items`.
#[derive(Debug, Clone)]
pub struct FeedItem {
    pub title: String,
    pub link: String,
    pub summary: Option<String>,
}

#[derive(Debug)]
pub enum FetchError {
    Request(reqwest::Error),
    Parse(feed_rs::parser::ParseFeedError),
}

impl fmt::Display for FetchError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            FetchError::Request(e) => write!(f, "request failed: {e}"),
            FetchError::Parse(e) => write!(f, "couldn't parse feed: {e}"),
        }
    }
}

/// Fetches and parses a feed at `uri`. Runs entirely on the Tokio runtime —
/// nothing in this function, or anything it calls, touches a widget or a
/// GObject. That's what makes it safe to run off the GLib main thread.
pub async fn fetch_feed(uri: &str) -> Result<Vec<FeedItem>, FetchError> {
    let response = reqwest::get(uri)
        .await
        .map_err(FetchError::Request)?
        .error_for_status()
        .map_err(FetchError::Request)?;

    let bytes = response.bytes().await.map_err(FetchError::Request)?;

    let parsed = feed_rs::parser::parse(&bytes[..]).map_err(FetchError::Parse)?;

    Ok(parsed
        .entries
        .into_iter()
        .map(|entry| FeedItem {
            title: entry
                .title
                .map(|text| text.content)
                .unwrap_or_else(|| "Untitled".to_string()),
            link: entry
                .links
                .first()
                .map(|link| link.href.clone())
                .unwrap_or_default(),
            summary: entry.summary.map(|text| text.content),
        })
        .collect())
}

FeedItem is a plain struct rather than a glib::wrapper! type, and that matters: constructing a GObject means calling into GObject’s type system, and this function has no business doing that from a Tokio worker thread. fetch_feed produces data; something back on the main thread decides what GObject, if any, that data becomes.

error_for_status() matters more than it looks. Without it, reqwest::get resolves Ok for a 404, a 500, or a hosting provider’s error interstitial — the HTTP request itself succeeded even though the content is a lie. Skip the check and feed_rs::parser::parse gets handed an HTML error page instead of a feed, fails to parse it, and the caller sees FetchError::Parse for what was actually a request-level failure. Checking status before reading the body is what keeps Request meaning “didn’t get a real feed body at all” — whether the cause is DNS, a timeout, or a 404 — and Parse meaning “got a body and it wasn’t a feed.”

feed_rs::parser::parse takes anything implementing std::io::Read (a byte slice qualifies) and returns entries regardless of whether the source was RSS or Atom. Gazette’s four sample feeds are a mix of both — This Week in GNOME and From the Architect are Atom, LWN and the Hacker News mirror are RSS — and fetch_feed doesn’t need to know or care which. That format detection is feed-rs’s job, done once, before any of Gazette’s code sees an entry.

One thing worth knowing, not fixing here: reqwest::get is a convenience function that builds a fresh Client — and with it, a fresh connection pool and TLS session — on every call. Fine for a post about the boundary between two executors, wasteful in code that fetches the same handful of feeds over and over. Building a Client once and sharing it alongside the Tokio handle is a refinement for a later post, once persistence is in the picture.


Crossing back

The feed-selected handler is where the two executors actually meet:

obj.connect_closure(
    "feed-selected",
    false,
    glib::closure_local!(move |window: &super::GazetteWindow, feed: Feed| {
        eprintln!("feed selected: {} ({})", feed.name(), feed.uri());

        // The GLib executor owns `feed` and `window`; the Tokio
        // executor (below) only ever sees the URI string it needs
        // to fetch. Neither side touches the other's objects
        // directly — the `.await` on the join handle is the only
        // crossing.
        let tokio_handle = window
            .application()
            .and_downcast::<crate::application::GazetteApplication>()
            .expect("window has a GazetteApplication")
            .tokio_handle();
        let uri = feed.uri();

        glib::MainContext::default().spawn_local(async move {
            let result = tokio_handle
                .spawn(async move { crate::fetch::fetch_feed(&uri).await })
                .await;

            match result {
                Ok(Ok(items)) => feed.set_items(items),
                Ok(Err(fetch_err)) => {
                    eprintln!("fetch failed for {}: {fetch_err}", feed.name());
                }
                Err(join_err) => {
                    eprintln!(
                        "fetch task for {} did not complete: {join_err}",
                        feed.name()
                    );
                }
            }
        });
    }),
);

Read it from the outside in. glib::MainContext::default().spawn_local(async move { ... }) schedules the outer future on the GLib main context — the same executor that owns every widget — running it cooperatively alongside everything else, exactly like a signal handler. Because it’s confined to the main context, it’s allowed to hold feed and eventually call feed.set_items(...). That’s still executor-one territory.

Inside it, tokio_handle.spawn(async move { crate::fetch::fetch_feed(&uri).await }) hands the actual work to executor two. Handle::spawn returns immediately with a JoinHandle; it doesn’t block the caller, which is the entire point of calling it from the main thread. .awaiting that JoinHandle is where the outer future pauses: control genuinely returns to the GLib main loop, which goes on painting, handling input, and dispatching other signals while Tokio’s worker threads do the fetch. When the Tokio task finishes, the main loop resumes this specific future exactly where it left off, back on the main thread, with result now holding a value.

Why Ok(Ok(items)) and not just Ok(items)? Two different things can go wrong, and they’re wrapped separately. The outer Result comes from JoinHandle itself — its Err means the spawned task panicked or was cancelled, a Tokio-level failure that has nothing to do with feeds. The inner Result is fetch_feed’s own Result<Vec<FeedItem>, FetchError>, a normal, expected outcome of trying to fetch something over a network that might be down. Matching both layers explicitly is what forces you to decide what each one means, rather than letting ? collapse them into a single generic failure.

And a specific footgun: tokio::spawn(...), the free function, panics here — tokio_handle.spawn(...) doesn’t. The free function only works from inside a future that’s already running on a Tokio runtime. Called from the GLib main thread, which isn’t inside any Tokio runtime, it panics; the exact wording has changed across Tokio versions, but it always amounts to “no runtime found in this context.” Handle::spawn, called on the Handle cloned out in main(), is explicitly designed to work from any thread, runtime or not — that’s the entire reason GazetteApplication stores a Handle instead of a Runtime. If you ever see that panic, you’ve reached for the free function where you needed the handle.

Run the app now. Click Hacker News: after a brief pause — a real network round trip, with the window still live under your cursor — real headlines appear on stderr. Click LWN.net Headlines before the first fetch has printed anything, and both proceed independently.


Feed remembers what it fetched

set_items is doing real work in that handler, and Feed needs somewhere to put what it’s given:

use crate::fetch::FeedItem;

#[derive(Debug, Default, Properties)]
#[properties(wrapper_type = super::Feed)]
pub struct Feed {
    #[property(get, set)]
    pub name: RefCell<String>,
    #[property(get, set)]
    pub uri: RefCell<String>,
    #[property(get, set)]
    pub unread_count: Cell<u32>,

    // Plain data, not a property: `Vec<FeedItem>` has no `glib::Value`
    // mapping, and nothing outside this module needs to bind to it
    // directly. `items()` and `set_items()` are the accessors.
    pub items: RefCell<Vec<FeedItem>>,
}
impl Feed {
    pub fn items(&self) -> Vec<FeedItem> {
        self.imp().items.borrow().clone()
    }

    /// Replaces this feed's items and emits `items-updated`. The count
    /// crossing over from a real fetch is what makes `unread-count` real
    /// too, rather than the placeholder zero every sample feed starts with.
    pub fn set_items(&self, items: Vec<FeedItem>) {
        self.set_unread_count(items.len() as u32);
        self.imp().items.replace(items);
        self.emit_by_name::<()>("items-updated", &[]);
    }
}

items isn’t a #[property]: a Vec<FeedItem> has no glib::Value representation the way a String or a u32 does, and nothing outside Feed needs to bind a widget to the raw list. It’s a RefCell for the same reason selected_feed was one back in Part 5 — a value that changes over the object’s life, read from one place (items()), written from one place (set_items()).

The items-updated signal was declared back in Part 2, on a Feed that never actually got new items, then Part 5 dropped it entirely to avoid emitting ceremony for nothing. It comes back here on the same impl block that already derives Feed’s properties — with one change from Part 2’s sketch:

#[glib::derived_properties]
impl ObjectImpl for Feed {
    fn signals() -> &'static [Signal] {
        static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new();
        SIGNALS.get_or_init(|| vec![Signal::builder("items-updated").build()])
    }
}

Part 2’s version carried a u32 payload — the new item count, passed directly on the signal. This one carries nothing. Feed has an unread-count property now, so a listener that wants the count can just read feed.unread_count() instead of catching it off the emission. A signal that announces something changed and lets listeners go look is easier to evolve than one that ships a payload every listener has to keep accepting — that’s the shape the rest of this post builds on.

#[glib::derived_properties] has to stay on this impl, even though signals() has nothing to do with properties: it’s what generates properties(), property(), and set_property() from the #[property] fields on the struct above. Drop it while adding signals() and every #[property] accessor Feed already has — name(), uri(), set_unread_count() — silently stops working.

What’s different now is that set_items is the only thing that ever emits items-updated, and it’s only ever called with data that actually came off the network. set_unread_count runs first, before the emit, and the count that shows up in the sidebar is the number of items this fetch found.

That’s a narrower claim than “unread,” on purpose. Every fetch sets unread-count to the total item count, not the count of items the user hasn’t seen. Refetch a feed after reading everything in it and the badge goes straight back up to the full number. unread_count is standing in for a property Gazette doesn’t have the machinery to compute correctly yet — real read-tracking needs per-item state, which doesn’t exist until this series adds persistence. Until then, read the name as aspirational, not as a bug you need to chase.


A consumer for items-updated

set_items emits; something has to be listening. Each sample feed gets a logging handler when the store is built, in constructed():

let store = gio::ListStore::new::<Feed>();
for (name, uri) in SAMPLE_FEEDS {
    let feed = Feed::new(name, uri);

    // Reacting to items-updated is a separate job from fetching
    // them — the fetch (wired below, on feed-selected) never
    // logs directly, it just calls `feed.set_items(...)`.
    feed.connect_closure(
        "items-updated",
        false,
        glib::closure_local!(move |feed: Feed| {
            let items = feed.items();
            eprintln!("{}: {} items", feed.name(), items.len());
            for item in items.iter().take(3) {
                eprintln!("  - {}", item.title);
            }
        }),
    );

    store.append(&feed);
}

Notice the fetch code in feed-selected never calls eprintln! on success. It just calls feed.set_items(items) and stops. Printing what arrived is this closure’s job, connected once per feed, entirely separate from the code that went and got the data. That’s the same lesson Part 5 landed with the placeholder binding and the feed-selected logger: fetching is one job, reacting to what was fetched is another, and the signal between them is what keeps neither one needing to know the other exists. A future persistence layer connects its own handler to items-updated later without touching a line of fetch.rs.


Sharp edges

A RefCell borrow held across an .await fails the same way a borrow held across a signal emit did in Part 5, just harder to spot. The failure shape: let items = self.items.borrow_mut(); some_future.await; inside a spawn_local block, where the code resumed after the .await — or a handler running on a different turn of the main loop, if the borrow escapes further — tries to borrow the same cell. Signal handlers run synchronously, so a Part 5-style borrow panic happens right where you’d look for it. An .await point hands control back to the main loop for an arbitrary amount of time: anything else the loop dispatches in between, including another handler that reaches for the same RefCell, can trigger the panic somewhere that looks nothing like the code that caused it. Keep borrows scoped tightly, and never hold one across an .await.

What happens if you click the same feed five times in a row? Five independent fetches run concurrently, each racing to call set_items on the same Feed when it finishes — last one to land wins, and items-updated fires five times for one selection. Harmless for a post about the executor boundary, but worth knowing before you go adding a refresh action, which makes hammering that shortcut the obvious next thing to try. A real fix would track the in-flight JoinHandle on Feed and abort or ignore a new request while one’s outstanding — outside this post’s scope, but that’s where the fix would go.

window.application() returns None before the window has one, and expect turns that into a panic. In practice this never fires here: GazetteWindow::new always takes an application, and nothing runs feed-selected before the window exists. The expect documents an invariant rather than checking one — this window always has an application by the time it can receive input. If you ever construct a GazetteWindow without one, a test most likely, this is where it breaks.


What we have so far

Selecting a feed now does real work. A Tokio runtime, built once alongside the GTK application and never touched by it directly, does the actual fetching and parsing, and the only thing that ever crosses back onto the GLib main thread is a Result full of plain data. Feed::items-updated fires for real for the first time since Part 2 declared it, driven by whatever feed-rs found at the far end of a real URL.

Nothing about this displays anywhere yet — the items live in a RefCell a logging handler reads, and the sidebar still shows nothing but a name and a count. The fetch mechanism this post built doesn’t need to change at all to support that; it already hands off a Vec<FeedItem> to whatever wants to render it.


What comes next

That’s the gap the next post closes: an article list for the selected feed, a content pane for the selected article, and the adaptive layout that collapses both into a single pane on a narrow window. The two-executor pattern from this post doesn’t change to support any of it.

Further out, unread_count and items want somewhere durable to live, so a refetch doesn’t erase read state and a restart doesn’t lose it either. That’s the persistence layer this series has been setting up for since the OnceCell and RefCell conventions in Part 5 — a real place for items-updated to write to, instead of a logging handler standing in for one.


The source code at the end of this post lives on the part-6 branch of fromthearchitect/gnome-rust-gazette.