Field Notes: The syspower Shutdown Bug
Field notes — debugging session

Why Shutdown & Reboot Were Silently Dying in syspower

A learning plan built from today's investigation, for going back over the concepts rather than just the fix.

2026-07-05 archangel · Hyprland · Arch Linux syspower 2026.01.01-1 → 2026.07.05-1
Symptom
Shutdown and Reboot did nothing. Hibernate, Suspend, and Cancel worked fine.
Root cause
A background thread touched GTK widgets directly. GTK aborted the whole process (SIGABRT) before the actual poweroff/reboot command ever ran.
Why not the others
Hibernate/Suspend run their system() call before the crashing code. Cancel never reaches that code at all.
Fix
Marshalled every widget update onto the main thread with Glib::MainContext::invoke(); patched, rebuilt, and reinstalled the package.

C++, translated

You don't read C++, so here's the vocabulary for the exact syntax that shows up in the code below — nothing more.

std::thread t(&Class::method, this); t.detach();
Starts method running as its own independent thread of execution, then .detach() means the caller walks away — it never waits for that thread, never finds out if it crashed, never gets a result back.Where: the line that kicks off the whole broken sequence.
label_status.set_label(...)
No object name in front looks like a free-floating function call, but this is a method call on the window's own label_status widget — C++ classes can call their own methods and touch their own member variables without writing this-> in front every time.Where: almost every line inside the broken function.
[this]() { ... }
A lambda — an inline, anonymous, throwaway function. The [this] in brackets is its "capture list": it says the code inside is allowed to reach back out and use the surrounding object's own data and widgets.
sigc::mem_fun(*this, &syspower::on_timer_tick)
Packages "call this particular object's on_timer_tick method" into a value that can be handed off to something else — here, a recurring timer — to actually call later.
std::string::npos
A special "nothing found" value. .find(x) returns it when x isn't in the string. Comparing a position against npos is how C++ code checks "did that search actually find anything?"
continue; vs break;
continue jumps straight back to the top of the loop and re-checks its condition; break leaves the loop entirely. Skipping a step that was supposed to move the loop forward, before a continue, is exactly how the bug below happens.
system("cmd")
A plain C function (not GTK, not C++-specific): hand it a string, it runs that string in a shell, blocks until the shell command finishes, and returns its exit code. This is literally the line that turns clicking "Shutdown" into a real systemctl poweroff.
01
Provenance

Is the code you're reading the code that's running?

Before touching anything, we confirmed three separate things had to line up: the installed package version (pacman -Qi syspower), the exact git commit yay actually built from (the PKGBUILD's pkgver() derives the version string from git show -s --format=%cd), and whether that commit still matched GitHub's current main. It did — 1a74a78, "Minor improvements" — so the GitHub source was trustworthy to read.

AUR packages that build source=("git+https://...") with no pinned tag/commit will silently pick up whatever HEAD was at build time. That's worth checking before you debug from the README.

Why it mattered here: if the cached commit had been stale, every line number and function name in this whole investigation would've pointed at the wrong code.
02
Forensics

Reading a crash you didn't watch happen

The app doesn't log. But every abnormal exit on a systemd system leaves a coredump. coredumpctl list syspower showed four past SIGABRT crashes, and coredumpctl info <pid> printed a full per-thread backtrace without needing to reproduce anything live.

The signature: the crashing thread's stack ended in g_assertion_message_expr → abort, called from inside a GTK signal emission, on the main thread — while a second thread was sitting in syspower::action_thread. Two threads, one GTK. That's the whole story, before reading a line of source.

Why it mattered here: this is what turned "shutdown doesn't work" from a guess into a specific, provable claim before any code was changed.
03
Concurrency model

GTK has exactly one thread that's allowed to touch it

GTK (and the GLib main loop underneath it) isn't lock-protected for concurrent access. Every widget, every signal, every piece of window state assumes only the thread running the app's GMainContext ever reads or writes it. That thread is normally whichever one called Gtk::Application::run() — the "main thread."

action_thread() was spawned as a detached std::thread and called label_status.set_label(...), progressbar_sync.set_fraction(...), add_css_class(...) directly — real widget mutations, from the wrong thread, running concurrently with the main thread's own event loop iteration.

Original — src/window.cpp View lines 220–245 on GitHub →
void syspower::action_thread() {
    add_css_class("in_action");                 // ← widget call, wrong thread
    ...
    revealer_box.set_reveal_child(false);        // ← widget call, wrong thread
    ...
    label_status.set_visible(true);              // ← widget call, wrong thread
    progressbar_sync.set_visible(true);          // ← widget call, wrong thread
    ...
    label_status.set_label("Closing programs..."); // ← widget call, wrong thread
    progressbar_sync.set_fraction(1);            // ← widget call, wrong thread
    syspower_functions::kill_child_processes();  // ← fine, no GTK involved

Every one of those flagged lines is a method on a Gtk:: widget that belongs to the main thread — see label_status.set_label(...) in the glossary above. They're called here from inside the thread spawned at lines 332–333, not the main thread.

Why it mattered here: this single design mistake is the actual root cause. Everything else in the fix exists to route around it.
04
Why only two buttons

The fork inside on_button_clicked

All six buttons funnel through one function. It builds a command string, then — for every button — ends with the same two lines that spawn the crash-prone thread. The buttons only differ in what happens before that point.

Original — src/window.cpp View lines 291–334 on GitHub →
void syspower::on_button_clicked(const std::string& button) {
    ...
    if (button == "shutdown") {
        command += " poweroff";           // just builds a string —
        button_text = "Shutting down..."; // doesn't run anything yet
    }
    else if (button == "reboot") {
        command += " reboot";             // same — string only
        ...
    }
    else if (button == "suspend") {
        system((cmd + " suspend").c_str()); // ← runs right here, for real
        for (const auto &window : windows)
            window->close();
        close();
    }
    else if (button == "hibernate") {
        system((cmd + " hibernate").c_str()); // ← runs right here, for real
        ...
    }
    else if (button == "cancel") {
        ...
        return;                            // ← leaves before the thread below
    }

    std::thread thread_action(&syspower::action_thread, this); // everyone else lands here
    thread_action.detach();

For Shutdown/Reboot, system(...) — see the glossary above — never gets called up here at all. It only happens at the very end of action_thread() (line 273), after every one of the crash-prone widget calls from module 03. Suspend/Hibernate call system(...) immediately, right here, on the main thread, before any of that — so the real action is already done by the time anything can go wrong. Cancel just returns and never reaches the std::thread line at all.

Why it mattered here: this is the actual mechanism behind "hibernate and cancel work, shutdown and reboot don't" — not a coincidence, a direct consequence of where each branch's system() call sits relative to the crash.
05
The subtle part

Not every "thread-safe-looking" call actually is

The obvious fix looks like Glib::signal_idle().connect(...) — glibmm's wrapper for "run this on the main loop." But its own header is explicit: "This method is not thread-safe. You should call it … only from the thread where the SignalIdle object's MainContext runs." The plain C g_idle_add() is safe from any thread; glibmm's C++ wrapper around it isn't, because of extra bookkeeping it does for sigc::trackable.

The one glibmm call actually documented as safe to invoke from a worker thread is Glib::MainContext::get_default()->invoke(slot) — it wraps g_main_context_invoke(), which is built for exactly this: hop a function call onto the owning thread and (in the cross-thread case) wait for it to run.

Before — as written upstream
label_status.set_label("Closing programs...");   // direct call, worker thread
progressbar_sync.set_fraction(1);
After — the fix, installed locally
main_context->invoke([this]() {
    label_status.set_label("Closing programs..."); // same call, now hops
    progressbar_sync.set_fraction(1);               // onto the main thread
    return false;
});

The [this]() { ... } part is the lambda from the glossary above — the actual widget calls inside it don't change at all, they're just handed to invoke() instead of being run directly, so GTK sees them arrive on the one thread it trusts. This same wrapping was applied to every widget-touching block in action_thread().

Why it mattered here: the fix wraps every widget-touching block in action_thread() in main_context->invoke([...]{ ...; return false; }) instead of calling GTK directly or through signal_idle().
06
Bonus bug, same file

A continue that forgot to move the loop forward

While in functions.cpp's kill_child_processes(), a second, independent bug turned up: the loop parsing hyprctl clients -j called continue whenever a pid matched the app's own pid or its parent's — but the line that advances pos to the next match sits after that continue. Same pos, same branch, forever. An infinite loop, not a crash — CPU pegged, the function (and everything after it) never returns.

Original — src/functions.cpp View lines 44–75 on GitHub →
while (pos != std::string::npos) {
    size_t start = output.find(':', pos);
    if (start == std::string::npos)
        continue;                 // pos never moves → spins forever
    ...
    // Don't kill own process or parent
    if (std::stoi(pid) == own_pid || std::stoi(pid) == parent_pid)
        continue;                 // same problem — pos still doesn't move
    ...
    pos = output.find(searchString, pos + 1); // ← the advance every continue above skips
}

See continue vs break and std::string::npos in the glossary. Every continue here re-checks while (pos != std::string::npos) with the exact same pos it already had — so if the app's own pid (or its parent's) ever shows up in the window list, this doesn't error, it just never comes back.

It likely wasn't the one causing today's specific crash (that was the GTK threading issue), but it was a live landmine sitting in the exact same code path shutdown/reboot depend on, so it got fixed alongside the real cause.

Why it mattered here: a good pattern to keep an eye out for in your own code — any continue/break inside a loop whose advancement step lives at the bottom, unguarded.
07
Packaging

Patching an AUR package without forking it on GitHub

The workflow: clone the exact commit yay built (git clone ~/.cache/yay/syspower/syspower), edit and commit the fix locally, then copy the cached PKGBUILD and repoint its source=() at the local patched clone with the name::url syntax — source=("syspower::git+file:///path/to/patched") — bumping pkgrel. makepkg builds a real, installable .pkg.tar.zst from that, same as any AUR package, and pacman -U installs it under pacman's normal tracking.

Why it mattered here: this keeps the fix reversible and pacman-aware — no manually overwritten binaries, no drift from the package database.
08
Safe testing

Rehearsing a command you can't afford to get wrong

You were right to not want to click a real "Shutdown" button on unverified code. systemctl has a built-in --dry-run flag — "Only print what would be done" — supported directly by poweroff, reboot, suspend, hibernate, and a handful of others. It needs no elevated privilege and touches nothing. In the end you tested with the real command anyway, but it's worth keeping in your back pocket: whenever a fix's payoff is a command you only get to run once.

Go deepersystemctl(1)

Try it yourself

Small exercises to check the concepts stuck, roughly in order of how they came up today.