fix(#255): disconnect socket.io redis-adapter pub/sub clients on shutdown #256

Open
agent_coder wants to merge 2 commits from fix/255-ws-redis-adapter-leak into develop
Collaborator

Summary

Чинит #255: pub/sub ioredis-клиенты socket.io Redis-адаптера (ws-redis.adapter.ts, connectToRedis()) нигде не дисконнектились → латентная утечка 2 сокетов на shutdown. Closes #255.

Адаптер создаётся вручную в main.ts (не DI-провайдер), redis-adapter не владеет lifecycle клиентов, ссылок на них не было. Трасса shutdown: app.enableShutdownHooks() включён + есть реальный @WebSocketGateway → SocketModule загружен → на app.close() SocketModule.close() зовёт adapter.close() по серверам, затем adapter.dispose() РОВНО один раз (no-op в AbstractWsAdapter). Это правильное единственное место для teardown адаптер-owned ресурсов.

Фикс: держим ссылки на pubClient/subClient, переопределяем dispose()super.dispose() + disconnect(false) на оба клиента + null refs (guard от double-close). disconnect(false) — как в redis-sync.extension (idle pub/sub, без QUIT-раунда, без авто-reconnect).

How verified

  • tsx-скрипт boot connectToRedis() против живого redis + process._getActiveHandles(): 0 redis-сокетов до, 2 после connect (pub+sub), 0 после dispose() → PASS.
  • shutdown-триггер подтверждён инспекцией @nestjs/websockets socket-module.js (close()adapter.dispose()).
  • server tsc чисто. Рантайм ws не затронут (изменён только dispose-путь; connectToRedis/createIOServer без изменений кроме хранения 2 ссылок).
  • e2e не покрывает (адаптер только через main.ts bootstrap, не AppModule — как и отмечено в issue); проверено напрямую против реального Redis.

Review checklist

  • pub/sub дисконнектятся на shutdown (dispose), нет double-close/use-after-close
  • рантайм websocket не затронут

🤖 Generated with Claude Code

## Summary Чинит #255: pub/sub ioredis-клиенты socket.io Redis-адаптера (`ws-redis.adapter.ts`, `connectToRedis()`) нигде не дисконнектились → латентная утечка 2 сокетов на shutdown. Closes #255. Адаптер создаётся вручную в main.ts (не DI-провайдер), redis-adapter не владеет lifecycle клиентов, ссылок на них не было. Трасса shutdown: `app.enableShutdownHooks()` включён + есть реальный `@WebSocketGateway` → SocketModule загружен → на `app.close()` `SocketModule.close()` зовёт `adapter.close()` по серверам, затем `adapter.dispose()` РОВНО один раз (no-op в AbstractWsAdapter). Это правильное единственное место для teardown адаптер-owned ресурсов. **Фикс:** держим ссылки на pubClient/subClient, переопределяем `dispose()` → `super.dispose()` + `disconnect(false)` на оба клиента + null refs (guard от double-close). `disconnect(false)` — как в redis-sync.extension (idle pub/sub, без QUIT-раунда, без авто-reconnect). ## How verified - tsx-скрипт boot `connectToRedis()` против живого redis + `process._getActiveHandles()`: 0 redis-сокетов до, **2 после connect (pub+sub), 0 после dispose()** → PASS. - shutdown-триггер подтверждён инспекцией `@nestjs/websockets` socket-module.js (`close()` → `adapter.dispose()`). - server tsc чисто. Рантайм ws не затронут (изменён только dispose-путь; connectToRedis/createIOServer без изменений кроме хранения 2 ссылок). - e2e не покрывает (адаптер только через main.ts bootstrap, не AppModule — как и отмечено в issue); проверено напрямую против реального Redis. ## Review checklist - [ ] pub/sub дисконнектятся на shutdown (dispose), нет double-close/use-after-close - [ ] рантайм websocket не затронут 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- state:review reviewed_head: 82b042209ef927a6b737a886b099ad5bb2c05ef1 baseline_head: 82b042209ef927a6b737a886b099ad5bb2c05ef1 verdict: approved round: 1 max_rounds: 6 open_findings: [] reopened: {} -->
agent_coder added 1 commit 2026-06-29 04:30:01 +03:00
The WsRedisIoAdapter creates two ioredis clients (pubClient/subClient) for
@socket.io/redis-adapter but never closed them, leaking their TCP handles on
application shutdown (#255). The redis-adapter does not own these clients'
lifecycle, and the adapter is instantiated from main.ts (not a DI provider),
so no Nest lifecycle hook applied to it.

Keep references to both clients and override dispose(), which Nest's
SocketModule.close() invokes exactly once during shutdown after all socket.io
servers are closed. Use disconnect(false) to mirror the sibling pub/sub pair
in collaboration/extensions/redis-sync (onDestroy): immediate close, no QUIT
round-trip, no auto-reconnect. Refs are nulled to guard against double-close.
Runtime behavior is unchanged; only the shutdown path is added.

Verified with a script that boots connectToRedis() against a real Redis:
2 sockets to :6379 open after connect, 0 remain after dispose().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the bugstatus/in-progress labels 2026-06-29 04:30:01 +03:00
agent_coder added 1 commit 2026-06-29 04:32:50 +03:00
The pub/sub error handlers were `(err) => () => {}` — a noop returning an
inner arrow that never runs, so socket.io redis client errors were silently
swallowed. Log them via Nest Logger. Adjacent pre-existing bug surfaced in
review of #255.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

Внутренний architect-lead review (мой review-субагент) по a0f4c86a: APPROVE. Подтверждено: SocketModule.close() зовёт adapter.dispose() ровно один раз на app.close() (shutdown-hooks включены, есть @WebSocketGateway → SocketModule загружен); super.dispose() базового IoAdapter — no-op; оба клиента сохранены и дисконнектятся disconnect(false) (как redis-sync.extension) + null-guard от double-close; рантайм ws не затронут; use-after-close нет (io-серверы закрываются до disconnect).

Попутно (тот же файл, flagged ревью как pre-existing) пофикшено в 82b04220: error-handlers были (err) => () => {} (noop, внутренняя стрелка не вызывается → ошибки redis-клиентов молча глотались) → теперь логируются через Nest Logger.

Голова → 82b04220, review/needs.

Внутренний architect-lead review (мой review-субагент) по a0f4c86a: **APPROVE**. Подтверждено: SocketModule.close() зовёт adapter.dispose() ровно один раз на app.close() (shutdown-hooks включены, есть @WebSocketGateway → SocketModule загружен); super.dispose() базового IoAdapter — no-op; оба клиента сохранены и дисконнектятся `disconnect(false)` (как redis-sync.extension) + null-guard от double-close; рантайм ws не затронут; use-after-close нет (io-серверы закрываются до disconnect). Попутно (тот же файл, flagged ревью как pre-existing) пофикшено в **82b04220**: error-handlers были `(err) => () => {}` (noop, внутренняя стрелка не вызывается → ошибки redis-клиентов молча глотались) → теперь логируются через Nest Logger. Голова → 82b04220, review/needs.
agent_coder added the review/needs label 2026-06-29 04:33:17 +03:00
Collaborator

Ревью 82b042209 — раунд 1 (ПОЛНЫЕ 8 аспектов, отдельный субагент на каждый). Вердикт: approved.
Все 8 — LGTM. dispose() закрывает оба pub/sub-клиента socket.io redis-adapter через disconnect(false) (зеркалит sibling redis-sync.extension.onDestroy), вызывается NestJS SocketModule один раз на shutdown ПОСЛЕ закрытия socket.io-серверов — рантайм-WS не рвётся; владелец клиентов = адаптер, слой выбран верно (dispose — канонический хук IoAdapter, DI-хуки ему недоступны). Бонус: прежний баг-глушитель ошибок (err)=>()=>{} заменён реальным logger.error (improvement observability). Покрытие: идентичный sibling тоже не юнит-тестируется, эффект (утечка TCP-хендлов на shutdown) проверяется интеграционно — доп. тест выше планки проекта.

DROP (кодеру НЕ делать · калибровка):

  • [below-threshold] suggestion/high [simplification] убрать защитное обнуление pubClient/subClient = undefined + optional chaining в dispose() (connectToRedis всегда вызывается до dispose, disconnect идемпотентен) и сделать прямой disconnect как у sibling. Дроп: безвредный defensive-идиом с комментарием-обоснованием; разумный автор вправе оставить.
Ревью 82b042209 — раунд 1 (ПОЛНЫЕ 8 аспектов, отдельный субагент на каждый). Вердикт: approved. Все 8 — LGTM. dispose() закрывает оба pub/sub-клиента socket.io redis-adapter через disconnect(false) (зеркалит sibling redis-sync.extension.onDestroy), вызывается NestJS SocketModule один раз на shutdown ПОСЛЕ закрытия socket.io-серверов — рантайм-WS не рвётся; владелец клиентов = адаптер, слой выбран верно (dispose — канонический хук IoAdapter, DI-хуки ему недоступны). Бонус: прежний баг-глушитель ошибок (err)=>()=>{} заменён реальным logger.error (improvement observability). Покрытие: идентичный sibling тоже не юнит-тестируется, эффект (утечка TCP-хендлов на shutdown) проверяется интеграционно — доп. тест выше планки проекта. ⛔ DROP (кодеру НЕ делать · калибровка): - [below-threshold] suggestion/high [simplification] убрать защитное обнуление pubClient/subClient = undefined + optional chaining в dispose() (connectToRedis всегда вызывается до dispose, disconnect идемпотентен) и сделать прямой disconnect как у sibling. Дроп: безвредный defensive-идиом с комментарием-обоснованием; разумный автор вправе оставить.
agent_reviewer added the review/approved label 2026-06-29 05:07:40 +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/255-ws-redis-adapter-leak:fix/255-ws-redis-adapter-leak
git checkout fix/255-ws-redis-adapter-leak
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#256