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>).
Она уничтожается только когда:
- Клиент явно вызывает
DELETE /session/:id(closeSession) - Общий дочерний процесс
qwen --acpкрашится (обработчикchannel.exited) - Процесс демона получает
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. Цели дизайна
- Автоматически освобождать бездействующие сессии, чьи клиенты исчезли и у которых нет активной работы в процессе.
- Никогда не уничтожать сессию с активным запросом — это молча прервёт видимую пользователем работу.
- Сохранять персистентные данные сессии — освобождается только состояние моста в памяти;
транскрипты на диске (
SessionService) не трогаются. Пользователи могут выполнитьsession/loadилиsession/resumeдля восстановления. - Наблюдаемость — отправлять отдельное SSE-событие, чтобы клиенты знали, ПОЧЕМУ сессия закрыта (таймаут простоя vs. явное закрытие vs. сбой).
- Настраиваемость — операторы и тесты могут подстраивать таймауты или полностью отключить сборщик.
- Без новых зависимостей / компонентов — реализация полностью внутри существующего замыкания моста.
Не цели
- Управление сессиями между рабочими областями (это ответственность шлюза).
- 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 sweep | ACP-поверх-HTTP соединение | Удаляет транспортные соединения /acp (другой уровень) |
writerIdleTimeoutMs | Подписчик SSE | Вытесняет одного зависшего подписчика SSE |
| Сборщик отключений (server.ts) | Ручное порождение | Удаляет сессии, владелец которых отключился ВО ВРЕМЯ рукопожатия POST /session |
Два механизма работают вместе, покрывая жизненный цикл очистки сессии:
-
Close-on-last-detach (основной) — когда
detachClientудаляет последнего зарегистрированного клиента И не остаётся подписчиков SSE, сессия закрывается немедленно черезcloseSessionImpl. Это обрабатывает нормальный путь: пользователь закрывает вкладку → очистка React →POST /session/:id/detach. -
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 Предикат бездействия сессии
Сессия подлежит утилизации, когда выполняются все следующие условия:
- Нет активного запроса:
entry.promptActive === false - Нет активных подписчиков SSE:
entry.events.subscriberCount === 0 - Превышена длительность бездействия:
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, который:
- Удаляет из
byId/defaultEntry - Отменяет ожидающие разрешения через
permissionMediator.forgetSession - Публикует событие
session_closed(сreason: 'idle_timeout') - Закрывает EventBus
- Отправляет
connection.cancel()дочернему процессу ACP (best-effort) - Запускает
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откладывается. ВнутриcloseSessionbyId.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' |
| 9 | closeSession с явной причиной | Вызвать closeSession с reason: 'idle_timeout', проверить опубликованное событие |
| 10 | Несколько бездействующих сессий удаляются за один тик | Создать 3 бездействующие сессии, сдвинуть время, запустить тик, проверить, что все 3 удалены |
| 11 | Сессия с пульсом в пределах TTL переживает проверку | Создать сессию, зафиксировать пульс, сдвинуть время чуть меньше TTL, проверить, что сессия пережила |
| 12 | Таймер бездействия канала срабатывает после удаления последней сессии | Создать 1 сессию (последняя на канале), удалить её, проверить, что у канала вызван startIdleTimer |
5.2 Интеграционные тесты (server.test.ts)
| № | Тест | Описание |
|---|---|---|
| 1 | GET /health?deep=1 отражает количество сессий, очищенных сборщиком | Запустить демон, создать сессии, сдвинуть время, проверить, что эндпоинт health показывает уменьшенное количество |
| 2 | SSE-подписчик получает session_closed с reason: 'idle_timeout' | Открыть SSE, отключиться, переподключиться до TTL, затем дать TTL истечь, проверить событие |
6. Значения по умолчанию конфигурации
| Параметр | По умолчанию | Обоснование |
|---|---|---|
sessionReapIntervalMs | 60 000 (1 минута) | Достаточно часто, чтобы избежать длительного накопления, достаточно дёшево (простой обход Map) для частого выполнения |
sessionIdleTimeoutMs | 1 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 последовательных окон heartbeat | P2 |
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 на канале. Дополнительная логика не требуется. |