diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 6185635d..4315bcf8 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -260,6 +260,30 @@ failure is logged before the next step runs; `exit(0)` is still attempted after three steps. Later signals remain ignored. The direct regression coverage lives in `src/main.test.ts` under `termination shutdown lifecycle`. +## SIGUSR2 UDEV HOTPLUG HOOK [EXISTS] + +`main.ts`'s `process.on("SIGUSR2", udevDeviceUpdate)` re-scans Cam Link USB2 + +audio devices on an Elgato/USB-audio hot(un)plug. The signal is delivered by two +udev rules in `deployment/` (`98-ceralive-audio.rules`, +`99-ceralive-check-usb-devices.rules`) that MUST target the unit's main pid: + +``` +RUN+="/usr/bin/systemctl kill --kill-whom=main --signal=SIGUSR2 ceralive.service" +``` + +NOT `pkill -f ceralive` (the retired form). `pkill -f` substring-matched +avahi-daemon's process title `avahi-daemon: registering [ceralive.local]` (the +device hostname is `ceralive.local`) and killed mDNS on every hotplug. +`--kill-whom=main` scopes the signal to the tracked MAIN pid via the unit cgroup — +mirroring the old `pkill -o` single-process intent — so the whole-cgroup default +(`all`) can never collaterally SIGUSR2-terminate `srtla_send`/`bcrpt` (which share +`ceralive.service`'s cgroup while streaming and do NOT handle SIGUSR2). `systemctl +kill` from a udev `RUN+=` is safe: it creates no systemd job (returns after the +PID-1 D-Bus request), so the `--no-block` / deadlock caveats that apply to +`start`/`stop`/`restart` do not apply. Regression lock: +`src/tests/udev-rules-sigusr2-scope.test.ts` (static assertion on the shipped rule +files). Do NOT reintroduce a broad `pkill`, and do NOT drop `--kill-whom=main`. + ## BROADCAST EVENTS The backend pushes typed events to all connected clients via `rpc/events.ts`. Each event type carries a monotonic `seq` counter (`Map`) that resets to 0 on server restart. diff --git a/apps/backend/src/tests/udev-rules-sigusr2-scope.test.ts b/apps/backend/src/tests/udev-rules-sigusr2-scope.test.ts new file mode 100644 index 00000000..a3293d3b --- /dev/null +++ b/apps/backend/src/tests/udev-rules-sigusr2-scope.test.ts @@ -0,0 +1,119 @@ +/* + * Regression guard for the SIGUSR2 udev-hotplug delivery mechanism. + * + * BUG (confirmed live on real hardware): the two hotplug udev rules signalled the + * backend with `pkill -o -SIGUSR2 -f ceralive`. `pkill -f` substring-matches the + * FULL command line of EVERY process. Avahi renames its own process title to + * `avahi-daemon: registering [.local]` once it registers a hostname; + * this device's hostname is `ceralive.local`, so avahi-daemon's title contains + * the substring "ceralive" and was caught by the same broad pkill. avahi-daemon + * (no `Restart=`) died on every USB/audio hotplug and mDNS stayed dead until + * reboot. + * + * FIX: deliver the signal through `systemctl kill --kill-whom=main + * --signal=SIGUSR2 ceralive.service`, which targets exactly the unit's tracked + * MAIN pid via its cgroup — never another process. `--kill-whom=main` also + * preserves the old `pkill -o` single-process intent, so the whole-cgroup default + * (`all`) can never collaterally SIGUSR2-terminate srtla_send/bcrpt (which share + * the cgroup while streaming and do NOT handle SIGUSR2). + * + * This suite is a static assertion on the shipped rule FILES (the source of truth + * copied verbatim into the .deb via scripts/build/shared-build-functions.sh), so + * a future edit that reintroduces the broad `pkill -f` — or drops the main-pid + * scoping — goes red here. + */ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +// src/tests -> src -> backend -> apps -> CeraUI repo root, then deployment/. +const DEPLOYMENT_DIR = join( + import.meta.dir, + "..", + "..", + "..", + "..", + "deployment", +); + +const AUDIO_RULES = join(DEPLOYMENT_DIR, "98-ceralive-audio.rules"); +const USB_RULES = join(DEPLOYMENT_DIR, "99-ceralive-check-usb-devices.rules"); + +// The exact scoped delivery idiom every RUN+= hotplug signal must now use. +const SCOPED_SIGNAL = + "/usr/bin/systemctl kill --kill-whom=main --signal=SIGUSR2 ceralive.service"; + +/** Non-comment, non-blank rule lines (udev comments start with `#`). */ +function ruleLines(contents: string): string[] { + return contents + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("#")); +} + +/** Every RUN+= assignment target in the file, unquoted. */ +function runTargets(contents: string): string[] { + const targets: string[] = []; + const re = /RUN\+="([^"]*)"/g; + for (const line of ruleLines(contents)) { + let m: RegExpExecArray | null = re.exec(line); + while (m !== null) { + // biome-ignore lint/style/noNonNullAssertion: capture group 1 always present on match + targets.push(m[1]!); + m = re.exec(line); + } + } + return targets; +} + +describe("udev SIGUSR2 hotplug rules — scoped, avahi-safe delivery", () => { + const audio = readFileSync(AUDIO_RULES, "utf8"); + const usb = readFileSync(USB_RULES, "utf8"); + + it("neither rule uses the broad `pkill -f ceralive` that killed avahi-daemon", () => { + // Assert against ACTIVE rule lines only — a `#`-comment may legitimately + // name the retired `pkill -f ceralive` idiom to explain the fix. + const activeLines = [...ruleLines(audio), ...ruleLines(usb)]; + expect(activeLines.length).toBeGreaterThan(0); + for (const line of activeLines) { + // The literal defect: a full-command-line substring match on "ceralive". + expect(line).not.toMatch(/pkill[^\n]*-f[^\n]*ceralive/); + } + // No `pkill` at all remains in either RUN+= target. + for (const target of [...runTargets(audio), ...runTargets(usb)]) { + expect(target).not.toContain("pkill"); + } + }); + + it("both rules deliver SIGUSR2 via the unit-scoped systemctl kill idiom", () => { + expect(runTargets(audio)).toContain(SCOPED_SIGNAL); + expect(runTargets(usb)).toContain(SCOPED_SIGNAL); + }); + + it("every RUN+= target scopes the signal to the ceralive.service MAIN pid", () => { + const targets = [...runTargets(audio), ...runTargets(usb)]; + expect(targets.length).toBeGreaterThan(0); + for (const target of targets) { + // Must name the unit explicitly (cgroup scoping) … + expect(target).toContain("ceralive.service"); + // … and target only the main pid, so a whole-cgroup SIGUSR2 can never + // terminate srtla_send/bcrpt during a live stream. + expect(target).toContain("--kill-whom=main"); + expect(target).toContain("--signal=SIGUSR2"); + // udev RUN+= is exec'd without a shell → absolute path is mandatory. + expect(target.startsWith("/usr/bin/systemctl ")).toBe(true); + } + }); + + it("audio rule: every signal path converges on the single scoped RUN+= line", () => { + // All GOTO branches jump to LABEL="signal_ceralive"; the label must be + // backed by exactly one RUN+= line so no path can reach an unscoped kill. + const gotoCount = (audio.match(/GOTO="signal_ceralive"/g) ?? []).length; + expect(gotoCount).toBeGreaterThan(0); + expect(audio).toContain('LABEL="signal_ceralive"'); + + const audioTargets = runTargets(audio); + expect(audioTargets).toHaveLength(1); + expect(audioTargets[0]).toBe(SCOPED_SIGNAL); + }); +}); diff --git a/deployment/98-ceralive-audio.rules b/deployment/98-ceralive-audio.rules index 30de6d78..9c8e1fae 100644 --- a/deployment/98-ceralive-audio.rules +++ b/deployment/98-ceralive-audio.rules @@ -22,6 +22,11 @@ ATTR{id}=="OsmoAction5*", ATTR{id}="OsmoAction5", GOTO="signal_ceralive" ATTR{id}="usbaudio" LABEL="signal_ceralive" -RUN+="/usr/bin/pkill -o -SIGUSR2 -f ceralive" +# Target the ceralive.service unit's MAIN pid via its cgroup — NOT +# `pkill -f ceralive`, which substring-matched avahi-daemon's process title +# (`avahi-daemon: registering [ceralive.local]`) and killed mDNS. --kill-whom=main +# preserves the old `pkill -o` single-process intent without signalling +# srtla_send/bcrpt in the same cgroup (SIGUSR2 would terminate them). +RUN+="/usr/bin/systemctl kill --kill-whom=main --signal=SIGUSR2 ceralive.service" LABEL="end" diff --git a/deployment/99-ceralive-check-usb-devices.rules b/deployment/99-ceralive-check-usb-devices.rules index 11545167..d835dd50 100644 --- a/deployment/99-ceralive-check-usb-devices.rules +++ b/deployment/99-ceralive-check-usb-devices.rules @@ -1 +1,7 @@ -ENV{DEVTYPE}=="usb_device", ENV{ID_VENDOR_ID}=="0fd9", RUN+="/usr/bin/pkill -o -SIGUSR2 -f ceralive" +# Elgato/Cam Link (vendor 0fd9) hot(un)plug: poke the CeraUI backend to re-scan +# USB2 capture + audio devices. Target the ceralive.service unit's MAIN pid via +# its cgroup — NOT `pkill -f ceralive`, which substring-matched avahi-daemon's +# process title (`avahi-daemon: registering [ceralive.local]`) and killed mDNS. +# --kill-whom=main preserves the old `pkill -o` single-process intent without +# signalling srtla_send/bcrpt in the same cgroup (SIGUSR2 would terminate them). +ENV{DEVTYPE}=="usb_device", ENV{ID_VENDOR_ID}=="0fd9", RUN+="/usr/bin/systemctl kill --kill-whom=main --signal=SIGUSR2 ceralive.service"