fix(#252): close leftover ioredis handles so e2e jest exits cleanly (no forceExit) #254

Open
agent_coder wants to merge 1 commits from fix/252-e2e-open-handles into develop
Collaborator

Summary

Чинит причину #252: e2e-server jest виснет (не выходит) из-за двух незакрытых ioredis-сокетов к :6379 после app.close(). Реальный lifecycle-фикс, БЕЗ --forceExit. Closes #252.

Диагностика на реальных PG+Redis (process._getActiveHandles() как оракул, т.к. --detectOpenHandles сам виснет): до close — 32 сокета; после app.close() оставались 2 ioredis→:6379. Остальное (BullMQ очереди+воркеры, @nestjs/schedule таймеры, RedisModule, pg-pool, cache Keyv, hocuspocus pub/sub, unref'нутые sweep-таймеры) гасится корректно.

Два production-clean фикса:

  1. throttle.module.ts — передавал готовый new Redis(...) в ThrottlerStorageRedisService; с инстансом либа ставит disconnectRequired=false → её onModuleDestroy не дисконнектит. Передаём ioredis-ОПЦИИ → сервис владеет и дисконнектит клиента.
  2. collaboration.gateway.ts — создавал source new RedisClient(...), который RedisSyncExtension только дублирует в pub/sub; extension.onDestroy гасит дубли, но не source. Держим ссылку и дисконнектим source в destroy() после hocuspocus onDestroy.

Урезанный тест-модуль НЕ делал: полный AppModule — намеренная smoke-цель (boot реального Fastify + GET /), урезание снизило бы покрытие; вместо этого сделал чистый shutdown.

How verified

  • test:e2e (--runInBand, БЕЗ forceExit): тест проходит, exit 0, ~18s (было — вис бесконечно), без «Jest did not exit».
  • --detectOpenHandles --runInBand: exit 0 ~23s, отчёта об открытых хендлах нет.
  • Дамп хендлов после фиксов: после app.close() активных хендлов нет ({}).
  • server tsc: чисто. Существующий e2e-тест проходит.

Review checklist

  • jest e2e выходит чисто без forceExit (open handles закрыты)
  • throttler и collab-redis по-прежнему работают в рантайме (фиксы только про shutdown-владение/дисконнект)

CI (timeout-minutes в develop.yml/test.yml) оставлен как safety-net (не трогал yaml).

🤖 Generated with Claude Code

## Summary Чинит причину #252: e2e-server jest виснет (не выходит) из-за двух незакрытых ioredis-сокетов к :6379 после `app.close()`. Реальный lifecycle-фикс, БЕЗ `--forceExit`. Closes #252. Диагностика на реальных PG+Redis (`process._getActiveHandles()` как оракул, т.к. `--detectOpenHandles` сам виснет): до close — 32 сокета; после `app.close()` оставались **2 ioredis→:6379**. Остальное (BullMQ очереди+воркеры, @nestjs/schedule таймеры, RedisModule, pg-pool, cache Keyv, hocuspocus pub/sub, unref'нутые sweep-таймеры) гасится корректно. **Два production-clean фикса:** 1. `throttle.module.ts` — передавал готовый `new Redis(...)` в `ThrottlerStorageRedisService`; с инстансом либа ставит `disconnectRequired=false` → её onModuleDestroy не дисконнектит. Передаём ioredis-ОПЦИИ → сервис владеет и дисконнектит клиента. 2. `collaboration.gateway.ts` — создавал source `new RedisClient(...)`, который RedisSyncExtension только дублирует в pub/sub; extension.onDestroy гасит дубли, но не source. Держим ссылку и дисконнектим source в `destroy()` после hocuspocus onDestroy. Урезанный тест-модуль НЕ делал: полный AppModule — намеренная smoke-цель (boot реального Fastify + GET /), урезание снизило бы покрытие; вместо этого сделал чистый shutdown. ## How verified - `test:e2e` (`--runInBand`, БЕЗ forceExit): тест проходит, **exit 0, ~18s** (было — вис бесконечно), без «Jest did not exit». - `--detectOpenHandles --runInBand`: exit 0 ~23s, отчёта об открытых хендлах нет. - Дамп хендлов после фиксов: после `app.close()` активных хендлов нет (`{}`). - server tsc: чисто. Существующий e2e-тест проходит. ## Review checklist - [ ] jest e2e выходит чисто без forceExit (open handles закрыты) - [ ] throttler и collab-redis по-прежнему работают в рантайме (фиксы только про shutdown-владение/дисконнект) CI (`timeout-minutes` в develop.yml/test.yml) оставлен как safety-net (не трогал yaml). 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- state:review reviewed_head: 82747202814cde8a01e4045023ba6c8302aa8f7f baseline_head: 82747202814cde8a01e4045023ba6c8302aa8f7f verdict: approved round: 1 max_rounds: 6 open_findings: [] reopened: {} -->
agent_coder added 1 commit 2026-06-29 04:13:09 +03:00
The full-AppModule e2e (apps/server/test/app.e2e-spec.ts) passed but jest
never exited, burning CI to its timeout. Diagnosis (process._getActiveHandles
after app.close()) showed exactly two ioredis sockets to :6379 still open after
shutdown; everything else (BullMQ queues/workers, @nestjs/schedule intervals,
nestjs-ioredis, nestjs-kysely pg pool, @nestjs/cache-manager Keyv store,
hocuspocus pub/sub) already closes on app.close().

The two leaks were owned-but-never-closed clients:

1. ThrottleModule passed a pre-built `new Redis(...)` instance to
   ThrottlerStorageRedisService. With an instance, the lib sets
   disconnectRequired=false, so its onModuleDestroy never disconnects.
   Pass ioredis options instead so the service owns + disconnects the client.

2. CollaborationGateway created a source `new RedisClient(...)` that
   RedisSyncExtension only duplicates into pub/sub; the extension's onDestroy
   disconnects those duplicates but not the source. Keep a reference and
   disconnect it after the hocuspocus onDestroy hook in destroy().

Both are real lifecycle fixes (production shutdown is now clean too), so no
--forceExit is needed. Verified against real Postgres+Redis:
  - test:e2e (no forceExit, --runInBand) exits 0 in ~18s (was: hung forever)
  - --detectOpenHandles exits 0 with no open-handle report
  - active handles after app.close(): none
CI timeout-minutes safety nets left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the bugteststatus/in-progress labels 2026-06-29 04:13:09 +03:00
Author
Collaborator

Внутренний architect-lead review (мой review-субагент) по 82747202: APPROVE. Обе правки корректны и ограничены teardown — рантайм throttling/collab не затрагивается: (a) throttler получает ioredis-ОПЦИИ → сервис ставит disconnectRequired=true и дисконнектит в onModuleDestroy (набор опций host/port/db/password/family идентичен прежнему, ничего не потеряно); (b) collab-gateway держит ссылку на source-клиент и дисконнектит его в destroy() после onDestroy (use-after-disconnect/double-disconnect невозможны — source не используется в рантайме, расширение работает через duplicate()). Цикл сошёлся. Pre-existing утечка pub/sub в ws-redis.adapter (вне scope #252) вынесена в issue #255. Голова → 82747202, review/needs.

Внутренний architect-lead review (мой review-субагент) по 82747202: **APPROVE**. Обе правки корректны и ограничены teardown — рантайм throttling/collab не затрагивается: (a) throttler получает ioredis-ОПЦИИ → сервис ставит disconnectRequired=true и дисконнектит в onModuleDestroy (набор опций host/port/db/password/family идентичен прежнему, ничего не потеряно); (b) collab-gateway держит ссылку на source-клиент и дисконнектит его в destroy() после onDestroy (use-after-disconnect/double-disconnect невозможны — source не используется в рантайме, расширение работает через duplicate()). Цикл сошёлся. Pre-existing утечка pub/sub в ws-redis.adapter (вне scope #252) вынесена в issue #255. Голова → 82747202, review/needs.
agent_coder added the review/needs label 2026-06-29 04:17:12 +03:00
Collaborator

Ревью 827472028 — раунд 1 (ПОЛНЫЕ 8 аспектов, отдельный субагент на каждый). Вердикт: approved.
Все 8 — LGTM. Закрытие ioredis-хендлов посажено на корректные lifecycle-хуки: gateway держит исходный клиент и disconnect()-ит в destroy()→onModuleDestroy после того, как RedisSyncExtension.onDestroy закрыл pub/sub-дубликаты (порядок верный, идемпотентно, withRedis=false → no-op); throttle делегирует владение ThrottlerStorageRedisService (передача опций вместо инстанса → disconnectRequired=true, закрытие в onModuleDestroy) — заодно убран импорт ioredis (упрощение). Прод-runtime не затрагивается (disconnect только в shutdown-хуках); forceExit в репо не было — фикс закрывает реальные хендлы, а не маскирует. Покрытие: новый путь проходится в e2e (app.close()), чистый выход процесса и есть регрессионный сигнал. Открытых находок нет.

Ревью 827472028 — раунд 1 (ПОЛНЫЕ 8 аспектов, отдельный субагент на каждый). Вердикт: approved. Все 8 — LGTM. Закрытие ioredis-хендлов посажено на корректные lifecycle-хуки: gateway держит исходный клиент и disconnect()-ит в destroy()→onModuleDestroy после того, как RedisSyncExtension.onDestroy закрыл pub/sub-дубликаты (порядок верный, идемпотентно, withRedis=false → no-op); throttle делегирует владение ThrottlerStorageRedisService (передача опций вместо инстанса → disconnectRequired=true, закрытие в onModuleDestroy) — заодно убран импорт ioredis (упрощение). Прод-runtime не затрагивается (disconnect только в shutdown-хуках); forceExit в репо не было — фикс закрывает реальные хендлы, а не маскирует. Покрытие: новый путь проходится в e2e (app.close()), чистый выход процесса и есть регрессионный сигнал. Открытых находок нет.
agent_reviewer added the review/approved label 2026-06-29 05:04:00 +03:00
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/252-e2e-open-handles:fix/252-e2e-open-handles
git checkout fix/252-e2e-open-handles
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#254