longPoll: a 409 Conflict permanently stops the polling loop (#1350) - #1358
Conversation
Michi-Task: c9p1 Michi-Issue: 1350
Michi-Task: d4t2 Michi-Issue: 1350
Michi-Task: e7d3 Michi-Issue: 1350
|
😺 Michi — all tasks done, CI green, marked ready for review. Done
CI: GitHub Actions (typecheck, build, unit on Bun + Node 22/24/26) and AppVeyor all pass; E2E skipped by design. Full local gate ( Waiting for your merge. 🐾 |
Michi-Task: f8h4 Michi-Issue: 1350
|
😺 Michi — added T4 for the second half of #1350 (the "process lingers dead" symptom); CI green. Investigation: the core pump does not itself hang — verified empirically:
Note: preventing the lingering itself is inherently the caller's open resource (or T1) — a library can't force-close handles it doesn't own — so T4 makes the failure unmissable rather than force-killing the process. Happy to instead add an CI: all GitHub Actions + AppVeyor green; E2E skipped by design. Full local gate ( Waiting for your merge. 🐾 |
…it (#1350) Michi-Task: g5w5 Michi-Issue: 1350
|
😺 Michi — added T5, the real "not stopping" bug, found by tracing the mitubo-bot commits; CI green. The production hang wasn't the poll loop — it was webhook-server shutdown hanging on a stuck connection during Fly scale-to-zero idle-exit (fixed downstream in mitubo-bot T5 ( CI: all GitHub Actions + AppVeyor green; E2E skipped by design. Full local gate ( Waiting for your merge. 🐾 |
Michi-Task: h6a1 Michi-Issue: 1350
Michi-Task: i7b2 Michi-Issue: 1350
Michi-Task: j8c3 Michi-Issue: 1350
|
😺 Michi — applied the Codex (gpt-6-astra) review; CI green.
One deliberate scope note: I did not add an explicit force- CI: all GitHub Actions + AppVeyor green; E2E skipped by design. Full local gate ( Waiting for your merge. 🐾 |
Michi-Task: m1f6 Michi-Issue: 1350
…1350) Michi-Task: n2g7 Michi-Issue: 1350
Michi-Task: o3h8 Michi-Issue: 1350
Michi-Task: p4j9 Michi-Issue: 1350
There was a problem hiding this comment.
🟡 Changes recommended
There are verified edge-case correctness issues (conflict retry streak semantics and a potential ERR_SERVER_NOT_RUNNING crash path in gracefulClose) plus a test that mutates global signal handlers in a way that can be flaky under concurrent test execution.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR addresses Telegram getUpdates polling resilience by treating 409 Conflict as a recoverable polling-specific condition (with bounded retries + backoff), and strengthens Node runner shutdown behavior to avoid silent dead processes or hanging webhook shutdowns.
Changes:
- Update
longPollto retry on409 Conflictwith dedicated delay and a max consecutive-conflict bound; addisPollConflict/HTTP_STATUS_CONFLICTto centralize error taxonomy. - Make managed Node runners (
run,startWebhook) more operationally robust via shared shutdown-signal handling and non-hanging webhook shutdown (gracefulClose). - Add unit tests covering 409 conflict recovery/bounds, fatal poll-stop surfacing to stderr, and webhook shutdown edge cases.
File summaries
| File | Description |
|---|---|
| test/unit/server.test.ts | Adds webhook shutdown and signal-handling/idempotency tests. |
| test/unit/run.test.ts | Tests that run() surfaces fatal poll-stops to stderr and rethrows. |
| test/unit/longpoll.test.ts | Adds coverage for 409 conflict retry/backoff/bounds and counter reset. |
| src/node/signals.ts | Introduces shared SIGINT/SIGTERM install/remove helper for Node runners. |
| src/node/server.ts | Adds gracefulClose, makes webhook handler promise rejection safe, uses shared shutdown-signal plumbing. |
| src/node/run.ts | Uses shared shutdown-signal plumbing; logs fatal poll-stop to stderr before rethrow. |
| src/core/longpoll.ts | Implements 409 conflict retry strategy + new LongPollOptions fields. |
| src/core/errors.ts | Adds HTTP 409 constant and isPollConflict classifier alongside isTransientError. |
| package.json | Bumps Node engine minimum to >=18.2.0. |
| doc/api.md | Regenerates API docs for new exports/options and updated docstrings. |
| CLAUDE.md | Updates documented minimum Node version to 18.2+. |
Review details
- Files reviewed: 11/11 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| export function gracefulClose(server: http.Server, timeoutMs: number): ReturnType<typeof setTimeout> { | ||
| server.close(); // stop accepting; resolves once existing connections end | ||
| server.closeIdleConnections?.(); // drop idle keep-alive sockets now | ||
| const forceTimer = setTimeout(() => server.closeAllConnections?.(), timeoutMs); | ||
| forceTimer.unref?.(); // don't let the timer itself hold the loop open |
| const pollConflict = isPollConflict(err); | ||
| if (!retry || !(isTransientError(err) || pollConflict)) throw err; | ||
| if (pollConflict && ++conflicts > maxConflictRetries) throw err; |
| // Detach any pre-existing signal listeners (e.g. the test runner's) so our | ||
| // synthetic emit reaches only startWebhook's handler; restored in finally. | ||
| const savedInt = process.listeners("SIGINT"); | ||
| const savedTerm = process.listeners("SIGTERM"); | ||
| process.removeAllListeners("SIGINT"); | ||
| process.removeAllListeners("SIGTERM"); |
Michi-Task: s7m2 Michi-Issue: 1350
…ning (#1350) Michi-Task: q5k0 Michi-Issue: 1350
Michi-Task: r6l1 Michi-Issue: 1350
|
😺 Michi — applied the Copilot review; CI green.
CI: all GitHub Actions + AppVeyor green; E2E skipped by design. Full local gate ( Waiting for your merge. 🐾 |
Closes #1350
longPoll: treat a 409 poll conflict as recoverable - back off (its own longer delay) and resume, bounded by a max consecutive-conflict count that then throws; log +onError, counter resets on any successful pollretry: falsedoc/api.mdfor the newLongPollOptionsfieldsrun(): a fatal poll-stop must not be silent - the managed runner surfaces it to stderr before re-throwing, so a dropped/late-observed rejection can't leave the process alive with dead polling; unit teststartWebhook: cap graceful shutdown so an idle keep-alive (or stuck) connection can't leave a stopped-but-never-exiting process - drop idle connections, force-close pastshutdownTimeoutMs; unit test + docsengines, docs) -closeIdleConnections/closeAllConnectionsneed 18.2, so>=18could still hang (Codex P2)startWebhook's shutdown idempotent: ashuttingDownguard schedules a single force-timer, so repeated signals can't leak a timer that fires after resolve (Codex)startWebhooksignal-driven shutdown (idle close, force-close on timeout, repeated signals, handler cleanup) andlongPollconflict counter resets on a successful (incl. empty) poll (Codex)isPollConflict+HTTP_STATUS_CONFLICT) intoerrors.tsnext toisTransientError, so all error taxonomy lives in one modulesrc/node/withShutdownSignals, used by bothrun()andstartWebhook()(one place for the signal set)gracefulClose(server, timeoutMs)inserver.ts(close + drop idle + bounded force-close) so shutdown mechanics are self-contained and directly unit-testablecreateWebhookServernow.catches the handler promise, so a socket destroyed mid-body (ECONNRESET fromreadBody) can't become an unhandled rejection that crashes the process mid-shutdown; regression test (Codex P2)startWebhook's shutdown handlers only after the server is listening, so an early signal can't callserver.close()on a not-yet-listening server (ERR_SERVER_NOT_RUNNING) - no try/catch needed (Copilot)longPoll: reset the conflict counter on a non-conflict transient error so the bound is on truly consecutive 409s (409 -> 5xx -> 409 no longer counts as two); test (Copilot)