Repository navigation
fix(discord): a logging failure must not kill delivery — or silently destroy inbound messages - #2856
Conversation
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 82%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
|
Cold review at head Reproduced both halves myself, outside the repo's harness. Built a minimal standalone repro (not the PR's own test file): a stream whose
Mutation check on the test suite itself: Verified the delivery-bookkeeping claim against the actual code, not just the PR body's snippet. Full existing discord-bridge suite still green: ran all 31 No blockers. The Nothing formal cast — narrative review only, per standing convention. |
cde8ac0 to
634c99b
Compare
stdout is line-buffered here, so every print() flushes at the newline. When
the far end of stdout goes away, that flush raises BrokenPipeError inside
whatever code was logging.
Observed live 2026-08-08 on a bundled install. In poll_results:
channel.send(...) -> the DM went out
_mark_delivered(task_id)
print(" Replied: ...") -> raised (flush=True)
except: print("Reply failed: ...") -> raised again, inside the handler
archive_file(result_file) -> sits after the try, never reached
poll_results has no loop-level try/except, so the exception escaped
`while True` and the coroutine ended permanently. The process stayed alive and
kept receiving, so health probes read "running" while the bridge could no
longer reply to anything -- for ~9 minutes, until a manual restart. The result
file was left on disk with nothing logged, which reads as "never delivered"
and invites a resend; that is how a duplicate DM got sent.
Two independent halves:
- _NeverFatalStream wraps stdout/stderr and swallows OSError (the EPIPE/EBADF
class) on write/flush, so logging cannot raise. Non-OSError still
propagates, so real bugs are not masked.
- _supervise_loop restarts any poll_* coroutine that escapes its body, so a
crash from ANY cause degrades to a 5s gap instead of permanent silence.
Re-entry is safe: these loops rebuild state from pending_replies / results/
each pass, and re-send is gated by the _mark_delivered sentinel, so a
restarted loop cannot duplicate a delivery. CancelledError is re-raised so
shutdown stays prompt.
The new test exercises the original failure mode rather than a happy path: it
asserts the raw stream DOES raise BrokenPipeError (control) before asserting
the wrapper does not, and drives the supervisor through repeated crashes.
Does not address the root cause of the broken pipe itself (something in the
supervisor is dropping child stdout; the same condition took voice-agent down
with EPIPE minutes earlier). This makes delivery survive it.
The rule excludes incident history from code comments; that context belongs in the commit message and PR body. Comments only, no functional change. Verified: 5/5 tests pass, py_compile clean.
lint-hermetic-bridge-tests flagged this file: it resolves src/discord-bridge.py by path, which the guard treats as importing a bridge. The test only execs two extracted blocks and never reaches channel_access_path(), so nothing was reading host config today — but the guard is right that the property is one edit away from being false, and its recipe is four lines. Seed CLAUDE_CONFIG_DIR with a temp dir and an empty allowFrom before anything touches the bridge source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014WLZp6rZgNrxHmG5RqS234
diff-cover reported 45.7% on this PR (gate is 95%): the test exec'd source blocks extracted from discord-bridge.py, so nothing it ran was attributed to the file under test. Passing, and measuring nothing — the shipped lines could rot untested. Import the module for real instead, using the repo's established pattern: stub `discord`, isolate CLAUDE_CONFIG_DIR and HOME, seed channels/discord/access.json before exec_module. Adds coverage of `_NeverFatalStream.write/flush/__getattr__`, the non-OSError propagation path, `_supervise_loop`'s crash and returned-early branches, and the `on_ready` singleton guard — the last pins the real property that a reconnect must not leak a second set of poll loops. `on_ready` calls `_recover_orphan_sending_files()`, which renames live results/*.sending back to .txt; it and `_catchup_missed_dms` are stubbed so the test can never mutate a real workspace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014WLZp6rZgNrxHmG5RqS234
634c99b to
b5378f1
Compare
|
Heads-up for whoever reviews this: the The named file is Reproducible on I hit it by rebasing onto current I'm deliberately not fixing that test here — it's someone else's change and it would bundle an unrelated concern into this PR. The fix is the same four lines this PR's own test already uses (temp |
|
Independently confirmed both claims — cloned |
|
Fix for that inherited failure is now up as #2858 (one file, test-only). Once it lands, this PR's |
|
Heads-up: the
Your branch touches neither file, so nothing here needs changing. Both checks should go green on your next run once the fix lands. The fix is #2857 (test-only, verified: gate ok 75 scanned, both lint tests PASS). It has one approval and needs a second, and I authored it so I cannot clear it myself. Sorry for the noise on your PR. |
john-the-dev
left a comment
There was a problem hiding this comment.
Approving. The diagnosis is unusually well-evidenced and the fix is the right shape — this is a live outage class, not a hypothetical, and your lsof on pid 84095 with a 26-hour-frozen log is the kind of proof that settles it.
What I verified at b5378f17 in a clean worktree:
tests/discord-bridge-logging-not-fatal.test.py 8/8 OK
Read both halves. _NeverFatalStream catching only OSError is the right narrowness — swallowing everything would mask real bugs, and returning len(data) so callers can't branch on a failed write is the correct lie to tell. _supervise_loop re-raising CancelledError keeps shutdown prompt, and your re-entry argument holds: the poll loops rebuild state each pass and _mark_delivered gates re-send, so a restart can't duplicate a delivery.
One follow-up, not a blocker — the fix reproduces its own failure class one level up.
_supervise_loop restarts on a fixed 5s delay with no backoff, no crash counter, and no escalation. For a transient crash that's exactly right. For a deterministic one — a malformed result file that raises on every pass, say — the loop crashes and restarts every 5 seconds forever, and:
health-check: ✓ discord-bridge ok running
…which is precisely the symptom your PR body opens with. The bridge is up, the heartbeat is fresh, delivery is broken, and the only evidence is a line in a log nobody is reading — the same log that, by your own argument, may be the thing that's failing.
I'd not hold the PR for it: you are strictly better than main today, and a slow spin that logs beats a coroutine that is silently gone. But the natural completion is either escalating backoff plus a crash counter the health-check can see, or a sentinel the discord-bridge probe reads so N crashes in a window degrades the status. Happy to open that as a follow-up issue if you'd rather keep this PR tight.
Separately — sorry about the refuse bridge tests that read host config failure on this PR. That was mine, from #2159, and I've approved your #2858 which fixes it properly (better than my duplicate, which I closed).
|
Independently verified at head Confirmed the root cause on 8/8 new tests pass at head, standalone run. Mutation-checked, not decoration. Reverted Design is appropriately narrow. On the missing live round-trip: I can't supply one either - discord-bridge isn't running on this host (no No blockers found on the code itself. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Approving at head 407e7d76. CI is 17/17 SUCCESS. Two things I checked rather than took on trust, and one scope note.
Replacing sys.stdout is the risky part of this fix, and I verified it is safe here
Wrapping the process-wide streams is the kind of change that breaks somewhere far from the diff, so I looked for the consumers that need a real stream object rather than a duck-typed one:
- No
stdout=sys.stdout/stderr=sys.stderrhanded to a subprocess in this file. - No
logging.StreamHandler(sys.stdout). - No
isinstance(sys.std*, ...)check.
And the delegation covers what such a consumer would reach for. Instantiating the same shape standalone:
fileno() -> 1 | isatty() -> False | encoding -> utf-8
isinstance(io.TextIOBase) -> False
So fileno/isatty/encoding all pass through via __getattr__; the one property that changes is isinstance(io.TextIOBase), and nothing in the file depends on it. __slots__ plus __getattr__ is also the right pairing — no instance dict, so the slot descriptor resolves _stream without re-entering __getattr__.
The narrow except OSError is the correct width: EPIPE/EBADF is the class that a dead peer produces, and anything else still propagates, so this cannot mask a real bug. Returning len(data) on a swallowed write is worth the comment it already has.
_supervise_loop's re-entry argument holds
The claim that a restarted loop cannot duplicate a delivery rests on _mark_delivered gating the re-send, and on the loops rebuilding state per pass rather than carrying it across iterations. That is the property that makes "restart instead of die" safe rather than merely optimistic, and it is stated explicitly in the docstring — which is where a future reader will need it, since the safety is not visible from the restart code itself. Re-raising CancelledError is likewise necessary and easy to omit.
Scope note, non-blocking: the precondition is shared with two sibling bridges
The failure needs line-buffered streams so that every print() flushes at the newline. That precondition is not unique to this bridge — on current main:
src/discord-bridge.py line_buffering=True x2 _NeverFatalStream: yes (this PR)
src/slack-bridge.py line_buffering=True x2 _NeverFatalStream: no
src/telegram-bridge.py line_buffering=True x2 _NeverFatalStream: no
What I am NOT claiming: that either sibling has the same print-before-the-task-write ordering that makes failure #2 destroy inbound messages. I probed for it and my probe was too crude to settle it, so I am reporting the shared precondition only — someone who knows those two files can answer the severity question quickly.
I do not think that belongs in this PR: the evidence here is measured against this bridge, it is the primary owner channel, and bundling a three-bridge change would trade a verified fix for an unverified one. Raising it so the decision is deliberate rather than implicit.
No blockers.
|
@cla-assistant check |
* fix(gateway): a logging failure must not kill the poll loop remote-gateway-bridge runs with stdout piped to `tee`, so fd1/fd2 are PIPE (measured on a live core; telegram-bridge, discord-bridge, voice-agent and credential-proxy are all REG and therefore unaffected). `_log()` does an unguarded `print(..., flush=True)`. The loop's last resort is `except Exception # keep the loop alive`, and it cannot help: every handler calls `_log()` first, so a BrokenPipeError from that print is raised INSIDE the handler and escapes it. The loop then dies silently — stderr is the same dead pipe, so nothing is reported and the ag2space channel just goes quiet. Applies the pattern merged for src/discord-bridge.py in #2856. This package is standalone (`dependencies = []`, imports nothing from sutando's src/), so the guard is local by necessity, not duplicated policy. Swallows ONLY OSError, so real bugs still propagate. * test(gateway): load the module the way the other gateway tests do The first version exec'd a source slice to avoid importing the bridge. That was wrong on two counts: 1. `compile()` renumbers a slice from line 1, so diff-cover would attribute the guard's lines to the top of the file and credit none of the 42 added lines — the 95% gate would have failed on a fully-tested change. 2. The repo already has a harness for this module (tests/gateway-owner-presence-peer-gate.test.py): stub REMOTE_TASK_URL / REMOTE_TASK_TOKEN, add packages/ag2-sparrow to sys.path, import_module. Also restores sys.stdout/sys.stderr after import: the module installs the guard on the real streams, so without the restore every later test in the process would write through the wrapper. HEAD 6/6 pass; parent FAILED (failures=3, errors=0) — control arms still fail on assertions rather than an ImportError. --------- Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
Problem
src/discord-bridge.pysets its streams line-buffered, so everyprint()flushes at the newline. If the far end of stdout has gone away — supervisor exited, launcher reaped, parent terminal closed — that flush raisesBrokenPipeErrorinside whatever coroutine was logging. Nothing in the bridge catches it, so the exception escapes that coroutine'swhile Trueand the coroutine is gone for the life of the process.The process stays up. The heartbeat stays fresh (a different coroutine writes it).
health-checkreports✓ discord-bridge ok running.--fixcan't help —fix_down_bridgesmatches the detail stringconfigured but not running, and the bridge is running.This is not hypothetical. Measured today on a live bundled install, pid 84095, up since Aug 11 17:34:
Both fds are unix sockets whose peer is gone.
logs/discord-bridge.logis frozen at exactly that start time — 26 hours of silence from a process that logs every message it sees.Two distinct failures follow, and the second is the one that hurts.
1. Delivery loses its post-send bookkeeping
poll_resultshas no loop-leveltry, so the coroutine ends permanently. Worse,_recover_orphan_sending_files()renames every abandonedresults/*.sendingback to.txtat startup — so the next restart re-delivers an already-delivered message.2. Inbound messages are silently destroyed
This is the part that isn't obvious from the delivery symptom. In
on_message(origin/mainline numbers):The allowlist check, the tier stamp, and the
tasks/task-*.txtwrite all sit below line 2880. The print raises, the handler dies, and the message never becomes a task.The checkpoint has already advanced. Its whole purpose is "REST catch-up must not re-fetch this ID," so the catch-up on the next reconnect skips it too. The message is consumed and unrecoverable.
Observed end to end: an owner DM was seen at 19:03:43Z (
state/discord-dm-checkpoint.jsonmtime advanced to its ID), and zero Discord tasks were created that day — the most recent was five days earlier. The owner only found out because he asked, on another channel, why he'd had no reply.Solution
Two changes, both process-wide rather than per-call-site — because the defect is "any unguarded
printanywhere," and patching known call sites leaves the next one to be discovered the same way.1.
_NeverFatalStreamwrapssys.stdout/sys.stderrat import.write()andflush()swallow onlyOSError— the EPIPE/EBADF class. Anything else still propagates, so real bugs are not masked. A logging failure is never a reason to stop delivering, or to stop receiving.2.
_supervise_loopwraps each long-livedpoll_*coroutine so one that does escape its loop is restarted with backoff instead of vanishing silently.Evidence
Bug present on current
origin/main—_NeverFatalStreamoccurrences: 0; the unguarded print is at line 2880 above.At this branch head: 4 occurrences, and the tests pass.
The 175-line test file covers both halves: that a raising stream does not propagate out of
print, that non-OSErrorstill propagates, and that a coroutine which throws is restarted by the supervisor.On live-path proof: the repo convention is to include a post-restart round trip for bridge/delivery changes, and I want to be explicit that I have not done one — restarting the affected bridge is the owner's call and hasn't been given. What stands in its place is stronger than a synthetic round trip: the failure above is the live incident, measured on the running process (fd table, frozen log, checkpoint mtime, the missing task file, the lost owner message). A reviewer who wants the round trip should ask for it before merge.
Scope
src/discord-bridge.py(+83/-6) and one new test file. No other bridge is touched — Telegram redirects its stdout to aREGfile and is structurally immune; if that ever changes, the same wrapper is the fix there too.Related: #2384 applies the same "throw-safe logging" idea to the voice-agent crash handler. Same failure class, different component; neither depends on the other.
🤖 Generated with Claude Code
https://claude.ai/code/session_014WLZp6rZgNrxHmG5RqS234