Skip to content

feat(app): graceful shutdown on SIGTERM/SIGINT via App.stop#20

Merged
BlindMaster24 merged 4 commits into
devin/1777024936-rate-limitfrom
devin/1777025566-graceful-shutdown
Apr 27, 2026
Merged

feat(app): graceful shutdown on SIGTERM/SIGINT via App.stop#20
BlindMaster24 merged 4 commits into
devin/1777024936-rate-limitfrom
devin/1777025566-graceful-shutdown

Conversation

@devin-ai-integration
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot commented Apr 24, 2026

Summary

Wires the process lifecycle so a SIGTERM or SIGINT (ctrl-c, docker stop, k8s eviction) drains running services and closes the DB before exit, instead of yanking the process mid-flight.

Why: container orchestrators send SIGTERM and then SIGKILL after a grace period. Until now we had only partial coverage (Telegram polling was stopping itself on signal, but the HTTP server kept holding connections, the parser kept polling the site, and the Sequelize pool was never closed). That caused half-written parser cache files on rolling restarts, Docker logs full of received signal, exiting followed by hard kills, and dangling DB connections in logs.

What's new:

  • AppService gets an optional stop?(): Promise<any> | any;.
  • App.stop({ timeoutMs }) iterates services in reverse registration order (so e.g. tg/vk/viber/google_calendar get stopped before http/parser), calls each service's stop() if defined, and applies a per-service timeout (default 15s) so one stuck service can't block shutdown. After all services are stopped it calls sequelize.close().
  • HttpService.stop() — captures the http.Server returned by listen() and server.close()s it on shutdown; existing in-flight requests are allowed to finish.
  • ParserService.stop() — flips a _stopping flag and resolves the current delayPromise so the infinite parse loop breaks on the next tick instead of running another full parse.
  • TgBot.stop() — exposes the grammY Bot.stop() through the AppService interface; removed the inline process.once('SIGINT'/'SIGTERM', ...) it used to register — that handler only stopped polling and left everything else running. Centralizing in App.stop() fixes the ordering.
  • src/index.ts — one shutdown handler for both signals. Logs, triggers App.stop(), and a 30s hard-timeout setTimeout(..., 30_000).unref() guarantees the process exits even if something hangs past the per-service budget.

Not touched (yet): VkBot, ViberBot, AliceApp, VKApp, GoogleService, ImageService, Api, Timetable, BotService. VK long-polling stops cleanly when the event-loop drains; Viber and Alice run on the shared express server so HttpService.stop() handles them; the rest don't own background work. Follow-ups can land these later without changing the contract.

Tests (tests/app/shutdown.test.ts):

  • stops services in reverse registration order and closes the DB exactly once
  • continues to the next service when one of them throws, still closes the DB
  • enforces the per-service timeout and moves on without waiting for the stuck service

Checks:

  • pnpm run test:all — 32 files / 210 tests pass
  • pnpm run ts-check / pnpm run lint / pnpm run format:check — clean
  • pnpm audit --audit-level=low — no known vulnerabilities

Run the new tests with: pnpm exec vitest run tests/app/shutdown.test.ts

Stacked on PR #19.

Review & Testing Checklist for Human

  • docker compose up -d then docker compose stop bot — container should exit cleanly (exit code 0) within ~15s instead of being force-killed after grace period. docker compose logs bot should show Остановка..., Остановлено: http, etc., then БД отключена.
  • With polling Telegram bot enabled, send a ctrl+c while a message is being handled — request should finish, then process exits.
  • Run without any services that implement stop()App.stop() should still close the DB and exit.
  • Confirm the 30s hard-timeout fallback by temporarily replacing one service's stop() with () => new Promise(() => {}) — process should still exit after 30s with code 1.

Notes

  • Default per-service timeout is 15s; hard-timeout in src/index.ts is 30s. Tune these via App.stop({ timeoutMs }) if you want tighter/looser SLAs.
  • Next: PR #M (grammY smoke tests) closes out the plan.

Link to Devin session: https://app.devin.ai/sessions/7732f5fd16e9448295cbabeb8b5f471a
Requested by: @BlindMaster24


Open in Devin Review

@devin-ai-integration
Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@BlindMaster24 BlindMaster24 merged commit a1ceb92 into devin/1777024936-rate-limit Apr 27, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant