fix(sharednd): address review — stop/start race, onExit cleanup, no busy loop off niri
- Debounce stop transitions by 1 s (host update tick): a stop/start pair
from switching casts collapses into no transition, so the async
`dnd-set off` can no longer race the `dnd-status` query and drop
ownership mid-share. Also stops DND flapping.
- Add onExit: if the plugin owns DND when disabled/reloaded, turn it off
via a detached runAsync (survives VM teardown).
- Gate detection on NIRI_SOCKET + commandExists("niri") instead of an
unconditional retry loop: on other compositors (or niri installed but
not running) the service now spawns nothing. If NIRI_SOCKET is set but
the binary is missing from PATH, warn once.
- Make the stream retry loop self-terminating (parent gone or niri
socket removed): hard host exits skip process-group cleanup and were
leaking orphaned loops that respawned `niri msg` every 3 s across
sessions (observed 11 such orphans over 4 days).
- Bump version to 1.1.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
704fa538b0
commit
5e9027da1a
+8
-4
@@ -13,6 +13,9 @@ enabled. When the last screencast stops, notifications come back.
|
|||||||
- On every cast event it re-queries `niri msg -j casts` as the authoritative
|
- On every cast event it re-queries `niri msg -j casts` as the authoritative
|
||||||
state, so missed or reordered events cannot desync it. The stream is wrapped
|
state, so missed or reordered events cannot desync it. The stream is wrapped
|
||||||
in a shell retry loop and resends full state on reconnect.
|
in a shell retry loop and resends full state on reconnect.
|
||||||
|
- Stop transitions are debounced by ~1 second: switching what is being shared
|
||||||
|
produces a stop/start event pair, which the debounce collapses — DND does
|
||||||
|
not flap, and the stop/start race cannot drop ownership.
|
||||||
- DND is toggled through the host IPC (`noctalia msg notification-dnd-set`),
|
- DND is toggled through the host IPC (`noctalia msg notification-dnd-set`),
|
||||||
so the usual OSD feedback appears.
|
so the usual OSD feedback appears.
|
||||||
|
|
||||||
@@ -35,7 +38,8 @@ enabled. When the last screencast stops, notifications come back.
|
|||||||
|
|
||||||
## Requirements & limitations
|
## Requirements & limitations
|
||||||
|
|
||||||
- Requires **niri** (detection is niri IPC; on other compositors the plugin
|
- Requires **niri** (detection is niri IPC). Detection only starts inside a
|
||||||
is inert and shows a warning at startup).
|
niri session (`NIRI_SOCKET` set and the `niri` binary in `PATH`); on other
|
||||||
- There is no plugin-disable hook in the host: if you disable the plugin
|
compositors the service is inert and spawns no processes.
|
||||||
mid-share while it owns DND, DND stays on — toggle it manually.
|
- Disabling or reloading the plugin mid-share while it owns DND turns DND
|
||||||
|
back off (`onExit` cleanup).
|
||||||
|
|||||||
@@ -5,13 +5,15 @@
|
|||||||
# appears, notification DND is enabled through the host IPC
|
# appears, notification DND is enabled through the host IPC
|
||||||
# (notification-dnd-set); when the last one disappears, DND is restored.
|
# (notification-dnd-set); when the last one disappears, DND is restored.
|
||||||
# If DND was already on before sharing started, the plugin does not take
|
# If DND was already on before sharing started, the plugin does not take
|
||||||
# ownership and leaves it on afterwards (configurable).
|
# ownership and leaves it on afterwards (configurable). Detection only
|
||||||
|
# starts inside a niri session (NIRI_SOCKET + niri binary present); on
|
||||||
|
# other compositors the service is inert and spawns nothing.
|
||||||
#
|
#
|
||||||
# External requirements: niri (screencast events come from niri IPC).
|
# External requirements: niri (screencast events come from niri IPC).
|
||||||
|
|
||||||
id = "whyoolw/sharednd"
|
id = "whyoolw/sharednd"
|
||||||
name = "ShareDND"
|
name = "ShareDND"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
min_noctalia = "5.0.0"
|
min_noctalia = "5.0.0"
|
||||||
author = "whyoolw"
|
author = "whyoolw"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
+67
-25
@@ -7,16 +7,29 @@
|
|||||||
-- `niri msg -j casts`, which is the authoritative state, so no per-event
|
-- `niri msg -j casts`, which is the authoritative state, so no per-event
|
||||||
-- bookkeeping is needed and reconnects re-sync for free (the stream sends
|
-- bookkeeping is needed and reconnects re-sync for free (the stream sends
|
||||||
-- the full current state, including CastsChanged, on connect).
|
-- the full current state, including CastsChanged, on connect).
|
||||||
|
--
|
||||||
|
-- Stop transitions are debounced by STOP_DEBOUNCE seconds. Switching what is
|
||||||
|
-- being shared surfaces as a stop/start event pair; acting on the stop
|
||||||
|
-- immediately would race the follow-up status query (the async `off` can
|
||||||
|
-- land after the query already read DND as on), dropping ownership and
|
||||||
|
-- leaving DND off for the rest of the new sharing session. The debounce
|
||||||
|
-- collapses the pair into no transition, and also keeps DND from flapping.
|
||||||
|
|
||||||
|
local STOP_DEBOUNCE = 1.0
|
||||||
|
|
||||||
local function cfg(key)
|
local function cfg(key)
|
||||||
return noctalia.getConfig(key)
|
return noctalia.getConfig(key)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- true while at least one screencast is believed to be running
|
-- debounced sharing state that the DND logic acts on
|
||||||
local sharingActive = false
|
local sharingActive = false
|
||||||
-- true when this plugin enabled DND (and therefore owns turning it off)
|
-- true when this plugin enabled DND (and therefore owns turning it off)
|
||||||
local dndSetByUs = false
|
local dndSetByUs = false
|
||||||
|
|
||||||
|
-- raw state from the last casts query + pending debounced stop deadline
|
||||||
|
local rawActive = false
|
||||||
|
local stopDeadline = nil -- os.clock() timestamp, nil when no stop is pending
|
||||||
|
|
||||||
local queryInFlight = false
|
local queryInFlight = false
|
||||||
local queryDirty = false
|
local queryDirty = false
|
||||||
|
|
||||||
@@ -87,15 +100,17 @@ local function onSharingStopped()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function applyState(activeCount)
|
local function applyState(activeCount)
|
||||||
local active = activeCount > 0
|
rawActive = activeCount > 0
|
||||||
if active == sharingActive then
|
if rawActive then
|
||||||
return
|
-- A restart within the debounce window cancels the pending stop, so a
|
||||||
end
|
-- stop/start pair from switching casts is no transition at all.
|
||||||
sharingActive = active
|
stopDeadline = nil
|
||||||
if active then
|
if not sharingActive then
|
||||||
onSharingStarted()
|
sharingActive = true
|
||||||
else
|
onSharingStarted()
|
||||||
onSharingStopped()
|
end
|
||||||
|
elseif sharingActive and stopDeadline == nil then
|
||||||
|
stopDeadline = os.clock() + STOP_DEBOUNCE
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -126,21 +141,48 @@ local function onEventLine(line)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Boot. The niri check is a user-facing warning only; the stream loop below
|
-- Host-driven tick (~250 ms): commits a pending stop once the debounce
|
||||||
-- retries forever anyway, which also covers niri's socket coming up after
|
-- window has passed without sharing resuming.
|
||||||
-- noctalia during session start.
|
function update()
|
||||||
noctalia.runAsync("niri msg version", function(res)
|
if stopDeadline ~= nil and os.clock() >= stopDeadline then
|
||||||
if res == nil or res.exitCode ~= 0 then
|
stopDeadline = nil
|
||||||
noctalia.notifyError(noctalia.tr("notify.no_niri_title"), noctalia.tr("notify.no_niri_body"))
|
if sharingActive and not rawActive then
|
||||||
noctalia.log("sharednd: niri IPC not reachable at startup")
|
sharingActive = false
|
||||||
|
onSharingStopped()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end)
|
|
||||||
|
|
||||||
-- The process group is killed on plugin disable, taking the shell loop and
|
|
||||||
-- the stream down with it.
|
|
||||||
if not noctalia.runStream("while :; do niri msg -j event-stream 2>/dev/null; sleep 3; done", onEventLine) then
|
|
||||||
noctalia.log("sharednd: failed to start niri event-stream")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Safety net in case the initial CastsChanged is ever missed.
|
-- Called by the host on plugin disable/reload. The callback-less runAsync
|
||||||
queryCasts()
|
-- spawns a detached process, so it survives the VM teardown.
|
||||||
|
function onExit()
|
||||||
|
if dndSetByUs then
|
||||||
|
noctalia.runAsync("noctalia msg notification-dnd-set off")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Boot. `niri msg` needs the niri binary and NIRI_SOCKET (set only inside a
|
||||||
|
-- niri session), so gate detection on both instead of spawning a retry loop
|
||||||
|
-- that can never succeed on other compositors.
|
||||||
|
local niriSocket = noctalia.getenv("NIRI_SOCKET")
|
||||||
|
if niriSocket == nil or niriSocket == "" then
|
||||||
|
-- Expected on other compositors: stay silent and spawn nothing.
|
||||||
|
noctalia.log("sharednd: no NIRI_SOCKET, not a niri session — detection disabled")
|
||||||
|
elseif not noctalia.commandExists("niri") then
|
||||||
|
-- Inside a niri session but the binary is unreachable — worth a warning.
|
||||||
|
noctalia.notifyError(noctalia.tr("notify.no_niri_title"), noctalia.tr("notify.no_niri_body"))
|
||||||
|
noctalia.log("sharednd: NIRI_SOCKET set but niri not in PATH — detection disabled")
|
||||||
|
else
|
||||||
|
-- The process group is killed on plugin disable, taking the shell loop and
|
||||||
|
-- the stream down with it. That cleanup does not run when the host dies
|
||||||
|
-- hard (crash, session logout), so the loop also exits on its own once the
|
||||||
|
-- parent is gone or the niri socket disappears — otherwise orphaned loops
|
||||||
|
-- would keep respawning `niri msg` every 3 s across sessions.
|
||||||
|
local loop = 'P=$PPID; while kill -0 "$P" 2>/dev/null && [ -S "$NIRI_SOCKET" ]; do'
|
||||||
|
.. " niri msg -j event-stream 2>/dev/null; sleep 3; done"
|
||||||
|
if not noctalia.runStream(loop, onEventLine) then
|
||||||
|
noctalia.log("sharednd: failed to start niri event-stream")
|
||||||
|
end
|
||||||
|
-- Safety net in case the initial CastsChanged is ever missed.
|
||||||
|
queryCasts()
|
||||||
|
end
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"settings.always_off_after.label": "Always disable DND after sharing",
|
"settings.always_off_after.label": "Always disable DND after sharing",
|
||||||
"settings.always_off_after.description": "Turn Do Not Disturb off when sharing ends even if it was already enabled before sharing started. When off, DND is only disabled if this plugin enabled it.",
|
"settings.always_off_after.description": "Turn Do Not Disturb off when sharing ends even if it was already enabled before sharing started. When off, DND is only disabled if this plugin enabled it.",
|
||||||
"notify.no_niri_title": "ShareDND: niri not found",
|
"notify.no_niri_title": "ShareDND: niri not found",
|
||||||
"notify.no_niri_body": "Screen sharing detection uses niri IPC (niri msg). The plugin will keep retrying, but it only works under niri."
|
"notify.no_niri_body": "This looks like a niri session (NIRI_SOCKET is set), but the niri binary is not in PATH, so screen sharing cannot be detected."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user