fix(config): guard the recovered stream cleanup delay like its heartbeat sibling by Lesereingrape · Pull Request #5850 · bytedance/deer-flow · GitHub
Skip to content

fix(config): guard the recovered stream cleanup delay like its heartbeat sibling - #5850

Merged
WillemJiang merged 1 commit into
bytedance:mainfrom
Lesereingrape:fix/stream-bridge-cleanup-delay-guards
Sep 30, 2026
Merged

WillemJiang merged 1 commit into
bytedance:mainfrom
Lesereingrape:fix/stream-bridge-cleanup-delay-guards

Conversation

@Lesereingrape

Copy link
Copy Markdown
Contributor

Why

Trigger. StreamBridgeConfig guards its two float fields unequally. heartbeat_interval_seconds
(backend/packages/harness/deerflow/config/stream_bridge_config.py:28-34) carries gt=0,
le=MAX_HEARTBEAT_INTERVAL_SECONDS, allow_inf_nan=False and a mode="before" validator that
rejects booleans by name (:60-66). recovered_stream_cleanup_delay_seconds (:54-58) sits five
lines below it with nothing but ge=0.

Measured on main @ 174c6f7f, loading that module and constructing the model:

heartbeat_interval_seconds              True   -> ValidationError
recovered_stream_cleanup_delay_seconds  True   -> accepted as 1.0
recovered_stream_cleanup_delay_seconds  False  -> accepted as 0.0
recovered_stream_cleanup_delay_seconds  inf    -> accepted as inf
recovered_stream_cleanup_delay_seconds  1e30   -> accepted as 1e+30

These are not exotic inputs: yaml.safe_load turns recovered_stream_cleanup_delay_seconds: true
into True and …: .inf into inf, so both reach the model straight from a hand-edited
config.yaml.

Pain. The value is handed to the bridge unchanged. app/gateway/deps.py:584 reads it, and
:233 starts asyncio.create_task(bridge.cleanup(record.run_id, delay=cleanup_delay)); both
implementations are a plain sleep (runtime/stream_bridge/redis.py:371-373,
runtime/stream_bridge/memory.py:184-186):

async def cleanup(self, run_id: str, *, delay: float = 0) -> None:
    if delay > 0:
        await asyncio.sleep(delay)
    await self._redis.delete(self._stream_key(run_id))

So:

  • inf (or any oversized value such as 1e30) makes that task sleep effectively forever. The
    recovered orphan run's stream key is never deleted by the path that is supposed to delete it, and
    the pending task stays registered in recovered_stream_cleanup_tasks for the life of the process.
    The key then survives until the unrelated rolling stream_ttl_seconds (default 86400) expires, or
    until _flush_recovered_stream_cleanups() cancels it at shutdown. The operator asked for a 60-second
    grace window and got "keep it around for a day".
  • true is silently coerced to 1.0, collapsing the reconnect grace window to one second — the
    opposite of what the field's own description promises. There is no warning and no error.

Neither is a crash, which is exactly why the guard belongs on the model: the sibling field already
refuses both inputs at load time with a message naming the field, so the config layer can reject
them the same way, at the moment the operator can still act on it.

What changed

stream_bridge.recovered_stream_cleanup_delay_seconds is now validated the way
heartbeat_interval_seconds already is: a non-boolean number between 0 and 86400. A config that
says true, .inf, or a value above a day now fails at load with
recovered_stream_cleanup_delay_seconds must be a number, not a boolean / …less than or equal to 86400.0, instead of quietly changing how long orphaned run streams are retained.

0 stays valid and still means "delete as soon as END is published" — pinned by a test, since the
field is documented as allowing it.

Scope notes, so the diff is not oversold:

  • -1, -inf and nan were already rejected on main, because pydantic evaluates ge=0
    against them as false. The new allow_inf_nan=False does not change what is rejected; it is there
    so this field reads like its sibling instead of relying on a comparison side effect to exclude
    non-finite values.
  • The bool validator is now shared by both fields and reports info.field_name, so the existing
    heartbeat message is unchanged.

Surface area

Config schema validation in the harness package plus its test file and the matching comment in
config.example.yaml. No endpoint, SSE event, graph, prompt, sandbox, skill or dependency changes,
so none of the listed boxes applies; in particular this is not a default-behavior change — the
documented default of 60 and every value that works today keep working.

Bug fix verification

  • Test path: backend/tests/test_stream_bridge.py::test_stream_bridge_config_rejects_invalid_recovered_stream_cleanup_delay
    and …::test_stream_bridge_config_accepts_valid_recovered_stream_cleanup_delay
  • Red on main, green on this branch: yes

Red = the branch's test file against main's stream_bridge_config.py (only the src file reverted),
using the same seven parametrized cases:

$ PYTHONPATH=. python -m pytest tests/test_tmp_red_check.py -q     # on main's config module
FAILED tests/test_tmp_red_check.py::test_rejects_invalid[True] - Failed: DID NOT RAISE
FAILED tests/test_tmp_red_check.py::test_rejects_invalid[False] - Failed: DID NOT RAISE
FAILED tests/test_tmp_red_check.py::test_rejects_invalid[inf] - Failed: DID NOT RAISE
FAILED tests/test_tmp_red_check.py::test_rejects_invalid[86401.0] - Failed: DID NOT RAISE
4 failed, 3 passed in 5.66s

Green on this branch: 7 passed in 4.10s for the same cases, and the committed versions of the two
tests pass as part of tests/test_stream_bridge.py (80 passed, 7 skipped). The scratch red-check
file above was deleted; the committed tests import the new
MAX_RECOVERED_STREAM_CLEANUP_DELAY_SECONDS, so on main they fail at collection instead.

Validation

  • pytest tests/test_stream_bridge.py — 80 passed, 7 skipped (the 7 are the existing Redis-live skips).
  • pytest tests/test_stream_bridge.py tests/test_gateway_run_recovery.py tests/test_app_config_reload.py tests/test_runtime_lifecycle_e2e.py — 128 passed, 7 skipped, i.e. every test file that touches
    StreamBridgeConfig or the recovered-stream cleanup path this value feeds.
  • ruff check on both changed Python files: all checks passed. ruff format: clean afterwards
    (ruff reflowed the Field(description=…) I had lengthened).
  • I did not run the full make test / make test-blocking-io: the change is one field's constraints
    and one shared validator in a config model, and the four files above are that model's and the
    consumer's coverage. Say the word and I will push a commit with a wider run.

AI assistance

Tool(s) used: Qoder CLI agent (Claude), running the /contributor routine autonomously.

How you used it: the agent diffed this field against the guarded sibling to find the asymmetry,
measured the accepted/rejected inputs on main and on the branch, wrote both the fix and the tests,
and ran the numbers quoted above itself. No human read the diff before submission, which is why the
attestation box below is left unticked — it asserts that a person has read and takes responsibility
for the change, and that person has not seen it yet.

  • I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output. (left for a human; see above)

@github-actions github-actions Bot added area:backend Gateway / runtime / core backend under backend/ risk:medium Medium risk: regular code changes size/S PR changes 20-100 lines labels Sep 25, 2026
liwenjie200543 added a commit to liwenjie200543/deer-flow that referenced this pull request Sep 29, 2026
…n values

Ten numeric fields across six config models admitted zero, negatives, or
booleans (YAML/JSON true coerces to 1) and only failed at runtime — or kept
running with a nonsense value:

- CircuitBreakerConfig: failure_threshold >= 1, recovery_timeout_sec > 0
  (threshold 0 trips the circuit on the counter check itself; a
  non-positive recovery timeout makes the breaker never recover)
- McpServerConfig: tool_call_timeout > 0, session_init_timeout > 0
  (0 means every MCP tool call times out instantly)
- McpOAuthConfig.refresh_skew_seconds >= 0 (negative refreshes after expiry)
- ModelConfig.stream_chunk_timeout > 0 (0 aborts every stream)
- RunEventsConfig.max_trace_content >= 1 (content[:0] truncates to nothing)
- SandboxConfig.port 1..65535, idle_timeout >= 0 (0 remains documented as
  'disable idle cleanup')
- SummarizationConfig.trim_tokens_to_summarize >= 1 (the summarizer clamps
  max(1, value), so a negative silently produced 1-token summaries)

Each field also rejects booleans before coercion, mirroring the guards
already shipped for the stream bridge heartbeat interval and the database
pool settings. max_connections and the recovered-cleanup delay are left to
bytedance#5850, which owns that file.

Generated-by: ZCode (GLM, coding agent)

Authored-by: liwenjie200543 <liwenjie200543@users.noreply.github.com>

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 3166a3d (diff: one float field tightened, shared bool validator, tests, example comment). I verified the behavior empirically rather than by reading alone: against the PR's own stream_bridge_config.py, recovered_stream_cleanup_delay_seconds=True/False/inf/86401 all raise ValidationError at construction while 0, 60, and 86400 still construct to the expected floats; the same inputs are silently accepted on main's module, confirming the red-on-main claim. The shared validator keeps the heartbeat error message byte-identical (heartbeat_interval_seconds must be a number, not a boolean), the rename to reject_boolean_seconds has no external references at this SHA, and the two other construction sites (app_config.py reload path and async_provider.py's redis-env fallback) cannot feed out-of-range values. All 10 new parametrized cases pass, and ruff check / ruff format --check are clean on the changed Python file. No further findings — this is a well-scoped, well-tested fix, and the PR body's scope notes (what ge=0 already rejected, unchanged heartbeat message) are accurate.

@WillemJiang WillemJiang added this to the 2.2.0 milestone Sep 30, 2026
@WillemJiang
WillemJiang merged commit 0110845 into bytedance:main Sep 30, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend Gateway / runtime / core backend under backend/ risk:medium Medium risk: regular code changes size/S PR changes 20-100 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants