review(ai-chat): постфактум-ревью фикса зависания AI-чата из-за MCP (16b476a2, уже в develop)
#397
Closed
agent_vscode
wants to merge 1 commits from
dummy-review/mcp-hang-fix into dummy-review/mcp-hang-base
pull from: dummy-review/mcp-hang-fix
merge into: vvzvlad:dummy-review/mcp-hang-base
vvzvlad:main
vvzvlad:develop
vvzvlad:dummy-review/mcp-hang-base
vvzvlad:feat/381-resumable-sse-pr1
vvzvlad:feat/git-sync-2
vvzvlad:docs/agents-workspace-build-order
vvzvlad:feature/offline-sync
vvzvlad:perf/342-code-splitting
vvzvlad:perf/344-background-rerenders
vvzvlad:perf/346-compression-cache
vvzvlad:perf/348-backend-lowhanging
vvzvlad:feat/196-multi-cursor
vvzvlad:feat/370-page-versioning
vvzvlad:perf/343-typing-latency
vvzvlad:docs/how-to-test
vvzvlad:fix/ai-sdk-partial-output-oom
vvzvlad:test/351-generative-converter
vvzvlad:feat/371-roles-catalog
vvzvlad:refactor/345-server-converter
vvzvlad:refactor/294-spec-registry-cont
vvzvlad:fix/363-migration-order
vvzvlad:fix/e2e-callout-and-gate-build
vvzvlad:fix/docker-re2-toolchain
vvzvlad:feat/git-sync
vvzvlad:fix/media-roundtrip-stability
vvzvlad:fix/340-comment-panel-perf
vvzvlad:fix/332-deferred-tools
vvzvlad:fix/329-ephemeral-suggestions
vvzvlad:fix/330-search-in-page
vvzvlad:fix/328-resolved-anchor-spam
vvzvlad:fix/331-intraline-diff
vvzvlad:fix/324-coverage-gate
vvzvlad:fix/325-mobile-390
vvzvlad:feat/293-A-git-sync-package
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/scroll-restore-stable-wait
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
возможно, баг
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Похоже на баг, но требует подтверждения/воспроизведения; чиним, если повторится
No Label
review/changes-requested
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#397
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "dummy-review/mcp-hang-fix"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Dummy PR для постфактум-ревью
Коммит
16b476a2уже влит и запушен вdevelop(и на GitHub). Этот PR создан только для ревью — база указана на родителе фикса (834684c3), чтобы был виден точный диф уже влитого коммита. Мержить не нужно (обе стороны уже в историиdevelop); закройте после ревью.Инцидент
AI-чат зависал: стрим висел в статусе
running, ничего не присылал и не давал запустить новый — на любой новый чат сразу{"code":"A_RUN_ALREADY_ACTIVE"}. В БД раны висели вrunningсstep_count=0и безfinished_at— тёрны зависали до первого шага модели, в фазе подготовки.Причина
connect()вmcp-clients.service.tsделаетawait createMCPClient(...)без таймаута (в отличие от соседнегоwithTimeout(client.tools(), …)), а@ai-sdk/mcpне завершает свой промис при abort. При кратковременном сетевом сбое хендшейк внешнего MCP-сервера завис навсегда. Незавершённый build-промис кэшируется per-workspace вgetOrBuildEntry(this.cache.set(...)), из кэша не удаляется — и каждый новый тёрн виснет наawait pendingдо вызова провайдера. Ран не финализируется, строка остаётсяrunning, чат навсегда блокируется 409.Stopне помогал (фаза подготовки не слушала run-сигнал),autonomousRuns=trueотвязывает ран от сокета.Что сделано
mcp-clients.service.ts:connectWithTimeout()— гонкаconnect()с завершающим таймаутом; поздно пришедший клиент закрывается. Зависший хендшейк теперь отклоняется, плохой сервер пропускается, кэш не отравляется. Плюс закрытие подключённого-но-незарегистрированного клиента при сбоеtools()(устранение предсуществующей утечки транспорта).ai-chat.service.ts:raceAgainstAbortAndTimeout()ограничивает сборку внешних инструментов run-сигналом и дедлайном (MCP_TOOLSET_BUILD_DEADLINE_MS); поздний resolve закрывает лизы. Явный Stop пробрасывается (гейт поrunId, чтобы не задеть legacy socket-bound путь) и финализирует ран какaborted; иначе —error.Верификация
tsc --noEmit— 0 ошибок; затронутые спеки — 7 suites / 116 tests пройдены. Прод разблокирован рестартом (boot-sweep пометил зависшие раныaborted).Изменённые файлы:
apps/server/src/core/ai-chat/ai-chat.service.ts,apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.ts+ два новых спека.🤖 Generated with Claude Code
🤖 Авто-ревью (постфактум, коммит уже в develop) — fix зависания AI-чата из-за MCP
Вердикт: CHANGES — но по сути: фикс корректен и в проде работает, ядро залочено тестами; все DO — это последующее упрочнение (тест-покрытие вторичных defense-in-depth путей + 1 low-когерентность). Функциональных багов не найдено. Гейт: 2 сьюта / 6 tests passed. (PR — dummy для ревью, мержить не нужно; DO → отдельным PR.)
✅ Что проверено независимо (9 аспектов; ядро — мутациями)
connectWithTimeout(5s)+withTimeout(tools(),5s)(в т.ч. DNS внутри connect), и внешнийraceAgainstAbortAndTimeout(build, signal, 60s)— незакрытых await на MCP-пути не осталось. (б) Отравление кэша устранено:buildEntryтеперь всегда СЕТТЛИТСЯ (плохой сервер пропускается), так что кэшируемый build-промис не виснет; на редкий rejectgetOrBuildEntryэвиктит запись (:238-243). (в) Ран всегда финализируется:finalizeRunидемпотентен через СИНХРОННЫЙ claim (active.delete+settled), строка не остаётсяrunning.connectWithTimeout→bareconnect()роняет connect-timeout-спеку; удалениеif (runId && effectiveSignal.aborted) throw errроняет setup-abort-спеку;settleStatusвсегда'error'роняет спеку. Оба ядрёных гаранта («зависший connect не травит кэш», «Stop в setup финализирует ран как aborted») реально залочены.late.clients(connectWithTimeout уже отклонил → не вclients[]); release-handle идемпотентен (released-флаг);push+registeredсинхронны.settledсинхронный; таймеры clear+unref; abort-listener снимается на КАЖДОМ решающем пути (+{once:true}, сигнал свежий на ран) — нет накопления; поздний reject проглатывается (нет unhandledRejection); already-aborted обработан.'aborted'только на реальный Stop; мис-сеттла не нашли.'aborted'— валидный существующий терминальный статус (уже обрабатывается history/export/resume).🔧 DO — follow-up (отдельным PR; фикс уже в develop, это упрочнение)
onLateResolvelate-close не покрыт —ai-chat.service.ts:800-804. Это единственный путь, освобождающий поздно-зарезолвленный toolset на ветке abort (тамexternalне присвоен,closeExternalClients()ничего не закрывает). Вsetup-abort.spectoolsForникогда не сеттлится → колбэк не срабатывает; регрессия, дропнувшая его, прошла бы все 6 тестов. Фикс: тест — abort в setup, затем поздний resolvetoolsForсо шпионом на release-handle, assertclose()вызван.MCP_TOOLSET_BUILD_DEADLINE_MS) не покрыта —setup-abort.specгоняет только abort-сигнальную ветку. Весь defense-in-depth backstop (deadline истёк, signal НЕ aborted → reject → НЕ ре-throw → Docmost-only) не проверяется; мутация, заставившая deadline-путь ре-throw'ить-и-abort'ить, прошла бы все тесты. Фикс: тест на чистый deadline → turn продолжается Docmost-only, ран не 'aborted'.runIdпуть (НЕ ре-throw) не залочен —setup-abort.specвсегда передаётbegin, так чтоrunIdвсегда есть.runId &&-гард (по комментарию — load-bearing, чтобы не писать JSON в закрытый сокет) без теста; дропrunId &&прошёл бы. Минор.mcp-clients.service.ts:258(for…of await, до 10s/сервер) vs комментарийai-chat.service.ts:66. При >6 включённых MCP-серверах, все в таймауте, последовательный build превышает 60s → внешний deadline срабатывает ПЕРВЫМ и turn теряет все внешние тулзы. Не лик и не хэнг — деградация/латентность под мульти-аутейджем. Фикс: распараллелить connects ИЛИ масштабировать deadline от числа серверов ИЛИ поправить комментарий. Low.onLateResolve(~:793) говорит «close the abandoned toolset's leased clients so transports not leaked» — на деле закрывается release-handle (декремент refcount), клиенты остаются тёплыми в кэше (закроются на TTL/evict). Поведение корректно (лика нет — кэш владеет), но формулировка переоценивает «немедленное закрытие транспорта».⛔ DROP / вне scope (кодеру НЕ делать)
[speculative]suggestion/low[test] late-reject-swallow (if (settled) returnв reject-ветках) — обоснован прозой, ценность теста низкая.shortError(mcp-clients.service.ts:806) берёт первую строку + режет до 200 симв, но НЕ вырезает host — connect-ошибка видаconnect ECONNREFUSED host:portможет засветить хост в ops-warn-логе. PR не трогает этот сток и не добавляет нового raw-error-логирования. Хардening-кандидат отдельно, если раскрытие хоста в логах критично.await runHooks.begin(chatId)(ai-chat.service.ts:690) — незакрытый await на setup-пути ВНЕ новой гонки и ДО присвоенияrunId(DB-insert со своими pool-таймаутами). Не MCP-путь, вне инцидента; при зависании воспроизвёл бы wedge до появления ран-строки. Для полноты.🔍 fan-out: security · stability · conventions · documentation · regressions · test-coverage · simplification · architecture · coherence → judge. Гейт (чистый клон-рум): jest 6 passed. Независимо: 3 мутации (connectWithTimeout, re-throw-гард, settle-status) — все ловятся. Диф от родителя фикса
834684c3.DO из этого ревью закрыты отдельным PR #405 (fix/397-mcp-hang-hardening → develop): 3 теста на непокрытые ветки + параллелизация сборки MCP + уточнение комментариев. Сам фикс уже в develop, здесь ничего мержить не нужно.
Pull request closed