Skip to content

Implement the FileToLink improvement plan (H1-H10, M1-M14, L1-L10) + review hardening - #50

Closed
fyaz05 wants to merge 94 commits into
mainfrom
feat/improvement-plan
Closed

fyaz05 wants to merge 94 commits into
mainfrom
feat/improvement-plan

Conversation

@fyaz05

@fyaz05 fyaz05 commented Sep 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

Implements the improvement plan (H1–H10, M1–M14, L1–L10) plus a full review cycle on top of it: an independent audit of the branch, then an independent review of the fixes themselves, which found and fixed two regressions the fixes had introduced.

159 → 296 unit tests. Coverage 44.4%, with the bot plugin surface now measured rather than hidden behind dead omit config.

Phase 1 — the plan itself

  • Correctness: tz-aware token datetimes, TTL index conflict handling + paged backfill, self-heal deletes only on true absence (transport errors → 503 + Retry-After), fail-closed gates, RPS breaker on the immediate path, boot exit codes, bounded FloodWait waits, real config.env.local precedence
  • Security: hash-pinned Docker install (uv export + --require-hashes) with CI requirements↔lock sync gates, single-pass access-log redaction + log-forging escape, scheme allowlist in self-update, activation-redirect hardening
  • Infra: Makefile, CI integration tier (testcontainers Mongo), workflow concurrency + timeouts, hermetic unit tier
  • Hygiene: dead code removed; lean comments

Phase 2 — audit of the branch, and the fixes it demanded

Merge blockers

  • pymongo 4.18.1 carried three CVEs (CVE-2026-96747/48/49) and CI's pip-audit would have gone red; bumped to 4.19.0 with targeted transitive bumps for the rest
  • pyrofork's speedup extra requires uvloop/tgcrypto with no platform marker, so uv sync and pip install -r requirements.txt failed on Windows. The extra is now requested only off win32 — the dev host can install and run the suite; POSIX deploys keep both speedups
  • The ban gate failed open: a banned user posting as the channel or an anonymous admin skipped the ban check entirely. Now fails closed, matching the documented no-sender contract
  • Buffered touches were lost at shutdown: the buffer was cleared before the write and except Exception cannot catch CancelledError. Since last_seen_at is the TTL field, that aged live files out of the vault

Correctness and the public link surface

  • Shutdown was bounded to a single shared budget (worst case was 220 s against Docker's 10 s grace) with a floor per step, plus stop_grace_period and a persisted log volume
  • users.id dedup no longer sorts the collection in memory and deletes with one ObjectId per user in a single BSON document; it also no longer lets one index failure abort the ten after it, and never collapses documents that have no id
  • Force-sub admits by status — get_chat_member only raises for a user absent from the channel, so banned and left members were being admitted
  • Links are escaped at all three message sites; the client parses Markdown and HTML by default, so this was a live injection surface
  • Multi-range Range headers are ignored per RFC 9110 instead of rejected with 400; a negative file_size can no longer produce Content-Length: -1
  • GLOBAL_RPS_LIMIT warning corrected — the cap was live all along; a queue-full rejection edits the "In Queue" notice instead of leaving it stale; the priority tier can no longer starve regular users

Phase 3 — an independent review of my own fixes

A subagent reviewed the 26 fix commits and found two regressions I introduced:

  1. I trimmed the RPC media-budget set after a line-scoped grep missed two multi-line calls — /log (5 MiB) and /shell had silently moved onto a 30 s budget with no retry. Fixed, and the shape set is now asserted against the call sites read out of the source.
  2. The queue-full wait estimate read from_user off a notice the bot itself sent, so it estimated against the bot's empty window. Fixed, with a fake that models a bot-authored notice so it can't hide again.

It also proved one of my tests was worthless: the starvation test drained a fixed set and passed even with priority-first. Rewritten to refill priority on every pop, which is the actual starvation condition.

Testing

Every fix is mutation-checked: revert the change, watch the test fail. Ten invariants that previously passed the whole suite while broken now bite. Coverage honesty was itself a fix — bot/plugins/* was omitted by config that never worked (no __init__.py, so coverage skipped the directory anyway), hiding ~900 statements; the marker is now present and the surface is measured.

test_shutdown_e2e drives the real shutdown_services and found a third bug of mine: the shield swallowed wait_for's timeout cancellation, so a hung Mongo was recorded as a clean teardown and the process exited 0 claiming success while the buffer never landed. The cancellation is re-raised now.

Verification

296 unit + 3 integration tests (real MongoDB via testcontainers), ruff, ruff-format, mypy, bandit, vulture, pip-audit, uv lock --check, dep-count gate — green locally and on every push. Docker image builds in CI; CodeQL is clean on this branch and closes the two high-severity shortener.py substring-sanitization alerts and the deleted-workflow alert that main still carries.

Known follow-ups (not in this PR)

  • files has no dedup recovery path, unlike users; a failed unique index there means permanently split counters
  • Index degradation is logged but not surfaced in /stats
  • The four bot handler modules remain largely untested (measured at 0% and now visible, which is why the honest total is 44% rather than the 48% an omitting config reported)
  • A true container SIGTERM test needs real Telegram credentials; the PID-1 chain (exec in thunder.sh, pyrofork's handler) is pinned statically instead

…H1, H3, L8)

H1 — security dependency pass:
- pyproject.toml declares 8 exact-pinned runtime deps (was 10 unpinned)
- aiohttp 3.14.3 (OSV-clean; the 3 Jan-2026 CVEs), uvloop 0.22.1 (py3.13),
  tgcrypto -> tgcrypto-pyrofork 1.2.8 (maintained crypto backend)
- requirements.txt kept as a generated export for the Docker path
- pip-audit verified clean locally; enforced in CI

H3 — quality.yml gates (replaces docker-only CI; docker build now runs
after quality passes):
- ruff check + format, mypy (permissive baseline), pytest unit tier,
  pip-audit, bandit -ll, vulture dead-code gate (whitelist.py),
  dependency-count gate (>8 direct deps fails the build)
- .pre-commit-config.yaml mirrors the local workflow

L8 — repo hygiene pack:
- Dockerfile: non-root USER, HEALTHCHECK -> /health, git kept for H9 updates
- Makefile (format/lint/test/audit/run), .dockerignore (image no longer
  ships .git/README/tests), dependabot.yml (pip + github-actions)
- SECURITY.md, CONTRIBUTING.md
- AGENTS.md rewritten: new conventions (tg_call, preflight chain, HTML
  escaping, budgets), command table generated from bot/registry.py
- config_sample.env: every new env var annotated (PRIVATE_MODE,
  ENABLE_SHELL, ENABLE_LEGACY_LINKS, FILE_TTL_DAYS, worker pools,
  MAX_CONCURRENT_STREAMS, touch buffer, LOG_LEVEL/LOG_FORMAT, ...)
The boot path interpolated UPSTREAM_REPO/UPSTREAM_BRANCH into a
shell=True command chain and ran 'rm -rf .git', 'git reset --hard' and
mutated global git config — a live injection vector at every container
boot, and a failed update could leave a half-wiped tree.

- argv-list subprocess (shell=False); remote URL stays an argv element
- 'git pull --ff-only' replaces the init/commit/reset dance
- strictly best-effort: failures log and keep the current code running
- no-ops cleanly when git is absent or the tree is not a repo
- thunder.sh reduced to boot orchestration (update failure no longer
  blocks boot); /restart marker flow unchanged
…4, L1-L7, L9-L10)

P2 — core hygiene (H4, H5, H10):
- utils/safe_call.py: tg_call helper + reply/send/edit/delete/answer_safe
  wrappers; all ~90 inline try/except-FloodWait pairs migrated (budget:
  custom_dl streaming loop + canonical ingest loop only, as planned)
- dead code purged: dl.html + its render branch, tokens.list_tokens/
  get_user, bot_utils.notify_ch, rate_limiter.get_queue_status,
  file_properties.get_fids, canonical_files.touch_file_record +
  get_file_by_message_id (+ db twin), on_fallback_message stream path,
  Var.MULTI_CLIENT, MSG_STATS_ERROR, dead client_max_size
- three drifted media maps unified into utils/media_types.py (H4c)
- speedtest-cli removed (archived upstream, H5a); shortener rebuilt on
  aiohttp, cloudscraper dropped (H5b; curl_cffi optional extra)
- H10: LOG_LEVEL/LOG_FORMAT, shared redact_secrets(), access-log
  middleware with sha256-hashed file tokens, /log uploads redacted +
  capped at 45 MiB

P3 — runtime correctness (H6, H7, H8):
- rate limiter: worker pool (EXECUTOR_WORKERS), charge-at-exec (the old
  code double-charged: enqueue + executor), requeue-on-FloodWait with
  attempt counter (long FloodWaits no longer stall every user), global
  RPS token-bucket breaker (burst 2x), bounded structures + 5-min sweep,
  limiter occupancy in /stats
- flag_cache.py: TTL+LRU cache in front of ban/authorization checks,
  FAIL-CLOSED on DB errors (a Mongo outage no longer un-bans everyone),
  invalidated by admin mutators; OWNER_ID missing now refuses boot
- H8: Mongo timeoutMS=5000, tg_call RPC budgets (transfers unbounded),
  shortener/keepalive 10 s, last sync psutil call moved to a thread

P4 — robustness & UX parity (M1-M14):
- M6: vars.py validates ALL config problems together with named vars,
  config.env.local layering, bounds enforcement
- M1: bot/registry.py single command registry -> menu (owner-only
  hidden, 256-char truncation), /help section, AGENTS.md drift test
- M3: /health endpoint (zero deps); keepalive repointed off the
  GitHub-redirecting root onto /health with status checking
- M2: typed player page (video/audio/image/non-media cards) from mime
  type, CWD-independent Jinja loader, noindex/nofollow, noscript hint
- M7: user-facing surfaces are HTML with html.escape()d file names,
  titles and ban reasons; markdown escaper retired
- M8: atomic token activation (find_one_and_update conditioned on
  activated != True — exactly one concurrent /start wins) +
  /activate/{token} web route for shorteners
- M4a: broadcast on a 4-worker queue pool with Mongo cursor streaming,
  200 ms pacing, progress every 25 sends, cancel preserved
- M4b: batch on a 5-worker pool, order-preserving results, skipped vs
  failed counters, 30+2n deadline, MAX_BATCH_FILES enforced
- M5: shortener cache + singleflight, Bearer header auth, host
  validation, https-only, no redirects
- M9: MAX_CONCURRENT_STREAMS env admission cap (503 + Retry-After kept)
- M10: self-healing vault records — unresolved vault message => record
  deleted + 404 => next upload re-ingests; Content-Length verified
  against the actual vault media (stuck-at-99% fix)
- M11: callback panic-isolation guard with error-ID owner notification;
  close_panel permission check; catch-all answered (was already present)
- M12: unified preflight chain (banned -> private-mode -> token ->
  force-sub -> shortener) + PRIVATE_MODE allowlist; /start keeps the
  activation flow reachable; utils->bot inversions broken via client
  params (lazy fallback keeps compatibility)
- M13: bounded shutdown drain (30 s, work_loads-based) + aggregated
  teardown errors, non-zero exit on failure; restart-marker preserved
- M14: touch buffer capped with drop-on-overflow counter, env-tunable
  1-60 s flush, single BulkWrite per cycle

P5 — polish (L1-L7, L10):
- L1: ENABLE_LEGACY_LINKS flag (default on; off => 410) + legacy page
  cache (repeat /watch views no longer hit Telegram)
- L2: optional FILE_TTL_DAYS TTL index with backfill; expiry note in
  link messages only when enabled
- L3: /status gains DC id, inflight, touch-buffer stats, no-store
- L4: 32-hex public hash for new uploads; 20-hex links stay valid
- L5: session files chmod 0600 after startup
- L6: RFC 5987 filename* with ASCII fallback; 416 + Content-Range kept
- L7: preconnect hints, inline fallback stylesheet + noscript so the
  player page stays usable with CDNs blocked
- L10: /shell behind ENABLE_SHELL kill-switch (default off) + command log
…n tier

- conftest.py: hermetic env bootstrap (Thunder.vars validates at import)
- unit tier (pytest -m unit, asyncio_mode=auto, no network/Mongo):
  human_readable, time_format, media_types unification, safe_call
  FloodWait/timeout semantics, flag_cache TTL/LRU/fail paths, rate
  limiter window math + breaker + sweep, canonical hash dual-length +
  merge precedence, stream_routes parsing/range/disposition, shortener
  registry + host validation, secret redaction, config validation
  (subprocess: all problems reported together, OWNER_ID boot-fail),
  registry menu/help/AGENTS.md drift checks
- coverage gate --cov-fail-under=35 as the starting bar (raise to 80 on
  core modules next)
- tests/integration: testcontainers MongoDB (ingest-claim + atomic token
  activation), TEST_INTEGRATION=1 gated, skipped cleanly without Docker
  (mirrors ThunderGo's build-tag-gated tier, but opt-in runnable in CI)
…7 -> 7 blocks

Remaining 7 inline FloodWait references are the deliberate ones:
- safe_call.py (the helper itself, 2)
- custom_dl.py streaming pool loops (2, explicitly allowed by plan)
- rate_limiter.py requeue-on-FloodWait executor logic (1)
- canonical_files.py re-raise into the ingest retry loop (1)
- broadcast.py classification of exhausted FloodWait as failed send (1)
Comment thread .github/workflows/quality.yml Fixed
Comment thread .github/workflows/quality.yml Fixed
Comment thread Thunder/utils/shortener.py Fixed
Comment thread Thunder/server/stream_routes.py Fixed
Real bugs found by the recheck pass:
- shortener: allow_redirects=False on ClientSession() raises TypeError at
  runtime (aiohttp rejects it there) and silently disabled the M5
  shortener; redirects now enforced per-request on all 4 plugin calls
- database: missing await on AsyncCollection.aggregate() broke the
  duplicate-user dedup path (async-for over a coroutine raises TypeError)

Audit gaps closed (plan fidelity):
- H1: commit uv.lock (hash-pinned, 70 pkgs, uv sync --frozen verified on
  py3.13.15) + pip-audit in dev group + uv lock --check CI gate
- H4b/L8 hygiene: remove committed runtime log Thunder/logs/bot.txt,
  gitignore Thunder/logs/ (+ fix log.text -> log.txt)
- L8: __version__ now injectable via APP_VERSION env (default 2.2.0,
  aligned with pyproject; /status reads it)
- quality.yml: docker publish restricted to refs/heads/main so feature
  pushes can never overwrite fyaz05/thunder:latest
- mypy made BLOCKING: all 44 errors fixed (2 real bugs above + typed
  dict/float annotations + defensive list-narrowing + targeted
  stub-union ignores with comments); Makefile lint no longer || true
- 45 tracked files restored to 644 (stray 755 mode bits)

Gates on final tree: ruff check+format PASS, mypy 0 errors (35 files),
103 unit tests PASS (py3.13.15, locked deps), vulture PASS, bandit
medium+ = 0, pip-audit clean, dep count = 8, uv lock --check PASS
CRITICAL
- rate_limiter: deferred requeue busy-spun with ZERO yield points
  (Event.wait fast-path + uncontended Lock on py3.13) -> whole event
  loop froze up to 300s; now parks the pool (event clear + call_later
  timer), re-wakes on enqueue, timer cancelled at shutdown
- quality.yml: invalid YAML (unquoted ':' in step name) killed every
  gate; Makefile recipes had spaces not tabs (all targets dead)

HIGH
- callbacks: close_panel compared clicker to the BOT's id -> every
  non-owner Close button rejected; now allows panel reply target /
  private-chat peer (+ owner)
- ban gate was fail-OPEN: is_user_banned swallowed Mongo outages into
  None (negative-cached 5 min) -> raise_on_error=True for the flag
  loader; ban/unban now invalidate the flag cache
- HTML injection (M7 gaps): file_name in link messages, first_name in
  new-user log, chat_title/source_info in 5 stream.py sites escaped
- custom_dl: mid-stream FloodWait restarted from the original offset,
  re-sending delivered bytes (corrupted downloads); now resumes at
  chunk_offset + chunks_done; get_message via bounded tg_call
- stream_routes: Range end >= file_size now clamps per RFC 7233
  (was a hard 416, breaking resume clients)
- integration tier was dead twice over: wrong testcontainers import +
  missing dep + unit cov threshold; testcontainers[mongodb] declared,
  cov flags moved out of addopts
- access-log redaction hashed m.group(0)[:-len(m.group(0))] == ""
  (constant pseudonym for every file)
- CI now installs the hash-pinned uv.lock (uv sync --frozen) and
  audits the locked env incl. transitives; permissions hardened

MEDIUM
- touches added during an in-flight flush are re-armed; failed
  bulk-flush merges back instead of dropping increments
- canonical validation RPC errors keep the cached record instead of
  re-copying into BIN (vault churn)
- broadcast: strong task refs, return_exceptions, finally-cleanup of
  workers/status/registry; completion only on success
- shortener: init lock (session leak), singleflight resolves waiters
  on cancellation, Bitly host validation, session closed at shutdown
- requeued requests no longer re-charge the user window; breaker
  token consumed only at exec; cancelled workers release counts
- flusher lifecycle, force_channel negative-cache, token consume
  expiry guard, tokens -> aware UTC, update.py missing return +
  credential redaction + orphaned config backup recovery, startup
  teardown order (touch drain before db.close), umask 077, PRIVATE_MODE
  x TOKEN_ENABLED fail-fast, preflight unknown-gate warning, /log
  off-loop, /listauth batched, curl_cffi floor 0.15.0 (CVE-2026-33752)

LEANNESS
- dead _cache_by_message_id map + get_file_by_message_id removed;
  Var.LOG_LEVEL/LOG_FORMAT dead attrs removed; dead skip param and
  RPCError branch removed; help panel reuses build_help_text; README
  /speedtest residue removed; dep-count gate added to make audit

Tests: 107 passed (+4 regression: Range clamp, singleflight, park x2);
ruff/mypy/vulture/bandit/pip-audit/uv-lock gates all green.
- shortener: replace substring host matching with parsed-hostname
  exact/suffix match (_host_matches) across all 4 HTTP plugins;
  lookalikes like 'bitly.com.evil.com' or 'evil.com/bitly.com' no
  longer select a provider plugin (CodeQL high:
  py/incomplete-url-substring-sanitization)
- /activate: strict shape check (_is_activation_token) on the token
  before building the t.me redirect -- tokens are token_urlsafe(32),
  43 URL-safe chars; malformed input now 400s without a DB roundtrip
  (CodeQL medium: py/url-redirection)
- tests: +21 regression tests (host matching incl. adversarial
  lookalikes, activation token shape incl. injection payloads)
CodeQL py/url-redirection still tracked the shape-validated token into
the Location header; build the deep link via quote_plus(safe='') so the
token can only occupy the query-value slot. No-op for valid tokens
(already URL-safe); +2 tests.
Comment thread Thunder/server/stream_routes.py Fixed
CodeQL py/url-redirection models only '+ right-operand' as a
sanitizer (f-strings and quote_plus are not barriers -- verified
against UrlRedirectCustomizations.qll on github/codeql@main).  Build
the deep link so the constant 'https://t.me/' prefix is provably
outside user control; output is byte-identical for valid tokens.
Behavioral (verified by repro/code-reading):
- caption commands: /link & /dc parsed msg.text (None for captions
  under pyrofork's caption-matching filter) -> AttributeError dead air;
  now text-or-caption
- channel-ban gate: per-post DB find_one, no cache, no invalidation;
  now flag-cached with add/remove invalidation (fail-open kept
  deliberately: leave_chat on a Mongo outage would be destructive)
- legacy route error masking: get_file_info swallowed every exception
  into an unread 'error' key -> transient outages surfaced as 404;
  now propagates (route ladder maps 404/500 correctly)
- markup injection (pyrofork DEFAULT = markdown-then-HTML, so
  html.escape alone is insufficient): /dc templates converted to
  HTML + escaped + ParseMode.HTML pinned; force-sub prompt escaped;
  ban reason escaped; file-request sends pin ParseMode.HTML
- /restart: os.execv skipped the M13 touch-buffer drain, losing view
  counts; now drains before execv
- access-log middleware logged nothing for non-HTTP 500s (response
  unbound NameError swallowed); response now initialized to None
- integration fixture: env+reload never rebound the frozen Var/db
  copies -> tier could not pass; now binds Database directly and
  rebinds module handles, restoring state after
- Docker HEALTHCHECK: naive PORT parse broke on inline comments in
  the shipped sample config; now comment-stripping
- GLOBAL_RPS_LIMIT dead without GLOBAL_RATE_LIMIT: boot warning (M6)
- token_col: compound (user_id, activated, expires_at) index added
- MSG_TOKEN_FAILED: error_id kwarg was silently dropped (no slot)
- update.py: rejects leading-dash UPSTREAM_REPO/BRANCH (argv hygiene)
- _validate_short_url: trailing-dot FQDN now tolerated (parity with
  _host_matches); import_plugins glob sorted; get_user deduped;
  force-channel button reuses cached get_force_info; render_template
  reuses quote_media_name; breaker comment states real invariant

Leanness (dead code, vulture-verified):
- deleted whitelist.py (entirely stale; vulture passes without),
  ShortenerError, module-level request_executor, duplicate
  status_options handler, _REDACT_SEGMENTS, production-dead mime_for

Infra:
- Dockerfile: drop build-essential/libssl-dev (all wheels, ~150MB)
- image tags: + SHA tag for rollback; .dockerignore: cache dirs
- pre-commit ruff rev v0.8.4 -> v0.16.6 (matches lock); Makefile
  audit now audits the locked env (CI parity); dependabot uv.lock
  note; README: dead speedtest section removed, docker run now
  mounts config.env (documented flow was broken)

Gates: ruff/format clean, mypy 0 (35 files), 130 tests PASS,
vulture (whitelist-free) clean, bandit clean, uv lock --check PASS
…rmed dev extra

- C-1: AsyncMongoClient now uses tz_aware=True; pymongo's default returns
  naive UTC datetimes, and comparing them against datetime.now(UTC) in
  tokens.consume() raised TypeError on EVERY real activation -- the
  flagship M8 /start <token> path was dead at runtime
- lost CAS races now distinguish 'already' (winner activated) from
  'invalid' (expired / missing expires_at) instead of always saying 'already'
- pyproject: 'testcontainersongodb]>=4.13' -> 'testcontainers[mongodb]>=4.13'
  (invalid PEP 508; fresh 'uv lock' hard-failed on it)
- generate() no longer returns '' on an unreachable path (honest raise)
…d logs

Self-heal safety:
- transient Telegram failures (FloodWait exhaustion, timeout, transport)
  now raise TelegramUnavailable -> 503 + Retry-After; they no longer
  masquerade as FileNotFound, which made a brownout DELETE every vault
  record requested during the outage
- self-heal replacement preserves the existing public_hash (rewriting a
  legacy 20-char hash broke the 'legacy links stay valid forever' contract)
- stream_file fetches its target once (no re-fetch per FloodWait retry)
  and bounds total FloodWait sleep (60s) instead of pinning the handler
- safe_call caps a single FloodWait sleep at 30s (H8 budget honesty)

Gates / access control (fail-closed):
- preflight: unknown gate ids REJECT (were warn-and-continue = skipped
  security check); gate presets GATES_STANDARD/START/INFO added and
  common.py now routes through them (was hand-rolled per handler)
- PRIVATE_MODE denies unattributable senders (from_user None); channel
  handler refuses to mint public links in PRIVATE_MODE
- token deep-link uses quote_plus like the hardened /activate builder

Rate limiter:
- the immediate path now consumes a breaker token; a dry bucket falls
  through to the queue (bursts are shaped, not shed) -- the old top-shed
  also claimed queue-full, misleading users
- breaker gate unified on breaker.rate > 0 (GLOBAL_RPS_LIMIT-only setups)
- removed write-only user_queue_counts (+ its cancel-path leak edge)
- requeue-vs-rotation insertion policy documented

Boot / shutdown:
- boot failures exit 1 (restart policies can actually fire)
- background tasks tracked in one list (no locals().get bookkeeping);
  index-ensure task cancelled+awaited at shutdown; batched 10s wait
- deprecated get_event_loop().time() -> get_running_loop()

Config:
- config.env.local really overrides config.env now (load_dotenv
  override=False had inverted the documented precedence)
- str_to_int_set surfaces junk tokens (collect-all-errors)

Logging / misc:
- access log redacts legacy id-first capability hashes and /activate
  tokens; control chars escaped (log forging via %0A neutralized)
- redact_secrets covers API_HASH, session strings, ?start= activation tokens
- MSG_SHELL_OUTPUT_CAPTION fixes a format() KeyError that swallowed large
  /shell outputs; render_page no longer logs the capability hash
- shortener URLs html-escaped into the HTML links message
- update.py allows only https/http/git/ssh schemes (blocks ext::)
- leanness: dead touch_file_record, requested_action, task_done, peek/
  occupancy, unreachable branches, dead getattr defaults, duplicate disk
  syscalls, legacy-cache unique_id all removed
…+ integration tier

- Makefile recipes indented with hard TABs (spaces made every target fail
  with 'missing separator')
- Dockerfile installs requirements.lock (uv export of uv.lock,
  --require-hashes): transitives were previously resolved fresh and
  unpinned on every build, so CI's supply-chain guarantees never reached
  the shipped image; git dropped from the image (self-update no-ops
  without .git by design -- replace the image instead)
- CI: push trigger 'branches: ain]' corruption fixed to [main]
- CI: requirements.txt<->pyproject and requirements.lock<->uv.lock sync
  gates (two sources of truth must agree)
- CI: integration tier (real MongoDB via testcontainers) now actually
  runs -- the atomicity guarantees were never executed in CI
- CI: concurrency group + job timeout-minutes
- README: SLEEP_THRESHOLD 600 / GLOBAL_RATE_LIMIT False (vars.py ground
  truth); auto-update note (needs a git checkout)
- AGENTS.md documents the requirements.lock flow
…gressions

- test_preflight: preset ids resolve in the registry, documented order,
  unknown gate id rejects fail-closed, None-vs-False return contract
- test_config_env_layers: config.env.local overrides config.env; real
  environment still wins (subprocess, hermetic)
- test_canonical_files: replacement merge preserves legacy public_hash
- test_custom_dl_exceptions: transport errors -> TelegramUnavailable,
  genuine absence -> FileNotFound (self-heal safety contract)
- updated flag-cache and int-set tests to the leaner/fail-closed behavior
@fyaz05

fyaz05 commented Sep 6, 2026 •

Copy link
Copy Markdown
Owner Author

All audit findings fixed and green: 1 critical (tz-naive token activation crash), 12 majors (self-heal record deletion, fail-open gates, config.env.local precedence, Makefile/Docker/CI, TTL backfill, breaker bypass, log redaction, boot exit codes), plus minors and the leanness sweep — 88815ff..5a2cc09.

State: 159 unit + 3 integration tests, ruff, mypy, bandit, pip-audit all green in CI on head 5a2cc09.

Comment thread Thunder/server/stream_routes.py Fixed
Comment thread Thunder/server/stream_routes.py Fixed
…ntegration tests

The previous fix passed timeoutMS=120_000 to update_many, which pymongo
4.18 does not accept (no per-op CSOT override on CRUD) -- every boot with
FILE_TTL_DAYS>0 raised TypeError, aborted ensure_indexes and silently
skipped all unique indexes: the exact failure mode the fix was meant to
remove, masked in CI because tests only exercised the TTL=0 path.

Replace the single COLLSCAN update_many with an _id-paged micro-batch
migration (every statement an index seek inside the client-wide 5s
budget), bounded per boot and resumable via a migration marker; scope
the TTL conflict handler to IndexOptionsConflict (code 85) and treat
ExecutionTimeout as 'build continues server-side' instead of dropping a
live build. Add integration coverage for the full TTL lifecycle (first
boot backfill, TTL-change recreate with uniques intact, steady state,
and real row expiry).
…ogs, add route-ladder regression tests

- AppRunner(access_log=None): aiohttp's own access logger still emitted
  raw %r request lines on a latent sink (inert today only because the
  logger has no handlers; any future basicConfig() would have leaked
  unredacted paths).
- canonical stream warnings no longer log the capability hash alongside
  record/vault sizes; /log would have handed out a link fingerprint.
- unit tests pin the delivery ladder: TelegramUnavailable -> 503 with
  Retry-After and NO record deletion; true absence / media-less vault
  message -> self-heal + 404; admission slot released on every path.
Leftovers the fix session missed, all verified unreachable or write-only:
- config_parser try/except that cannot raise (digit filter + or 0)
- registry Command.hidden speculative knob ('none today')
- stream.py batch worker: orphan task_done() (no queue.join exists) and
  the dead getattr(Var, 'BATCH_WORKERS', 5) fallback
- decorators.py: unreachable empty-token guard (generate() raises, never
  returns empty)
- shortener.py: four dead getattr(Var) fallbacks (vars.py validates all)
- messages.py: unused MSG_SHELL_OUTPUT constant
- tests: immediate-path breaker consumption + dry-bucket fall-through,
  consume() status ladder incl. corrupt token without expires_at
…tency, doc truth

- vars.py honours THUNDER_SKIP_CONFIG_FILES=1; conftest sets it so a
  developer's own config.env cannot leak optional knobs into the unit
  tier; the subprocess precedence tests opt back out explicitly
- CI ruff scope now covers tests/ (was Thunder/ update.py only)
- Makefile: all targets through 'uv run' (was mixed ambient/project
  envs), find-based clean (globstar is not POSIX sh)
- requirements.txt header: the Docker image consumes requirements.lock;
  this file is the direct-dependency surface kept in sync by CI
- README: document config.env.local precedence (FAQ claimed
  'config.env Only')
The four sequential re.sub rules let the id-first rule re-match the
8-hex pseudonyms produced by the canonical rule (~39% of them end in
two digits), double-hashing the same file and stamping a misleading
"…" truncation marker on canonical /f/ links. One combined alternation
pass pins exactly-once semantics; 10 unit tests cover every family
(canon 20/32, watch, id-first, activate), the digit-tailed-pseudonym
regression, and the control-char escape.
Untyped function bodies were previously unchecked; enabling the flag
surfaces 5 real issues, all fixed at the annotation level:
- process_single(status_msg) accepts None from the batch worker, where
  no per-file status message exists (existing call sites already
  None-guard) -> Message | None
- get_readable_time() already int()-coerces internally and a test feeds
  it 90.9 -> accept int | float (the /status uptime caller passes float)
- pyrogram exposes Client.username dynamically; read via getattr with
  fallback in /status, pin via explicit ignore at boot
Fail-closed gates reject messages with no attributable from_user
(channel posts, anonymous admins); say so in the README token section
and the AGENTS gate-chain notes.
Every comment in the branch diff trimmed to 0-2 lines carrying only the
why/invariant/pitfall (plan tags H/M/L kept). No code changes: verified
by AST comparison (docstrings stripped) on all 47 touched .py files;
Makefile tabs, TOML/YAML parse, and the full local gate suite unaffected.
@fyaz05 fyaz05 changed the title Implement the FileToLink improvement plan (H1-H10, M1-M14, L1-L10) Implement the FileToLink improvement plan (H1-H10, M1-M14, L1-L10) + review hardening Sep 6, 2026
fyaz05 added 28 commits October 16, 2026 15:00
…tive sizes

Three findings from the audit, all on the public link surface.

A multi-range request ("bytes=0-1,3-4") is valid syntax that this server does
not implement, and it was answered with 400 -- telling the client its whole
request was malformed, which some players and proxies act on by abandoning the
transfer. RFC 9110 lets a server ignore a ranges-specifier it cannot satisfy,
and the route already collapses a full span into a plain 200, so multi-range
now takes that path. Genuinely unparseable input is still a 400: that is a real
client error, not an unimplemented feature.

A record whose file_size is negative passed the `== 0` guard and flowed into
Content-Length: -1. The guard is now `<= 0`. Tested against the serving helper
directly: at route level the vault size check runs first and would mask which
guard fired, so the route-level version of this test passed either way.

The two preview routes rebuilt the same response and headers; they now share
_preview_response, so a header added later cannot land on only one. Also
dropped the redundant bare "*" from Access-Control-Allow-Headers, and gave the
discarded _resolve_unique_id call a comment stating that the raise is the
point of it.

Left alone deliberately: slot release on an aborted stream is GC-timed, not
deterministic. It measures clean on CPython and the fix means subclassing
aiohttp's Payload for a path aiohttp already documents as self-closing --
not worth the complexity for a window that resolves in microseconds.
Two items left open by choice, now closed.

Priority fairness: the drain took the priority queue whenever it was non-empty,
so a continuous owner/authorized stream starved regular users indefinitely.
The position estimate was honest about the wait, but nothing bounded it. The
drain now alternates while both tiers are populated, with ties still going to
the owner; the cost is bounded at worst one deferred regular item rotated and
re-parked, which the park path already handles. test_rate_limiter_fairness pins
it -- restoring priority-first fails 5 of its 6 cases, including the starvation
one.

Media budgets: the RPC wall-clock budget is chosen from the callable's name,
which only works if the set is accurate. It was not. Fourteen of its fifteen
names were dead: Message.copy is the only media call routed through tg_call
(the ingest forward and the broadcast copy), while stream_media,
download_media and the send_/reply_ family are called directly. The dead names
implied coverage that did not exist. The set is now exactly what the code uses,
with the reasoning inline, and name resolution unwraps functools.partial so a
wrapped media call cannot silently inherit the 30s budget and fail an upload
mid-transfer. test_safe_call_budgets pins the set, both budget directions, the
partial case and the timeout=0 escape hatch; AGENTS.md says what a new media
call site must do.
…ixes

UNBOUNDED_SHAPES. I trimmed the shape set to {copy} on the grounds that
Message.copy was the only media call routed through tg_call. That was wrong:
my grep was line-scoped and missed two multi-line calls. admin.py ships a 5 MiB
/log tail and arbitrary /shell output through Message.reply_document, which I
had just moved onto the 30 s lightweight budget -- a production upload failure
on a slow uplink, with no retry. reply_document is back, and
test_safe_call_budgets now reads the call sites out of the source and asserts
every media shape found is in the set, so a third one cannot slip through the
same way. AGENTS.md corrected.

Queue-full wait estimate. replace_queue_notification read from_user off the
notice to estimate the wait -- but that notice was sent BY the bot, so it
estimated against the bot's own empty window and always told the user "less
than a minute". user_id is now passed in explicitly, and the fake models a
bot-authored notice so the regression cannot hide again.

Ingest claim. Raising the TTL to 900 s created a gap my change introduced: a
crashed worker's claim blocks the file for the TTL, but the waiter only exits
early once the claim is gone, and the retry budget was 10 x 15 s = 150 s. A
client gave up with "max ingest retries exhausted" while a stale claim still
held the file. The wait is now 90 s so the budget covers the TTL, pinned by an
invariant test in test_canonical_claim.

Also from the review: the touch-flush finally block re-flushed on a path
_bulk_flush can no longer take (it swallows every Exception itself), and the
drain's cancellation log claimed deltas "stay buffered" while the shielded
write was still running. Both corrected. The shutdown-bound test now counts the
real teardown call sites instead of hardcoding 5.

New coverage for behaviour the review found untested: link escaping at all
three sites (the batch chunk renderer was extracted into _batch_chunk_text so
it is testable at all), /listauth UTF-16 paging, the now-unconditional
RedactingFormatter, and the client stop-on-failed-start path. All
mutation-checked. 266 -> 289 tests, coverage 42.8% -> 43.9%.
…ound

/listauth: authorized_by and authorized_at are rendered into an HTML message
and were interpolated raw. They are DB-sourced today, so this is
defence-in-depth -- but it is the escaping contract (M7) and I had listed it
and not done it.

decorators.py: eleven identical `try: await reply_safe(...) except Exception:
pass` blocks, plus a twelfth that logged twice. All gate denials now go through
one _reply_ignore_failure helper, so the swallow-everything policy lives in one
place instead of a dozen copies (-25 net lines). A test asserts no gate calls
reply_safe directly again.

test_shutdown_e2e drives shutdown_services itself -- the real function, the real
rate limiter, the real touch buffer, with only Mongo and Telegram faked -- and
found a bug in my own shield. asyncio.shield means the caller's cancellation
(arriving via wait_for's timeout) was being swallowed by the drain's
`except asyncio.CancelledError`, so wait_for returned normally and a Mongo that
never answered was recorded as a *clean* teardown step. The shutdown would exit
0 and claim success while the touch buffer never landed. The cancellation is
now re-raised, so a hung Mongo is reported as a failure while the shielded
write still completes.

The same file pins the PID-1 chain statically -- thunder.sh must exec, the
Dockerfile must use it, and pyrofork's idle() must install the SIGTERM handler
-- since a real container SIGTERM test needs Telegram credentials.

289 -> 296 tests, coverage 44.4%. Every new test mutation-checked: the
shield-swallow and gate-refactor tests both fail when reverted.
test_shutdown_e2e.py's signal-chain test was missing @pytest.mark.unit, and
addopts = "-m unit" deselects anything unmarked -- so it was collected but
never run, in CI or locally. It is the only guard on the PID-1 chain the
whole shutdown budget rests on. Found by the CI-area review.

Two durable changes:

- The new CI step compares `pytest -m unit --collect-only` against
  `-m "not integration"`. Any unmarked test makes them differ, and the job
  fails with the reason. Verified by removing the marker again: 296 vs 297
  collected, guard fails; restored: 297 vs 297, guard passes.
- The pyrofork source-text assertion ("SIGTERM" in getsource(idle)) is
  replaced by a real one: tests/integration/test_signal_delivery.py starts a
  live interpreter inside idle(), sends SIGTERM, and asserts the finally
  block ran and the child exited 0. No Telegram credentials needed, POSIX
  only. The unit test now asserts only our half of the chain (thunder.sh
  execs python; the Dockerfile uses it) -- verified by removing the exec:
  the test fails.

296 -> 297 unit tests.
Blocking data loss on the public /link path, found by the bot-area review.

Chunks were cut by count (LINK_CHUNK_SIZE = 20), but each link embeds the
percent-encoded file name: one CJK glyph costs 9 characters, one emoji the
same. 20 such links rendered to ~12000 UTF-16 units against Telegram's 4096
cap. reply_safe raised, the handler logged the error and moved on, and the
user silently lost every link in the chunk -- in a private chat with no notice
at all. In a group the DM mirror failed too, so the only thing the user saw
was a partial-DM warning that blamed the wrong cause.

- _batch_chunks() measures the escaped link it is about to send. LINK_CHUNK_SIZE
  is gone; a budget replaces it, reserving room for the header, the DM prefix
  (which carries the chat title) and the <code> tags.
- A chunk that fails to send is now counted and reported to the user, rather
  than logged and forgotten. Silent loss is the part that mattered.
- Prevents the regression explicitly: tests/test_unit/test_batch_chunking.py
  asserts the old count-based chunk of 20 is still over the cap, so the guard
  keeps describing the real failure.

Verified by six mutations, each caught: budget check disabled (7 failures),
half the chunks dropped (3), raw link measured instead of escaped (1).

Two real defects fell out of writing the tests:

- /listauth's page loop referenced `pages` after my refactor and raised
  NameError -- caught the moment the loop was exercised for real.
- That loop was `if len(pages) > 1: sleep(delay)`, which paced *after every
  page including the last* and never between. Now idx+1 < len(pages).

paginate_lines() moves the page-splitting into messages.py beside
telegram_len(), so it is tested directly rather than through a handler. admin
and stream both derive their budgets from TELEGRAM_MAX_UNITS instead of a
bare 3500, which cannot drift from the real cap.

test_listauth_paging.py was vacuous -- it asserted _telegram_len arithmetic and
that a constant was positive, and the paging loop could be deleted wholesale
without failing. It now drives the real handler: every one of 50 records must
appear on some page, and page_count-1 sleeps must occur.

297 -> 321 unit tests, coverage 44.35% -> 45.37%.
The inline fallback stylesheet ships AFTER the CDN theme <link> in source
order, so at equal specificity it won -- on every page load, not just on a
broken one. Found by the HTTP-area review; confirmed by fetching the pinned
bundle at f1d2cff and reading what it actually declares:

  .drawer      { width; max-width; margin-top; opacity; animation }  no display
  .atmosphere  { position; inset; z-index; overflow }               no display
  .file-meta   { display:flex }                        specificity (0,1,0)
  body         { font; background; color; min-height }  no display

So the template's `.cinema, .drawer, .atmosphere, .masthead .file-meta
{ display:none }` was never overridden:

- the Open-In drawer was display:none on every page, and .drawer-panel is
  collapsed to max-height:0 until the JS adds .open -- so all 19 player links
  were unreachable, and the trigger button in the command bar did nothing
  visible when clicked;
- .atmosphere, the ambient background, was display:none;
- .file-meta lost to `.masthead .file-meta` on specificity (0,2,0 vs 0,1,0),
  so size/duration/resolution never appeared even though the JS fills them;
- body's own display:flex fallback layout applied to the whole page.

test_player_page.py asserted every one of those ids and data-player keys
existed. Existence is not visibility, which is why it never fired.

The fallback is now what it claimed to be -- a fallback:

- every rule is scoped to .fallback-ready, so a reachable CDN governs the
  layout completely;
- the class is added by markFallback(), wired to onerror= on the vidstack
  theme/layout/player assets, the pinned player.min.css and player.min.js.
  Previously an always-run script added it unconditionally, which defeats the
  whole purpose;
- markFallback() targets documentElement, since a head <link> onerror fires
  before <body> exists, and is declared ahead of every asset that can fail.

Verified by five mutations, each caught: unscoped rules restored (5 failures),
unconditional class add (1), onerror wiring removed (1), markFallback moved
after its callers (1), stylesheet deleted (1).

One of those caught a false positive in my own test -- the HTML comment
explaining the ordering literally contained the onerror string the test greps
for. Reworded rather than weakened.

321 -> 329 unit tests.
An unauthenticated RPC amplifier, found by the HTTP-area review.

render_page() fetches the vault message BEFORE it can verify the capability
hash: the hash is a prefix of the file unique id stored in the very message
being fetched, so verification is necessarily downstream of the fetch. parse_
media_request does shape-validate the hash first, so the attacker needs one
well-formed 6-hex value rather than literal garbage -- but that is enough.
A mismatched hash can never be cached, so every request is a fresh Telegram
RPC, each costing up to 60s x2 with retries=1, against the bot account.

The branch added admission control to /f/ (work_loads + select_optimal_client)
and nothing to /watch/.

- _preview_rpc_slot() caps concurrent vault lookups at 4 and sheds the rest
  with 503 + Retry-After, matching select_optimal_client's behaviour.
- A plain counter, not asyncio.Semaphore: check and increment have no await
  between them so asyncio cannot interleave, and a module-level Semaphore
  binds to the first event loop that touches it and then rejects every other
  one -- pytest-asyncio creates a loop per test.
- The slot wraps only the fetch, so a cache hit costs no budget even when the
  gate is saturated.
- A shed request no longer falls through to the ERROR log. Under attack a
  traceback per rejected request is its own outage.

Same amplifier, aimed at the log file: InvalidHash (the expected answer to a
wrong hash, and a 404) was logged at ERROR with a full traceback, so five
probes filled bot.txt -- which /log uploads. Now logged at debug, matching
_route_ladder.

tests/test_unit/test_preview_admission.py covers the bound, release on
success/failure/cancellation, the cache-hit-exemption, and that a shed request
spends zero RPCs. Six mutations caught.

One of my own tests passed for the wrong reason and I only found it by
mutation: test_cold_render_passes_through_the_admission_slot asserted
inflight == 0 *afterwards*, which is also true if the slot was never taken, so
removing the wiring entirely left it green. It now observes inflight from
inside the fake RPC.

Test suite: 24.6s -> 7.6s. Two tests were almost all of it:
- test_listauth_sends_every_page_over_the_cap (mine, earlier today) paid a real
  0.5s asyncio.sleep per page for a 20-page list: 10.1s of pure waiting.
- test_hung_mongo_during_shutdown_is_reported_as_a_failure waited out the real
  8s _TEARDOWN_STEP_SECONDS cap: 8.0s. Both now exercise the same mechanism
  with local caps; test_shutdown_budget.py still pins the production values
  against the source.

329 -> 340 unit tests, coverage 45.37% -> 46.63%.
The lifecycle review's mutation: deleting redact_secrets(record.getMessage())
from JsonFormatter leaves the entire suite green. With LOG_FORMAT=json that
formatter is the one writing Thunder/logs/bot.txt, which /log uploads -- so
BOT_TOKEN and the Mongo URI reach whoever can call /log.

test_log_formatter.py had five tests, all against RedactingFormatter, which
was the formatter nobody enabled. It is now parametrised over both, and each
formatter is checked on three paths that are genuinely distinct code:
- the message,
- a secret that arrives through %args rather than the format string,
- the traceback.

Plus: the JSON payload must still parse, must carry exc, must survive non-ascii
(ensure_ascii=False is deliberate -- mojibake in bot.txt is a support problem),
and a structural test that walks logger.py for formatter classes and requires
every one to redact, so wiring in a new non-redacting formatter fails.

Two mutations were NOT caught by the first version and exposed real gaps:

1. Returning redact_secrets(record.msg) instead of redact_secrets(super().
   format(record)). My args test passed, because record.msg contains the
   literal template text -- so "connecting with" and "retry 1/3" were both
   present and the secret was absent. The formatter had stopped interpolating
   and silently dropped the log line, which every absence-only assertion
   reads as safe. Fixed by asserting the surrounding text is still rendered.

2. The same mutation still passed after that, because neither presence
   assertion distinguishes "redacted" from "never interpolated". The invariant
   that closes it: if the secret is absent, a redaction marker must be
   present. Without it the mutation returns the template verbatim and every
   other assertion is satisfied.

Five mutations now caught, including the reviewer's original.

340 -> 358 unit tests, coverage 46.63% -> 46.73%.
Blocking, found by the data-area review, and it is my own code from an earlier
round of this branch.

_bulk_flush() called db.bulk_touch_file_records(...) without raise_on_error=True
and discarded the return value. That callee catches every Mongo exception,
logs it, and returns False by default. So in production:

- the exception never reached _bulk_flush, so `except BaseException` never ran
  and _requeue_touches() was dead code -- roughly 25 lines of re-queue logic
  plus test_failed_flush_keeps_deltas could never run;
- every buffered seen_count/reuse_count delta was dropped on one Mongo
  brownout, while schedule_touch_file_record had already incremented the
  in-memory cache, so cache and Mongo diverged permanently;
- drain_background_touch_tasks then returned cleanly and shutdown recorded the
  touch buffer as a successful step during an outage.

Fixed by passing raise_on_error=True, plus treating a falsy return as failure
so a future change to the callee's default cannot reintroduce it silently.

Why no test caught it: test_failed_flush_keeps_deltas replaced the db with a
stub that RAISES. The real callee does not raise unless asked. A mutation
returning False instead made the test fail, which is how the reviewer proved it
was passing for the wrong reason -- the stub modelled the bug, not the code.

Both stubs now mirror the real signature and return contract:

- _FakeDb in test_touch_buffer.py records raise_on_error and returns bool.
  It previously took only `rows`, so the new kwarg raised TypeError, the delta
  was re-queued forever and the flush loop spun at _FLUSH_DELAY_SECONDS = 0.
  That hung the file for 10 minutes before I added a timeout to the runner;
  it is the same "fake drifted from reality" defect, caught this time by the
  hang rather than by a green test.
- _FakeDb in test_shutdown_e2e.py gains post_close_writes: it now REJECTS
  writes after close(). That closes the lifecycle review's test defect -- the
  old fake accepted them, so "flush before db.close" was never actually pinned
  and db.close could be moved ahead of the drain freely.

New tests: the flush must ask the callee to raise; a falsy return must not be
read as success; the shutdown must flush before close().

Four mutations, each with an applied-check, all caught: raise_on_error dropped
(4 failures), return value ignored (1), re-queue removed (4), db.close moved
ahead of the drain (1). Two earlier attempts at the last two mutations were
silent no-ops -- wrong indent and a non-literal call -- which read as "not
caught" until I checked whether the edit had applied at all.

358 -> 360 unit tests, coverage 46.73% -> 46.76%.
The tooling was never the problem. Measured, with the dev group synced:

  uv run overhead alone         0.045s   (it felt like the culprit, it is not)
  ruff check + format-check    0.17s
  mypy Thunder update.py       1.4s
  bandit                       0.9s
  vulture                      0.3s
  pytest, ONE test node        3.8s    <-- floor
  pytest, one file             4.0s
  pytest, whole tier, no cov   6.8s
  pytest, whole tier + cov    10.7s

The floor is pyrogram: -X importtime attributes 2.8s of every pytest
invocation to `import pyrogram`, of which pyrogram.raw.types alone is 1.05s.
Every test module reaches it transitively through Thunder.bot, so selecting
one node does not make collection cheaper, and xdist would not help either --
every worker pays the same import in parallel.

So the cost was running the WRONG command, repeatedly: re-running the full
coverage tier after every three-line edit. That is now encoded rather than
remembered:

- scripts/dev.sh gates|file|quick|test|all, mirroring the Makefile targets for
  hosts without `make` (this one included -- make is not installed here).
  There is deliberately NO default subcommand: `all` used to fire on a bare
  invocation and silently spend 12s.
- AGENTS.md documents the measured table, because a repo contract that does
  not mention the floor is how the waste survived.
- Makefile gains the same targets.

Also fixes two tests that were, between them, most of the suite's runtime:
test_listauth_sends_every_page_over_the_cap paid a real 0.5s asyncio.sleep per
page for a 20-page list (10.1s), and test_hung_mongo_during_shutdown waited
out the real 8s _TEARDOWN_STEP_SECONDS to prove a mechanism (8.0s). Whole
tier: 24.6s -> 6.8s, and 10.7s with the coverage gate.

AGENTS.md now also says to keep new tests fast, with both cases above as the
worked examples.
Found by the self-review of my own preview-admission commit.

I moved the shed from an ERROR traceback to a plain re-raise inside
render_page, but _route_ladder then caught the resulting 503 and logged it:

    except web.HTTPException as e:
        logger.warning(f"HTTP exception in {label}: {e}")

so the flood was downgraded, not closed. An unauthenticated caller who can
trigger the shed can still write one line per request into bot.txt, which /log
uploads. My own test only asserted levelname == "ERROR", so it passed.

- _route_ladder logs WARNING only for 4xx. A 5xx there is either shedding or
  an outage already logged upstream, and the client already has the status and
  the Retry-After.
- 4xx still logs: that is a client mistake and someone should see it. There is
  a test for that too, because the fix could over-correct.

The shed response was also hand-rolling its headers and had no
Cache-Control: no-store, so a CDN or browser could cache a transient failure --
while every other error the server emits carried it. That is precisely how a
per-call-site header set goes stale, so the set now has one home:

Thunder/server/exceptions.py defines CORS_HEADERS, ERROR_HEADERS, RETRY_5,
RETRY_2 and unavailable(). It is the bottom of the server package: it imports
nothing, so stream_routes can use it at module level and render_template can
import it lazily (importing any Thunder.server submodule runs the package
__init__, which imports stream_routes, which imports render_template).
stream_routes keeps its _ERROR_HEADERS/_RETRY_5/_unavailable names as thin
aliases so nothing else had to change.

Five mutations caught: 5xx logged at WARNING, no-store dropped, Retry-After
dropped, 4xx silenced as well, and the shed headers hand-rolled again.

Also: scripts/dev.sh gains format and audit, which I reached for before
noticing they were missing.

381 passed, coverage 47.44%.
Blocking, found by the ingest review.

`async with file_ingest_lock(...)` wrapped the ENTIRE retry loop in
get_or_create_canonical_file, including _wait_for_other_worker_canonical_record's
up-to-90s sleep per attempt. A request for a file whose claim was held
elsewhere therefore occupied an executor worker for up to
_MAX_INGEST_RETRIES * _INGEST_CLAIM_WAIT_SECONDS -- about 15 minutes. The pool
has only EXECUTOR_WORKERS of them (default 5), and the cheapest trigger needs
no cross-process claim at all: five queued requests naming the same
file_unique_id contend on the in-process lock and each waits out the first
copy. The whole ingest queue is dead until they drain.

Two halves, because fixing only the first still parks the worker:

1. The lock now covers claim+copy ONLY. Serialising the copy is correct -- only
   one worker may upload a file. Serialising a sleep is not, and Mongo's claim
   CAS, not this lock, is the real mutex.

2. Sleeping the claim TTL out in-handler is backwards, so it no longer
   happens. The old comment demanded that the retry budget cover the TTL, which
   is why the constants were 10 x 90s. Instead: poll briefly (20s window, 2s
   interval), then raise IngestContended. The rate limiter turns that into a
   requeue, so the worker is freed immediately and the request returns in
   seconds to find the record the other worker produced. A crashed worker's
   claim outlives the TTL with nothing to show for it, so contention requeues
   are capped at MAX_CONTENDED_ATTEMPTS with a drop notification.

   I wrote a margin assert that forced budget >= 1.25 * TTL and then had to
   back it out: it pushes toward the behaviour that causes the bug.

The poll interval went 0.5s -> 2s. At 0.5s each poll cost a Mongo find plus a
Telegram get_messages when a stale record existed -- ~3.6k reads and 1.8k RPCs
per stuck request, and the RPCs could raise the FloodWait the poll was waiting
out.

Also:
- process_single and _actual_channel_receive_handler re-raise IngestContended
  past their broad `except Exception`. Without that the signal never reaches
  the limiter and the user gets nothing.
- process_batch cancels its sibling workers when gather propagates.
  process_single used to swallow everything, so gather never saw an exception
  and would have orphaned them.
- update_cached_file_id persists before caching. The reverse order left the
  cache holding a file_id Mongo does not know; nothing reads it for delivery
  today, so the blast radius was nil, but ordering it correctly is free.

Tests: get_or_create_canonical_file, file_ingest_lock,
_wait_for_other_worker_canonical_record and update_cached_file_id had ZERO
coverage -- which is why all of this shipped green. The two timing tests that
were pure arithmetic over constants are replaced: one asserted 10*90 >= 900
while shrinking the real window to 1s stayed green; the new ones assert the
inverse (the budget must NOT reach the TTL) and pin that the wait loop uses
the constants the budget is measured in.

Two of my own tests were vacuous on the first run and I only found it by
mutation: _msg() had no media, so get_uniqid returned nothing and the function
returned before any of the logic; and my contention test asserted the handler
never ran, when it runs and *then* raises.

Nine mutations caught, each asserting it applied: lock re-wrapped around the
loop, contention sleeping instead of requeueing, limiter branch deleted,
contention sharing the FloodWait counter, cap removed, each of the two
handler re-raises deleted individually, cache-before-Mongo restored, orphan
workers. The channel-handler re-raise survived at first because my
behavioural test only drove process_single -- now an AST guard requires every
caller to have one.

398 passed, coverage 48.87%.
Blocking, found by the test-quality review. I wrote `break` directly under a
comment claiming the opposite:

    except Exception as e:
        # one failed page must not silently drop the rest of the list
        logger.error(...)
        break

Each page is sized independently by paginate_lines, so one rejected page says
nothing about the next. `break` silently truncated the authorized-user list on
an audit command and the owner had no way to tell the list had ended early.

- continue, and count the failures
- the owner is told, via MSG_LISTAUTH_PARTIAL, so a truncated list is visible
  rather than assumed complete
- a test asserts the notice does NOT fire on the happy path

Also fixes the security-relevant Major next to it: _FakeLocksCol
re-derived claim liveness from its own clock and ignored the query it was
handed, so `acquire_file_ingest_claim`'s $or could be reduced to `{"_id": ...}`
-- stealing a LIVE claim, i.e. two workers copying the same file -- and every
test stayed green. The predicate IS the security property. The fake now
evaluates the filter the way Mongo does ($or, field-level $lte/$gt/$exists),
fails closed on any operator it cannot model, and records the filters it saw.

New tests pin the predicate rather than the outcome: a live claim must not be
stolen AND the steal must be expressed as an expiry condition in the query;
release must be owner-scoped in the filter, not merely in the result.

The fake needed two corrections to do that honestly: production puts $lte as
the *value* of a field ({"expires_at": {"$lte": now}}), not as a key, and an
unmodelled operator must fail closed rather than silently match.

/listauth tests were also pinned as "at least once" -- duplicating every record
passed. Now exactly once, verified by counting each User ID across all pages.

Four mutations caught, each asserting it applied: steal condition removed from
the query, release no longer owner-scoped, break restored, partial notice
removed.

404 passed, coverage 48.99%.
All three from the test-quality review, all mutation-verified as previously
survivable.

1. onerror="markFallback()" was substring-checked *somewhere*, not per asset.
   Stripping it from 6 of 8 CDN assets left the suite green: a blocked
   jsdelivr then never engages the fallback on a video/audio page and the user
   gets the half-broken layout. Now every player asset is checked, and the test
   fails loudly if the template changes shape so the scan stops seeing anything
   (preconnect hints, the font links and the logo are excluded -- they can fail
   harmlessly and are not the player).

2. The fallback class was substring-checked on each side independently, so
   renaming the CSS to `.fallback-ready-v2` while the JS kept
   `.fallback-ready` left the whole fallback stylesheet inert and the suite
   green. Now the set of classes the stylesheet conditions on must EQUAL the set
   the JS adds -- a match, not two substrings.

3. _default_timeout(reply_text) == _env_timeout() asserted f(x) == f():
   tautological by construction. Hardcoding _env_timeout to the default and
   deleting the Var.TG_RPC_TIMEOUT_SECONDS read left everything green, so the
   documented env knob had no coverage anywhere in the repo. Now asserted
   against Var, plus a source-level check so it fails even if the value
   coincidentally matches today.

Also: test_kind_branches claimed to cover the layout branches but every case
passed a real mime type, so _page_kind's extension sniffing and its
application/octet-stream guard were dead -- neutering them left the file green.
Seven new cases: generic mime sniffing to video/audio/image/other, no mime at
all, and a specific mime overriding a misleading extension.

Five mutations caught, each asserting it applied: onerror removed from 6 of 8
assets, from all 8, the env budget hardcoded, the Var read replaced by a
literal, the Var import removed outright, extension sniffing removed, and the
octet-stream guard removed. Two of the first attempts at these mutations never
applied -- ruff had reformatted the pattern I was matching on -- which reads as
"not caught" and is why each script asserts its edit landed.

414 passed, coverage 49.18%.
Both majors from the lifecycle and CI reviews.

1. A healthy multi-client stop exited 1. cleanup_clients stops every client
   individually at 10s each, inside a generic 8s step ceiling. Two clients and
   the step is cut, recorded as an error, and sys.exit(1) -- which restarts a
   PaaS that was shutting down perfectly fine. That is the "failure that never
   happened" this whole budget exists to prevent, and it fires on the most
   ordinary deploy there is.

   _safe_teardown_step takes a per-step `cap`, and the clients step gets
   10s x client count, bounded at 60s so 40 clients cannot hold the shutdown
   open. Timeouts stay errors, so a genuinely hung step is still reported.

2. The worst case did not fit the platforms we ship to. 40s budget x 2s floors
   x 5 steps = 50s. docker-compose allows 60s, but Heroku, Render and
   Kubernetes all SIGKILL at 30s and neither app.json nor render.yaml can
   raise it -- so a stop could be killed before the touch flush and db.close()
   ran, which is the exact outcome the budget exists to prevent.

   Budget is now 20s with 1.5s floors: 27.5s worst case, inside 30s. The
   import-time assert and the test both hold it there.

The teardown is now a declarative _teardown_plan() of (name, step, cap). That
is not cosmetic: my first attempt at the test only watched shutdown_services
finish, and pinning the clients cap back to the generic ceiling left it green,
because a test's fake cleanup returns long before any cap is reached. A test
cannot distinguish a correct cap from one that was never wired without
inspecting the wiring.

The grace-period test now spells out 30s rather than reading
_MIN_PAAS_GRACE_SECONDS from the module -- that constant is a claim about
Heroku/Render/Kubernetes, and a test that trusted it would merely agree with
the module's own assert. Raising both kept it green; now it fails.

Also pins that the touch flush precedes db.close() in the plan, and
_TEARDOWN_STEP_COUNT agrees with the plan's length rather than being restated
in each test.

Eight mutations caught: clients step back on the generic ceiling, the module
claiming a 60s grace period, budget raised to a 50s worst case with the guard
removed, step count drifting from the plan, teardown reordered so db.close
precedes the flush, cap unbounded, client count ignored, empty client set
giving a zero cap. The first attempt at the cap mutation escaped -- the e2e
test's fake cleanup finished in 0.06s so no cap was ever reached, which is what
prompted the plan refactor.

420 passed, coverage 49.18%.
… pressure

The last two Majors. Both were consequences of the length-based chunking fix.

1. A stalled reader held a stream slot forever. work_loads[client_id] is
   returned in the body generator's `finally`; a client that opens a connection
   and stops reading fills the transport's write buffer, suspends the
   generator at a `yield`, and never unwinds. EXECUTOR_WORKERS of those and the
   client is 503'd permanently with no in-flight request to explain it.

   The reclaim is a watchdog task, not a timeout inside the generator. I tried
   that first and the test proved it wrong: a timeout scoped to the generator
   cannot fire, because the thing pending is the CONSUMER's await -- cancelling
   that task unwinds nothing inside the generator. A separate task does not
   care where the generator is sitting. It closes the generator too, so the
   upstream vault fetch does not keep running.

   _slot_release() makes the release idempotent. Two paths can return the slot
   and either may get there first; a double release drives work_loads negative
   and makes select_optimal_client believe there is spare capacity, which is
   worse than the leak.

   The deadline is size-derived (64 KiB/s, floored at 120s, capped at 1800s).
   It is a bound on the leak, not a stall detector -- a prompt answer to "this
   client is not reading" needs progress-based reaping of live transfers, which
   is a larger change than this branch should absorb. That gap is stated in
   the code rather than papered over.

   _make_body() is extracted from _serve_media_response because aiohttp wraps
   the generator in a payload and it stops being iterable from outside -- which
   is precisely the object whose stalled state has to be testable.

2. Batch chunk sends bypassed the rate limiter entirely. The only rate-limited
   entry points are the whole-handler ones, so the chunk loop had nothing but a
   flat 0.5s sleep -- and length-based chunking raised messages per batch 1x-4x.
   A 50-file batch of CJK names is 10-13 chunks, each a group reply PLUS a DM
   mirror: 20-26 messages in ~7 seconds. That is what trips FloodWait, and
   every resulting requeue stalls a worker.

   Capping the chunk count would mean dropping links, which is worse. The pause
   now scales with how many messages the batch will produce and defers to the
   limiter's breaker. It scales with the TOTAL, not the remainder: my first
   version used the remainder and the test caught it decaying, which sends the
   tail faster than the head -- backwards, since the window fills progressively.

Nine mutations caught, each asserting it applied: watchdog not created,
watchdog releases but leaves the fetch open, release not idempotent, stream
deadline unbounded and uncapped, pause flat, pause decaying, breaker ignored,
pause uncapped.

Two of those escaped the first round and both were my tests' fault: the helper
that built the body re-implemented the idempotency guard instead of calling the
production one, so making the real guard non-idempotent stayed green; and the
pause-cap test could not distinguish the explicit cap from the burst scaling
that already bounds itself, so it now uses a huge breaker backoff -- the one
input that can actually be unbounded.

445 passed, coverage 50.42%.
Six false-confidence fixes, five code minors, two CI/Docker corrections.

TEST DEFECTS

- _FakeCursor.sort was a no-op, so removing the backfill's real `.sort("_id",
  1)` left every test green -- while the `_id: {$gt: last_id}` paging depends
  on exactly that ordering: an unsorted page skips rows permanently and still
  stamps the "converged" marker. The fake now sorts, and serves docs reversed so
  a caller that forgets to sort fails. Removing the real sort now fails 3 tests.
- The touch buffer's two bounds ("memory stays capped", "repeated failures must
  not grow it") were enforced by nothing -- deleting either while-loop stayed
  green. Both are now pinned, including that the oldest are dropped.
- The RPC-budget shape list named nine shapes and omitted send_animation,
  send_video_note, send_voice, send_paid_media and copy_message, so adding one
  as a tg_call site inherited the 30 s budget unnoticed. It is now derived from
  pyrofork's own Client/Message, with text shapes excluded on purpose (the API
  caps them) and an assertion that the derivation still works.
- The four byte-identical fallback-selector parametrizations were strictly
  weaker than the scoping test beside them. Deleted; the remaining test asserts
  it actually found a hidder, so a template change cannot silently stop it.
- `body.replace(" ", "")` missed \n and \t, so a hidder written across two lines
  passed. Normalised with re.sub(r"\s+").
- `src`/`download_src` are rendered with |safe while their siblings are
  autoescaped. |safe is load-bearing, so the guarantee has to come from the
  value -- now pinned via quote_media_name, with five hostile names checked for
  attribute breakout. The test's own _render helper was interpolating the raw
  filename and bypassing the production encoding, so it was measuring itself.

CODE MINORS

- The touch buffer had two different rules for one bound: the schedule site
  REJECTED a new key when full (keeping the oldest) while the merge path EVICTED
  the oldest (keeping the newest). Now both evict, so the buffer always holds
  the most recent increments.
- html.escape(ban_details.get("reason")) raises AttributeError on a stored
  `reason: null` -- which add_banned_user's signature permits. The ban is denied
  but the outer except swallows the reply, so the user is told nothing.
- flag_cache._inflight was uncapped; only _data honoured max_items. Bounded, and
  it skips coalescing rather than growing past the cap -- correctness never
  depended on the coalescing, only the number of backend reads.
- rate_limiter added a deque per distinct user with no hard bound between
  5-minute sweeps. Sweep-bounded is not bounded; added MAX_TRACKED_USERS.
- _deferred_timer was left dangling after firing, so the next park cancelled an
  already-fired timer. Nulled by the callback.

The two P2 test footguns are gone too: the /listauth tests patched
`admin.asyncio.sleep`, which IS the asyncio module, replacing sleep
process-wide including during teardown. There is now a named
`_sleep_between_pages()` to patch. And one test lost its patch during the
refactor and was paying 0.5s x 20 for a 10s test.

CI + DOCKER

- The marker guard took `tail -1 | cut -d' ' -f1`, which captures whatever is
  last on stdout: any change to pytest's trailer makes BOTH sides capture the
  same garbage, the comparison passes, and the gate silently stops guarding.
  Now the counts are grepped out and an unparseable summary fails loudly.
  Verified: stripping a marker still fails it; feeding the old pipeline a
  changed trailer shows it passing vacuously while the new one fails.
- ruff and mypy now cover scripts/, which held an unlinted bench.py.
- The Dockerfile healthcheck stripped `#` from PORT while vars.py does not, so a
  commented PORT probed healthy while boot hard-failed. Both read it the same.

450 passed, coverage 50.64%.
Six of the drift items the reviews flagged, each verified against the code
rather than against the review's summary.

FACTUALLY WRONG

- docs/API.md said a multi-range request gets 400. It does not: parse_range_
  header IGNORES it and returns the full span as a 200, deliberately, because
  RFC 9110 permits ignoring an unsatisfiable ranges-specifier and a 400 makes
  some players and proxies abandon the transfer. The doc's parenthetical "stricter
  than RFC 9110's ignore" described the opposite of the code's reasoning.
- The CORS line listed an allowed `*` request header that the server never
  sends. Corrected to what CORS_HEADERS actually carries, including the methods.

INCOMPLETE

- /status is documented as a flat "version, uptime, bot username, client count,
  per-client inflight + DC". It is three grouped objects and also reports the
  touch buffer. Now written out.

CONTRACT DRIFT

- AGENTS.md described the rate limiter as "two-tier deque (priority_queue
  first)". It alternates strictly while both tiers are populated -- that is what
  stops a continuous owner stream starving regular users, and it is a guarantee
  the doc did not state. Also records the ingest-contention requeue and the new
  hard bound on tracked users.

A PROMISED LOG THAT WAS NOT THERE

- README says the bot "infers them and logs what it chose" for FQDN/PORT/
  NO_PORT/HAS_SSL. vars.py did infer them and only ever logged the gate mode at
  DEBUG, which is off by default -- so the promise was invisible exactly when
  an operator needed it, and those four values decide the public URL. Now one
  INFO line naming what it chose.

I chased a suspected mojibake in three of these files (em dashes rendering as
"?" in the console) and confirmed the bytes are valid U+2014. Not a defect;
recording it so nobody else spends the time.
The recheck found what the first pass missed: _safe_teardown_step is not only
called from shutdown_services. Three boot-failure paths call it directly, and
all three stopped clients with the generic 8s ceiling. With more than one client
that cut the stop, so a failed boot left sessions half-stopped -- session files
left behind on disk and clients that had already connected.

Not a restart loop -- those paths exit 1 deliberately, and being cut does not
change that. But it is the exact failure the scaled cap exists to prevent, in
three places the plan refactor never touched because _teardown_plan only covers
the graceful path.

The test walks the AST rather than racing timeouts, and checks BOTH shapes: the
keyword cap= at the three direct call sites, and the positional entry in
_teardown_plan, which the keyword walk cannot see at all. Pinning only one shape
would have left the other unchecked -- the positional entry is the one that
matters most, since it is the ordinary stop.

Also pins the count at three, so a fourth unscaled call site is caught rather
than tolerated by a >=.

Four mutations caught: a boot-failure path losing its cap, the plan's entry
losing its cap, the plan pinning the generic ceiling explicitly, and a fourth
unscaled call site appearing.

My first attempt asserted ">= 4 sites" and failed on a clean tree -- the plan
entry is a tuple, not a call, so there are three of each, not four. The count is
now exact rather than a guess that happened to be wrong.

451 passed.
AGENTS.md published quick=6.8s, test=10.7s, all=12s. Re-measured best of three
at the current 451 unit tests: 9.0s, 14.3s, 16.0s. The test count grew from 414
to 451 and the suite with it, and nobody re-measured.

This is the table whose entire job is to stop someone re-running the full tier
per three-line edit. A table that understates the cost by a third is worse than
no table, because it is trusted precisely when it is least checked.

Also records the measurement conditions and says to re-measure before trusting
the absolute numbers, since they drift with the test count.

While measuring, confirmed one claim that was still exactly right and is worth
saying out loud: `dev.sh file` on a SINGLE NODE costs the same as a whole file
(3.9s both). The floor is the pyrofork import, not the tests, so selecting
narrower buys nothing. That is now in the table too, because it is the kind of
thing people assume otherwise and then sit waiting for.

gates green, test_registry green (it is the test that keeps the commands table
honest).
A review list is a list of suspicions, not a list of bugs. These four were on
it and I checked each rather than changing code on the strength of the note.
All four are fine; recording why so nobody re-derives it.

- _env_timeout's memoisation is deliberate and documented in place ("static per
  process"), with a lazy Var import to avoid coupling. The thing that was
  actually wrong -- a test asserting _default_timeout() == _env_timeout(), which
  is true by construction and pins nothing -- was fixed in adf631e by asserting
  against Var instead.
- The module-level asyncio.Lock in canonical_files was flagged as loop-bound.
  Since 3.10 the loop is acquired lazily on first use, not bound at
  construction. Verified empirically rather than from memory: acquired and
  released across two separate event loops, no error.
- stale_record was flagged dead. It is live -- the reusable-record lookup feeds
  _merge_replacement_record when a stale canonical row is found.
- pyaes is genuinely sdist-only (no wheel in uv.lock), and the Dockerfile is
  slim with no build tooling. Not a bug: pyaes 1.6.1 is pure Python, no C
  extension and no ctypes/cffi, so pip builds a wheel from the sdist with no
  compiler. Documented the dependency at the install line, since that is the
  part a future change would silently invalidate.

Three of four were "the note was wrong". That is the expected ratio for a
review list and not a reason to skip checking -- the fourth was a real latent
trap, just not a live bug.
Four of these are mine from adf631e and 1e5051b, and three of them were
introduced by the "fixes" I made in those commits. Verified each against the
code before acting; all five reproduced.

1. THE SCALED SHUTDOWN CAP NEVER BOUND ANYTHING. The branch's headline claim.
   _safe_teardown_step grants min(ceiling, budget_left), so the ceiling is only
   an upper bound -- the SHARED budget is what actually binds. With a 20s
   budget, two clients needing 10s each consumed the pool four other steps also
   draw from and were cut anyway: recorded as an error, exit 1, PaaS restart
   loop. Measured: 3 clients need 30s and were granted 19.9s.

   The clients step now has its OWN slice (shared=False in the _Step plan), so
   the 10s-per-client scaling means something. Budget rebalanced 20s -> 10s for
   the four cheap steps and the clients slice capped at 12s: 10 + 4x1.5 + 12 =
   28s, inside the 30s the manifests allow. Sized the slice DOWN rather than the
   budget up, because the two are not equivalent -- cutting the shared budget
   risks the touch flush and loses deltas, while the slice only has to cover a
   healthy stop (~0.1s per client).

2. THE TOUCH BUFFER EVICTED THE HOTTEST FILE. Assigning to an existing
   OrderedDict key does NOT reorder it, so a file touched fifty times stayed at
   position 0 and was the first thing popitem(last=False) discarded -- losing
   every delta it had accumulated while keeping thousands of files touched once
   each. My previous commit's claim ("the buffer always holds the most recent
   increments") was exactly backwards. move_to_end in the merge branch and in
   _requeue_touches, where a persistently-failing entry had the same problem.

3. MAX_TRACKED_USERS WAS DEFINED TWICE. I added a 50_000 that the pre-existing
   4096 shadowed two lines later, so the hard bound ran at 4096 under a comment
   documenting 50000. Ruff's F811 does not flag a plain module-level rebinding,
   so the blocking lint gate was green over the dead code.

4. EVICTION RESET AN ACTIVE USER'S RATE-LIMIT WINDOW. The new admission bound
   evicted by dict order, which is FIRST-EVER-SEEN order -- so under a flood of
   new ids it dropped whoever is currently active, handing them a fresh window.
   Twenty lines below, sweep() already sorted by recency with a comment saying
   dict order was wrong. Both paths now use one _evict_inactive_users helper.

5. THE WATCHDOG'S aclose() CLAIM WAS FALSE. aclose() only works on a generator
   suspended at a yield; while a consumer is actively pulling -- the normal
   state, and what a slow upstream looks like -- it is suspended inside
   await stream_file(...) and aclose raises "asynchronous generator is already
   running". So the upstream fetch was never stopped, and contextlib.suppress
   turned that into silence. The slot reclaim does work, which is the part that
   mattered; the guard now checks ag_running, the CancelledError suppression is
   gone, and the docstring says what is and is not guaranteed.

ALSO REVERTED

flag_cache's max_items cap on _inflight, from adf631e. It addressed a
non-problem -- _load_and_store pops its own entry on both success and failure,
so the dict only holds loaders running right now and is bounded by concurrency
by construction -- and introduced a real bug: the store is fenced on
_inflight.get(key) is task, which an unregistered inline call never satisfies,
so the value was returned and NEVER CACHED. Every later read paid another
backend round-trip. Reverted with the reasoning recorded in place.

Also corrected the stream-deadline comment, which claimed the deadline "only
bites on a socket that has been idle for its whole budget". There is no idle
check anywhere: it is pure wall-clock, and at the 1800s cap a 4 GiB download
needs 2.28 MiB/s to survive. Stated as wall-clock with the numbers.

NINE MUTATIONS, ALL CAUGHT -- but only after writing the behavioural tests.
The first pass caught 4 of 9, and the 5 that escaped were exactly the
properties my tests asserted as WIRING rather than SEMANTICS: a tuple contains
a scaled cap, an OrderedDict drops from one end, a dict evicts a key. Reading
the numbers was never enough. The three that took a second attempt: the
recency test needed a victim that is both first-inserted AND still active (an
idle victim is correctly evicted), and the aclose test needed to await the
watchdog task, because a task's exception only reaches the loop handler at GC
time -- and because release() runs first, the visible effects are identical
either way.

472 passed, coverage 50.96%.
…pacing signal

Completes the adversarial review's nine items.

6. THE ADMISSION LADDER BYPASSED THE IDEMPOTENCY GUARD. _slot_release made the
   generator's finally and the watchdog mutually exclusive, but the ladder
   decremented work_loads directly in all five of its handlers. A failure between
   "watchdog created" and "Response returned" was therefore released TWICE --
   once by the ladder, once by the watchdog at its deadline -- driving the
   counter negative, which makes select_optimal_client believe there is spare
   capacity. That is the exact failure 85dd747's own comment says is worse than
   the leak being fixed.

   The ladder now owns the closure and yields it; _serve_media_response takes
   it as an argument and reuses it rather than minting a second one; HEAD uses
   it too. One closure per request, released once, no ordering hazard.

   Two of my four mutation attempts escaped the first time and both were my
   tests' fault. A HEAD-only test cannot see a second closure being minted,
   because the HEAD branch returns BEFORE the code that would swap it -- the
   streaming path is where it happens, so the test drives a GET whose body is
   never consumed. And "a route stops threading the closure" is a property of a
   call site, not of arithmetic, so it needed an AST check like the one the
   cleanup_clients sites already have.

9. THE BREAKER BRANCH WAS DEAD IN THE SHIPPED CONFIG. _breaker_rate() returns 0
   unless GLOBAL_RPS_LIMIT > 0 or GLOBAL_RATE_LIMIT is on, and both ship
   disabled. So "defers to the limiter's breaker when it has real pressure to
   report" held only for operators who opted in, and all four breaker tests
   monkeypatched a fake breaker -- none of them reflected a default deployment.
   Tests passing for the wrong reason, in the same shape as every other instance
   this branch has produced.

   _chunk_pause now also reads queue depth, which is populated regardless of
   configuration, so a default deployment has a live pressure signal. The
   breaker still wins when it has something to say. A test pins the shipped
   values against the real Var so the claim cannot rot unnoticed, and another
   asserts a deep backlog NUDGES rather than pinning us to the 4s cap -- the
   outer cap alone made unbounded queue scaling look correct, which is how that
   mutation escaped the first round.

Eight mutations across the two fixes, all caught after the second attempt.

479 passed, coverage 51.07%.
…the loop table

AGENTS.md described the rate limiter as bounded and swept, but not HOW, and
both hows were the source of two defects in 85dd747:

- The per-user table is bounded at admission, not only by the sweep. A timer
  bounds the steady state, not what can be created between ticks. Eviction is
  by least-recently-active, never dict order -- keys are only inserted when
  absent, so dict order is first-ever-seen, and evicting by it hands a currently
  active user a fresh rate-limit window.
- The RPS breaker reports nothing unless GLOBAL_RPS_LIMIT > 0 or
  GLOBAL_RATE_LIMIT is on, and both ship disabled. Any code consulting it as
  its only pressure signal is dead on a default deployment. stream.py's batch
  pacing reads queue depth as well for that reason.

Also documents two behaviours this branch introduced that nothing described:
the stream-slot release contract (one idempotent closure shared by the ladder,
the generator's finally and the watchdog) with the wall-clock transfer deadline
and its real cost -- a client under size/1800s is cut mid-transfer and must
resume with a Range; and the shutdown budget, including why cleanup_clients
needs its own slice (with a shared budget the grant is min(cap, budget_left),
so scaling the cap achieves nothing).

Every number checked against the code rather than against the prose: 28.0s
worst case inside the 30s grace, clients step shared=False, 64 KiB/s with 120s
floor and 1800s cap, MAX_TRACKED_USERS assigned once at 4096, admission and
sweep both routing through _evict_inactive_users, breaker.rate 0.0 on shipped
config, and queue depth 3 moving the batch pause 0.5s -> 1.5s.

The fast-loop table has now been measured at 414, 451 and 479 tests -- quick
tracked 6.8s -> 9.0s -> 9.8s -- so it records that it drifts rather than
implying the numbers are stable.

479 passed.
… from 85dd747

Verified each against the code before acting; all three reproduced.

1. THE EVICTION I UNIFIED WAS O(n*k). Fixing the rate-limiter bound by routing
   admission and the sweep through one _evict_inactive_users was the right call.
   Re-implementing the sweep's eviction as `min()` per victim instead of one
   `sorted()` was not. Measured on this host, evicting down from 8x the cap:

       min()-per-victim   66.2 s       sorted-once   0.021 s     3193x

   And this is the wrong place to be slow: sweep() is `async def`, called
   directly from the sweeper with no to_thread, so 66 s of blocking CPU freezes
   the whole bot -- under exactly the flood of distinct user ids the bound
   exists to survive. Fixing the recency bug cost a self-inflicted DoS. Sorted
   once, prefix dropped.

   Note what it took to pin this: NO functional assertion can see it. Every
   behavioural test passed with the O(n*k) form. It is now tested with a 2 s
   clock budget against a ~0.05 s correct runtime, plus an AST check for the
   loop, because a property about cost is not a property about behaviour.

2. THE "10s PER CLIENT" SCALING WAS DECORATIVE. `_client_stop_cap()` computed
   min(12, n * 10). Since 12 < 2*10, min() picked the constant for EVERY n >= 2
   -- the grant was a flat 12 s for any multi-client deployment, while
   cleanup_clients stops clients sequentially at 10 s each. One hung client
   burns 10 s, the second gets 2 s, and that is recorded and exits 1: exactly
   the failure 85dd747 item 1 opens with.

   The scaling cannot be made to fit. Two hung clients need 20 s and the rest of
   the teardown needs the remainder of 30 s. So it is gone: the slice is flat,
   sized for a healthy stop (~0.1 s per client) plus one stuck client. A test
   asserts the helper does not come back.

   And the assertion that would have caught it -- `two == 2 * per_client` -- was
   DELETED in that same commit and replaced with `12 > 8`, which cannot fail.
   Reinstated in the form that is true now.

3. THE BOOT-FAILURE PATHS WERE BOUNDED BY NOTHING. All four call
   _safe_teardown_step with no deadline, so every step took its FULL ceiling,
   behind a _cancel_tasks allowed 30 s on two of them: an 82 s unwind against a
   30 s grace, with the import-time assert blind to it because it only does
   arithmetic over constants and never looks at a call site. They now share the
   budget and the floors, behind a 5 s cancel -- 22 s worst case, with a second
   assert and a structural test that no teardown step anywhere lacks a deadline.

Also, from the same round:

- The worst case under-counted by a full floor: app_runner.cleanup() is
  budgeted through the same max(..., FLOOR) rule but runs OUTSIDE
  _teardown_plan. The real figure was 29.5 s, not 28 s, and the margin was
  0.5 s rather than 2 s. `_PRE_PLAN_FLOOR_STEPS` accounts for it; the bound is
  now 27.5 s.
- `_chunk_pause` reads the process-wide limiter's occupancy, and three pacing
  tests asserted exact equality against it without patching it -- green only
  because other tests' fixtures happen to drain the queues on teardown. An
  autouse fixture now neutralises the pressure signal.
- Deleted two tests that could not fail: one recomputed the shutdown arithmetic a
  fourth time (a strict consequence of three existing assertions), and one
  asserted a pause was bounded when the outer cap makes any such check a
  tautology.
- `test_the_breaker_is_inert_in_the_shipped_config` read the real Var, so a
  developer with GLOBAL_RATE_LIMIT="True" in config.env got a red unit tier --
  contradicting the hermetic contract. Driven through explicit values now.
- `release_slot` is required, not defaulted to None. HEAD calls it before any
  fallback could run, so omitting it was a TypeError and a 500 on the one path
  that most needs to work.
- Nothing pinned that a route threads the LADDER's closure rather than minting
  one; `release_slot=_slot_release(client_id)` passed the old keyword check and
  reintroduced the double-decrement. Now asserted, along with the ladder's `as`
  binding.
- Removed the dead `_prune_inflight` left by the flag_cache revert (vulture
  scores an unused method at 60%, below the CI gate's threshold, so it stayed).
- `response.body.close()` is a coroutine; calling it bare emitted "coroutine was
  never awaited" and did nothing. Awaited, with the comment corrected.

Ten mutations, all caught. Three needed a technique the obvious assertion cannot
use: a clock budget for the complexity property, an exact-match against the
longest run of teardown calls for the step count (a smaller count still passes
the < 30s check, which is why that check alone was not enough), and a signature
inspection for the required parameter -- with a default restored, HEAD calls
None() and raises the SAME TypeError, so the behaviour is identical and only
the declaration distinguishes them.

483 passed, coverage 51.10%. AGENTS.md updated to the new constants, with the
worst cases verified against the module rather than against the prose.
…rwise

Found by pointing the bot at a real Atlas cluster, not by any test.

Var.NAME has exactly one consumer: Database(Var.DATABASE_URL, Var.NAME). It is
the Mongo database selector and nothing else -- the startup banner takes the
bot's name from bot_info.first_name, straight from the Telegram API. But
config_sample.env described it as "Bot application name", and
DATABASE_URL's example was "mongodb+srv://user:pass@host/db", which reads as
though the path selects the database.

So the two documented mechanisms both point somewhere other than the one that
actually decides. An operator who points DATABASE_URL at a new cluster gets a
bot that quietly keeps using the old NAME's database -- which is indistinguishable
from total data loss. Verified on a live cluster: a URI whose path said
'frdeewf' was served from 'ThunderF2L'.

No behaviour change, deliberately. Making the URI path win would repoint every
existing deployment at an empty database, which is a far worse failure than the
one being fixed. Instead:

- vars.py warns at boot when the URI names a database that disagrees with NAME,
  stating which one wins and how to change it. Verified against the real URI:
  it fires, and stays a WARNING so the boot still proceeds -- an error here
  would break every deployment that has the mismatch today.
- config_sample.env now says NAME is the database, and that DATABASE_URL's
  path is ignored (its example no longer shows one).
- The banner-vs-database confusion is pinned: __main__.py must not read Var.NAME
  and must derive the printed name from the Telegram API.

Five tests, four mutations, all caught. Two needed a second attempt and both
were my test's fault:

- Checking the sample for a "/db" path by looking for a dot in the last segment
  cannot tell a bare `db` from a hostname. Parsed with urlsplit instead.
- Scanning for `Var.NAME` missed a consumer added INSIDE vars.py as a bare
  `NAME`, so the test stayed green while the claim it made was false. Now an
  AST walk that every read of NAME must sit inside the disagreement check.
  My first attempt at that asserted NAME is never read at all, which was
  simply wrong -- the warning legitimately compares and quotes it.

The new warning also surfaced a latent inconsistency in an existing fixture:
test_config_env_layers.py used DATABASE_URL "mongodb://localhost/x" against the
default NAME, so it was silently carrying a configuration the bot warns about.
Fixed the fixture (dropped the path) rather than asserting the warning away --
those tests are about layer precedence, not about the database.

488 passed, coverage 51.21%.
@fyaz05 fyaz05 closed this Oct 16, 2026
@fyaz05
fyaz05 deleted the feat/improvement-plan branch October 16, 2026 20:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants