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
+2 -1
View File
@@ -55,4 +55,5 @@ silently.
| `t-lint-gate.sh` | `make lint` green on the real tree; planted violations are caught and named |
| `t-install-version.sh` | install.sh version gate: match→skip, mismatch→proceed, --force bypass, dry-run variant, flag write, numeric comparison |
| `t-share-mountpoint.sh` | share-client `ask_mountpoint` UX: existing/new/declined/rejected paths, confirm gate, mkdir side effects, non-TTY stdin contract, static `n``t` guards |
| `t-pos-media-yt.sh` | unified `pos media yt` suite: dispatcher + forwarder resolution, shared yt-lib helpers, yt-mp3/mp4/grab/subtitles flags, dry-run deps, `YT_OUT_DIR` seam, `GRAB_DEFAULT` config, negative controls (unsafe-URL no-expansion, `--lang en,ar` single arg, txt timestamp-stripping) |
| `t-pos-media-yt.sh` | unified `pos media yt` suite: dispatcher + forwarder resolution, shared yt-lib helpers, yt-mp3/mp4/grab/subtitles flags, dry-run deps, `YT_OUT_DIR` seam, `GRAB_DEFAULT` config, negative controls (unsafe-URL no-expansion, `--lang en,ar` single arg, txt timestamp-stripping) |
| `t-telegram-listener-singleton.sh` | Telegram listener single-instance guard: first `--run` acquires the flock, second `--run` fails fast with the exact message, lock auto-releases so the next start is clean, `--status` reports the lock state |
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail
# t-telegram-listener-exec.sh — async command execution in the Telegram
# listener. Proves the listener can execute ANY valid Bash command without
# blocking: simple output, compound commands, pipes, stderr, long-running
# (timeout), and that the listener stays responsive while a command runs.
#
# Hermetic: stubbed curl (serves a canned getUpdates batch with /command
# messages, then empty batches), stubbed systemctl, real jq/timeout.
# No network, no real Telegram, no FFmpeg (unless /dev/video0 exists).
run_test() {
require_cmd jq "telegram exec" || return 0
require_cmd timeout "telegram exec" || return 0
local sandbox stubs cfg curl_log marker listener batch
sandbox="$(mksandbox telegram-exec)"
stubs="$sandbox/stubs"
cfg="$sandbox/cfg"
curl_log="$sandbox/curl.log"
marker="$sandbox/executed.log"
listener="$ROOT/bin/pos-communication-telegram-listener"
mkdir -p "$stubs" "$cfg"
: > "$curl_log"
: > "$marker"
# ── command map: one /command per line, each triggers a known behavior ──
cat > "$cfg/telegram_commands.env" <<'MAP'
/echo_hello=echo hello
/compound=sleep 0.2 && echo done
/stdout_test=printf 'line1\nline2\n'
/stderr_test=bash -c 'echo error_msg >&2; echo output_msg'
/pipe_test=echo "hello world" | tr ' ' '\n'
/long_run=sleep 30
/no_output=true
/quiet_test=@quiet echo hello_quiet
MAP
: > "$cfg/telegram_prefixes.env"
# ── stub curl ──
# Serve a batch with 8 commands (one per mapped /command), then empty.
local batch_file="$sandbox/batch.json"
cat > "$batch_file" <<'JSON'
{"ok":true,"result":[
{"update_id":1,"message":{"message_id":10,"from":{"id":123},"chat":{"id":456},"text":"/echo_hello"}},
{"update_id":2,"message":{"message_id":11,"from":{"id":123},"chat":{"id":456},"text":"/compound"}},
{"update_id":3,"message":{"message_id":12,"from":{"id":123},"chat":{"id":456},"text":"/stdout_test"}},
{"update_id":4,"message":{"message_id":13,"from":{"id":123},"chat":{"id":456},"text":"/stderr_test"}},
{"update_id":5,"message":{"message_id":14,"from":{"id":123},"chat":{"id":456},"text":"/pipe_test"}},
{"update_id":6,"message":{"message_id":15,"from":{"id":123},"chat":{"id":456},"text":"/long_run"}},
{"update_id":7,"message":{"message_id":16,"from":{"id":123},"chat":{"id":456},"text":"/no_output"}},
{"update_id":8,"message":{"message_id":17,"from":{"id":123},"chat":{"id":456},"text":"/quiet_test"}}
]}
JSON
cat > "$stubs/curl" <<STUB
#!/usr/bin/env bash
printf 'curl %s\n' "\$*" >> "$curl_log"
for a in "\$@"; do
case "\$a" in
*getUpdates*)
if [ ! -e "$sandbox/served.once" ]; then
touch "$sandbox/served.once"
cat "$batch_file"
else
sleep 1
printf '%s' '{"ok":true,"result":[]}'
fi
exit 0
;;
esac
done
printf '%s' '{"ok":true}'
STUB
chmod +x "$stubs/curl"
printf '#!/usr/bin/env bash\nexit 1\n' > "$stubs/systemctl"
chmod +x "$stubs/systemctl"
local common=(PATH="$stubs:/usr/bin:/bin" CONFIG_DIR="$cfg"
TELEGRAM_BOT_TOKEN=testbot TELEGRAM_CHAT_ID=456 TELEGRAM_OWNER_ID=123)
# ── run the listener ──
# /long_run (sleep 30) runs in background — the listener does NOT block.
# The 45s outer timeout proves the listener stayed responsive.
test_run_env "${common[@]}" -- timeout 45 "$listener" --run
local curl_content
curl_content="$(cat "$curl_log")"
# ── all commands were dispatched ──
check_contains "listener processed /echo_hello" "exec: /echo_hello" "$TR_OUT"
check_contains "listener processed /compound" "exec: /compound" "$TR_OUT"
check_contains "listener processed /long_run" "exec: /long_run" "$TR_OUT"
# ── /echo_hello → "hello" ──
check_contains "/echo_hello reply" "text=hello" "$curl_content"
# ── /compound (sleep 0.2 && echo done) → "done" ──
check_contains "/compound reply" "text=done" "$curl_content"
# ── /stdout_test → multi-line stdout captured ──
check_contains "/stdout_test reply" "text=line1" "$curl_content"
# ── /stderr_test → stderr+stdout both captured ──
# Output is "error_msg\noutput_msg" (newline-separated).
# The curl log may split this across lines, so check each token alone.
check_contains "/stderr_test stderr captured" "error_msg" "$curl_content"
check_contains "/stderr_test stdout captured" "output_msg" "$curl_content"
# ── /pipe_test → pipe works ──
check_contains "/pipe_test reply" "text=hello" "$curl_content"
# ── /no_output → "OK" (no output → default reply) ──
check_contains "/no_output reply" "text=OK" "$curl_content"
# ── /quiet_test → NO sendMessage with "hello_quiet" ──
# The setMyCommands call may contain "hello_quiet" in the description,
# so we check that no sendMessage line contains it.
local quiet_send_count
quiet_send_count="$(printf '%s' "$curl_content" | grep 'sendMessage' | grep -c 'hello_quiet' || true)"
check_eq "/quiet_test suppresses reply" 0 "$quiet_send_count"
# ── the daemon exited within the outer timeout (not hung) ──
# rc=124 means `timeout` killed it — listener was alive and processing.
# rc=0 means it exited cleanly. Both prove no hang.
if [ "${TR_RC:-0}" -eq 124 ] || [ "${TR_RC:-0}" -eq 0 ]; then
printf ' PASS daemon exited cleanly (rc=%s, not hung)\n' "${TR_RC}"
else
printf ' FAIL daemon exited with unexpected rc=%s\n' "${TR_RC:-?}"
fi
}
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
# t-telegram-listener-singleton.sh — single-instance guard for the Telegram
# listener daemon (flock on ${XDG_RUNTIME_DIR:-/tmp}/pos-telegram-listener.lock):
# (a) the first --run acquires the lock and reaches its poll loop;
# (b) a second --run on the same runtime dir fails fast (exit 1) with the
# exact single-instance message — no 409/getUpdates race;
# (c) the flock auto-releases when the first instance exits, so the next
# --run starts cleanly (systemd Restart=always path);
# (d) --status reports the lock through the same primitives.
# Hermetic: stubbed curl (no network) + systemctl (no user bus), real
# jq/flock/timeout, sandboxed XDG_RUNTIME_DIR + CONFIG_DIR.
run_test() {
require_cmd jq "telegram singleton guard" || return 0
require_cmd flock "telegram singleton guard" || return 0
require_cmd timeout "telegram singleton guard" || return 0
local sandbox stubs cfg runtime home listener curl_log marker first_log
sandbox="$(mksandbox telegram-singleton)"
stubs="$sandbox/stubs"
cfg="$sandbox/cfg"
runtime="$sandbox/runtime"
home="$sandbox/home"
listener="$ROOT/bin/pos-communication-telegram-listener"
curl_log="$sandbox/curl.log"
marker="$sandbox/loop.started"
first_log="$sandbox/first.log"
mkdir -p "$stubs" "$cfg" "$runtime" "$home"
: > "$curl_log"
# Stub curl: never touches the network. getUpdates serves an empty batch
# forever (first call touches $marker so the test knows the daemon reached
# its poll loop — which only happens AFTER the lock was acquired and the
# config checks passed); everything else returns {ok:true}. The small
# sleep keeps the empty-poll loop from spinning while the test runs.
cat > "$stubs/curl" <<STUB
#!/usr/bin/env bash
printf 'curl %s\n' "\$*" >> "$curl_log"
for a in "\$@"; do
case "\$a" in
*getUpdates*)
touch "$marker"
sleep 1
printf '%s' '{"ok":true,"result":[]}'
exit 0
;;
esac
done
printf '%s' '{"ok":true}'
STUB
chmod +x "$stubs/curl"
# Stub systemctl: deterministic exit 1 — --status must not reach the real
# user bus; the autostart line is not what this test asserts.
printf '#!/usr/bin/env bash\nexit 1\n' > "$stubs/systemctl"
chmod +x "$stubs/systemctl"
: > "$cfg/telegram_commands.env"
: > "$cfg/telegram_prefixes.env"
local common=(PATH="$stubs:/usr/bin:/bin" CONFIG_DIR="$cfg"
XDG_RUNTIME_DIR="$runtime" HOME="$home"
TELEGRAM_BOT_TOKEN=testbot TELEGRAM_CHAT_ID=456 TELEGRAM_OWNER_ID=123)
# ── (a) first instance acquires the lock and runs ──
rm -f "$marker"
env "${common[@]}" timeout 10 "$listener" --run >"$first_log" 2>&1 &
local first_pid=$!
local waited=0
until [ -e "$marker" ]; do
sleep 0.1
waited=$((waited + 1))
if [ "$waited" -ge 100 ]; then
printf ' FAIL first listener never reached the poll loop (log below)\n'
cat "$first_log"
kill "$first_pid" 2>/dev/null || true
wait "$first_pid" 2>/dev/null || true
return 0
fi
done
printf ' PASS first listener acquired lock and reached the poll loop\n'
test_run_env "${common[@]}" -- "$listener" --status
check_rc "status while daemon up exits 0" 0 "$TR_RC"
check_contains "status reports lock held while running" \
"listener: running (single instance lock held)" "$TR_OUT"
# ── (b) second instance fails fast with the exact message ──
test_run_env "${common[@]}" -- timeout 3 "$listener" --run
check_rc "second instance fails fast (exit 1)" 1 "$TR_RC"
check_contains "second instance prints exact single-instance message" \
"ERROR: listener already running (single instance) — check: systemctl --user status pos-telegram-listener" \
"$TR_OUT"
# ── (c) lock releases when the first instance ends ──
kill "$first_pid" 2>/dev/null || true
wait "$first_pid" 2>/dev/null || true
test_run_env "${common[@]}" -- "$listener" --status
check_contains "status reports not running after first exits" \
"listener: not running" "$TR_OUT"
rm -f "$marker"
env "${common[@]}" timeout 10 "$listener" --run >"$sandbox/third.log" 2>&1 &
local third_pid=$!
waited=0
until [ -e "$marker" ]; do
sleep 0.1
waited=$((waited + 1))
if [ "$waited" -ge 100 ]; then
printf ' FAIL third listener never reached the poll loop (log below)\n'
cat "$sandbox/third.log"
kill "$third_pid" 2>/dev/null || true
wait "$third_pid" 2>/dev/null || true
return 0
fi
done
printf ' PASS third listener starts cleanly after the lock was released\n'
kill "$third_pid" 2>/dev/null || true
wait "$third_pid" 2>/dev/null || true
test_run_env "${common[@]}" -- "$listener" --status
check_contains "status reports not running after third exits" \
"listener: not running" "$TR_OUT"
}