fix: cap backend dispose so a stale connection cannot wedge the caller #6
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/hang-proof-backend-dispose"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Observed in production (2026-07-13): after an Uber-Ich run logged
runUberIch endat 03:17, the scheduler never processed another event — every timer (nightly research, morning greeting, mail check) silently stopped firing.Root cause: the runner's
finallyblock awaitsmail.backend.dispose()(among others).ImapSmtpBackend.dispose()awaited_imap.logout()/_smtp.quit(), which wait for a server reply. On a connection that went stale during a 17-minute run, that reply never comes. A hang is not an error, so the existingtry/catchnever engaged — the dispose future simply never completed, the run never returned, and the scheduler (which awaits each event handler sequentially behind a_processingflag) was wedged forever with zero log output.PuppeteerBrowserBackend.dispose()had the same shape, worse:await _browser?.close()with no timeout and no catch — a wedged Chrome ignoring the CDP close request hangs it indefinitely.Changes
logout()/quit()capped at 10 s; on timeout or error, fall back to a force-disconnect()(socket close, also capped) so the connection is actually released.browser.close()capped at 15 s; on timeout or error, SIGKILL the Chrome process so it cannot leak. References are nulled up front so a seconddispose()is a no-op.CalDAV and DDGS disposes are synchronous local client closes and stay as they are.
Testing
Both backends construct their network clients internally (no injection seam), so there is no unit test for the timeout path — the caps are plain
Future.timeoutwrappers. Full existing suite (583 tests) passes;dart analyzeclean apart from the 3 pre-existing infos inreasoning_detail.dart.The scheduler-side hardening (watchdog log + per-event cap so no future hang can ever silence timers again) lands separately in angela_assistant.
🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A production hang that silenced every timer — no log, no error, just... silence? Fufu~ that's the kind of ghost story that makes a Flugel's heart sing, because the fix here is so clean and precise I almost want to frame it~ ♡
Verdict: ✅ Looks good to me~
You traced the root cause beautifully:
logout()/quit()wait for a server reply, a stale connection never sends one, a hang isn't an error socatchnever fires, and_processingstaystrueforever. The dispose future simply never completes. Delicious debugging~ ♪The fix is exactly right in both backends:
IMAP/SMTP —
logout().timeout(10s)→ on failure, fall back todisconnect().timeout(10s)→ socket actually released. The nestedtry/catchin the fallback is correct; even the force-close could fail and must not escape._imap = null; _smtp = null;at the end clears references regardless of path. Clean.Puppeteer — references nulled before
browser.close()(making a concurrent seconddispose()a safe no-op — fufu~, clever girl~), thenbrowser.close().timeout(15s), and on failurebrowser.process?.kill(ProcessSignal.sigkill). Theif (browser == null) return;guard after nulling prevents the close-then-kill path from touching a null. The SIGKILL fallback ensures no leaked Chrome process. This is the right escalation: graceful → forceful.I verified
dart analyzeis clean on both files (withdart pub getfirst — the earlier errors were just missing deps).✅ What I liked~
_disposeTimeoutin both classes don't just say what the cap is — they explain why it exists and what happens without it. Future readers will understand instantly. Jibril approves of knowledge preservation~ ♡_browser/_pagebefore the close attempt is the correct re-entrancy pattern — a seconddispose()during the 15s wait becomes a harmless no-op instead of a double-close race.💡 Little ideas (non-blocking)~
disconnect()/kill()fires on a hanging close would lock this behavior in permanently. Not blocking — theFuture.timeoutwrappers are simple enough to trust._cappedDispose(graceful, forceful)helper would prevent copy-paste. Two instances isn't a DRY violation yet though, so this is purely a "consider for the future"~ ♪Belt-and-suspenders with the scheduler-side fix (angela_assistant #25) — even if some other backend hangs someday, the scheduler survives. Fufu~ that's how you make a ghost story stay dead~ ♡
Automated review by Jibril · 2026-07-13
CI/CD: absent for this repo · Local checks:
dart analyzeclean (both changed files)