Skip to content

core: defer SIGUSR2 force update to the main loop - #1062

Open
PeterDessev wants to merge 1 commit into
hyprwm:mainfrom
PeterDessev:fix/sigusr2-async-signal-safety
Open

PeterDessev wants to merge 1 commit into
hyprwm:mainfrom
PeterDessev:fix/sigusr2-async-signal-safety

Conversation

@PeterDessev

Copy link
Copy Markdown

Summary

handleForceUpdateSignal performs the entire force-update inside the signal
handler. That is not async-signal-safe, and it causes two distinct failures:
label updates being silently dropped (#1031), and — less often but much worse —
the lock screen deadlocking outright.

This moves the work to the main loop via a self-pipe, reusing the existing
enqueueForceUpdateTimers() deferral.

The problem

static void handleForceUpdateSignal(int sig) {
    if (sig == SIGUSR2) {
        for (auto& t : g_pHyprlock->getTimers()) {
            if (t->canForceUpdate()) {
                t->call(t);
                t->cancel();
            }
        }
    }
}

Signal delivery interrupts a thread at an arbitrary instruction and runs the
handler nested inside whatever that thread was doing. This handler takes
m_sLoopState.timersMutex, allocates (the getTimers() copy), and re-enters
widget code.

Dropped updates (#1031). CLabel::onTimerUpdate() skips when
m_pendingResource is set, and that flag is cleared by the resource manager on
the main loop. Firing updates from a signal means observing that state at a
random point — often mid-flight, and possibly inside onAssetUpdate() between
clearing the flag and assigning the asset. Which labels happen to have a request
outstanding at that instruction is chance, which matches the report in #1031:
"sometimes only one of them does at a time. Which one updates is not
deterministic."

Deadlock. If the signal lands while the interrupted thread is inside
malloc or already holds timersMutex, the handler blocks on a lock only that
thread could release. The lock screen freezes with the main thread parked in
futex_do_wait at 0% CPU and no way to authenticate. This is rare per signal
but reachable in seconds by anything that force-updates at a few Hz.

The fix

The handler does the only async-signal-safe thing available — write() a byte
to a self-pipe. The read end joins the existing pollfd array, so the poll thread
wakes the main loop exactly as it already does for wayland and dbus, and the
main loop calls enqueueForceUpdateTimers().

That function already existed and is already used by five call sites in the auth
and fingerprint paths for precisely this reason — deferring widget work to the
main loop. The signal handler was the one place bypassing it.

Why a self-pipe rather than a sig_atomic_t flag

The poll thread continues on EINTR without notifying the main loop, so a
bare flag would not be observed until at worst the loop's 5 s wait_for expired.
The pipe makes the wake deterministic and reuses the existing plumbing.

The drain is deliberately not gated on revents: revents is written by the
poll thread and read from the main loop, so testing it there would be racy.
Draining is cheap and idempotent, so the pipe is simply read every iteration.

Drive-by

pollfds[1].revents was read even when dbus was absent, in which case that slot
was never initialised. The fd indices are now tracked explicitly.

Testing

Built and run on Hyprland 0.56.2 with a widget config using multiple
cmd[update:…:true] + onclick labels plus a force-updated image, driven by
an external daemon sending SIGUSR2 on state changes.

  • Before: reproducible freeze under rapid SIGUSR2 (volume button widgets clicked
    quickly), confirmed as a deadlock — 0 CPU, main thread in futex_do_wait,
    hyprlock holding no pipes and no children.
  • After: multiple lock sessions, >844 signals, >9 408 timer passes — no freeze, no
    stalls, timer count bounded at 23–29.

Relationship to #1031

This removes the nondeterministic skips. It does not eliminate skipping
entirely: an update that arrives while a command genuinely has not finished yet
is still dropped by the m_pendingResource guard, which is the guard doing its
job. Measured after this patch:

gap since previous force pass passes avg skips/pass
< 100 ms 259 2.21
100–250 ms 515 0.02
250 ms – 1 s 28 0.00
> 1 s 99 0.00

So the residual is purely a function of force-updating faster than the commands
complete, which is a separate concern from the race fixed here. I have not
reproduced #1031's specific setup (multiple labels plus a slow image reload), so
I would rather the reporter confirm than close it from here. Happy to follow up
with a change that coalesces a pending update instead of dropping it, if that
behaviour is wanted.

Not addressed

handleUnlockSignal has the same class of problem — it calls std::vformat and
the logger from signal context. Less severe, since unlocking is one-shot and
terminal, but it is the same bug. Left out to keep this to one concern; happy to
fix it here or separately.


🤖 Drafted with Claude Code

Running the force update in signal context takes a mutex and allocates,
which can deadlock the main loop and races the resource manager. Defer to
the main loop via a self-pipe and enqueueForceUpdateTimers().
@PeterDessev
PeterDessev marked this pull request as ready for review August 16, 2026 02:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant