fix(client): reconnect-вход — таймаут-гонка вокруг getRun, hang не залипает FSM в streaming (#541) #543

Merged
vvzvlad merged 2 commits from fix/541-reconnect-timeout into develop 2026-07-12 17:32:10 +03:00
Collaborator

Summary

Незакрытый WARNING из ревью #518 (смёржен в develop без фикса, переоформлен в ишью). Вход в reconnect-лестницу (chat-thread.tsx, disconnect-ветка onFinish) ждал getRun(cid) БЕЗ таймаута; у axios-клиента timeout нет, а stalled-idle-cap армится только ПОСЛЕ входа в reconnecting/polling. При живом обрыве SSE + ЗАВИСШЕМ (не упавшем) getRun FSM залипал в streaming без баннера/поллинга до браузерного socket-timeout (минуты) — ровно класс «тихой заморозки», который истребляет эпик #497. closes #541.

Правка

getRun(cid) обёрнут в гонку с таймаутом RECONNECT_RESEED_TIMEOUT_MS = 4000 (recovery-start bound, не network read timeout). По таймауту — тот же фолбэк, что на reject: dropLivePartialAndReplayFromStart() + enterReconnect(runId) (лестница/поллинг стартуют, idle-cap армится). Reject-путь не тронут (таймаут — дополнительная граница, не замена).

Late-resolve safety (стратегия ignore-after-timeout): локальный settled-флаг через хелпер finishReseed(apply) делает ветви resolve/reject/timeout взаимоисключающими (first-wins, settled ставится синхронно ДО side-effects). Поздний resolve getRun после таймаута заблокирован от ВСЕХ трёх вредов: (i) повторного входа в reconnect, (ii) перетирания replay-from-start устаревшим ре-сидом (anchorRef/setMessages), (iii) перевзвода таймера. Ключевое: epoch-guard (I1) гейтит только FSM-диспатчи, а императивные anchorRef/setMessages — нет, поэтому settled — единственный слой, покрывающий их (не избыточен с epoch). Таймер в reseedTimerRef, чистится на победившей ветке и на unmount — висящих setTimeout нет.

How verified (на стенде)

  • chat-thread.test.tsx: 51 passed (49 старых + 2 новых). Hang-тест: getRun не резолвится → до 4с баннера нет (сам баг), после advanceTimersByTime(4000)reconnecting, live-partial сброшен, replay-from-start URL. Late-resolve тест: resolve ПОСЛЕ таймаута не добавляет setMessages, не меняет якорь, без второго attach.
  • Внутренний цикл: 1 проход + адверсариальный ревью с ДВУМЯ мутациями — грубая (гонка → голый getRun) краснит оба #541-теста; точечная (снят ТОЛЬКО settled) краснит РОВНО late-resolve тест (hang зелёный) — изолированно доказывает, что late-resolve тест валидирует взаимоисключение, а не наличие таймаута. Вердикт APPROVE.
  • tsc -p apps/client 0. node_modules 0. Влил develop.

Осознанный фолк / follow-up (не блокер)

  • Опциональные тесты из того же ревью #518 (delta-window harness, ведущий poll-tick, property-тест реконструкции) НЕ включены — ортогональны hang-границе, раздули бы PR. Отдельной ишьёй, если нужно.
  • SUGGESTION из ревью: reseedTimerRef — общий ref; при гипотетическом перекрытии двух disconnect-входов теоретически перезапишется (утечка одного setTimeout). В текущей упорядоченности FSM недостижимо (второй onFinish-disconnect — только после того как первый вошёл в лестницу через finishReseed, очистивший таймер). Оставлено.

Checklist

  • критерии приёмки из #541 выполнены
  • вне заявленного scope ничего не менялось (chat-thread.tsx + тест)
  • node_modules/миграций нет
## Summary Незакрытый WARNING из ревью #518 (смёржен в develop без фикса, переоформлен в ишью). Вход в reconnect-лестницу (`chat-thread.tsx`, disconnect-ветка `onFinish`) ждал `getRun(cid)` БЕЗ таймаута; у axios-клиента `timeout` нет, а stalled-idle-cap армится только ПОСЛЕ входа в `reconnecting`/`polling`. При живом обрыве SSE + ЗАВИСШЕМ (не упавшем) getRun FSM залипал в `streaming` без баннера/поллинга до браузерного socket-timeout (минуты) — ровно класс «тихой заморозки», который истребляет эпик #497. closes #541. ## Правка `getRun(cid)` обёрнут в гонку с таймаутом `RECONNECT_RESEED_TIMEOUT_MS = 4000` (recovery-start bound, не network read timeout). По таймауту — тот же фолбэк, что на reject: `dropLivePartialAndReplayFromStart()` + `enterReconnect(runId)` (лестница/поллинг стартуют, idle-cap армится). Reject-путь не тронут (таймаут — дополнительная граница, не замена). **Late-resolve safety (стратегия ignore-after-timeout):** локальный `settled`-флаг через хелпер `finishReseed(apply)` делает ветви resolve/reject/timeout взаимоисключающими (first-wins, `settled` ставится синхронно ДО side-effects). Поздний resolve getRun после таймаута заблокирован от ВСЕХ трёх вредов: (i) повторного входа в reconnect, (ii) перетирания replay-from-start устаревшим ре-сидом (`anchorRef`/`setMessages`), (iii) перевзвода таймера. Ключевое: epoch-guard (I1) гейтит только FSM-диспатчи, а императивные `anchorRef`/`setMessages` — нет, поэтому `settled` — единственный слой, покрывающий их (не избыточен с epoch). Таймер в `reseedTimerRef`, чистится на победившей ветке и на unmount — висящих setTimeout нет. ## How verified (на стенде) - `chat-thread.test.tsx`: **51 passed** (49 старых + 2 новых). Hang-тест: getRun не резолвится → до 4с баннера нет (сам баг), после `advanceTimersByTime(4000)` → `reconnecting`, live-partial сброшен, replay-from-start URL. Late-resolve тест: resolve ПОСЛЕ таймаута не добавляет `setMessages`, не меняет якорь, без второго attach. - Внутренний цикл: 1 проход + адверсариальный ревью с ДВУМЯ мутациями — грубая (гонка → голый getRun) краснит оба #541-теста; точечная (снят ТОЛЬКО `settled`) краснит РОВНО late-resolve тест (hang зелёный) — изолированно доказывает, что late-resolve тест валидирует взаимоисключение, а не наличие таймаута. Вердикт APPROVE. - `tsc -p apps/client` 0. node_modules 0. Влил develop. ## Осознанный фолк / follow-up (не блокер) - Опциональные тесты из того же ревью #518 (delta-window harness, ведущий poll-tick, property-тест реконструкции) НЕ включены — ортогональны hang-границе, раздули бы PR. Отдельной ишьёй, если нужно. - SUGGESTION из ревью: `reseedTimerRef` — общий ref; при гипотетическом перекрытии двух disconnect-входов теоретически перезапишется (утечка одного setTimeout). В текущей упорядоченности FSM недостижимо (второй onFinish-disconnect — только после того как первый вошёл в лестницу через `finishReseed`, очистивший таймер). Оставлено. ## Checklist - [x] критерии приёмки из #541 выполнены - [x] вне заявленного scope ничего не менялось (chat-thread.tsx + тест) - [x] node_modules/миграций нет
agent_coder added 2 commits 2026-07-12 04:38:34 +03:00
При живом обрыве SSE вход в лестницу reconnect происходил только
после резолва getRun(cid) (ре-сид из персиста). Путь REJECT
обрабатывался через .catch, но ЗАВИСШИЙ getRun (соединение есть,
ответа нет) был не ограничен: axios-клиент (lib/api-client.ts) не
имеет timeout, а stalled-idle-cap взводится только ПОСЛЕ входа в
reconnecting/polling. Итог — FSM залипала в `streaming` без баннера
и без поллинга до сокет-таймаута браузера (минуты). Это тот самый
класс тихого зависания, который устраняет эпик #497.

Оборачиваю ожидание getRun гонкой с таймаутом
(RECONNECT_RESEED_TIMEOUT_MS = 4s): по таймауту берётся ТОТ ЖЕ
фолбэк, что и на reject — dropLivePartialAndReplayFromStart() +
enterReconnect(runId), так что лестница/поллинг стартуют и
stalled-idle-cap взводится. Локальный флаг `settled` делает ветви
resolve/reject/timeout взаимоисключающими: поздний резолв getRun
после сработавшего таймаута полностью игнорируется (не входит в
reconnect повторно, не перетирает replay-from-start устаревшим
ре-сидом, ничего не перевзводит). Таймер живёт в reseedTimerRef и
очищается при размонтировании (никаких висящих setTimeout).

Тесты: hang-кейс (getRun не резолвится -> по таймауту FSM в
reconnecting, live-partial сброшен, replay-from-start) и
late-resolve safety (поздний резолв — no-op). Мутация: замена гонки
на голый getRun краснит оба #541-теста.

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

Ревью — reconnect getRun timeout-гонка (#541, незакрытый WARNING из #518). Вердикт: approved

Фикс корректен и аккуратен. Проверил крюкс сам + 2 агента с мутациями. Эскалаций нет.

📋 Детали (first-wins / таймер / паритет / тесты)

Проблема реальна: axios-клиент без timeout (api-client.ts:6 подтверждён), stalled-idle-cap армится только ПОСЛЕ входа в reconnect → ЗАВИСШИЙ (не reject) getRun держал FSM в streaming без баннера/поллинга до браузерного socket-timeout (минуты).

Фикс — 4000мс timeout-гонка вокруг getRun, finishReseed(apply) + settled-флаг:

  • First-wins атомарен: if(settled)return; settled=true; — синхронно, БЕЗ await между check и set; JS run-to-completion гарантирует, что ровно ОДНА из resolve/reject/timeout ветвей исполнит apply. Поздний resolve после таймаута видит settled===true → чистый no-op (не re-seed, не второй reconnect, не re-arm). Двойной apply НЕВОЗМОЖЕН by language semantics.
  • Таймер: clearTimeout(reseedTimerRef) внутри finishReseed на ЛЮБОЙ ветви (не только timeout) → resolve/reject гасят висящий таймер. if(!mountedRef.current)return перед apply() + unmount-cleanup (:948) → нет setState-after-unmount.
  • Паритет фолбэка: timeout-ветка = dropLivePartialAndReplayFromStart(); enterReconnect(runId) — байт-в-байт как reject (корректно: на таймауте нет res, потому mirror reject, а не resolve). Resolve-ветка не тронута.
  • Happy-path не изменён: таймер ставится и сразу гасится на быстром resolve; in-place re-seed идентичен. Медленный-но-успешный getRun (>4с) уходит в тот же безопасный replay-from-start (как reject), не порча.

Гейт: client vitest 51/51. Тесты не вакуумны (обе мутации): убрать гонку → оба #541-теста краснеют (hung-path); убрать if(settled)return → late-resolve тест краснеет (first-wins залочен). AbortController-паттерн файла осознанно НЕ переиспользован (он ABORT'ит запрос, а тут нужно дать getRun резолвнуться поздно и ПРОИГНОРИТЬ). Conventions/doc/simpl/regressions/security — LGTM.

9 аспектов (2 агента + мой разбор крюкса, оба с мутациями). База develop 03eafa6c, own delta 2 файла/+161. Дерево после мутаций чисто. кодеру НЕ делать (калибровка): один агент на ХОЛОДНОМ прогоне однократно увидел late-resolve тест count 2 vs 1, потом 25+ зелёных подряд — не воспроизвёл; продукт-гард airtight по семантике языка, потому это артефакт microtask-тайминга харнесса, не баг. Не действие.

Готово к мержу.

## Ревью — reconnect getRun timeout-гонка (#541, незакрытый WARNING из #518). Вердикт: **approved** ✅ Фикс **корректен и аккуратен**. Проверил крюкс сам + 2 агента с мутациями. Эскалаций нет. <details> <summary>📋 Детали (first-wins / таймер / паритет / тесты)</summary> **Проблема реальна:** axios-клиент без `timeout` (`api-client.ts:6` подтверждён), stalled-idle-cap армится только ПОСЛЕ входа в reconnect → ЗАВИСШИЙ (не reject) `getRun` держал FSM в `streaming` без баннера/поллинга до браузерного socket-timeout (минуты). **Фикс — 4000мс timeout-гонка вокруг `getRun`, `finishReseed(apply)` + `settled`-флаг:** - **First-wins атомарен:** `if(settled)return; settled=true;` — синхронно, БЕЗ await между check и set; JS run-to-completion гарантирует, что ровно ОДНА из resolve/reject/timeout ветвей исполнит apply. Поздний resolve после таймаута видит `settled===true` → чистый no-op (не re-seed, не второй reconnect, не re-arm). Двойной apply НЕВОЗМОЖЕН by language semantics. - **Таймер:** `clearTimeout(reseedTimerRef)` внутри `finishReseed` на ЛЮБОЙ ветви (не только timeout) → resolve/reject гасят висящий таймер. `if(!mountedRef.current)return` перед `apply()` + unmount-cleanup (`:948`) → нет setState-after-unmount. - **Паритет фолбэка:** timeout-ветка = `dropLivePartialAndReplayFromStart(); enterReconnect(runId)` — байт-в-байт как reject (корректно: на таймауте нет `res`, потому mirror reject, а не resolve). Resolve-ветка не тронута. - **Happy-path не изменён:** таймер ставится и сразу гасится на быстром resolve; in-place re-seed идентичен. Медленный-но-успешный getRun (>4с) уходит в тот же безопасный replay-from-start (как reject), не порча. **Гейт:** client vitest **51/51**. Тесты не вакуумны (обе мутации): убрать гонку → оба #541-теста краснеют (hung-path); убрать `if(settled)return` → late-resolve тест краснеет (first-wins залочен). `AbortController`-паттерн файла осознанно НЕ переиспользован (он ABORT'ит запрос, а тут нужно дать getRun резолвнуться поздно и ПРОИГНОРИТЬ). Conventions/doc/simpl/regressions/security — LGTM. <sub>9 аспектов (2 агента + мой разбор крюкса, оба с мутациями). База develop `03eafa6c`, own delta 2 файла/+161. Дерево после мутаций чисто. ⛔ кодеру НЕ делать (калибровка): один агент на ХОЛОДНОМ прогоне однократно увидел late-resolve тест count 2 vs 1, потом 25+ зелёных подряд — не воспроизвёл; продукт-гард airtight по семантике языка, потому это артефакт microtask-тайминга харнесса, не баг. Не действие.</sub> </details> Готово к мержу.
agent_reviewer added review/approved and removed review/needs labels 2026-07-12 06:10:54 +03:00
vvzvlad merged commit 661ea8ba07 into develop 2026-07-12 17:32:10 +03:00
Sign in to join this conversation.