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
johnrichardrinehart added a commit to johnrichardrinehart/nix-config that referenced this pull request Nov 1, 2026
hyprlock runs SIGUSR2 force updates inside the signal handler.
face-scan-key sends SIGUSR2 when a scan ends, which is also when PAM
succeeds and hyprlock redraws. A signal that lands while the main thread
is inside getpwuid() makes the handler wait on that lock forever, so the
lock screen hangs after a successful unlock.

Carry hyprwm/hyprlock#1062, which defers the update to the main loop
through a self-pipe. It does not apply to 0.9.5, so build main.
@johnrichardrinehart

Copy link
Copy Markdown

Independent confirmation of the deadlock, plus two related findings from testing this PR.

A third lock in the field: getpwuid()

A hyprlock 0.9.5 lock screen (niri, PAM + a face-recognition step) hung after a successful authentication: pam_authenticate returned, the keyring unlocked, but the session never unlocked. An external helper sends SIGUSR2 when a face scan ends, which is exactly when PAM finishes and hyprlock redraws. Main thread of the hung process (SigBlk showed only SIGUSR2 blocked, i.e. still inside the handler):

#0  __lll_lock_wait_private () from libc.so.6
#1  getpwuid () from libc.so.6
#2  IWidget::formatString(std::string) ()
#3  CLabel::onTimerUpdate() ()
#4  ... CLabel::plantTimer()::{lambda #2} ...
#5  CTimer::call(...) ()
#6  handleForceUpdateSignal(int) ()
#7  <signal handler called>
#8  __syscall_cancel_arch () from libc.so.6
...
#11 connect () from libc.so.6
#12 open_socket () / __nscd_open_socket ()
#14 nscd_getpw_r () / __nscd_getpwuid_r ()
#16 getpwuid_r () / getpwuid ()
#18 IWidget::formatString(std::string) ()
#19 CPasswordInputField::updatePlaceholder() ()
#20 CPasswordInputField::draw(...) ()
#21 CRenderer::renderLock(...) ()
#22 CSessionLockSurface::render() ()
#23 _CWlCallbackDone(...) ()
...
#30 CHyprlock::run() ()

formatString() calls getpwuid() unconditionally on every draw, and with nscd/nsncd that is a socket round trip held under glibc's lock, so the window is wide. No PAM thread was left; only the main loop was stuck.

Reproduction, before and after

Headless sway, a config with an input field and a cmd[update:0:1] label, SIGUSR2 sent from a shell loop:

build ~kHz bursts ~50 Hz for 3–8 s
v0.9.5 4/4 aborted (malloc(): corrupted top size) within ~300 signals 1/7 deadlocked (main thread in the handler, label frozen)
main (9de8719) + this PR 5/5 survived (5.6k–36k signals) 7/7 survived, label refreshed on every signal

With the PR, single signals a second apart (the real-world pattern) refreshed the label 5/5. We now carry this PR on top of main in our NixOS config.

Finding 1: SIGUSR1 deadlocks the same way

As noted under "Not addressed", handleUnlockSignal is still unsafe. It is reachable: after a burst, a single SIGUSR1 left the main thread asleep on a futex with only SIGUSR1 blocked (inside the handler, after logging "Unlocking with a SIGUSR1"), 3/3 times. The same self-pipe could carry an unlock request.

Finding 2: force updates re-fire cancelled timers

enqueueForceUpdateTimers() calls every timer with canForceUpdate(), including ones already cancel()ed in the same processTimers() pass. They are only erased after the pass, and each call replants a label timer through CLabel::onTimer. When several force-update timers fire in one pass, the label timer set multiplies. After a kHz burst, one run logged 1.2M Trying to update label, but a resource is still pending! warnings for ~24k signals, and spaced SIGUSR2s afterwards refreshed the label 0–1 out of 5. This is pre-existing (0.9.5 can't survive the burst to show it) and not reachable at normal signal rates. Skipping t->cancelled() timers in that loop, and taking timersMutex in getTimers(), look like the minimal fixes.

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.

2 participants