Skip to Content
ДизайнSession Idle ReaperSession Idle Reaper — Дизайн-документ

Session Idle Reaper — Дизайн-документ

Статус: Черновик
Автор: qinqi
Дата: 2026-06-08
Область: packages/acp-bridge/src/bridge.ts, packages/cli/src/serve/server.ts


1. Постановка задачи

1.1 Текущее поведение

После создания сессия моста постоянно хранится в памяти (byId: Map<string, SessionEntry>). Она уничтожается только когда:

  1. Клиент явно вызывает DELETE /session/:id (closeSession)
  2. Общий дочерний процесс qwen --acp крашится (обработчик channel.exited)
  3. Процесс демона получает SIGTERM / SIGINT (shutdown)

Нет автоматического таймаута простоя для сессий. Метки времени пульса (sessionLastSeenAt, clientLastSeenAt) записываются recordHeartbeat, но никогда не используются для вытеснения (в комментарии к полю упоминается будущая “политика отзыва (PR 24)”, которая так и не была реализована).

1.2 Влияние

СценарийСимптом
Пользователь открывает несколько вкладок браузера, закрывает их без вызова DELETE /sessionСессии накапливаются в byId, каждая содержит кольцо EventBus (~2–4 МБ)
Накоплено 20 сессий (по умолчанию maxSessions)SessionLimitExceededError при новом spawnOrAttach — пользователь заблокирован
Долгоживущий демон с частой сменой вкладокНеограниченный рост памяти из-за колец воспроизведения EventBus и состояния сессии на стороне ACP
Расширение IDE перезагружается / крашитсяСессии-сироты никогда не очищаются

1.3 Почему сейчас

Демон всё чаще используется как долгоживущий сервер рабочей области (десктопное приложение, расширения IDE, веб-интерфейс). Сбои клиента и временные проблемы с сетью — норма; полагаться на явный DELETE для очистки неприемлемо.


2. Цели дизайна

  1. Автоматически освобождать бездействующие сессии, чьи клиенты исчезли и у которых нет активной работы в процессе.
  2. Никогда не уничтожать сессию с активным запросом — это молча прервёт видимую пользователем работу.
  3. Сохранять персистентные данные сессии — освобождается только состояние моста в памяти; транскрипты на диске (SessionService) не трогаются. Пользователи могут выполнить session/load или session/resume для восстановления.
  4. Наблюдаемость — отправлять отдельное SSE-событие, чтобы клиенты знали, ПОЧЕМУ сессия закрыта (таймаут простоя vs. явное закрытие vs. сбой).
  5. Настраиваемость — операторы и тесты могут подстраивать таймауты или полностью отключить сборщик.
  6. Без новых зависимостей / компонентов — реализация полностью внутри существующего замыкания моста.

Не цели

  • Управление сессиями между рабочими областями (это ответственность шлюза).
  • LRU-вытеснение при достижении maxSessions (ценная, но отдельная работа — отслеживается как доработка).
  • Сжатие кольца EventBus для бездействующих сессий (низкий приоритет из-за ограничения в 20 сессий; отслеживается как доработка).
  • Адаптивное давление на основе RSS (требует опроса process.memoryUsage() и проектирования политики; отслеживается как доработка).

3. Архитектура

3.1 Обзор

Замыкание моста (createHttpAcpBridge) ├─ byId: Map<sessionId, SessionEntry> ← существующее ├─ channelInfo: ChannelInfo ← существующее ├─ idleTimer (уровень канала) ← существующее └─ sessionReaper: NodeJS.Timeout ← НОВОЕ ├─ сканирует byId каждые REAP_INTERVAL_MS ├─ пропускает сессии с активным запросом ├─ пропускает сессии с живыми подписчиками SSE ├─ закрывает сессии, превысившие TTL простоя └─ генерирует session_closed { reason: 'idle_timeout' }

3.2 Взаимосвязь с существующими механизмами

МеханизмОбластьЧто управляет
channelIdleTimeoutMs + startIdleTimerКанал (дочерний процесс)Завершает дочерний процесс qwen --acp, когда ВСЕ сессии ушли
Сборщик сессий (данный дизайн)Сессия (запись в памяти)Закрывает отдельные сессии при простое
ConnectionRegistry sweepACP-поверх-HTTP соединениеУдаляет транспортные соединения /acp (другой уровень)
writerIdleTimeoutMsПодписчик SSEВытесняет одного зависшего подписчика SSE
Сборщик отключений (server.ts)Ручное порождениеУдаляет сессии, владелец которых отключился ВО ВРЕМЯ рукопожатия POST /session

Два механизма работают вместе, покрывая жизненный цикл очистки сессии:

  1. Close-on-last-detach (основной) — когда detachClient удаляет последнего зарегистрированного клиента И не остаётся подписчиков SSE, сессия закрывается немедленно через closeSessionImpl. Это обрабатывает нормальный путь: пользователь закрывает вкладку → очистка React → POST /session/:id/detach.

  2. Session idle reaper (резервный) — периодическое сканирование сессий без активного запроса и без подписчиков SSE, которые не получали heartbeat в течение заданного TTL. Это перехватывает путь сбоя: браузер убит, сеть потеряна, kill -9 — запрос на detach никогда не был отправлен, поэтому clientIds всё ещё показывает зарегистрированных клиентов, но сессия фактически осиротела.


4. Детальный дизайн

4.1 Новые параметры конфигурации (BridgeOptions)

interface BridgeOptions { // ... существующие поля ... /** * Как часто сборщик сессий сканирует `byId` на предмет бездействующих сессий, * в миллисекундах. По умолчанию: 60_000 (1 минута). Установите 0 или Infinity, * чтобы полностью отключить сборщик. Таймер имеет `.unref()`. */ sessionReapIntervalMs?: number; /** * Сессия с НУЛЕВЫМИ активными подписчиками SSE И НУЛЕВЫМИ зарегистрированными * клиентами, которая не получала heartbeat в течение этого количества * миллисекунд, считается бездействующей и будет утилизирована. * * По умолчанию: 30 * 60_000 (30 минут). * Установите 0 или Infinity, чтобы отключить утилизацию бездействующих сессий. */ sessionIdleTimeoutMs?: number; }

CLI поверхность (флаги qwen serve):

--session-reap-interval-ms <ms> Интервал сканирования сборщика (по умолчанию 60000, 0=отключить) --session-idle-timeout-ms <ms> Порог бездействия (по умолчанию 1800000, 0=отключить)

4.2 Предикат бездействия сессии

Сессия подлежит утилизации, когда выполняются все следующие условия:

  1. Нет активного запроса: entry.promptActive === false
  2. Нет активных подписчиков SSE: entry.events.subscriberCount === 0
  3. Превышена длительность бездействия: now - lastActivity(entry) > sessionIdleTimeoutMs

Примечание: сборщик намеренно НЕ проверяет clientIds.size. Он покрывает путь сбоя, когда detach никогда не был отправлен — clientIds всё ещё показывает зарегистрированных клиентов, но сессия фактически осиротела. Нормальный путь (клиент отправляет detach) обрабатывается вместо этого механизмом close-on-last-detach.

Где lastActivity(entry) определяется как:

function lastActivity(entry: SessionEntry): number { // `sessionLastSeenAt` — это эпоха в миллисекундах (от Date.now()); // `createdAt` — строка ISO 8601 — парсим в эпоху в миллисекундах как запасной вариант. return entry.sessionLastSeenAt ?? Date.parse(entry.createdAt); }

Примечание: entry.createdAt имеет тип string (ISO 8601), а не число. Date.parse здесь безопасен — формат всегда new Date().toISOString() (см. createSessionEntry, bridge.ts:1883).

Обоснование для каждой проверки:

ПроверкаПочему
Нет активного запросаГоловной / автономный запрос (например, конвейер CLI, cron-задача) может выполняться без подписчика SSE. Его утилизация убьёт работу.
Нет подписчиков SSEПодключённый клиент активно слушает. Даже если он не отправлял heartbeat, само SSE-соединение доказывает активность.
Длительность бездействияЛьготный период, чтобы кратковременно отключившиеся клиенты могли переподключиться без потери сессии.

4.3 Действие при утилизации

Для каждой сессии, прошедшей предикат бездействия, сборщик вызывает:

await closeSession(sessionId, { reason: 'idle_timeout' });

При этом повторно используется существующий путь closeSession, который:

  1. Удаляет из byId / defaultEntry
  2. Отменяет ожидающие разрешения через permissionMediator.forgetSession
  3. Публикует событие session_closedreason: 'idle_timeout')
  4. Закрывает EventBus
  5. Отправляет connection.cancel() дочернему процессу ACP (best-effort)
  6. Запускает startIdleTimer на канале, если это была последняя сессия

Почему closeSession, а не killSession?

killSession — это внутренний путь принудительной утилизации, предназначенный для гонки отключения при handshake спавна (гарантия requireZeroAttaches, tombstone spawnOwnerWantedKill). closeSession — это документированный клиентский путь, который публикует session_closed (а не session_died) и корректно обрабатывает телеметрию. Сборщик — это «корректное закрытие от имени отсутствующего клиента», поэтому closeSession — правильная семантика.

4.4 Расширение closeSession для приёма причины закрытия

В настоящее время closeSession жёстко задаёт reason: 'client_close' в событии session_closed. Необходимо сделать этот параметр параметризуемым.

Подход: Добавить новый необязательный параметр opts в closeSession, а не перегружать BridgeClientRequestContext (который является контекстом запроса клиента — добавление reason туда было бы нарушением уровней, поскольку «причина» — это решение серверной стороны, а не то, что клиент передаёт в заголовке).

// bridgeTypes.ts — новый тип + изменение сигнатуры: export interface CloseSessionOpts { /** Переопределить значение по умолчанию 'client_close' в событии session_closed. */ reason?: string; } closeSession( sessionId: string, context?: BridgeClientRequestContext, opts?: CloseSessionOpts, ): Promise<void>;
// bridge.ts — implementation change: async closeSession(sessionId, context, opts) { // ... const reason = opts?.reason ?? 'client_close'; entry.events.publish({ type: 'session_closed', data: { sessionId, reason, ... }, }); }

Existing callers (DELETE /session/:id route) pass no opts, defaulting to 'client_close'. The reaper passes { reason: 'idle_timeout' }.

4.5 Жизненный цикл сборщика неактивных сессий

// Inside createHttpAcpBridge closure: const resolvedReapIntervalMs = resolvePositiveMs( opts.sessionReapIntervalMs, 60_000, ); const resolvedIdleTimeoutMs = resolvePositiveMs( opts.sessionIdleTimeoutMs, 30 * 60_000, ); let sessionReaper: ReturnType<typeof setInterval> | undefined; function startSessionReaper(): void { if (resolvedReapIntervalMs <= 0 || resolvedIdleTimeoutMs <= 0) return; sessionReaper = setInterval(() => { if (shuttingDown) return; const now = Date.now(); for (const [id, entry] of byId) { if (entry.promptActive) continue; if (entry.events.subscriberCount > 0) continue; const lastActive = entry.sessionLastSeenAt ?? Date.parse(entry.createdAt); const idle = now - lastActive; if (idle < resolvedIdleTimeoutMs) continue; writeStderrLine( `qwen serve: reaping idle session ${JSON.stringify(id)} ` + `(idle for ${Math.round(idle / 1000)}s, threshold ${Math.round(resolvedIdleTimeoutMs / 1000)}s)`, ); // Pass `undefined` context (no client) and `{ reason }` opts. bridgeImpl .closeSession(id, undefined, { reason: 'idle_timeout' }) .catch((err) => { writeStderrLine( `qwen serve: session reaper failed to close ${JSON.stringify(id)}: ${String(err)}`, ); }); } }, resolvedReapIntervalMs); sessionReaper.unref(); } function stopSessionReaper(): void { if (sessionReaper !== undefined) { clearInterval(sessionReaper); sessionReaper = undefined; } }

Примечание: bridgeImpl ссылается на объект моста, возвращённый функцией createHttpAcpBridge; таким образом, closeSession имеет полный доступ к состоянию в замыкании. На практике это реализовано как прямой вызов внутренней функции closeSessionImpl из замыкания.

Интеграция в жизненный цикл:

  • startSessionReaper() вызывается при создании моста (после проверки параметров, вместе с существующей настройкой channelIdleTimeoutMs).
  • stopSessionReaper() вызывается как в shutdown(), так и в killAllSync().

4.6 Взаимодействие с существующими вызывающими closeSession

Вызывающий кодВлияние
Маршрут DELETE /session/:idНет — opts не передаётся, по умолчанию reason: 'client_close'
Сборщик сессий (данный проект)Передаёт opts: { reason: 'idle_timeout' }
Отложенная очистка в detachClientВызывает killSession (не closeSession), без изменений
Обработчик channel.exitedПубликует session_died, без изменений
shutdown()Публикует session_died с причиной daemon_shutdown, без изменений

4.7 Потокобезопасность

Обратный вызов сборщика выполняется в цикле событий Node.js. Ключевые соображения:

  • Итерация for...of синхронна. Сборщик синхронно проверяет условие бездействия для каждой записи, затем запускает closeSession(...).catch(...) для подходящих. В теле цикла нет await — все закрытия запускаются в одной границе микрозадачи, после чего цикл завершается.
  • byId.delete откладывается. Внутри closeSession byId.delete выполняется после первого await (notifyAgentSessionClose). Таким образом, удаления происходят в микрозадачах уже после завершения for...of. Поскольку каждый closeSession работает с уникальным ключом, псевдонимов не возникает. А for...of уже закончил итерацию, поэтому удаление во время итерации не является проблемой.
  • Гонка двойного закрытия. Если клиент вызывает DELETE /session/:id для той же сессии между проверкой сборщика и асинхронным выполнением closeSession, то closeSession сборщика выбросит SessionNotFoundError (перехватывается .catch()). Безопасно.
  • Гонка переподключения. Если клиент переподключается к сессии (регистрирует clientId / открывает SSE) между проверкой сборщика и выполнением closeSession, то closeSession всё равно выполнится и закроет сессию. Клиент получит session_closed и должен будет перезагрузиться. Это окно крайне узкое (один синхронный тик setInterval), последствия безвредны — нет потери данных, только предложение перезагрузиться. При значении TTL по умолчанию 30 минут такая ситуация крайне маловероятна.
  • Одновременный вызов spawnOrAttach, создающий новую сессию во время сканирования сборщиком, не будет учтён (мы итерируем записи byId на момент начала каждого тика). Это безопасно — новые сессии свежие и не достигнут порога бездействия.

4.8 Изменение формата провода

Поле data.reason события session_closed уже существует со значением 'client_close'. Мы добавляем два новых значения:

  • 'idle_timeout' — отправляется сборщиком бездействующих сессий (страховка для упавших клиентов)
  • 'last_client_detached' — отправляется при закрытии по откреплении последнего клиента (обычное закрытие вкладки)

Это обратно совместимо — существующий код SDK, проверяющий reason === 'client_close', просто не совпадёт с новыми значениями, а универсальный обработчик терминальных фреймов (isTerminalLifecycleEvent) уже обрабатывает session_closed независимо от причины.


5. План тестирования

5.1 Модульные тесты (bridge.test.ts)

ТестОписание
1Бездействующая сессия удаляется после таймаутаСоздать сессию, сдвинуть время за sessionIdleTimeoutMs, запустить тик сборщика, проверить, что сессия удалена из byId и опубликовано событие session_closed с reason: 'idle_timeout'
2Сессия с активным промптом НЕ удаляетсяСоздать сессию, запустить промпт, сдвинуть время, проверить, что сессия пережила тик сборщика
3Сессия с живым SSE-подписчиком НЕ удаляетсяСоздать сессию, подписаться на её EventBus, сдвинуть время, проверить, что сессия пережила тик
4Сессия с зарегистрированным клиентом НЕ удаляетсяСоздать сессию, зарегистрировать clientId, сдвинуть время, проверить, что сессия пережила тик
5Сборщик отключён при interval = 0Передать sessionReapIntervalMs: 0, проверить, что setInterval не запущен
6Сборщик отключён при timeout = 0Передать sessionIdleTimeoutMs: 0, проверить, что setInterval не запущен
7Сборщик останавливается при shutdownВызвать shutdown(), проверить, что clearInterval был вызван
8Причина closeSession по умолчанию — ‘client_close’Вызвать closeSession без явной причины, проверить, что опубликованное событие имеет reason: 'client_close'
9closeSession с явной причинойВызвать closeSession с reason: 'idle_timeout', проверить опубликованное событие
10Несколько бездействующих сессий удаляются за один тикСоздать 3 бездействующие сессии, сдвинуть время, запустить тик, проверить, что все 3 удалены
11Сессия с пульсом в пределах TTL переживает проверкуСоздать сессию, зафиксировать пульс, сдвинуть время чуть меньше TTL, проверить, что сессия пережила
12Таймер бездействия канала срабатывает после удаления последней сессииСоздать 1 сессию (последняя на канале), удалить её, проверить, что у канала вызван startIdleTimer

5.2 Интеграционные тесты (server.test.ts)

ТестОписание
1GET /health?deep=1 отражает количество сессий, очищенных сборщикомЗапустить демон, создать сессии, сдвинуть время, проверить, что эндпоинт health показывает уменьшенное количество
2SSE-подписчик получает session_closed с reason: 'idle_timeout'Открыть SSE, отключиться, переподключиться до TTL, затем дать TTL истечь, проверить событие

6. Значения по умолчанию конфигурации

ПараметрПо умолчаниюОбоснование
sessionReapIntervalMs60 000 (1 минута)Достаточно часто, чтобы избежать длительного накопления, достаточно дёшево (простой обход Map) для частого выполнения
sessionIdleTimeoutMs1 800 000 (30 минут)Щедрый льготный период для переподключения. Соответствует ConnectionRegistry.idleTtlMs для единообразия ментальной модели

7. Наблюдаемость

  • stderr лог: qwen serve: reaping idle session "<id>" (idle for Nms) при каждом сборе, в соответствии с существующим соглашением о префиксе qwen serve:.
  • Событие телеметрии: session.close с операцией qwen-code.daemon.bridge.operation: 'session.close' (повторно использует существующий путь телеметрии closeSession).
  • Метрика телеметрии: sessionLifecycle('close') (повторно использует существующий счётчик).
  • Событие SSE: session_closed с data.reason: 'idle_timeout'.

8. Последующая работа (Вне рамок)

ЭлементОписаниеПриоритет
LRU-вытеснение при maxSessionsВместо отклонения новых сессий вытеснять наименее недавно активную неактивную сессиюP1
Компактизация кольца EventBusСжимать кольцо для сессий с 0 подписчиков для экономии памятиP2
Адаптивное давление на основе RSSОтслеживать process.memoryUsage().rss и снижать TTL простоя при нехватке памятиP2
Активность клиента на основе heartbeatАвтоматически отключать клиентов, пропустивших N последовательных окон heartbeatP2

9. Риски и меры по их снижению

РискМера по снижению
Сборщик закрывает сессию, к которой бесклиентское приложение собирается переподключитьсяTTL по умолчанию 30 минут является щедрым; бесклиентские приложения должны отправлять heartbeats. Транскрипт на диске сохраняется — session/load восстанавливает его.
closeSession внутри сборщика выбрасывает исключение, нарушая цикл сканированияКаждое закрытие обрабатывается в собственном .catch() — один сбой не блокирует другие
Итерация сборщика по byId во время одновременного closeSession из другого путиИтерация ES2015 Map допускает удаление текущего/предыдущего ключей. Двойное закрытие идемпотентно (byId.get возвращает undefined → SessionNotFoundError ловится в .catch() сборщика).
Производительность сканирования 20 сессий каждые 60 секундТривиальная — 20 чтений Map + 4 проверки полей. Нет I/O.
Взаимодействие таймера простоя каналаКогда последняя сессия собрана, closeSession уже вызывает startIdleTimer на канале. Дополнительная логика не требуется.
Last updated on