fix: Telegram listener — async command execution + singleton guard
gates / consistency-and-conventions (push) Successful in 23s
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:
+2
-1
@@ -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 |
|
||||
@@ -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
|
||||
}
|
||||
Executable
+129
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user