test(int): убрать избыточный forceExit — bounded teardown уже расхангивает test:int (#382) #383

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

Summary

Убирает "forceExit": true из apps/server/test/jest-integration.json. closes #382

Пластырь в develop добавил ДВЕ вещи: forceExit И bounded destroyTestDb (sql.end({ timeout: 5 })). Настоящий фикс — bounded teardown; forceExit был избыточным и вдобавок МАСКИРОВАЛ, выходит ли процесс сам. Убираю его; bounded destroyTestDb оставлен как защита (как и просили в DoD).

Почему зависало (root cause)

Изначальный destroyTestDb звал Kysely.destroy() → postgres.js sql.end() БЕЗ таймаута. .end() без таймаута ждёт незавершённые/залипшие соединения пула бесконечно. В CI одно соединение оставалось занятым на момент afterAll.end() блокировался → hook-таймаут 60с, а открытые сокеты пула держали процесс → «Jest did not exit» → ~14 мин до отсечки джобы. Bounded { timeout: 5 } даёт grace-период на слив активных запросов, затем force-закрывает сокеты — teardown всегда завершается.

Аудит открытых handles (почему forceExit можно убрать безопасно)

Прошёл по ВСЕМ спекам, которые создают внешние handles:

  • ai-chat-stream.int-spec.ts — реальный http.createServer + сокет-клиент; закрывается в finally через server.close() (makeRealResponse().cleanup). Heartbeat — это счётчик, НЕ таймер (в сервисе нет ни одного setInterval/setTimeout), так что «залипшего» интервала нет.
  • public-share-workspace-limiter.int-spec.tsnew Redis(..., { lazyConnect: false }); закрывается await redis.quit() в afterAll.
  • Общие пулы БД (getTestDb) — закрываются bounded destroyTestDb в afterAll каждой спеки.

Спеки НЕ поднимают полноценный Nest-app (нет NestFactory/createTestingModule/*.module-импортов) → нет BullMQ-воркеров и лишних Redis-подключений.

How verified (локальный стенд: pg18 + redis, реальный конвертер собран)

  • pnpm --filter server test:int (реальная CI-команда, без forceExit): 16 suites / 66 passed, процесс выходит САМ за 21–48с, без «Jest did not exit» и без «Force exiting» — 2 прогона.
  • --detectOpenHandles --runInBand: чисто, ни одного открытого handle (3 прогона; в т.ч. при ВРЕМЕННОМ откате teardown к unbounded .end() — суита всё равно выходила сама, т.е. детерминированной течи в спеках на стенде нет).
  • ai-chat-stream — без 60-сек залипания в afterAll.

Честно: исходное CI-зависание на локальном стенде воспроизвести НЕ удалось даже с unbounded teardown (суита выходит сама, detectOpenHandles чист) — это указывает, что детерминированной течи в спеках нет, а блокировка была на уровне .end() в специфичных для CI условиях (залипшее соединение). Bounded teardown ровно это и снимает. Финальная проверка — CI этого PR (джоба test / test, шаг Run server integration tests): она гоняет test:int уже без forceExit и упадёт по таймауту, если где-то остался handle.

Checklist

  • forceExit убран из jest-integration.json
  • bounded destroyTestDb оставлен (защита от залипшего соединения)
  • --detectOpenHandles чист, суита выходит сама ~20–48с
  • вне заявленного scope ничего не менялось (диф — одна строка)
## Summary Убирает `"forceExit": true` из `apps/server/test/jest-integration.json`. closes #382 Пластырь в develop добавил ДВЕ вещи: `forceExit` И bounded `destroyTestDb` (`sql.end({ timeout: 5 })`). Настоящий фикс — bounded teardown; `forceExit` был избыточным и вдобавок МАСКИРОВАЛ, выходит ли процесс сам. Убираю его; bounded `destroyTestDb` оставлен как защита (как и просили в DoD). ## Почему зависало (root cause) Изначальный `destroyTestDb` звал `Kysely.destroy()` → postgres.js `sql.end()` БЕЗ таймаута. `.end()` без таймаута ждёт незавершённые/залипшие соединения пула бесконечно. В CI одно соединение оставалось занятым на момент `afterAll` → `.end()` блокировался → hook-таймаут 60с, а открытые сокеты пула держали процесс → «Jest did not exit» → ~14 мин до отсечки джобы. Bounded `{ timeout: 5 }` даёт grace-период на слив активных запросов, затем force-закрывает сокеты — teardown всегда завершается. ## Аудит открытых handles (почему forceExit можно убрать безопасно) Прошёл по ВСЕМ спекам, которые создают внешние handles: - **`ai-chat-stream.int-spec.ts`** — реальный `http.createServer` + сокет-клиент; закрывается в `finally` через `server.close()` (`makeRealResponse().cleanup`). Heartbeat — это счётчик, НЕ таймер (в сервисе нет ни одного `setInterval`/`setTimeout`), так что «залипшего» интервала нет. - **`public-share-workspace-limiter.int-spec.ts`** — `new Redis(..., { lazyConnect: false })`; закрывается `await redis.quit()` в `afterAll`. - Общие пулы БД (`getTestDb`) — закрываются bounded `destroyTestDb` в `afterAll` каждой спеки. Спеки НЕ поднимают полноценный Nest-app (нет `NestFactory`/`createTestingModule`/`*.module`-импортов) → нет BullMQ-воркеров и лишних Redis-подключений. ## How verified (локальный стенд: pg18 + redis, реальный конвертер собран) - `pnpm --filter server test:int` (реальная CI-команда, без forceExit): **16 suites / 66 passed, процесс выходит САМ за 21–48с**, без «Jest did not exit» и без «Force exiting» — 2 прогона. - `--detectOpenHandles --runInBand`: **чисто**, ни одного открытого handle (3 прогона; в т.ч. при ВРЕМЕННОМ откате teardown к unbounded `.end()` — суита всё равно выходила сама, т.е. детерминированной течи в спеках на стенде нет). - `ai-chat-stream` — без 60-сек залипания в `afterAll`. **Честно:** исходное CI-зависание на локальном стенде воспроизвести НЕ удалось даже с unbounded teardown (суита выходит сама, detectOpenHandles чист) — это указывает, что детерминированной течи в спеках нет, а блокировка была на уровне `.end()` в специфичных для CI условиях (залипшее соединение). Bounded teardown ровно это и снимает. Финальная проверка — CI этого PR (джоба `test / test`, шаг `Run server integration tests`): она гоняет `test:int` уже без forceExit и упадёт по таймауту, если где-то остался handle. ## Checklist - [x] `forceExit` убран из jest-integration.json - [x] bounded `destroyTestDb` оставлен (защита от залипшего соединения) - [x] `--detectOpenHandles` чист, суита выходит сама ~20–48с - [x] вне заявленного scope ничего не менялось (диф — одна строка)
agent_coder added 1 commit 2026-07-06 04:08:43 +03:00
The int suite could not self-exit after the ESM fix (8e125799) unmasked 4
specs, and was patched with two things: forceExit:true AND a bounded
destroyTestDb (sql.end({ timeout: 5 })). The bounded teardown is the real
fix — postgres.js .end() without a timeout blocks indefinitely on a stuck
pooled connection (the CI-observed "Jest did not exit"); the { timeout: 5 }
grace drains then force-closes sockets so teardown always completes.

forceExit was redundant belt-and-suspenders that also HID whether the
process truly exits on its own. Removing it: every handle-creating spec is
verified to close its handle — ai-chat-stream closes its http.createServer
in a finally, public-share-workspace-limiter closes its ioredis via
redis.quit() in afterAll, and the shared DB pools close via the bounded
destroyTestDb. --detectOpenHandles is clean and the suite self-exits.

Kept: the bounded destroyTestDb (defense for a genuinely stuck connection).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-06 04:08:50 +03:00
Collaborator

🤖 Авто-ревью · #383 (test(int): убрать избыточный forceExit — bounded teardown уже расхангивает test:int, #382), голова 84334a1f

Вердикт: PASS — удаление forceExit безопасно и полезно; настоящий фикс (bounded destroyTestDb) на месте и работает. Воспроизвёл объективку сам — сьют выходит чисто без forceExit. Ставлю review/approved.

Дифф — ровно одна строка: - "forceExit": true из apps/server/test/jest-integration.json. Bounded destroyTestDb (sql.end({ timeout: 5 })) оставлен как реальное исправление зависания.

Проверено объективно (CI-fidelity, реальный прогон)

Поднял pgvector/pgvector:pg17 + redis:7 в Docker (совпадает с дефолтами db.ts: docmost:docmost_dev_pw@localhost:5432, redis://127.0.0.1:6379), прогнал ровно как CI — jest --config test/jest-integration.json, без forceExit:

Test Suites: 16 passed, 16 total
Tests:       66 passed, 66 total
JEST_EXIT=0   WALL=48s   (кап был 480s; прежний ханг — ~14 мин)
grep "did not exit|open handle|Force exiting" → пусто

Процесс вышел сам, без предупреждения «Jest did not exit» — то есть forceExit ничего живого не маскировал. (Строки disk on fire / duplicate key / Corrupt tool_allowlist в логе — это симулируемые ошибки самих error-path тестов, не падения; все 66 зелёные.)

Аудит открытых хэндлов (почему forceExit избыточен)

Прошёлся по всем 16 int-спекам — каждый долгоживущий хэндл явно закрывается:

  • DB-пул (все спеки) → bounded destroyTestDb в afterAll (db.ts:88): sql.end({ timeout: 5 }) закрывает пул НАПРЯМУЮ (не Kysely.destroy(), который звал бы .end() без таймаута) — с жёстким грейсом, зависнуть не может. Это и был root-cause ханга (#382). global-teardown.ts тоже зовёт его.
  • Redis (public-share-workspace-limiter.int-spec.ts, реальный ioredis, db:15) → afterAllredis.quit().
  • HTTP-сервер (ai-chat-stream.int-spec.ts:58) → тест-скоупный, server.close() внутри самого теста.

Убрав forceExit, будущая утечка хэндла теперь всплывёт как видимый ханг, а не замаскируется — плюс к наблюдаемости. Совпадает с DoD #382 (bounded teardown — защита, forceExit — лишний).

Аспекты (судил напрямую — 1-строчный тест-конфиг)
  • stability/regressions (главное): риск = ре-ханг при снятии forceExit — снят реальным прогоном (чистый self-exit 48s, 66/66) + аудитом хэндлов. Нет.
  • coherence: цель #382 достигнута — убран маскирующий флаг, реальный фикс (bounded teardown) сохранён.
  • security — нет поверхности; test-coverage — это тест-конфиг, не логика; conventions/docs/simplification/architecture — тривиально чисто (удаление одной опции).
  • Объективка воспроизведена мной (CI пока pending) — «зелёный чек, на который можно показать», а не рассуждение.

Единственный существенный вопрос PR — «выходит ли процесс сам без forceExit» — проверил реальным прогоном сьюта (Docker pgvector+redis, 66/66, exit 0, 48s, ноль «did not exit»). Ветка чистая, можно мёржить.

## 🤖 Авто-ревью · #383 (test(int): убрать избыточный forceExit — bounded teardown уже расхангивает test:int, #382), голова `84334a1f` **Вердикт: PASS** ✅ — удаление `forceExit` безопасно и полезно; настоящий фикс (bounded `destroyTestDb`) на месте и работает. **Воспроизвёл объективку сам** — сьют выходит чисто без forceExit. Ставлю review/approved. Дифф — ровно одна строка: `- "forceExit": true` из `apps/server/test/jest-integration.json`. Bounded `destroyTestDb` (`sql.end({ timeout: 5 })`) оставлен как реальное исправление зависания. ### Проверено объективно (CI-fidelity, реальный прогон) Поднял `pgvector/pgvector:pg17` + `redis:7` в Docker (совпадает с дефолтами `db.ts`: `docmost:docmost_dev_pw@localhost:5432`, `redis://127.0.0.1:6379`), прогнал ровно как CI — `jest --config test/jest-integration.json`, **без** forceExit: ``` Test Suites: 16 passed, 16 total Tests: 66 passed, 66 total JEST_EXIT=0 WALL=48s (кап был 480s; прежний ханг — ~14 мин) grep "did not exit|open handle|Force exiting" → пусто ``` Процесс **вышел сам**, без предупреждения «Jest did not exit» — то есть forceExit ничего живого не маскировал. (Строки `disk on fire` / `duplicate key` / `Corrupt tool_allowlist` в логе — это симулируемые ошибки самих error-path тестов, не падения; все 66 зелёные.) ### Аудит открытых хэндлов (почему forceExit избыточен) Прошёлся по всем 16 int-спекам — каждый долгоживущий хэндл явно закрывается: - **DB-пул** (все спеки) → bounded `destroyTestDb` в `afterAll` (`db.ts:88`): `sql.end({ timeout: 5 })` закрывает пул НАПРЯМУЮ (не `Kysely.destroy()`, который звал бы `.end()` без таймаута) — с жёстким грейсом, зависнуть не может. Это и был root-cause ханга (#382). `global-teardown.ts` тоже зовёт его. - **Redis** (`public-share-workspace-limiter.int-spec.ts`, реальный ioredis, db:15) → `afterAll` → `redis.quit()`. - **HTTP-сервер** (`ai-chat-stream.int-spec.ts:58`) → тест-скоупный, `server.close()` внутри самого теста. Убрав forceExit, будущая утечка хэндла теперь всплывёт как видимый ханг, а не замаскируется — плюс к наблюдаемости. Совпадает с DoD #382 (bounded teardown — защита, forceExit — лишний). <details> <summary>Аспекты (судил напрямую — 1-строчный тест-конфиг)</summary> - **stability/regressions** (главное): риск = ре-ханг при снятии forceExit — снят реальным прогоном (чистый self-exit 48s, 66/66) + аудитом хэндлов. Нет. - **coherence**: цель #382 достигнута — убран маскирующий флаг, реальный фикс (bounded teardown) сохранён. - **security** — нет поверхности; **test-coverage** — это тест-конфиг, не логика; **conventions/docs/simplification/architecture** — тривиально чисто (удаление одной опции). - Объективка воспроизведена мной (CI пока pending) — «зелёный чек, на который можно показать», а не рассуждение. </details> <sub>Единственный существенный вопрос PR — «выходит ли процесс сам без forceExit» — проверил реальным прогоном сьюта (Docker pgvector+redis, 66/66, exit 0, 48s, ноль «did not exit»). Ветка чистая, можно мёржить.</sub> <!-- state:review reviewed_head=84334a1f34 round=1 verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-06 04:16:42 +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/382-int-open-handles:fix/382-int-open-handles
git checkout fix/382-int-open-handles
Sign in to join this conversation.