A learning plan built from today's investigation, for going back over the concepts rather than just the fix.
SIGABRT) before the actual poweroff/reboot command ever ran.system() call before the crashing code. Cancel never reaches that code at all.Glib::MainContext::invoke(); patched, rebuilt, and reinstalled the package.You don't read C++, so here's the vocabulary for the exact syntax that shows up in the code below — nothing more.
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 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] 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.on_timer_tick method" into a value that can be handed off to something else — here, a recurring timer — to actually call later..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 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.systemctl poweroff.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.
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.
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.
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.
on_button_clickedAll 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.
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.
system() call sits relative to the crash.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.
label_status.set_label("Closing programs..."); // direct call, worker thread
progressbar_sync.set_fraction(1);
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().
action_thread() in main_context->invoke([...]{ ...; return false; }) instead of calling GTK directly or through signal_idle().continue that forgot to move the loop forwardWhile 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.
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.
continue/break inside a loop whose advancement step lives at the bottom, unguarded.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.
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.
Small exercises to check the concepts stuck, roughly in order of how they came up today.