Repository navigation
Conversation
…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)
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.
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
Owner
Author
|
All audit findings fixed and green: 1 critical (tz-naive token activation crash), 12 majors (self-heal record deletion, fail-open gates, State: 159 unit + 3 integration tests, ruff, mypy, bandit, pip-audit all green in CI on head |
…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.
…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%.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Retry-After), fail-closed gates, RPS breaker on the immediate path, boot exit codes, bounded FloodWait waits, realconfig.env.localprecedenceuv export+--require-hashes) with CI requirements↔lock sync gates, single-pass access-log redaction + log-forging escape, scheme allowlist in self-update, activation-redirect hardeningPhase 2 — audit of the branch, and the fixes it demanded
Merge blockers
pymongo4.18.1 carried three CVEs (CVE-2026-96747/48/49) and CI'spip-auditwould have gone red; bumped to 4.19.0 with targeted transitive bumps for the restpyrofork'sspeedupextra requiresuvloop/tgcryptowith no platform marker, souv syncandpip install -r requirements.txtfailed on Windows. The extra is now requested only offwin32— the dev host can install and run the suite; POSIX deploys keep both speedupsexcept Exceptioncannot catchCancelledError. Sincelast_seen_atis the TTL field, that aged live files out of the vaultCorrectness and the public link surface
stop_grace_periodand a persisted log volumeusers.iddedup 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 noidget_chat_memberonly raises for a user absent from the channel, so banned and left members were being admittedRangeheaders are ignored per RFC 9110 instead of rejected with 400; a negativefile_sizecan no longer produceContent-Length: -1GLOBAL_RPS_LIMITwarning 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 usersPhase 3 — an independent review of my own fixes
A subagent reviewed the 26 fix commits and found two regressions I introduced:
/log(5 MiB) and/shellhad 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.from_useroff 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_e2edrives the realshutdown_servicesand found a third bug of mine: the shield swallowedwait_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-severityshortener.pysubstring-sanitization alerts and the deleted-workflow alert that main still carries.Known follow-ups (not in this PR)
fileshas no dedup recovery path, unlikeusers; a failed unique index there means permanently split counters/statsexecinthunder.sh, pyrofork's handler) is pinned statically instead