fix(runtime): prevent IndexError in MemoryStreamBridge._make_gap on empty events buffer (#5047)

* fix(runtime): prevent IndexError in MemoryStreamBridge._make_gap on empty events buffer

* fix(runtime): handle empty stream replay gap bounds across backend and frontend

- Clamp MemoryStreamBridge queue_maxsize at 1 and validate StreamBridgeConfig.queue_maxsize >= 1
- Update StreamGap docstring to clarify None retained bounds
- Allow StreamReplayGapData and parseStreamReplayGap in frontend to accept string | null bounds, safely resuming when bounds are null
- Add backend and frontend regression unit tests for queue clamping and null bounds replay gap

* docs(stream-bridge): bump config_version and document empty buffer replay gap behavior

* docs: document nullable gap bounds and sync helm config_version to 37
This commit is contained in:
Nefelibata 2026-08-29 17:21:29 +08:00 committed by GitHub
parent 3b592c2053
commit c6f6a01f56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 162 additions and 30 deletions

View File

@ -846,13 +846,16 @@ data: {"code":"stream_replay_gap","run_id":"run-123","requested_event_id":"17180
```
The frame deliberately has no SSE `id:`. Consumers must reload durable thread
state and persisted run events/messages, then may reconnect from
`latest_available_event_id` to follow newer live events. A gap does not cancel
the active run. The same signal applies when a no-cursor subscriber has already
established an empty-stream wait but the first Redis wake-up falls behind before
delivery; in that case `requested_event_id` is `null`. Malformed cursor handling
is backend-specific and is not the same as a valid cursor that was evicted.
The frame deliberately has no SSE `id:`. Both `earliest_available_event_id` and
`latest_available_event_id` are `string | null` (they are `null` when no events
are retained in the buffer). Consumers must reload durable thread state and
persisted run events/messages, then may reconnect from `latest_available_event_id`
to follow newer live events, or rejoin without a cursor when the buffer is empty
(`latest_available_event_id` is `null`). A gap does not cancel the active run.
The same signal applies when a no-cursor subscriber has already established an
empty-stream wait but the first Redis wake-up falls behind before delivery; in
that case `requested_event_id` is `null`. Malformed cursor handling is
backend-specific and is not the same as a valid cursor that was evicted.
---

View File

@ -159,7 +159,7 @@ data: {"code":"stream_replay_gap","run_id":"...","requested_event_id":"...","ear
```
`gap` 帧没有 SSE `id:`,后面也没有正常 `end`;当前订阅随即关闭。它是恢复边界而不是客户端断开,因此不会触发 `on_disconnect=cancel`。客户端必须丢弃不再可信的瞬时状态,重新读取 thread checkpoint 和持久化 run-event/message history,再以 `latest_available_event_id` 为游标跟随新事件。DeerFlow Web UI 自动执行此流程并最多连续恢复五次。
`gap` 帧没有 SSE `id:`,后面也没有正常 `end`;当前订阅随即关闭。它是恢复边界而不是客户端断开,因此不会触发 `on_disconnect=cancel`。`earliest_available_event_id` 与 `latest_available_event_id` 在缓冲区无保留事件时为 `null`。客户端必须丢弃不再可信的瞬时状态,重新读取 thread checkpoint 和持久化 run-event/message history,再以 `latest_available_event_id` 为游标跟随新事件(若缓冲区为空即 `latest_available_event_id` 为 `null`,则不带游标重新加入流)。DeerFlow Web UI 自动执行此流程并最多连续恢复五次。
Redis 对无游标、空 stream 上已经建立的阻塞等待也遵循相同契约:第一次 `XREAD` 唤醒的数据在交付前仍是 provisional baseline,bridge 会用下一次事务快照确认其尾 ID 仍在保留窗口。若生产者已经裁剪了该基线,订阅直接返回 `requested_event_id: null` 的 `gap`,不会先交付 retained tail。这个检查有明确的性能代价:每轮订阅需要一个包含 `XRANGE`、`XREVRANGE`、非阻塞 `XREAD` 的事务快照;空闲时还需要单独的阻塞 `XREAD` 来唤醒。

View File

@ -20,6 +20,7 @@ class StreamBridgeConfig(BaseModel):
)
queue_maxsize: int = Field(
default=256,
ge=1,
description="Maximum number of events retained per run (memory bridge queue size / redis stream MAXLEN).",
)
max_connections: int | None = Field(

View File

@ -37,12 +37,13 @@ class StreamGap:
``requested_event_id`` is the reconnect cursor, or the most recently
delivered event for a live subscriber that fell behind. The retained
bounds let callers reload durable state and resume at the current tail
without mistaking a partial replay for a complete one.
without mistaking a partial replay for a complete one. When nothing is
retained in the buffer, the bounds are ``None``.
"""
requested_event_id: str | None
earliest_available_event_id: str
latest_available_event_id: str
earliest_available_event_id: str | None
latest_available_event_id: str | None
HEARTBEAT_SENTINEL = StreamEvent(id="", event="__heartbeat__", data=None)

View File

@ -32,7 +32,7 @@ class MemoryStreamBridge(StreamBridge):
"""
def __init__(self, *, queue_maxsize: int = 256) -> None:
self._maxsize = queue_maxsize
self._maxsize = max(1, queue_maxsize)
self._streams: dict[str, _RunStream] = {}
self._counters: dict[str, int] = {}
@ -67,8 +67,8 @@ class MemoryStreamBridge(StreamBridge):
def _make_gap(stream: _RunStream, requested_event_id: str | None) -> StreamGap:
return StreamGap(
requested_event_id=requested_event_id,
earliest_available_event_id=stream.events[0].id,
latest_available_event_id=stream.events[-1].id,
earliest_available_event_id=stream.events[0].id if stream.events else None,
latest_available_event_id=stream.events[-1].id if stream.events else None,
)
def _resolve_start_offset(self, stream: _RunStream, last_event_id: str | None) -> int | StreamGap:
@ -85,7 +85,7 @@ class MemoryStreamBridge(StreamBridge):
# watermark keep the legacy replay-from-earliest behavior.
seq = self._parse_event_seq(last_event_id)
if seq is not None:
if stream.events and seq < stream.start_offset:
if seq < stream.start_offset:
return self._make_gap(stream, last_event_id)
local_index = seq - stream.start_offset
if 0 <= local_index < len(stream.events) and stream.events[local_index].id == last_event_id:

View File

@ -8,6 +8,7 @@ from collections import defaultdict
import anyio
import pytest
from pydantic import ValidationError
from deerflow.config.stream_bridge_config import StreamBridgeConfig, set_stream_bridge_config
from deerflow.runtime import END_SENTINEL, HEARTBEAT_SENTINEL, MemoryStreamBridge, StreamGap, make_stream_bridge
@ -1072,6 +1073,64 @@ async def test_memory_malformed_last_event_id_is_not_reported_as_gap():
assert all(not isinstance(entry, StreamGap) for entry in received)
@pytest.mark.anyio
async def test_memory_make_gap_handles_empty_events_buffer():
"""_make_gap returns StreamGap with None bounds when events deque is empty."""
bridge = MemoryStreamBridge(queue_maxsize=2)
run_id = "run-empty-stream-gap"
stream = bridge._get_or_create_stream(run_id)
assert len(stream.events) == 0
gap = bridge._make_gap(stream, "100-0")
assert gap == StreamGap(
requested_event_id="100-0",
earliest_available_event_id=None,
latest_available_event_id=None,
)
def test_memory_stream_bridge_clamps_queue_maxsize():
"""queue_maxsize <= 0 is clamped to at least 1."""
bridge = MemoryStreamBridge(queue_maxsize=0)
assert bridge._maxsize == 1
bridge_neg = MemoryStreamBridge(queue_maxsize=-5)
assert bridge_neg._maxsize == 1
def test_stream_bridge_config_validates_queue_maxsize():
"""StreamBridgeConfig rejects queue_maxsize < 1."""
with pytest.raises(ValidationError):
StreamBridgeConfig(queue_maxsize=0)
with pytest.raises(ValidationError):
StreamBridgeConfig(queue_maxsize=-1)
@pytest.mark.anyio
async def test_memory_subscribe_replays_gap_when_buffer_empty_and_cursor_behind_watermark():
"""Subscribing with a cursor behind start_offset when buffer is empty yields StreamGap with None bounds."""
bridge = MemoryStreamBridge(queue_maxsize=2)
run_id = "run-empty-retained-gap"
stream = bridge._get_or_create_stream(run_id)
# Simulate a stream whose retained buffer was cleared / advanced
stream.start_offset = 10
stream.events.clear()
received = [
entry
async for entry in bridge.subscribe(
run_id,
last_event_id="5-0",
heartbeat_interval=0.1,
)
]
assert len(received) == 1
assert received[0] == StreamGap(
requested_event_id="5-0",
earliest_available_event_id=None,
latest_available_event_id=None,
)
@pytest.mark.anyio
async def test_make_stream_bridge_uses_docker_redis_env(monkeypatch):
"""Docker can enable Redis bridge without editing config.yaml."""

View File

@ -15,7 +15,7 @@
# ============================================================================
# Bump this number when the config schema changes.
# Run `make config-upgrade` to merge new fields into your local config.yaml.
config_version: 36
config_version: 37
# ============================================================================
# Logging
@ -2330,13 +2330,13 @@ run_ownership:
#
# stream_bridge:
# type: memory # single-process only
# queue_maxsize: 256 # data events retained per run. A reconnect
# queue_maxsize: 256 # data events retained per run (min: 1). A reconnect
# # older than this window receives SSE `gap`.
#
# stream_bridge:
# type: redis # recommended for Docker / multi-worker gateway
# redis_url: redis://redis:6379/0
# queue_maxsize: 256 # data events retained per run (Redis MAXLEN).
# queue_maxsize: 256 # data events retained per run (Redis MAXLEN, min: 1).
# # Older valid cursors receive SSE `gap`.
# stream_ttl_seconds: 86400 # rolling TTL for retained stream buffers.
# # Refreshed on each publish/publish_end; set 0

View File

@ -124,7 +124,7 @@ they resolve from the `secrets` map):
```yaml
config: |
config_version: 36
config_version: 37
models:
- name: gpt-4
use: langchain_openai:ChatOpenAI

View File

@ -243,7 +243,7 @@ ingress:
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
# inline literal secret values here. The default enables provisioner sandbox.
config: |
config_version: 36
config_version: 37
log_level: info
models: []

View File

@ -97,7 +97,7 @@ Edit-and-rerun is deliberately latest-turn-only. `core/messages/utils.ts::getLat
- **Thread routes** — construct Web UI chat paths through `core/threads/utils.ts::pathOfThread()`, which percent-encodes both custom agent names and thread IDs before inserting them into route segments
- **LangGraph client** is a singleton obtained via `getAPIClient()` in `core/api/`
- **Run stream options** are sanitized by `core/api/stream-mode.ts`: the Gateway-supported set is `values`, `messages-tuple`, `updates`, `debug`, `tasks`, `checkpoints`, and `custom`; any request containing an unsupported mode throws before HTTP instead of being partially forwarded or silently defaulting to `values`. `streamResumable` is retained by thread hooks only for SDK-side reconnect bookkeeping but stripped before the HTTP request because the Gateway does not accept that request option; actual replay uses the SSE `Last-Event-ID` cursor. Keep this boundary aligned with the backend request schema; `messages` and `events` are not supported and must not be forwarded.
- **SSE replay gaps** are handled in `core/api/api-client.ts`, which wraps both initial and joined run streams because the upstream SDK ignores unknown event names. An id-less backend `gap` control frame clears stale reconnect metadata, emits an internal `stream_replay_gap` custom event, reloads durable thread values, and rejoins after the server-provided retained tail, with up to five recovery rejoins after the original stream (six total stream calls on an all-gap exhaustion path). The wrapper remains a lazy async iterable because the SDK consumes it with `for await`. `core/threads/hooks.ts` clears optimistic/transient/subtask state, invalidates durable history caches, and shows the localized recovery warning; never let a gap fall through as a normal stream finish or cancel the still-running backend run.
- **SSE replay gaps** are handled in `core/api/api-client.ts`, which wraps both initial and joined run streams because the upstream SDK ignores unknown event names. An id-less backend `gap` control frame clears stale reconnect metadata, emits an internal `stream_replay_gap` custom event, reloads durable thread values, and resumes after the server-provided retained tail when one exists (or rejoins without a cursor if the buffer is empty), with up to five recovery rejoins after the original stream (six total stream calls on an all-gap exhaustion path). The wrapper remains a lazy async iterable because the SDK consumes it with `for await`. `core/threads/hooks.ts` clears optimistic/transient/subtask state, invalidates durable history caches, and shows the localized recovery warning; never let a gap fall through as a normal stream finish or cancel the still-running backend run.
- **Streaming Markdown rendering** is owned by `core/streamdown`: Streamdown's `animated` / `isAnimating` API handles incremental word animation, while the shared `streamdownRenderingPlugins` config registers the named code-highlighting and Mermaid plugins required by Streamdown 2.5. Keep wrappers and derived configs wired to that shared object; do not reintroduce a rehype plugin that wraps every word, because reparsing a growing block remounts old words and replays their animation.
- Citation links in message and artifact Markdown must derive their `citation:` label from the full `ReactNode` children tree, since Streamdown may provide element or array children during streaming rather than a plain string.
- **Environment validation** uses `@t3-oss/env-nextjs` with Zod schemas (`src/env.js`). Skip with `SKIP_ENV_VALIDATION=1`

View File

@ -75,8 +75,8 @@ export type StreamReplayGapData = {
code: "stream_replay_gap";
run_id: string;
requested_event_id: string | null;
earliest_available_event_id: string;
latest_available_event_id: string;
earliest_available_event_id: string | null;
latest_available_event_id: string | null;
recovery: "reload_durable_state";
};
@ -93,7 +93,7 @@ export class StreamReplayGapError extends Error {
readonly recoveryCause?: unknown,
) {
super(
`Unable to recover SSE history after ${recoveryAttempts} attempts (requested ${gap.requested_event_id ?? "initial stream"}, earliest ${gap.earliest_available_event_id})`,
`Unable to recover SSE history after ${recoveryAttempts} attempts (requested ${gap.requested_event_id ?? "initial stream"}, earliest ${gap.earliest_available_event_id ?? "none"})`,
);
this.name = "StreamReplayGapError";
}
@ -106,12 +106,16 @@ function parseStreamReplayGap(data: unknown): StreamReplayGapData {
const value = data as Record<string, unknown>;
const requestedEventId = value.requested_event_id;
const earliestAvailableEventId = value.earliest_available_event_id;
const latestAvailableEventId = value.latest_available_event_id;
if (
value.code !== "stream_replay_gap" ||
typeof value.run_id !== "string" ||
(requestedEventId !== null && typeof requestedEventId !== "string") ||
typeof value.earliest_available_event_id !== "string" ||
typeof value.latest_available_event_id !== "string" ||
(earliestAvailableEventId !== null &&
typeof earliestAvailableEventId !== "string") ||
(latestAvailableEventId !== null &&
typeof latestAvailableEventId !== "string") ||
value.recovery !== "reload_durable_state"
) {
throw new Error("Invalid stream replay gap payload.");
@ -244,7 +248,7 @@ async function* recoverStreamReplayGaps({
threadId: string | null | undefined;
expectedRunId: () => string | undefined;
initialStream: AsyncIterable<StreamPart>;
resume: (runId: string, lastEventId: string) => AsyncIterable<StreamPart>;
resume: (runId: string, lastEventId?: string) => AsyncIterable<StreamPart>;
}): AsyncGenerator<StreamPart> {
let stream = initialStream;
let recoveryAttempts = 0;
@ -276,8 +280,8 @@ async function* recoverStreamReplayGaps({
// The SDK would otherwise ignore an unknown `gap` event and report a
// normal finish. Surface a custom control event to DeerFlow's hook, reload
// durable values, then explicitly follow only events newer than the
// retained tail captured by the server.
// durable values, then resume after the retained tail when it exists
// (or rejoin without a cursor if the buffer is empty).
clearReconnectRun(threadId, runId);
yield {
event: "custom",
@ -294,7 +298,7 @@ async function* recoverStreamReplayGaps({
}
rememberReconnectRun(threadId, runId);
stream = resume(runId, gap.latest_available_event_id);
stream = resume(runId, gap.latest_available_event_id ?? undefined);
}
}

View File

@ -450,6 +450,70 @@ test("recovers a gap emitted by the initial run stream", async () => {
expect(recoveryHeaders[0]?.get("Last-Event-ID")).toBe("5-0");
});
test("recovers from stream replay gap with null retained bounds", async () => {
const sessionStorage = makeSessionStorage();
const gap = {
code: "stream_replay_gap",
run_id: "run-null-bounds",
requested_event_id: "1-0",
earliest_available_event_id: null,
latest_available_event_id: null,
recovery: "reload_durable_state",
};
let recoveryLastEventId: string | null = "sentinel";
const fetchFn = rs.fn(async (url: string | URL, init?: RequestInit) => {
const path = url.toString();
if (path.endsWith("/threads/thread-null/runs/stream")) {
return makeSSEResponse(`event: gap\ndata: ${JSON.stringify(gap)}\n\n`, {
"Content-Location": "/threads/thread-null/runs/run-null-bounds",
});
}
if (path.includes("/threads/thread-null/state")) {
return new Response(
JSON.stringify({
values: { messages: [{ type: "ai", content: "checkpoint" }] },
}),
{ status: 200 },
);
}
if (path.includes("/runs/run-null-bounds/stream")) {
const headers = new Headers(init?.headers);
recoveryLastEventId = headers.get("Last-Event-ID");
return makeSSEResponse("event: end\ndata: null\n\n");
}
return new Response(JSON.stringify({ detail: "unexpected request" }), {
status: 500,
});
});
rs.stubGlobal("window", {
location: { origin: "http://localhost:2026" },
sessionStorage,
});
rs.stubGlobal("fetch", fetchFn);
const received: Array<{ id?: string; event: string; data: unknown }> = [];
for await (const entry of getAPIClient(true).runs.stream(
"thread-null",
"lead_agent",
{ streamResumable: true },
)) {
received.push(entry);
}
expect(received).toEqual([
{
event: "custom",
data: { type: "stream_replay_gap", ...gap },
},
{
event: "values",
data: { messages: [{ type: "ai", content: "checkpoint" }] },
},
{ event: "end", data: null },
]);
expect(recoveryLastEventId).toBeNull();
});
test("clears reconnect metadata when an initial stream gap resume is inactive", async () => {
const sessionStorage = makeSessionStorage();
const gap = {