Fix log pruning that silently never ran, and cover it behaviourally
prune-instance-logs! called three procedures that do not exist in this stdlib: string-prefix?, string-suffix? and sort. The real names are string-starts-with?, string-ends-with? (both subject-first) and list-sort (from (sigil list), which was not imported).
None of that failed the build. The function is wrapped in a guard -- a failure there costs disk, not correctness -- so at runtime the unbound variable was caught and swallowed, and pruning simply never happened. The log files looked entirely correct, because the bug was in what did NOT occur. A source read would have passed it; only asking the binary to prune a directory found it.
Pruning now also runs AFTER the instance opens its own log, so the cap counts this generation and the newest-first ordering can never select the live file for deletion.
Harness case 8 pins the behaviour: 15 stale generations plus a live one are capped to 10 newest, and the non-log neighbours (send-dedup.state, telegram-offset, the old shared courier.log) are left untouched.
Also pinned in the unit suite:
- the send-result strings must stay distinguishable from each other and from a bare "Message sent" (that string equality is the whole defect);
- claim-token-hash's zero-padding branch, which only executes for 1 token in 16 and would otherwise sit unrun until it failed in production. The expected value is derived independently rather than copied from output.
Suite 111 passed / 0 failed. Harness 38 checks, all passing, with the pre-fix control still producing 4 conflicts and 2 poller children.
repro/single_poller_harness.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/courier/main.sgl | 19 +++++++++++--------
test/test-claim.sgl | 10 ++++++++++
test/test-telegram.sgl | 38 ++++++++++++++++++++++++++++++++++++++
4 files changed, 118 insertions(+), 8 deletions(-)repro/single_poller_harness.pymodified
return cdef case_log_pruning(binp, run_dir): """Per-instance logs must not accumulate forever, and must not take anything else with them. Worth a behavioural case rather than trusting the source: the pruning code is wrapped in a guard (a failure there costs disk, not correctness), and a guard is exactly what hides a defect. The first version of this function called two procedures that do not exist in this stdlib; it compiled, it ran, the guard swallowed the unbound variable, and pruning silently never happened. Nothing about the log files would have looked wrong. """ print("=== 8 LOG PRUNING (bounded, and only OUR files) ===") d = os.path.join(run_dir, "prune") os.makedirs(os.path.join(d, "relays"), exist_ok=True) now = time.time() fakes = [] for i in range(15): p = os.path.join(d, f"courier-9{i:04d}.log") with open(p, "w") as f: f.write(f"fake generation {i}\n") # Staggered mtimes, oldest first, so "keep the newest N" is testable. os.utime(p, (now - (100 - i) * 60, now - (100 - i) * 60)) fakes.append(p) # Files that are NOT instance logs must survive untouched. bystanders = { os.path.join(d, "send-dedup.state"): "1 abc\n", os.path.join(d, "telegram-offset"): "12345", os.path.join(d, "courier.log"): "the old shared log\n", os.path.join(d, "courier-notes.txt"): "not a log\n", } for p, content in bystanders.items(): with open(p, "w") as f: f.write(content) c = Courier(binp, d, "prune-a") c.initialize() time.sleep(2) remaining = sorted(n for n in os.listdir(d) if n.startswith("courier-") and n.endswith(".log")) check("instance logs are capped at 10", len(remaining) == 10, f"{len(remaining)} remain: {remaining}") check("the instance's own log survived pruning", f"courier-{c.pid}.log" in remaining, f"{remaining}") check("the OLDEST fakes are the ones removed", os.path.basename(fakes[0]) not in remaining and os.path.basename(fakes[14]) in remaining, f"{remaining}") survived = [os.path.basename(p) for p in bystanders if os.path.exists(p)] check("non-log files (dedup state, offset) are untouched", len(survived) == len(bystanders), f"survived={survived} of {[os.path.basename(p) for p in bystanders]}") c.close_stdin() time.sleep(1) c.kill()EXPECTED_DRY_RUN = ("Message NOT sent: COURIER_DISABLE_TELEGRAM_SEND is set " "(dry-run, nothing delivered).") inst.kill() instances = [] case_log_pruning(args.bin, run_dir) dry_dir, text = case_dry_run(args.bin, run_dir) case_gag_does_not_poison(args.bin, run_dir, dry_dir, text)src/courier/main.sglmodified
(define-library (courier main) (import (sigil core) (sigil string) (sigil list) (sigil io) (sigil dotenv) (sigil async) (guard (e (else #f)) (let* ((entries (or (directory-list log-dir) '())) (logs (filter (lambda (n) (and (string-prefix? "courier-" n) (string-suffix? ".log" n))) (and (string-starts-with? n "courier-") (string-ends-with? n ".log"))) entries)) (paths (map (lambda (n) (path-join log-dir n)) logs)) (sorted (sort paths (lambda (a b) (> (or (file-mtime a) 0) (or (file-mtime b) 0)))))) (sorted (list-sort (lambda (a b) (> (or (file-mtime a) 0) (or (file-mtime b) 0))) paths))) (let loop ((rest sorted) (i 0)) (unless (null? rest) (when (>= i *log-keep-generations*) (number->string (process-id)) ".log")))) (ensure-directory log-dir) (prune-instance-logs! log-dir) (log-configure! target: (open-log-append! log-path)))) (log-configure! target: (open-log-append! log-path)) ;; AFTER opening ours, so the cap counts this instance's file too and ;; the newest-first ordering can never select it for deletion. (prune-instance-logs! log-dir))) ;; Configure logging from CLI args, opening any --log <path> target ;; in APPEND mode. This is the production path: the leader launchestest/test-claim.sglmodified
(claim-token-hash "9999999999:AAEEFFbbccddeeff00112233445566778899")))) ;; The zero-padding branch only runs for a hash with a leading zero nibble, ;; which is 1 token in 16 -- so it would otherwise sit unexecuted until it ;; failed in production. "tok-pad-2400" hashes to 0x6175bd22d28fede (15 hex ;; digits), which forces it. The expected value is derived independently of ;; this implementation: FNV-1a("tok-pad-2400") padded left to 16 hex digits, ;; first 12 taken. (test "zero-pads a short hash instead of truncating a different window" (assert-equal 12 (string-length (claim-token-hash "tok-pad-2400"))) (assert-equal "06175bd22d28" (claim-token-hash "tok-pad-2400"))) ;; The token must never be recoverable from anything written to disk. (test "never embeds the token itself" (let ((token "123456:SECRETSECRETSECRET"))test/test-telegram.sglmodified
(assert-equal "2.5 MB" (format-size (quotient (* 5 1024 1024) 2)))))(run-tests);; ============================================================;; Send-result strings: a caller must be able to tell delivery;; from non-delivery by reading the return value;; ============================================================;;;; The dry-run path used to return "Message sent." byte-for-byte, so every;; gagged send from 2026-07-06 to 2026-08-04 claimed a delivery it had not;; made -- and mimicked the silent-drop bug it was masking exactly, which;; made production observation of that bug impossible while the gag was on.;; These pin the property that failure cost us: the strings must be;; DISTINGUISHABLE. The end-to-end behaviour (what the MCP tool actually;; returns, and that a gagged send does not poison a later real one) is;; proved by repro/repro-single-poller.sh cases 5 and 6.(test-group "send-result strings" (test "the dry-run string does not contain a bare 'Message sent'" (assert-true (not (string-contains? *dry-run-not-sent-message* "Message sent")))) (test "the dry-run string does not begin with 'Message sent'" (assert-true (not (string-starts-with? *dry-run-not-sent-message* "Message sent")))) (test "the dry-run string says plainly that nothing was delivered" (assert-true (string-contains? *dry-run-not-sent-message* "NOT sent")) (assert-true (string-contains? *dry-run-not-sent-message* "COURIER_DISABLE_TELEGRAM_SEND"))) (test "the duplicate-suppressed string is not the plain success string" (assert-true (not (string=? *duplicate-suppressed-message* "Message sent."))) (assert-true (string-contains? *duplicate-suppressed-message* "duplicate suppressed"))) (test "the two non-delivery strings are distinct from each other" (assert-true (not (string=? *dry-run-not-sent-message* *duplicate-suppressed-message*)))))