fix(client): reconnect-вход — таймаут-гонка вокруг getRun, hang не залипает FSM в streaming (#541) #543
Reference in New Issue
Block a user
Delete Branch "fix/541-reconnect-timeout"
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?
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.settled) краснит РОВНО late-resolve тест (hang зелёный) — изолированно доказывает, что late-resolve тест валидирует взаимоисключение, а не наличие таймаута. Вердикт APPROVE.tsc -p apps/client0. node_modules 0. Влил develop.Осознанный фолк / follow-up (не блокер)
reseedTimerRef— общий ref; при гипотетическом перекрытии двух disconnect-входов теоретически перезапишется (утечка одного setTimeout). В текущей упорядоченности FSM недостижимо (второй onFinish-disconnect — только после того как первый вошёл в лестницу черезfinishReseed, очистивший таймер). Оставлено.Checklist
Ревью — 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-флаг: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.dropLivePartialAndReplayFromStart(); enterReconnect(runId)— байт-в-байт как reject (корректно: на таймауте нетres, потому mirror reject, а не resolve). Resolve-ветка не тронута.Гейт: 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-тайминга харнесса, не баг. Не действие.Готово к мержу.