headless-primary: make the watcher always-converge
Add a self-healing backstop so recovery no longer depends on an event firing: - periodic reconcile (every 8s): read-only drift check that runs apply ONLY when broken (no headless / DP-1 not mirroring / workspace trapped on a mirror / orphan / off-screen window) — no steady-state flicker. - delayed reconcile 1.5s after a monitor event to catch late workspace reassignment races. - socat auto-reconnect; exit cleanly when the session socket vanishes. New 'reconcile' subcommand. Verified: reconcile heals a deliberately broken mirror and no-ops on a healthy state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,8 +20,8 @@
|
|||||||
# Subcommands:
|
# Subcommands:
|
||||||
# apply (default) establish/repair state — idempotent (topology + rescues)
|
# apply (default) establish/repair state — idempotent (topology + rescues)
|
||||||
# rescue just pull off-screen floating windows back on-screen
|
# rescue just pull off-screen floating windows back on-screen
|
||||||
# watch long-running: re-apply on monitor hotplug AND rescue windows that
|
# reconcile read-only drift check; runs apply ONLY if something is wrong
|
||||||
# open off-screen (this is what makes stray windows self-heal)
|
# watch long-running self-healing daemon (events + periodic backstop)
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ WIDTH=5120
|
|||||||
HEIGHT=1440
|
HEIGHT=1440
|
||||||
RATE=60
|
RATE=60
|
||||||
POS="0x0"
|
POS="0x0"
|
||||||
|
RECONCILE_SECS=8 # periodic backstop cadence (self-heal if an event is missed)
|
||||||
CONF="$HOME/.config/sunshine/sunshine.conf"
|
CONF="$HOME/.config/sunshine/sunshine.conf"
|
||||||
|
|
||||||
ensure_hypr_sig() {
|
ensure_hypr_sig() {
|
||||||
@@ -52,28 +53,27 @@ headless_names() {
|
|||||||
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | sort -V
|
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | sort -V
|
||||||
}
|
}
|
||||||
|
|
||||||
# Recenter any FLOATING, mapped window whose rectangle does not intersect the
|
# Addresses of FLOATING, mapped windows whose rectangle does not intersect the
|
||||||
# monitor its workspace lives on (i.e. it's stranded off-screen after a monitor
|
# monitor their workspace lives on (stranded off-screen). Optional single-addr arg.
|
||||||
# topology change, or it opened off-screen). Pass a single window address to
|
offscreen_addrs() {
|
||||||
# check just that window (used on the openwindow event); no arg = sweep all.
|
local only="${1:-}" mons clients
|
||||||
rescue_offscreen_windows() {
|
|
||||||
local only="${1:-}" mons clients addrs a
|
|
||||||
have_tools || return 0
|
|
||||||
mons="$(hyprctl monitors all -j 2>/dev/null)" || return 0
|
mons="$(hyprctl monitors all -j 2>/dev/null)" || return 0
|
||||||
clients="$(hyprctl clients -j 2>/dev/null)" || return 0
|
clients="$(hyprctl clients -j 2>/dev/null)" || return 0
|
||||||
addrs="$(printf '%s' "$clients" | jq -r --argjson mons "$mons" --arg only "$only" '
|
printf '%s' "$clients" | jq -r --argjson mons "$mons" --arg only "$only" '
|
||||||
($mons | map({key:(.id|tostring), value:{x:.x,y:.y,w:.width,h:.height}}) | from_entries) as $M
|
($mons | map({key:(.id|tostring), value:{x:.x,y:.y,w:.width,h:.height}}) | from_entries) as $M
|
||||||
| .[]
|
| .[]
|
||||||
| select(.mapped == true and .floating == true)
|
| select(.mapped == true and .floating == true)
|
||||||
| select($only == "" or .address == $only)
|
| select($only == "" or .address == $only)
|
||||||
| . as $c | ($M[$c.monitor|tostring]) as $m
|
| . as $c | ($M[$c.monitor|tostring]) as $m
|
||||||
| select($m != null)
|
| select($m != null)
|
||||||
| select( ($c.at[0]+$c.size[0]) <= $m.x
|
| select( ($c.at[0]+$c.size[0]) <= $m.x or $c.at[0] >= ($m.x+$m.w)
|
||||||
or $c.at[0] >= ($m.x+$m.w)
|
or ($c.at[1]+$c.size[1]) <= $m.y or $c.at[1] >= ($m.y+$m.h) )
|
||||||
or ($c.at[1]+$c.size[1]) <= $m.y
|
| .address' 2>/dev/null
|
||||||
or $c.at[1] >= ($m.y+$m.h) )
|
}
|
||||||
| .address' 2>/dev/null)"
|
|
||||||
for a in $addrs; do
|
rescue_offscreen_windows() {
|
||||||
|
local a
|
||||||
|
for a in $(offscreen_addrs "${1:-}"); do
|
||||||
hyprctl dispatch focuswindow "address:$a" >/dev/null 2>&1
|
hyprctl dispatch focuswindow "address:$a" >/dev/null 2>&1
|
||||||
hyprctl dispatch centerwindow >/dev/null 2>&1
|
hyprctl dispatch centerwindow >/dev/null 2>&1
|
||||||
log "rescued off-screen window $a"
|
log "rescued off-screen window $a"
|
||||||
@@ -85,8 +85,8 @@ apply() {
|
|||||||
have_tools || { log "hyprctl/jq missing."; return 0; }
|
have_tools || { log "hyprctl/jq missing."; return 0; }
|
||||||
|
|
||||||
# 1. Exactly one headless output. Create if none; disable extras (remove is
|
# 1. Exactly one headless output. Create if none; disable extras (remove is
|
||||||
# unreliable for mirrored/persistent headless — it returns "output not
|
# unreliable for mirrored/persistent headless — returns "output not found"
|
||||||
# found" and exits 0). Keep the lowest-numbered.
|
# and exits 0). Keep the lowest-numbered.
|
||||||
mapfile -t hs < <(headless_names)
|
mapfile -t hs < <(headless_names)
|
||||||
local head="${hs[0]:-}"
|
local head="${hs[0]:-}"
|
||||||
if [[ -z "$head" ]]; then
|
if [[ -z "$head" ]]; then
|
||||||
@@ -113,11 +113,11 @@ apply() {
|
|||||||
log "DP-1 mirroring $head"
|
log "DP-1 mirroring $head"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Relocate any workspace that lives on a MIRRORED output onto the headless
|
# 4. Relocate any workspace stranded on a MIRRORED output onto the primary.
|
||||||
# primary. A mirror is excluded from the layout, so a workspace bound to it
|
# On swap-back Hyprland brings DP-1 up normal, binds a workspace to it, then
|
||||||
# is trapped and unreachable — exactly what happens on swap-back when
|
# we mirror it — trapping that workspace on a layout-excluded mirror. (You
|
||||||
# Hyprland brings DP-1 up normal, binds a workspace to it, then we mirror
|
# can move a workspace OFF a mirror but not ONTO one; tostring guards
|
||||||
# it. (tostring guards mirrorOf being null | "None" | a numeric id.)
|
# mirrorOf being null | "None" | a numeric id.)
|
||||||
local mon ws
|
local mon ws
|
||||||
for mon in $(hyprctl monitors all -j \
|
for mon in $(hyprctl monitors all -j \
|
||||||
| jq -r '.[] | select(((.mirrorOf // "None") | tostring | ascii_downcase) != "none") | .name'); do
|
| jq -r '.[] | select(((.mirrorOf // "None") | tostring | ascii_downcase) != "none") | .name'); do
|
||||||
@@ -150,39 +150,88 @@ apply() {
|
|||||||
log "headless-primary established on $head"
|
log "headless-primary established on $head"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Read-only drift check. Runs apply ONLY when something is actually wrong, so it
|
||||||
|
# never causes steady-state flicker. This is the backstop that makes the setup
|
||||||
|
# self-heal even if a Hyprland event is missed entirely.
|
||||||
|
reconcile() {
|
||||||
|
ensure_hypr_sig || return 0
|
||||||
|
have_tools || return 0
|
||||||
|
local mons broken=0 m
|
||||||
|
mons="$(hyprctl monitors all -j 2>/dev/null)" || return 0
|
||||||
|
|
||||||
|
# a) exactly one enabled headless output
|
||||||
|
[[ "$(printf '%s' "$mons" | jq -r '[.[]|select((.name|startswith("HEADLESS")) and (.disabled|not))]|length')" == "1" ]] || broken=1
|
||||||
|
|
||||||
|
# b) if DP-1 is present it must be mirroring (never a stray normal output)
|
||||||
|
if printf '%s' "$mons" | jq -e '.[]|select(.name=="DP-1")' >/dev/null 2>&1; then
|
||||||
|
printf '%s' "$mons" | jq -e '.[]|select(.name=="DP-1" and (((.mirrorOf//"None")|tostring|ascii_downcase)=="none"))' >/dev/null 2>&1 && broken=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# c) a workspace stranded on a mirrored output
|
||||||
|
for m in $(printf '%s' "$mons" | jq -r '.[]|select(((.mirrorOf//"None")|tostring|ascii_downcase)!="none")|.name'); do
|
||||||
|
hyprctl workspaces -j | jq -e --arg m "$m" '.[]|select(.monitor==$m)' >/dev/null 2>&1 && broken=1
|
||||||
|
done
|
||||||
|
|
||||||
|
# d) orphaned workspace
|
||||||
|
hyprctl workspaces -j | jq -e '.[]|select(.monitorID==-1)' >/dev/null 2>&1 && broken=1
|
||||||
|
|
||||||
|
# e) off-screen floating window
|
||||||
|
[[ -n "$(offscreen_addrs)" ]] && broken=1
|
||||||
|
|
||||||
|
if [[ "$broken" == "1" ]]; then
|
||||||
|
log "reconcile: state drift detected -> apply"
|
||||||
|
apply
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
watch() {
|
watch() {
|
||||||
ensure_hypr_sig || { log "Hyprland not running; watcher exiting."; return 0; }
|
ensure_hypr_sig || { log "Hyprland not running; watcher exiting."; return 0; }
|
||||||
have_tools || { log "hyprctl/jq missing; watcher exiting."; return 0; }
|
have_tools || { log "hyprctl/jq missing; watcher exiting."; return 0; }
|
||||||
command -v socat >/dev/null || { log "socat missing; no watcher."; return 0; }
|
command -v socat >/dev/null || { log "socat missing; no watcher."; return 0; }
|
||||||
local sock="${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.socket2.sock"
|
local sock="${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.socket2.sock"
|
||||||
log "watching Hyprland events on $sock"
|
log "watching Hyprland events on $sock (reconcile every ${RECONCILE_SECS}s)"
|
||||||
|
|
||||||
# monitoradded/removed -> full re-apply (re-impose mirror + rescue everything)
|
# Backstop: periodic reconcile self-heals even if an event never arrives.
|
||||||
# openwindow -> targeted rescue if the NEW window opened off-screen
|
# Exits when the session socket disappears (Hyprland gone) so we don't linger.
|
||||||
|
( while sleep "$RECONCILE_SECS"; do [[ -S "$sock" ]] || exit 0; reconcile; done ) &
|
||||||
|
local bg=$!
|
||||||
|
trap 'kill "$bg" 2>/dev/null' EXIT INT TERM
|
||||||
|
|
||||||
|
# Event loop with auto-reconnect (survives transient socat drops). Pinned to
|
||||||
|
# this session's socket; if it vanishes the session ended → exit cleanly and
|
||||||
|
# let the next login's exec-once start a fresh watcher.
|
||||||
|
while [[ -S "$sock" ]]; do
|
||||||
socat -U - "UNIX-CONNECT:$sock" 2>/dev/null | {
|
socat -U - "UNIX-CONNECT:$sock" 2>/dev/null | {
|
||||||
local last=0 now addr
|
local last=0 now addr
|
||||||
while read -r event; do
|
while read -r event; do
|
||||||
case "$event" in
|
case "$event" in
|
||||||
monitoradded*|monitorremoved*)
|
monitoradded*|monitorremoved*)
|
||||||
now=$(date +%s)
|
now=$(date +%s)
|
||||||
(( now - last < 2 )) && continue # debounce event bursts (v1+v2)
|
(( now - last < 2 )) && continue # coalesce the v1+v2 burst
|
||||||
last=$now
|
last=$now
|
||||||
log "monitor event (${event%%>*}) -> apply"
|
log "monitor event (${event%%>*}) -> apply (+delayed reconcile)"
|
||||||
apply
|
apply
|
||||||
|
( sleep 1.5; reconcile ) & # catch late workspace reassignment
|
||||||
;;
|
;;
|
||||||
openwindow*)
|
openwindow*)
|
||||||
addr="0x${event#openwindow>>}"; addr="${addr%%,*}"
|
addr="0x${event#openwindow>>}"; addr="${addr%%,*}"
|
||||||
sleep 0.3 # let the new window's geometry settle
|
sleep 0.3 # let the new window settle
|
||||||
rescue_offscreen_windows "$addr"
|
rescue_offscreen_windows "$addr"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
[[ -S "$sock" ]] || break
|
||||||
|
log "event socket dropped; reconnecting in 1s"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
log "watcher: session socket gone; exiting"
|
||||||
}
|
}
|
||||||
|
|
||||||
case "${1:-apply}" in
|
case "${1:-apply}" in
|
||||||
apply) apply ;;
|
apply) apply ;;
|
||||||
rescue) rescue_offscreen_windows "${2:-}" ;;
|
rescue) rescue_offscreen_windows "${2:-}" ;;
|
||||||
|
reconcile) reconcile ;;
|
||||||
watch) watch ;;
|
watch) watch ;;
|
||||||
*) echo "Usage: $(basename "$0") {apply|rescue|watch}" >&2; exit 1 ;;
|
*) echo "Usage: $(basename "$0") {apply|rescue|reconcile|watch}" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ clients. Inherent to cloning an ultrawide.
|
|||||||
|
|
||||||
| Path | Role |
|
| Path | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `bin/sunshine-headless-primary.sh` | The manager. `apply` establishes/repairs state (idempotent); `rescue` pulls off-screen floating windows back on-screen; `watch` re-applies on monitor hotplug AND auto-rescues windows that open off-screen. |
|
| `bin/sunshine-headless-primary.sh` | The manager. `apply` establishes/repairs state (idempotent); `rescue` pulls off-screen floating windows back on-screen; `reconcile` heals drift only if broken; `watch` the self-healing daemon (events + periodic reconcile backstop + auto-reconnect). |
|
||||||
| `bin/sunshine-prestart.sh` | Sunshine `ExecStartPre` — thin wrapper that runs `sunshine-headless-primary.sh apply` before the encoder probe. |
|
| `bin/sunshine-prestart.sh` | Sunshine `ExecStartPre` — thin wrapper that runs `sunshine-headless-primary.sh apply` before the encoder probe. |
|
||||||
| `~/.config/hypr/monitors.conf` | `DP-1` kept **normal** (safety fallback); `HEADLESS-1..4` default to `0x0`. The manager applies the `DP-1 → mirror HEADLESS-1` on top. |
|
| `~/.config/hypr/monitors.conf` | `DP-1` kept **normal** (safety fallback); `HEADLESS-1..4` default to `0x0`. The manager applies the `DP-1 → mirror HEADLESS-1` on top. |
|
||||||
| `~/.config/hypr/autostart.conf` | `exec-once` runs `apply` at login and starts the `watch` daemon. |
|
| `~/.config/hypr/autostart.conf` | `exec-once` runs `apply` at login and starts the `watch` daemon. |
|
||||||
@@ -77,16 +77,28 @@ clients. Inherent to cloning an ultrawide.
|
|||||||
|
|
||||||
### Self-healing (the watcher)
|
### Self-healing (the watcher)
|
||||||
|
|
||||||
`watch` subscribes to Hyprland's event socket and reacts:
|
`watch` is designed so the setup **always converges**, not just when an event
|
||||||
|
fires. Three layers:
|
||||||
|
|
||||||
- `monitoradded` / `monitorremoved` → full `apply` (re-impose the mirror,
|
1. **Events** (fast path) — subscribes to Hyprland's socket:
|
||||||
re-consolidate, rescue) — debounced 2s to coalesce the v1+v2 event pair.
|
- `monitoradded` / `monitorremoved` → `apply` immediately (debounced 2s to
|
||||||
- `openwindow` → targeted `rescue` of just that window if it opened off-screen
|
coalesce the v1+v2 burst), then a `reconcile` 1.5s later to catch a
|
||||||
(0.3s settle delay first).
|
workspace Hyprland reassigns to `DP-1` *after* apply already ran.
|
||||||
|
- `openwindow` → targeted `rescue` if that window opened off-screen.
|
||||||
|
2. **Periodic `reconcile` backstop** (every `RECONCILE_SECS`, default 8s) — a
|
||||||
|
read-only drift check that runs `apply` **only when something is actually
|
||||||
|
wrong** (no headless / DP-1 not mirroring / workspace trapped on a mirror /
|
||||||
|
orphaned workspace / off-screen window). This is the guarantee: even if an
|
||||||
|
event is missed entirely, the state self-heals within a few seconds. It does
|
||||||
|
nothing when healthy, so there's no steady-state flicker.
|
||||||
|
3. **Auto-reconnect** — if `socat` drops, the loop reconnects; if the session
|
||||||
|
socket disappears (Hyprland gone) it exits cleanly so the next login's
|
||||||
|
`exec-once` starts a fresh watcher (no cross-session duplicates).
|
||||||
|
|
||||||
This is what makes stray windows self-heal: a monitor swap or an app that opens
|
So a monitor swap, a trapped workspace ("can't show workspace 1"), or an app
|
||||||
off-screen (OBS, `bluetui`, kdenlive have all done this) gets pulled back into
|
that opens off-screen (OBS, `bluetui`, kdenlive have all done this) gets fixed
|
||||||
view automatically, no manual hunting.
|
automatically — near-instant via the event, or within ~8s via the backstop.
|
||||||
|
Force a check manually anytime with `sunshine-headless-primary.sh reconcile`.
|
||||||
|
|
||||||
### Critical implementation notes
|
### Critical implementation notes
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user