Repository navigation
core: defer SIGUSR2 force update to the main loop - #1062
Open
PeterDessev wants to merge 1 commit into
Open
PeterDessev wants to merge 1 commit into
PeterDessev wants to merge 1 commit into
Conversation
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
marked this pull request as ready for review
August 16, 2026 02:00
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
handleForceUpdateSignalperforms the entire force-update inside the signalhandler. 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
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 (thegetTimers()copy), and re-enterswidget code.
Dropped updates (#1031).
CLabel::onTimerUpdate()skips whenm_pendingResourceis set, and that flag is cleared by the resource manager onthe main loop. Firing updates from a signal means observing that state at a
random point — often mid-flight, and possibly inside
onAssetUpdate()betweenclearing 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
mallocor already holdstimersMutex, the handler blocks on a lock only thatthread could release. The lock screen freezes with the main thread parked in
futex_do_waitat 0% CPU and no way to authenticate. This is rare per signalbut 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 byteto 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_tflagThe poll thread
continues onEINTRwithout notifying the main loop, so abare flag would not be observed until at worst the loop's 5 s
wait_forexpired.The pipe makes the wake deterministic and reuses the existing plumbing.
The drain is deliberately not gated on
revents:reventsis written by thepoll 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].reventswas read even when dbus was absent, in which case that slotwas 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]+onclicklabels plus a force-updated image, driven byan external daemon sending SIGUSR2 on state changes.
quickly), confirmed as a deadlock — 0 CPU, main thread in
futex_do_wait,hyprlock holding no pipes and no children.
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_pendingResourceguard, which is the guard doing itsjob. Measured after this patch:
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
handleUnlockSignalhas the same class of problem — it callsstd::vformatandthe 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