Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>`) that resets to 0 on server restart.
Expand Down
119 changes: 119 additions & 0 deletions apps/backend/src/tests/udev-rules-sigusr2-scope.test.ts
Original file line number Diff line number Diff line change
@@ -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 [<hostname>.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);
});
});
7 changes: 6 additions & 1 deletion deployment/98-ceralive-audio.rules
Original file line number Diff line number Diff line change
Expand Up @@ -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"
8 changes: 7 additions & 1 deletion deployment/99-ceralive-check-usb-devices.rules
Original file line number Diff line number Diff line change
@@ -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"