AtlatestRepositorycourier

courier / tree / reprorepro-send-duplication.sh

1#!/usr/bin/env bash
2# Off-device regression harness for the courier SEND-PATH DUPLICATION storm.
3#
4# Proves, entirely against a local mock (NOTHING reaches real Telegram),
5# that one logical `send-message` is delivered EXACTLY ONCE even when the
6# leader's MCP client gives up on a slow send and SIGKILLs+restarts+re-issues
7# it -- the loop that spammed David with many copies of the same message.
8#
9# * FIXED binary (this worktree): storm -> 1 delivery.
10# * optional CONTROL binary (pre-fix master, set COURIER_CONTROL_BIN):
11# storm -> N>1 deliveries, demonstrating the bug the fix removes.
13# Mechanism of the storm (see src/courier/dedup.sgl):
14# the sendMessage ack read can take up to 25s; if the client's tool-call
15# timeout is shorter (or a human /mcp's because it "looks hung"), it kills
16# courier and re-issues the send. Each attempt reaches Telegram before the
17# kill. The pre-fix in-memory dedup was wiped on every restart, so it could
18# not suppress the retry; the fix persists the dedup to disk and records a
19# send BEFORE delivery, so a restarted courier suppresses the re-issue.
21# Usage:
22# ./repro-send-duplication.sh
23# COURIER_CONTROL_BIN=/path/to/prefix/courier ./repro-send-duplication.sh
24set -u
26HERE="$(cd "$(dirname "$0")" && pwd)"
27BIN="${COURIER_BIN:-$HERE/../build/dev/bin/courier}"
28PORT="${PORT:-8611}"
29PY="${PYTHON:-python3}"
30RUNDIR="$HERE/run"
31FAILED=0
33if [ ! -x "$BIN" ]; then
34 echo "FATAL: courier binary not found at $BIN (run 'sigil build' first)"
35 exit 2
36fi
38start_mock() {
39 rm -rf "$RUNDIR"; mkdir -p "$RUNDIR"
40 "$PY" "$HERE/mock_telegram.py" "$PORT" "$RUNDIR/mocklog" 2>/dev/null &
41 MOCK=$!
42 sleep 1
44stop_mock() { kill "$MOCK" 2>/dev/null; wait "$MOCK" 2>/dev/null; sleep 0.2; }
46# run_case <label> <bin> <expected-deliveries> <driver-args...>
47run_case() {
48 local label="$1" bin="$2" expect="$3"; shift 3
49 start_mock
50 "$PY" "$HERE/send_driver.py" --bin "$bin" \
51 --api-url "http://127.0.0.1:$PORT" \
52 --stats-url "http://127.0.0.1:$PORT/_stats" \
53 --mode-endpoint "http://127.0.0.1:$PORT/_mode" \
54 --logdir "$RUNDIR" "$@" >/dev/null 2>&1
55 local got
56 got="$(grep -c sendMessage "$RUNDIR/mocklog/sends.log" 2>/dev/null || echo 0)"
57 stop_mock
58 if [ "$got" = "$expect" ]; then
59 echo " PASS $label: $got delivery(ies) (expected $expect)"
60 else
61 echo " FAIL $label: $got delivery(ies) (expected $expect)"
62 FAILED=1
63 fi
66echo "=== FIXED binary: $BIN ==="
67run_case "baseline ok -> 1" "$BIN" 1 --mode ok --tool-timeout 30 --retries 0 --text baseline
68run_case "reset (ack lost) -> 1" "$BIN" 1 --mode reset --tool-timeout 30 --retries 0 --text reset
69run_case "STORM x3 -> 1 (exactly-once)" "$BIN" 1 --mode delay:8 --tool-timeout 3 --retries 3 --text storm3
70run_case "STORM x5 -> 1 (exactly-once)" "$BIN" 1 --mode delay:8 --tool-timeout 2 --retries 5 --text storm5
72if [ -n "${COURIER_CONTROL_BIN:-}" ] && [ -x "${COURIER_CONTROL_BIN}" ]; then
73 echo "=== CONTROL (pre-fix) binary: $COURIER_CONTROL_BIN ==="
74 # The pre-fix binary re-delivers once per attempt: 1 logical send + 3
75 # retries = 4 deliveries. This is the storm the fix removes.
76 run_case "STORM x3 -> 4 (bug present)" "$COURIER_CONTROL_BIN" 4 \
77 --mode delay:8 --tool-timeout 3 --retries 3 --text storm3
78else
79 echo "=== CONTROL skipped (set COURIER_CONTROL_BIN to a pre-fix courier) ==="
80fi
82rm -rf "$RUNDIR"
83if [ "$FAILED" = 0 ]; then
84 echo "ALL CHECKS PASSED"
85 exit 0
86else
87 echo "SOME CHECKS FAILED"
88 exit 1
89fi