Шина событий SSE и backpressure
Обзор
EventBus (packages/acp-bridge/src/eventBus.ts) — это in-memory pub/sub-шина для каждой сессии, обслуживающая SSE-маршрут демона GET /session/:id/events. Она присваивает каждому событию монотонный id, буферизует недавние события в кольцевом буфере ограниченного размера для воспроизведения по Last-Event-ID, рассылает опубликованные события всем подписчикам, применяет backpressure для каждого подписчика (предупреждение при заполнении живой очереди / сериализованных байтов на 75%, исключение при достижении лимита) и генерирует локальные для подписчика синтетические фреймы (client_evicted, slow_client_warning), которые SDK обрабатывает как полноценные события, но шина помечает без id, чтобы они не занимали слот в монотонной последовательности сессии.
В настоящее время EventBus является package-private для acp-bridge и используется фабрикой bridge через один замкнутый экземпляр на сессию. Будущий рефакторинг (указанный в строках 150–159 файла eventBus.ts) вынесет его на верхний уровень в качестве базового строительного блока, чтобы каналы, dual-output и будущие WebSocket-транспорты могли подписываться через ту же шину вместо запуска параллельных потоков.
Обязанности
- Назначать монотонные id событий для каждой сессии, начиная с 1.
- Буферизовать последние
ringSizeсобытий для воспроизведения при подписке сlastEventId. - Рассылать опубликованные события не более чем
maxSubscribersодновременным подписчикам. - Применять ограниченные очереди для каждого подписчика; отключать подписчиков, превышающих лимит живых фреймов или лимит живых сериализованных байтов, с помощью синтетического терминального фрейма
client_evicted. - Выдавать
slow_client_warningодин раз за эпизод переполнения при достижении 75% заполнения живой очереди фреймами или сериализованными байтами, с гистерезисом 37,5% для предотвращения повторяющихся предупреждений. - Оперативно разрывать подписки при
AbortSignal.abort(). - Корректно закрывать каждого подписчика при закрытии шины (например, при завершении сессии).
- Никогда не выбрасывать исключения из
publish(контракт гласит: “вызов publish всегда безопасен”).
Архитектура
| Константа | Значение | Назначение |
|---|---|---|
EVENT_SCHEMA_VERSION | 1 | Проставляется в каждом BridgeEvent.v; увеличивается при критических изменениях фреймов. |
DEFAULT_RING_SIZE | 8000 | Кольцо воспроизведения для каждой сессии. Переопределяется оператором через --event-ring-size. |
DEFAULT_MAX_QUEUED | 256 | Лимит отставания живых фреймов для каждого подписчика. |
DEFAULT_MAX_QUEUED_BYTES | 2 MiB | Лимит отставания живых сериализованных байтов для каждого подписчика. |
DEFAULT_MAX_SUBSCRIBERS | 64 | Лимит подписчиков для каждой сессии. |
WARN_THRESHOLD_RATIO | 0.75 | Доля maxQueued или maxQueuedBytes, при которой срабатывает slow_client_warning. |
WARN_RESET_RATIO | 0.375 | Доля для повторного взвода гистерезиса. |
MAX_EVENT_RING_SIZE (в bridge.ts) | 1_000_000 | Мягкий верхний предел для BridgeOptions.eventRingSize для предотвращения сбоев нехватки памяти (out-of-memory), вызванных опечатками. |
BridgeEvent
interface BridgeEvent {
id?: number; // монотонный для сессии; отсутствует в синтетических терминальных фреймах
v: 1; // EVENT_SCHEMA_VERSION
type: string; // один из 47 известных типов или расширяемый в будущем
data: unknown; // полезные данные (типизируются по типам в SDK; см. 09-event-schema.md)
_meta?: { serverTimestamp?: number; [key: string]: unknown }; // проставляется EventBus.publish
originatorClientId?: string; // устанавливается, если событие происходит от запроса с clientId
}SubscribeOptions
interface SubscribeOptions {
lastEventId?: number; // воспроизводить начиная после этого id (возобновление Last-Event-ID)
signal?: AbortSignal; // оперативно прерывает подписку
maxQueued?: number; // лимит отставания живых фреймов для подписчика; по умолчанию 256
}subscribe() возвращает AsyncIterable<BridgeEvent>. SSE-маршрут потребляет его с помощью for await. Регистрация является синхронной — к моменту возврата из subscribe() подписчик уже подключен, поэтому publish(), который соревнуется (race) с первым next() потребителя, всё равно будет доставлен.
Лимит живых байтов — это опция конструктора на уровне шины, предназначенная только для тестов / встроенных вызывающих объектов. Она не предоставляется как HTTP-параметр запроса, опция SDK, флаг CLI или возможность (capability), поскольку клиенты не должны иметь возможности увеличивать бюджет памяти демона.
BoundedAsyncQueue
Очередь для каждого подписчика. Два ключевых поведения:
- Живые лимиты применяются только к живым элементам. Элементы, вставленные через
forcePush(), несут тегforced: trueдля каждой записи и никогда не учитываются вliveCountилиliveBytes. Это позволяет пути воспроизведенияLast-Event-IDпринудительно отправлять (force-push) сотни исторических фреймов новому подписчику, не вызывая немедленного срабатывания живых лимитов и исключения только что возобновленного подписчика. liveCountиliveBytesподдерживаются как поля, а не выводятся из позицииforcedInBuf. Более ранняя эвристика на основе позиций сломалась, когдаslow_client_warningначал делать force-push в середине потока (предупреждения отправляются в КОНЕЦ очереди, а не в начало, как при воспроизведении). Тегиforcedдля каждой записи не зависят от позиции; живые записи также хранят оценку своих сериализованных байтов, поэтому очистка очереди уменьшаетliveBytes.- Сериализованные байты оцениваются лениво.
push()вычисляетBuffer.byteLength(JSON.stringify(event), 'utf8')только в том случае, если событие будет буферизовано. Если подписчик уже ожидаетnext(), событие доставляется напрямую, и оценка байтов не вычисляется. Если сериализация завершается ошибкой, демон выводит диагностическое сообщение в stderr (best-effort), и это событие пропускает учет байтов, сохраняя контрактpublish()“никогда не выбрасывает исключения”; при этом оно всё равно учитывается в лимите живых фреймов.
push(value, getBytes) возвращает результат accepted / rejected вместо блокировки или выброса исключения. Переполнение фреймов отклоняется с queue_overflow; переполнение байтов отклоняется с queue_bytes_overflow. Одно событие превышенного размера допускается, когда живая очередь пуста, но второе живое событие за ним исключает подписчика. forcePush(value) обходит оба лимита. close({drain?: boolean}) по умолчанию очищает (drains) ожидающие элементы; при прерывании (abort-path) передается drain: false, чтобы немедленно их отбросить.
Рабочий процесс
Публикация
publish никогда не выбрасывает исключения. Закрытие шины во время publish (путь завершения работы закрывает шины для каждой сессии перед ожиданием channel.kill()) возвращает undefined, а не выбрасывает исключение, поскольку агент всё ещё может выдавать уведомления sessionUpdate в небольшом окне между закрытием шины и уничтожением канала (channel kill).
Подписка + воспроизведение (с обнаружением исключения из кольца)
Если во время подписки subs.size >= maxSubscribers, выбрасывается SubscriberLimitExceededError — SSE-маршрут перехватывает его и сериализует синтетический фрейм stream_error для отклоненного клиента, чтобы тот не видел молчаливый пустой поток. Возврат пустого итерируемого объекта вместо этого лишил бы операторов видимости ситуации “некоторые клиенты получают события, а некоторые нет” под нагрузкой.
Исключение из кольца → state_resync_required (поток восстановления)
Когда потребитель переподключается с Last-Event-ID: N, а самое раннее сохранившееся событие в кольце имеет id > N + 1, события в диапазоне [N+1, earliestInRing-1] были исключены до переподключения потребителя. Наивное воспроизведение молча завершилось бы успехом с несмежным суффиксом, SDK reducer продолжил бы применять дельты, как если бы поток был смежным, и его состояние разошлось бы с истинным состоянием демона — без какого-либо терминального сигнала.
Реализовано в EventBus.subscribe():
- Сначала проверяется
opts.lastEventId >= this.nextId. Если это так, курсор клиента относится к более старой эпохе шины (перезапуск демона / реконструкция EventBus), поэтому шина выдаетreason: 'epoch_reset'и воспроизводит всё текущее кольцо. - В противном случае вычисляется
earliestInRing = this.ring[0]?.id. - Если
earliestInRing > opts.lastEventId + 1, делается force-push синтетического фрейма до фреймов воспроизведения:{ "v": 1, "type": "state_resync_required", "data": { "reason": "ring_evicted", "lastDeliveredId": <opts.lastEventId>, "earliestAvailableId": <earliestInRing> } } - После этого продолжается обычный цикл воспроизведения.
Критические контракты (и что было исправлено в ревью #4360):
- Нет
id— тот же паттерн без слота, что и уclient_evicted, поэтому он не занимает слот в монотонной последовательности сессии, которую наблюдают другие подписчики. - Поток остается открытым — в отличие от
client_evicted(действительно терминального),state_resync_requiredориентирован на восстановление. После этого продолжается поток воспроизведения + живых фреймов. - Reducer автоматически пропускает дельты — на стороне SDK переключается
awaitingResync = trueи применяются толькоstate_resync_required, терминальные фреймы и снимки полного состояния, пока код потребителя не вызоветloadSessionи не сбросит флаг. См.09-event-schema.mdдляRESYNC_PASSTHROUGH_TYPES. - Оптимизация для сети — фреймы остаются в сети, чтобы SDK при желании мог позже вычислить разницу “что вы пропустили”. Дополнительный цикл переподключения не требуется.
Терминальный поток исключения
Когда отставание живой очереди подписчика достигает лимита и следующий push() отклоняется:
- Пометить
sub.evicted = true. - Сформировать данные об исключении, вывести
logSubscriberEvicted(evictionData)в stderr, затем сконструировать фреймclient_evictedбезid. При переполнении фреймов используетсяreason: 'queue_overflow'; при переполнении байтов —reason: 'queue_bytes_overflow'. Оба включаютqueueSize,maxQueued,queuedBytesиmaxQueuedBytes; при переполнении байтов также включаетсяeventBytes. queue.forcePush(evictionFrame), чтобы итератор потребителя увидел один терминальный фрейм.queue.close(), чтобы итерация завершилась после терминального фрейма.- Вызвать
sub.dispose()— удаляет изsubsи отсоединяет слушательAbortSignal; без этой очистки замыкания зависших потребителей остаются активными до сборки мусораAbortSignal.
Процесс прерывания
AbortSignal.abort() → onAbort():
queue.close({drain: false})— сбрасываем буферизованные элементы, чтобы SSE-роут не продолжал сериализовать события в сокет, который никто не слушает.dispose()— идемпотентен благодаря флагуdisposed.
Уже прерванные сигналы на момент подписки вызывают onAbort() синхронно перед возвратом итератора.
Состояние и жизненный цикл
nextIdначинается с 1 и только увеличивается. ГеттерlastEventIdвозвращаетnextId - 1.ringограничен; вытеснение сдвигом (eviction-by-shift) имеет сложность O(n) при заполнении. ПриringSize=8000это занимает несколько миллисекунд в сессиях с высокой нагрузкой — что значительно ниже бюджета задержки на один фрейм. Рефакторинг под кольцевой буфер отложен до тех пор, пока профилирование не укажет на проблему или операторы не увеличат--event-ring-sizeна порядок.close()переключает флагclosed, закрывает очередь каждого подписчика и очищаетsubs. Последующие вызовыpublish()/subscribe()становятся no-op (publishвозвращает undefined;subscribeвозвращаетemptyAsyncIterable).- Каждая сессия владеет одним
EventBus. Закрытие шины происходит доchannel.kill(), поэтому незавершенные публикации во время выключения возвращают undefined вместо выбрасывания исключения.
Зависимости
- Используется в
packages/acp-bridge/src/bridge.ts(BridgeClient.sessionUpdate/BridgeClient.extNotification→events.publish(...)). - Используется в
packages/cli/src/serve/routes/sse-events.ts(обработчик SSE-роута →events.subscribe(...)с последующим форматированиемBridgeEventв SSE-фреймы для передачи по сети). - CLI-потребители импортируют шину событий напрямую из
@qwen-code/acp-bridge/eventBus. - SDK-потребитель:
packages/sdk-typescript/src/daemon/sse.ts(parseSseStream), затемasKnownDaemonEvent(см.09-event-schema.md,13-sdk-daemon-client.md).
Конфигурация
--event-ring-size <n>— глубина кольца для каждой сессии; мягкий лимит —MAX_EVENT_RING_SIZE = 1_000_000.- Query-параметр
?maxQueued=Nдля подписчика вGET /session/:id/events, диапазон[16, 2048]. SDK-клиенты предварительно проверяютcaps.features.slow_client_warningперед включением этой опции. - Опция конструктора
EventBus(..., { maxQueuedBytes })существует только для тестов и встраиваемых вызовов. По умолчанию 2 МиБ, невалидные значения выбрасываютTypeError. Query-параметр?maxQueuedBytesнамеренно отсутствует. BridgeOptions.eventRingSize(переопределяет значение по умолчанию для демона при встраиваемом использовании).- Теги возможностей (capability tags):
session_events,slow_client_warning,typed_event_schema.
Интеграция с клиентом: переподключение с Last-Event-ID
Сетевой формат
Каждый SSE-фрейм, содержащий идентификатор и отправляемый через GET /session/:id/events, включает строку id::
id: 42
event: session_update
data: {"id":42,"v":1,"type":"session_update","data":{...},"_meta":{"serverTimestamp":1719000000000}}
Синтетические/терминальные фреймы (state_resync_required, replay_complete, client_evicted, slow_client_warning, stream_error) отправляются без строки id: — они не продвигают монотонную последовательность в рамках сессии.
Протокол переподключения
При переподключении после разрыва связи клиент отправляет идентификатор последнего успешно полученного события в HTTP-заголовке Last-Event-ID:
GET /session/:id/events HTTP/1.1
Last-Event-ID: 42
Accept: text/event-streamEventBus демона воспроизводит все события из кольцевого буфера, у которых id > Last-Event-ID, а затем переходит к доставке в реальном времени. Синтетический фрейм replay_complete отмечает границу между воспроизведением и live-потоком:
// нет строки id: — синтетический фрейм
{
"v": 1,
"type": "replay_complete",
"data": { "replayedCount": 7, "lastReplayedEventId": 49 },
}Логика воспроизведения
| Сценарий | Поведение |
|---|---|
Отсутствует Last-Event-ID | Только live-поток; без воспроизведения. Обратная совместимость с клиентами до введения функции resume. |
Last-Event-ID: 0 | Воспроизведение всего кольцевого буфера с начала (ограничено --event-ring-size, по умолчанию 8000). |
Last-Event-ID: N, где ring[0].id <= N+1 | Непрерывное воспроизведение событий с id > N, затем live-поток. |
Last-Event-ID: N, где ring[0].id > N+1 | Обнаружен разрыв — перед воспроизведением сохранившегося суффикса отправляется state_resync_required (reason: 'ring_evicted'). SDK должен вызвать loadSession для восстановления полного состояния. |
Last-Event-ID: N, где N >= nextId | Сброс эпохи (перезапуск демона) — отправляется state_resync_required (reason: 'epoch_reset'), затем полное воспроизведение кольца. |
Правила валидации
Демон строго парсит Last-Event-ID:
- Принимаются только строки, состоящие исключительно из десятичных цифр (например,
"42"). - Нецифровые, отрицательные, дробные значения или значения при переполнении (превышающие
Number.MAX_SAFE_INTEGER) отклоняются без ошибки — поток запускается только в live-режиме, а демон логирует breadcrumb. - Директива
retry: 3000указывает совместимым реализациямEventSourceждать 3 секунды перед переподключением.
Обратная совместимость
Механизм Last-Event-ID подключается явно (opt-in):
- Клиенты, которые никогда не отправляют этот заголовок, получают только live-поток, идентичный поведению до введения функции resume.
- Более старые версии SDK, не отслеживающие идентификаторы событий, продолжают работать.
- Фрейм
replay_completeявляется синтетическим (безid:), поэтому он не вводит в заблуждение потребителей, не учитывающих идентификаторы.
Ограничения браузерного EventSource
Нативный браузерный API EventSource автоматически отслеживает последнее поле id: и отправляет его при переподключении. Однако он не может устанавливать кастомные заголовки (например, Authorization: Bearer). Клиентам, требующим аутентификации, необходимо использовать непосредственно fetch() + ручной парсинг SSE (как это делает TypeScript SDK через parseSseStream), а не EventSource. RestSseTransport в SDK демонстрирует этот паттерн — он устанавливает Last-Event-ID как явный HTTP-заголовок при вызове fetch().
Ограничения и известные нюансы
- У синтетических фреймов нет
id. Потребители SDK, использующиеLast-Event-IDдля resume, записывают только фреймы с идентификаторами;slow_client_warning,client_evicted,state_resync_requiredиreplay_completeне продвигают курсор и не потребляют порядковые номера в рамках сессии. Если между двумя live-фреймами с идентификаторами есть реальный разрыв, обрабатывайте его через путь ресинка при вытеснении из кольца / сбросе эпохи, а не как приватный синтетический фрейм. client_evictedработает для каждого подписчика, а не для сессии в целом. Тот же клиент может переподключиться.- Итератор
BoundedAsyncQueueне безопасен для конкурентных драйверов — два одновременных вызова.next()создали бы гонку за одно и то же событие. Использование в демоне последовательно (for await ... ofв обработчике SSE-роута), поэтому в продакшене это безопасно. - В настоящее время шина является package-private; каналы и веб-интерфейс должны подписываться через HTTP SSE-роут демона, а не напрямую через шину. В этапе 1.5 это ограничение будет снято.
Ссылки
packages/acp-bridge/src/eventBus.ts(весь файл)packages/acp-bridge/src/bridge.ts(места публикации, в особенностиBridgeClient.sessionUpdateи события разрешений F3)packages/cli/src/serve/routes/sse-events.ts(обработчик SSE-роута — форматируетBridgeEventв сетевой SSE)packages/sdk-typescript/src/daemon/sse.ts(парсер сетевого SSE на стороне клиента)- Описание сетевого протокола:
../qwen-serve-protocol.md(контракт переподключения сLast-Event-ID).