fix: Telegram listener — async command execution + singleton guard
gates / consistency-and-conventions (push) Successful in 23s

Root cause: run_and_reply() blocked the entire listener synchronously.
FFmpeg hung because child processes inherited stdin (waiting for 'q').
Long-running commands froze the listener for up to 120s.

Fix:
- Commands run in background with stdin=/dev/null, output to temp file
- reap_commands() collects output non-blocking after each getUpdates cycle
- SIGCHLD handler pre-caches exit codes via wait -n
- TERM/INT trap kills background processes and cleans temp files
- Singleton guard (flock) prevents duplicate listeners racing getUpdates

Tests:
- t-telegram-listener-exec.sh: 12 hermetic checks (echo, pipes, stderr,
  compound commands, long-running, quiet mode)
- t-telegram-listener-singleton.sh: 8 checks (lock acquire/release/status)

Architect verdict: accepted as-is, no re-architecture needed.
This commit is contained in:
Your Name
2026-09-09 17:17:56 -04:00
parent f14d24950a
commit df1cca478d
6 changed files with 363 additions and 24 deletions
+96 -22
View File
@@ -36,10 +36,12 @@ Commands:
(none) Interactive editor for the /command → bash map
--enable Install + start the systemd user service (autostarts on login)
--disable Stop + disable + remove the service
--status Show service state and the command map
--status Show service state (single-instance lock) and the command map
--sync-commands
Push the mapped /commands to the bot's "/" menu (setMyCommands)
--run Run the polling loop in the foreground (used by the service)
--run Run the polling loop in the foreground (used by the service).
Single instance: only one --run may poll the bot token at a
time — a second --run exits immediately with an error.
prefix [word [command...]]
Manage the text-prefix map (telegram_prefixes.env): any
non-command message '<prefix> <text>' runs the mapped command
@@ -337,24 +339,67 @@ prefix_map_show() {
return 1
}
# Run a mapped command line and reply with its output: empty output → "OK",
# non-zero exit → "exit <rc>" + output; quiet=1 suppresses the reply (for
# '@quiet ' entries that self-notify). Used by the /command map (60s cap)
# and the text-prefix bridge (120s cap for app calls).
# ── async command execution ─────────────────────────────────────
# Commands run in the background so the listener never blocks. stdin is
# /dev/null (prevents interactive hangs — FFmpeg reading 'q', scripts
# waiting for prompts); stdout+stderr go to a temp file; output is collected
# and replied asynchronously from the main loop.
#
# PID → metadata arrays (populated by run_and_reply, drained by reap_commands)
declare -A _CMD_OUT _CMD_MSG _CMD_QUIET
# Exit codes stored by the SIGCHLD handler (wait -n) so reap_commands can
# retrieve them without calling blocking wait.
declare -A _EXIT_CODES
# run_and_reply <cmdline> <msg_id> [timeout] [quiet]
# Starts the command in the background and returns immediately. The main
# loop calls reap_commands after each getUpdates cycle to collect output
# and send replies.
run_and_reply() {
local cmdline="$1" msg_id="$2" tmo="${3:-120}" quiet="${4:-0}" output rc
if output="$(timeout "$tmo" bash -c "$cmdline" 2>&1)"; then
rc=0
else
rc=$?
fi
[ "$quiet" -eq 1 ] && return
[ -n "$output" ] || output="OK"
if [ "$rc" -ne 0 ]; then
reply "$(printf 'exit %s\n%s' "$rc" "$output")" "$msg_id" "$rc"
else
reply "$output" "$msg_id"
fi
local cmdline="$1" msg_id="$2" tmo="${3:-120}" quiet="${4:-0}"
local out_file
out_file="$(mktemp /tmp/pos-cmd.XXXXXX)"
# stdin=/dev/null: prevents interactive hangs (FFmpeg 'q', read prompts).
# The child inherits nothing from the listener's own stdin.
timeout "$tmo" bash -c "$cmdline" </dev/null >"$out_file" 2>&1 &
local pid=$!
_CMD_OUT[$pid]="$out_file"
_CMD_MSG[$pid]="$msg_id"
_CMD_QUIET[$pid]="$quiet"
}
# reap_commands — called from the main loop after each getUpdates cycle.
# Checks every tracked PID with kill -0 (non-blocking); when a process has
# exited, reads its output file and sends the reply. Never blocks the loop.
reap_commands() {
local pid
for pid in "${!_CMD_OUT[@]}"; do
# Non-blocking: has the process exited?
if ! kill -0 "$pid" 2>/dev/null; then
# Retrieve exit code (SIGCHLD handler stores it; fallback to wait).
local rc="${_EXIT_CODES[$pid]:-}"
if [ -n "$rc" ]; then
unset _EXIT_CODES[$pid]
else
wait "$pid" 2>/dev/null; rc=$?
fi
local out_file="${_CMD_OUT[$pid]}"
local msg_id="${_CMD_MSG[$pid]}"
local quiet="${_CMD_QUIET[$pid]}"
local output=""
[ -s "$out_file" ] && output="$(cat "$out_file" 2>/dev/null)"
rm -f "$out_file"
if [ "$quiet" -ne 1 ]; then
[ -n "$output" ] || output="OK"
if [ "$rc" -ne 0 ]; then
reply "$(printf 'exit %s\n%s' "$rc" "$output")" "$msg_id" "$rc"
else
reply "$output" "$msg_id"
fi
fi
unset _CMD_OUT[$pid] _CMD_MSG[$pid] _CMD_QUIET[$pid]
fi
done
}
ui_run_command() {
@@ -521,8 +566,8 @@ disable_service() {
}
status() {
if systemctl --user is-active --quiet "$SERVICE" 2>/dev/null; then
echo "listener: running"
if lock_held; then
echo "listener: running (single instance lock held)"
else
echo "listener: not running"
fi
@@ -750,7 +795,31 @@ handle_message() {
run_and_reply "$value" "$msg_id" 60 "$quiet"
}
# ── single-instance guard ──────────────────────────────────────
# flock(1) on a runtime lockfile — the kernel drops the lock when the process
# dies, so there is no stale-lock/pidfile bookkeeping and the systemd
# Restart=always unit restarts cleanly. Two getUpdates loops on one bot token
# cause Telegram 409 conflicts and command stealing, so a second --run fails
# closed instead of racing the active listener.
LOCK_FILE="${XDG_RUNTIME_DIR:-/tmp}/pos-telegram-listener.lock"
acquire_lock() {
exec 9>"$LOCK_FILE"
flock -n 9 || err "listener already running (single instance) — check: systemctl --user status pos-telegram-listener"
}
lock_held() {
# Non-blocking probe: acquiring then dropping the flock in a subshell
# succeeds only when nobody else holds it. Returns 0 when held.
if ( flock -n 9 ) 9>"$LOCK_FILE" 2>/dev/null; then
return 1
fi
return 0
}
run_daemon() {
command -v flock &>/dev/null || err "flock not found (install util-linux)"
acquire_lock
command -v jq &>/dev/null || err "jq not found (install jq — in preinstall PACKAGES)"
load_config
[ -n "${TELEGRAM_BOT_TOKEN:-}" ] || err "No bot token — run 'pos config telegram'"
@@ -764,7 +833,10 @@ run_daemon() {
local offset=0
log "listener running (chat ${TELEGRAM_CHAT_ID}, owner ${TELEGRAM_OWNER_ID:-unset}) — Ctrl+C to stop"
trap 'kill $(jobs -p) 2>/dev/null; exit 0' TERM INT
# SIGCHLD: reap finished children and store their exit codes so
# reap_commands can retrieve them without blocking.
trap 'local _p; while _p=$(wait -n 2>/dev/null); do _EXIT_CODES[$_p]=$?; done' CHLD
trap 'kill $(jobs -p) 2>/dev/null; rm -f /tmp/pos-cmd.* 2>/dev/null; wait 2>/dev/null; exit 0' TERM INT
while true; do
local resp n i
resp="$(curl -fsS -m 45 "${API}/bot${TELEGRAM_BOT_TOKEN}/getUpdates" \
@@ -799,6 +871,8 @@ run_daemon() {
fi
handle_message "$text" "$msg_id" "$reply_text"
done
# Collect output from finished background commands and send replies.
reap_commands
done
}