host.daemon

Sentinel-tagged daemons on remote hosts: launch, discover, reap.

launch_command wraps an argv in bash -c 'exec -a "$1" "${@:2}"' so the process’s argv[0] IS the sentinel (discoverable via ps -eo args=), launched under systemd-run --user with a setsid fallback. Extracted from otto.tunnel.socat (#2b), renamed from otto.host.detached (2026-07-11): the module owns the daemon lifecycle vocabulary — launch, discover (ps scan), reap — shared by tunnels and link-impairment timers without a tunnel<->link import edge.

otto.host.daemon.launch_command(sentinel: str, argv: list[str]) str

Build the host.exec line for a detached, tagged, session-surviving daemon.

bash -c 'exec -a "$1" "${@:2}"' _ <sentinel> <program argv…> sets the process’s argv[0] to the sentinel (exec -a — a bash builtin; bash is required on the target host). argv is the FULL program argv (its first element is the program to run, e.g. "socat"), so the template must NOT hardcode a program name — hardcoding one runs prog prog <args…> and dies on the bogus duplicate.

Surviving the ssh session is the subtle part (found via live-bed e2e). On a systemd host, a process left in the ssh session’s scope is killed when that session ends, and setsid does NOT escape the session cgroup — so we launch it in the USER manager’s scope via systemd-run --user (no sudo, no root; the transient unit is --collect``ed on exit). On a non-systemd host (older distros the portability floor), ``systemd-run is absent and a plain setsid-detached background process survives normally, so we fall back to that.

systemd-run being present on PATH doesn’t guarantee it works (found via live-bed e2e against a centos:7 container): the binary ships in the base image even though nothing runs an actual systemd/dbus user session inside the container, so invoking it fails fast with “Failed to create bus connection: Connection refused” rather than being absent. The && folds that real invocation into the if condition itself (not just a command -v existence probe), so any failure — missing binary or a present-but-unusable one — falls through to the setsid branch. The real invocation is additionally bounded by _SYSTEMD_RUN_PROBE_TIMEOUT so a hang-shaped breakage (not just a fast-failing one) still folds through.

The whole if/then/else/fi conditional is wrapped in an outer bash -c '<body>' so the returned string is one opaque, quoted word — safe for a caller to compose into a larger command by naive textual prefixing (found via live-bed e2e: otto.host.privilege.PosixPrivilege ._elevate builds f"sudo -S -p 'x' {cmd}"; bash only recognizes if as the reserved word that opens a conditional in command-start position, so splicing it in as sudo’s trailing argument list demotes if to a literal word and leaves the later then/else/fi as syntax errors with no matching if. bash then refuses to parse the entire input line — not just this fragment — so not even the caller’s own sentinel echoes run, and the caller hangs until its own outer timeout. This is what otto.link’s expire-timer launch (the only sudo=True caller of this function) was actually hitting, not a systemd-run/dbus stall: reproduced 3/3 unpatched (12s bound, never completes) and 3/3 resolved by this wrap (sub-20ms, exit 0) against the live bed. A single outer bash -c layer is harmless to the survival mechanics — it only dispatches the real branch (systemd-run --user --collect or the backgrounded setsid subshell) and exits; the final exec -a’d process is unaffected either way).

otto.host.daemon.ps_scan_command(prefix: str) str

Portable ps scan for daemons whose argv[0] starts with <prefix>:.

Each field is its own -eo flag rather than one comma-joined -eo pid=,etime=,args= (found via live-bed e2e against a centos:7 container): procps-ng 3.3.10 silently mis-parses the comma-combined form (columns bleed into each other), while the separate-flag form produces identical output on modern procps (4.x) too. Formatted etime (not etimes) keeps 2.6.32-era userland working; || true so a no-match grep (exit 1) is not a command failure.

\grep (backslash-escaped) is load-bearing: sessions that run an interactive login shell (telnet terms) apply the distro alias grep='grep --color=auto', and with the session PTY as stdout the ANSI color codes it injects corrupt the sentinel tokens — the scan goes blind on exactly those hosts while ssh (non-interactive) stays clean (live-bed finding 2026-07-11). The backslash suppresses alias expansion in every POSIX shell and is a no-op where no alias exists.

prefix must match [A-Za-z0-9][A-Za-z0-9-]*: it lands inside a single-quoted grep BRE, where a quote would break the shell line, a regex metacharacter would change match semantics, and the || true tail would mask the resulting grep failure as an empty (falsely clean) scan.

Raises:

ValueError – If prefix contains characters outside that safe set.

otto.host.daemon.parse_etime(text: str) int

Procps etime ([[DD-]HH:]MM:SS or bare SS) → seconds.

Returns 0 for anything unparseable rather than raising — one host emitting a malformed etime must not take down a whole scan.

class otto.host.daemon.DaemonProcess(pid: int, age_seconds: int, token: str)

Bases: object

One sentinel-tagged daemon seen in a ps scan (token not yet decoded).

pid : int
age_seconds : int
token : str
otto.host.daemon.parse_ps_output(output: str, prefix: str) list[DaemonProcess]

Reconstruct tagged daemons from ps_scan_command() output.

Domain modules decode each DaemonProcess.token with their own sentinel parser; anything undecodable is theirs to skip.

otto.host.daemon.kill_command(pids: Iterable[int]) str

kill <sorted pids> line for reaping tagged daemons on one host.

Raises:

ValueError – If pids is empty — a bare kill is a usage error on the host, so an empty reap set must be handled by the caller.

otto.host.daemon.enc(value: str | int | None) str

Percent-encode one sentinel segment (safe=""); None → empty.

otto.host.daemon.dec(segment: str) str

Decode one percent-encoded sentinel segment.

otto.host.daemon.encode_token(prefix: str, version: str, segments: Sequence[str]) str

<prefix>:<version>:<payload segments joined with ':'>.

segments are the payload only and must be FINAL strings (already percent-encoded as the domain codec requires) — framing never re-encodes, so a domain is free to double-encode a compound segment.

otto.host.daemon.split_token(token: str, prefix: str, version: str, count: int) list[str] | None

Split a wire token; None for non-matching / other-version / malformed.

count is the expected PAYLOAD segment count (prefix and version are checked separately). Unknown versions parse to None, never an error — the stability contract lets old parsers ignore newer wire formats.