fwt_software/scripts/fgc

343 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# fgc — run fire_gimbal_control detached from the terminal, in a tmux session.
#
# The tower is operated over ssh. Launched directly, the program is a child of
# the ssh session's shell and dies with it — mid-scan, and with the camera torn
# down uncleanly (which wedges it for the next run). Here tmux owns the pty, so
# disconnecting is invisible to the program.
#
# scripts/fgc start [args...] start it detached (args go to the binary)
# scripts/fgc attach open the dashboard; Ctrl-b d to leave it running
# scripts/fgc status is it running, since when, as which PID
# scripts/fgc stop clean shutdown (camera + motor teardown)
# scripts/fgc peek print what the pane shows right now
# scripts/fgc restart [args...] stop, then start
#
# Detaching is Ctrl-b d. NOTE: the dashboard's own `q` key *exits the program*,
# it does not detach — that is the one thing to get right when attached.
#
# Only one instance may run. The binary enforces that itself with a lockfile
# (see src/core/SingleInstance.cpp), so a bare ./build/fire_gimbal_control is
# refused too, not just a second `fgc start`.
#
# Env: FGC_SESSION overrides the tmux session name (default "fgc").
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/.." && pwd)"
session="${FGC_SESSION:-fgc}"
target="=$session" # "=" makes tmux match the name exactly, not as a prefix
die() { echo "fgc: $*" >&2; exit 1; }
need_tmux() {
command -v tmux >/dev/null 2>&1 || die "tmux is not installed (apt-get install tmux)"
}
session_exists() { tmux has-session -t "$target" 2>/dev/null; }
# "1" if the pane's program has exited, "0" if alive, "" if there is no session.
# remain-on-exit keeps a finished pane around, so a session existing is not on
# its own proof that the program is running.
pane_dead() { tmux list-panes -t "$target" -F '#{pane_dead}' 2>/dev/null | head -1; }
running() { [[ "$(pane_dead)" == "0" ]]; }
# PID of the pane's process: the `_run` wrapper shell, which forwards signals to
# the app and reports its exit status to tmux. For the app's own PID use
# app_pid() — the lockfile is written by the program itself.
pane_pid() { tmux list-panes -t "$target" -F '#{pane_pid}' 2>/dev/null | head -1; }
# PID of fire_gimbal_control itself, from the lockfile it writes at startup.
app_pid() {
local f; f="$(lock_path)"
[[ -e "$f" ]] || return 1
local p; read -r p <"$f" 2>/dev/null || return 1
[[ -n "$p" ]] && echo "$p"
}
# capture-pane needs a pane target; a session name alone gets "can't find pane".
# %N pane ids are unambiguous, so resolve one rather than guessing at "fgc:0.0".
pane_id() { tmux list-panes -t "$target" -F '#{pane_id}' 2>/dev/null | head -1; }
# Mirrors SingleInstance::defaultLockPath(). Kept env-independent for the same
# reason it is there: $XDG_RUNTIME_DIR is absent in a non-interactive ssh shell.
lock_path() {
local uid; uid="$(id -u)"
if [[ -d "/run/user/$uid" ]]; then
echo "/run/user/$uid/fire_gimbal_control.lock"
else
echo "/tmp/fire_gimbal_control-$uid.lock"
fi
}
# Is the lock actually held right now? Asked by trying to take it, not by
# checking whether the recorded PID exists: with remain-on-exit set, tmux does
# not reap the pane's process until the pane is destroyed, so a finished run
# leaves a zombie that `kill -0` happily reports as alive. The lock itself is
# the only thing that answers the question, and the kernel drops it on death.
lock_held() {
local f="$1"
[[ -e "$f" ]] || return 1
! flock -n "$f" true 2>/dev/null
}
# Vimba X needs the GenTL transport-layer dir on GENICAM_GENTL64_PATH or
# VmbStartup() fails with "Could not start Vimba X API". The SDK installs that
# via /etc/profile.d, which non-interactive ssh shells don't source. Resolving it
# *here* — i.e. inside the pane, at launch — rather than in the caller's shell
# matters: a tmux server left over from an earlier, differently-configured ssh
# session would otherwise hand the new session its stale environment.
resolve_vimba_env() {
local s d
# The SDK's profile.d script reads GENICAM_GENTL64_PATH before setting it, so
# sourcing it under `set -u` aborts with "unbound variable" and the app never
# launches. Relax nounset for the duration of the source only.
set +u
for s in /etc/profile.d/*Vimba*GenTL*.sh; do
[[ -f "$s" ]] && . "$s"
done
set -u
if [[ -z "${GENICAM_GENTL64_PATH:-}" ]]; then
for d in "${VIMBA_CTI_PATH:-}" /opt/VimbaX/cti /opt/VimbaX_*/cti; do
if [[ -n "$d" && -d "$d" ]]; then export GENICAM_GENTL64_PATH="$d"; break; fi
done
fi
}
cmd_start() {
need_tmux
if running; then
die "already running (session '$session') — use 'fgc attach', or 'fgc restart'"
fi
if session_exists; then
# A dead pane left over by remain-on-exit from a previous run. Clear it,
# but say so — it is the only trace of how that run ended.
echo "fgc: clearing the finished session from a previous run" >&2
tmux kill-session -t "$target" 2>/dev/null || true
fi
# tmux hands the command to `sh -c`, so quote each forwarded argument rather
# than relying on the split surviving the round trip. No `exec` here, and no
# exec in `_run` either — see the comment there for why the wrapper shell has
# to stay in place. `fgc stop` signals the wrapper, which forwards.
local launch
launch="$(printf '%q ' "$here/fgc" _run "$@")"
# remain-on-exit=failed, not =on: a *failed* start must leave its output on
# screen rather than destroying the session and looking like nothing
# happened, but a clean exit must take the session with it. With =on, quitting
# the dashboard left an operator staring at a frozen dead pane with no process
# behind it — keys and Ctrl-C did nothing, and it read as a hung ssh session.
#
# It is a *window* option, and a pane that dies immediately takes the window
# with it before the option can be set — so create the session on a
# placeholder, set the option, then respawn the pane with the real command.
# -c: the program resolves ./config.ini and the output dir from the cwd. The
# pane runs this script's hidden `_run`, so the Vimba environment is resolved
# inside the pane; run.sh finds the binary, one place only.
tmux new-session -d -s "$session" -c "$repo" 'sleep 86400'
# A window option needs a window target; a session name alone gets
# "no such window". Resolve the window's own id (@N) and use that.
local win; win="$(tmux list-windows -t "$target" -F '#{window_id}' | head -1)"
local pane; pane="$(pane_id)"
# "failed" needs tmux >= 3.2; fall back to "on" rather than leaving it unset,
# where a failed start would vanish without a trace.
tmux set-option -w -t "$win" remain-on-exit failed >/dev/null 2>&1 \
|| tmux set-option -w -t "$win" remain-on-exit on >/dev/null
# Spell the way out along the bottom of the screen. Without it there is no
# clue on screen that Ctrl-b d exists — and if the program dies, the pane
# freezes with no process behind it, so keys and Ctrl-C do nothing and it
# reads as a hung ssh session. tmux's status line stays alive either way.
# These are *session* options, and set-option rejects the "=name" exact-match
# form that every other subcommand here takes ("no such session: =fgc"), so
# target the pane and let tmux resolve the session from it.
tmux set-option -t "$pane" status on >/dev/null
tmux set-option -t "$pane" status-style 'bg=colour24,fg=colour255' >/dev/null
tmux set-option -t "$pane" status-left ' fire-gimbal-control ' >/dev/null
tmux set-option -t "$pane" status-left-length 30 >/dev/null
tmux set-option -t "$pane" status-right-length 80 >/dev/null
tmux set-option -t "$pane" status-right \
' Ctrl-b d = detach, keeps running | q = quit the program ' >/dev/null
tmux respawn-pane -k -t "$win" -c "$repo" "$launch" >/dev/null
# Give it a moment to fail fast (bad config, lock already held, no camera).
sleep 2
local dead; dead="$(pane_dead)"
if [[ -z "$dead" ]]; then
# No session at all: the program exited 0 within the 2s window, so
# remain-on-exit=failed tore it down and took the output with it.
die "session '$session' is gone — the program exited immediately (status 0).
Reproduce it in the foreground to see why: scripts/run.sh $*"
fi
if [[ "$dead" == "1" ]]; then
local status
status="$(tmux list-panes -t "$target" -F '#{pane_dead_status}' | head -1)"
echo "fgc: failed to start (exit ${status:-?}):" >&2
tmux capture-pane -p -t "$(pane_id)" | sed '/^$/d' | tail -20 >&2
tmux kill-session -t "$target" 2>/dev/null || true
exit "${status:-1}"
fi
echo "fgc: started detached as PID $(pane_pid) (session '$session')"
echo " attach: scripts/fgc attach (Ctrl-b d to detach; 'q' quits the program)"
}
# Hidden: the command tmux runs inside the pane. Not for direct use.
#
# The app is run as a CHILD and waited on, rather than exec'd. That looks like a
# pointless extra process, and it is load-bearing: when the app is itself the
# pane's process, tmux never records an exit status for it (#{pane_dead_status}
# comes back empty and the process lingers as a zombie), so remain-on-exit=failed
# cannot tell success from failure and keeps *every* finished pane — which is
# what left an operator staring at a frozen dead pane after pressing q. With a
# shell in between to reap the child and exit with its status, tmux gets a real
# status and tears the session down on a clean quit. Measured: 3/3 kept when
# exec'd, 3/3 clean when parented.
cmd__run() {
resolve_vimba_env
# <&0 is required: bash redirects a background command's stdin from
# /dev/null unless it is given one explicitly, which would leave the
# dashboard unable to read a single keystroke.
"$here/run.sh" "$@" <&0 &
local child=$!
# Forward the signals `fgc stop` and a pane kill use, so the app still runs
# its own clean teardown (stop capture, close camera, save exposure store)
# instead of being killed under the wrapper.
trap 'kill -TERM "$child" 2>/dev/null' TERM
trap 'kill -INT "$child" 2>/dev/null' INT
trap 'kill -HUP "$child" 2>/dev/null' HUP
# A trapped signal makes `wait` return 128+n while the child is still
# shutting down, so keep waiting until it is actually gone before reporting.
local rc=0
wait "$child"; rc=$?
while kill -0 "$child" 2>/dev/null; do
wait "$child"; rc=$?
done
exit "$rc"
}
cmd_attach() {
need_tmux
running || die "not running — start it with 'fgc start'"
if [[ -n "${TMUX:-}" ]]; then
die "already inside tmux; use 'tmux switch-client -t $session'"
fi
echo "fgc: attaching — Ctrl-b d detaches and leaves it running ('q' would quit it)" >&2
exec tmux attach-session -t "$target"
}
cmd_status() {
need_tmux
local lock; lock="$(lock_path)"
if ! running; then
if session_exists; then
echo "fgc: not running — session '$session' holds a finished pane:"
tmux capture-pane -p -t "$(pane_id)" | sed '/^$/d' | tail -10 | sed 's/^/ /'
echo " clear it with 'fgc stop' (or just 'fgc start')"
else
echo "fgc: not running (no tmux session '$session')"
fi
# The lock outliving the session means an instance is running that fgc
# did not start — systemd, or someone's bare ./fire_gimbal_control.
if lock_held "$lock"; then
local pid; read -r pid <"$lock" || pid="?"
echo " but $lock is held by PID $pid — an instance is running outside tmux"
return 0
fi
return 1
fi
local app; app="$(app_pid || echo "$(pane_pid)")"
echo "fgc: running (session '$session', PID $app)"
ps -o lstart=,etime=,args= -p "$app" 2>/dev/null | sed 's/^/ /'
echo " lockfile: $lock"
}
cmd_stop() {
need_tmux
if ! running; then
if session_exists; then
tmux kill-session -t "$target" 2>/dev/null || true
echo "fgc: cleared the finished session '$session'"
else
echo "fgc: not running"
fi
return 0
fi
local pid; pid="$(pane_pid)"
# The pane should be our `_run` wrapper, which forwards the signal to the
# app. Signalling something else would look like a clean stop while the
# program kept running, so check rather than assume.
if ! ps -o args= -p "$pid" 2>/dev/null | grep -qE 'fgc _run|fire_gimbal'; then
echo "fgc: pane process $pid is not the fgc launcher:" >&2
ps -o args= -p "$pid" 2>/dev/null | sed 's/^/ /' >&2
die "refusing to signal it — attach and shut it down by hand"
fi
# SIGTERM, not a keystroke: Application installs a SIGTERM handler that runs
# the same clean teardown as the `exit` command (stop capture, close the
# camera, save the exposure store, park the motor), and unlike sending keys
# it works the same in TUI and headless mode. Never SIGKILL — an unclean
# exit leaves the camera wedged for the next run.
echo "fgc: stopping fire_gimbal_control (PID $(app_pid || echo '?')) via SIGTERM…"
kill -TERM "$pid" 2>/dev/null || true
# Wait on tmux's view of the pane, not on `kill -0`: while a pane is kept
# after exit, tmux holds the finished process as a zombie until the pane is
# destroyed, so `kill -0` would report it alive forever and every stop would
# "time out". `running` covers both endings — a clean exit destroys the
# session outright, a failed one leaves the pane behind with dead=1.
#
# 120s, because shutdown is not instant: the control loop only notices the
# flag between ticks, and a capture attempt that is timing out on the camera
# (3 tries x 2s) holds it for several seconds before that.
local i
for i in $(seq 1 120); do
running || break
sleep 1
done
if running; then
echo "fgc: PID $pid still running 120s after SIGTERM — attach and look at why;" >&2
echo " do NOT kill -9, that wedges the camera." >&2
return 1
fi
tmux kill-session -t "$target" 2>/dev/null || true
echo "fgc: stopped"
}
cmd_peek() {
need_tmux
running || die "not running"
# In TUI mode FTXUI draws on the alternate screen, so this is a snapshot of
# the live dashboard; in headless mode it is the log scrollback.
tmux capture-pane -p -S - -t "$(pane_id)"
}
cmd_restart() {
cmd_stop
cmd_start "$@"
}
sub="${1:-}"
[[ $# -gt 0 ]] && shift
case "$sub" in
start) cmd_start "$@" ;;
attach) cmd_attach ;;
status) cmd_status ;;
stop) cmd_stop ;;
peek|logs) cmd_peek ;;
restart) cmd_restart "$@" ;;
_run) cmd__run "$@" ;;
""|-h|--help|help)
# The header block above, minus the shebang: one source of truth.
awk 'NR>2 && /^#/ {sub(/^# ?/, ""); print; next} NR>2 {exit}' "${BASH_SOURCE[0]}"
[[ -z "$sub" ]] && exit 1 || exit 0 ;;
*) die "unknown command: $sub (try 'fgc --help')" ;;
esac