Stop a go2rtc that outlived a hard-killed proxy

SIGTERM already stops go2rtc (the signal handler), but SIGKILL and the OOM
killer give the proxy no chance to, and the orphan keeps :1984/:8555 bound
and a printer's access code in memory.

Two layers, neither needing unsafe or a new dependency:

- The watchdog records go2rtc's pid next to its config and, on startup,
  stops a leftover. It acts only if /proc confirms the pid is still *our*
  go2rtc - the configured binary on our own `-config` path - so a reused pid
  or another proxy's go2rtc is never touched; without /proc it does nothing.
  The pid file is removed when the supervisor goes away.
- deploy/continuum-proxy.service, whose KillMode=control-group (written out,
  with the reason) makes systemd kill the whole cgroup when the proxy dies.
  Syntax-checked with systemd-analyze only; not run under a real systemd.

Tests: the identity check, and a real stand-in process - a leftover is
terminated while an unrelated `sleep` survives. Making the identity check
always say yes fails both, so they do guard the behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 10:47:26 +00:00
co-authored by Claude Sonnet 5.5
parent 88bf1d1c2f
commit 987a7b4708
3 changed files with 175 additions and 0 deletions
+2
View File
@@ -110,6 +110,8 @@ src/
uplink/ WebSocket client to continuum-backend: connect, heartbeat, reconnect on drop
camera.rs Writes go2rtc's config from the camera URLs the printers report
go2rtc.rs Restarts the go2rtc camera-restreaming process if it dies, or when the config changes
and stops one a hard-killed proxy left behind (pid file next to its config)
deploy/ systemd unit template; its KillMode=control-group is what stops go2rtc outliving a SIGKILL'd proxy
printer/
mod.rs GenericPrinter trait + PrinterBase + PrinterHandle enum - the trait+enum
"inheritance" pattern this project uses instead of class inheritance
+38
View File
@@ -0,0 +1,38 @@
# Continuum proxy as a systemd service - the template for a Proxy Box or a
# Linux PC install. Adjust the paths; create the `continuum` user and put the
# settings it reads (continuum.toml, or the signed bundle on a Box) under
# /var/lib/continuum.
#
# Not exercised under a real systemd yet: this container's init is not
# systemd, so only `systemd-analyze verify` has been run against it.
[Unit]
Description=Continuum proxy (printer gateway)
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=continuum
# The proxy's relative defaults (./go2rtc.yaml, ./printers.toml, the go2rtc
# pid file) resolve here, and StateDirectory creates it owned by the user.
StateDirectory=continuum
WorkingDirectory=/var/lib/continuum
ExecStart=/usr/local/bin/continuum-proxy
# Always, including a clean exit: installing a new config from a pendrive
# deliberately ends the process and relies on this to start it on the new
# settings (src/main.rs, config_import).
Restart=always
RestartSec=2
# Do not change this to `process` or `none`. go2rtc is the proxy's child, so
# it lives in this unit's cgroup, and this is what makes systemd kill it
# whenever the proxy goes - including SIGKILL and the OOM killer, which give
# the proxy no chance to stop it itself. It is systemd's default; it is
# written out so nobody weakens it by accident. An orphaned go2rtc keeps
# :1984/:8555 bound and a printer's access code in memory.
KillMode=control-group
TimeoutStopSec=10
[Install]
WantedBy=multi-user.target
+135
View File
@@ -1,3 +1,4 @@
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
@@ -6,6 +7,76 @@ use tokio::process::Command;
use tokio::sync::Notify;
use tracing::{error, info, warn};
/// Where the running go2rtc's pid is recorded: next to its config, because
/// that is the one path this process already owns and the one a restarted
/// proxy is certain to compute again.
fn pid_path(config_path: &str) -> PathBuf {
PathBuf::from(format!("{config_path}.pid"))
}
/// Removes the pid file when the supervisor goes away, however it does:
/// the watchdog is dropped, not returned from, on a normal shutdown.
struct PidFile(PathBuf);
impl Drop for PidFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
/// Whether a `/proc/<pid>/cmdline` is the go2rtc this proxy started: the
/// configured binary as the program (or as the script an interpreter was
/// given, which is what a wrapper looks like) *and* our own config path after
/// `-config`. Both, because a pid alone proves nothing once the number has
/// been reused, and "some go2rtc" is not necessarily ours - another proxy on
/// the machine would run one against its own config.
fn is_our_go2rtc(cmdline: &[u8], bin: &str, config_path: &str) -> bool {
let args: Vec<&[u8]> = cmdline.split(|b| *b == 0).filter(|a| !a.is_empty()).collect();
let launched = args.iter().take(2).any(|a| *a == bin.as_bytes());
let configured = args.windows(2).any(|w| w[0] == b"-config" && w[1] == config_path.as_bytes());
launched && configured
}
/// Stops a go2rtc left behind by a proxy that was killed without a chance to
/// stop it (SIGKILL, the OOM killer, a power cut that kept the container).
///
/// Only acts on a pid this proxy recorded, and only after `/proc` confirms
/// that pid is still our go2rtc (`is_our_go2rtc`). Anywhere without `/proc`
/// it cannot confirm that, so it leaves the process alone: a leftover
/// go2rtc is a nuisance, killing someone else's process is not. The orphan
/// matters because it keeps :1984 and :8555 bound - so the new one cannot
/// start - and holds a printer's access code in memory.
async fn reclaim_orphan(bin: &str, config_path: &str) {
let pid_file = pid_path(config_path);
let Ok(text) = std::fs::read_to_string(&pid_file) else { return };
let _ = std::fs::remove_file(&pid_file);
let Ok(pid) = text.trim().parse::<u32>() else { return };
let ours = || {
std::fs::read(format!("/proc/{pid}/cmdline"))
.map(|cmdline| is_our_go2rtc(&cmdline, bin, config_path))
.unwrap_or(false)
};
if !ours() {
return;
}
warn!(pid, "a go2rtc left by an earlier proxy is still running; stopping it");
// The `kill` program rather than a libc call: signalling is the only
// reason this would need `unsafe` and a new dependency.
for (signal, patience) in [("-TERM", 30), ("-KILL", 30)] {
let _ = Command::new("kill").arg(signal).arg(pid.to_string()).status().await;
for _ in 0..patience {
if !ours() {
info!(pid, "the old go2rtc is gone");
return;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
error!(pid, "could not stop the old go2rtc; the new one may fail to bind its ports");
}
/// Supervises the go2rtc subprocess used for camera restreaming
/// (RTSP -> WebRTC). Restarts it with a fixed backoff whenever it exits, and
/// immediately when `restart` fires.
@@ -16,6 +87,11 @@ use tracing::{error, info, warn};
/// Before that existed this watchdog supervised a process whose config file
/// nothing wrote - it restarted go2rtc forever over a file that did not exist.
pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
// Before the first spawn: whatever the last proxy left behind owns the
// ports this one is about to ask for.
reclaim_orphan(&bin, &config_path).await;
let _pid_file = PidFile(pid_path(&config_path));
loop {
info!(%bin, %config_path, "starting go2rtc");
@@ -29,6 +105,9 @@ pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
match spawned {
Ok(mut child) => {
if let Some(Err(err)) = child.id().map(|pid| std::fs::write(pid_path(&config_path), pid.to_string())) {
warn!(?err, "could not record go2rtc's pid; a hard kill of the proxy would orphan it unnoticed");
}
tokio::select! {
// Whichever happens first. `kill_on_drop` means the
// child dies when `child` goes out of scope at the end
@@ -61,3 +140,59 @@ pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cmdline(args: &[&str]) -> Vec<u8> {
args.iter().flat_map(|a| a.bytes().chain([0])).collect()
}
#[test]
fn only_our_binary_on_our_config_counts() {
let ours = cmdline(&["go2rtc", "-config", "./go2rtc.yaml"]);
assert!(is_our_go2rtc(&ours, "go2rtc", "./go2rtc.yaml"));
// A wrapper script puts the interpreter first.
assert!(is_our_go2rtc(&cmdline(&["/bin/sh", "go2rtc", "-config", "./go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
// Another proxy's go2rtc, an unrelated process, and a reused pid.
assert!(!is_our_go2rtc(&cmdline(&["go2rtc", "-config", "/other.yaml"]), "go2rtc", "./go2rtc.yaml"));
assert!(!is_our_go2rtc(&cmdline(&["sleep", "-config", "./go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
assert!(!is_our_go2rtc(&cmdline(&["vim", "go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
assert!(!is_our_go2rtc(b"", "go2rtc", "./go2rtc.yaml"), "a zombie has an empty cmdline");
}
/// A stand-in go2rtc that runs until killed, started the way the real
/// one is, whose pid file is then found by a "restarted proxy".
#[cfg(target_os = "linux")]
#[tokio::test]
async fn a_leftover_go2rtc_is_stopped_and_a_stranger_is_not() {
let dir = std::env::temp_dir().join(format!("continuum-go2rtc-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("go2rtc");
// Stays alive until signalled, and (run as `sh <script> -config ..`)
// looks to /proc the way the real one does behind an interpreter.
std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 1; done\n").unwrap();
let (bin, config) = (script.to_string_lossy().into_owned(), dir.join("go2rtc.yaml").to_string_lossy().into_owned());
let mut orphan = std::process::Command::new("/bin/sh").args([&bin, "-config", &config]).spawn().unwrap();
let stranger = std::process::Command::new("sleep").arg("60").spawn().unwrap();
// The stranger's pid in the file: refused, and the file is cleared.
std::fs::write(pid_path(&config), stranger.id().to_string()).unwrap();
reclaim_orphan(&bin, &config).await;
assert!(std::fs::read(format!("/proc/{}/cmdline", stranger.id())).is_ok_and(|c| !c.is_empty()), "an unrelated process must survive");
assert!(!pid_path(&config).exists());
// The orphan's pid: stopped.
std::fs::write(pid_path(&config), orphan.id().to_string()).unwrap();
reclaim_orphan(&bin, &config).await;
let status = orphan.wait().unwrap();
assert!(!status.success(), "the leftover go2rtc was terminated by a signal");
let mut stranger = stranger;
let _ = stranger.kill();
let _ = stranger.wait();
let _ = std::fs::remove_dir_all(&dir);
}
}