Repository navigation
fix(config): guard the recovered stream cleanup delay like its heartbeat sibling - #5850
Conversation
…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
left a comment
There was a problem hiding this comment.
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.

Why
Trigger.
StreamBridgeConfigguards its two float fields unequally.heartbeat_interval_seconds(
backend/packages/harness/deerflow/config/stream_bridge_config.py:28-34) carriesgt=0,le=MAX_HEARTBEAT_INTERVAL_SECONDS,allow_inf_nan=Falseand amode="before"validator thatrejects booleans by name (
:60-66).recovered_stream_cleanup_delay_seconds(:54-58) sits fivelines below it with nothing but
ge=0.Measured on
main@174c6f7f, loading that module and constructing the model:These are not exotic inputs:
yaml.safe_loadturnsrecovered_stream_cleanup_delay_seconds: trueinto
Trueand…: .infintoinf, so both reach the model straight from a hand-editedconfig.yaml.Pain. The value is handed to the bridge unchanged.
app/gateway/deps.py:584reads it, and:233startsasyncio.create_task(bridge.cleanup(record.run_id, delay=cleanup_delay)); bothimplementations are a plain sleep (
runtime/stream_bridge/redis.py:371-373,runtime/stream_bridge/memory.py:184-186):So:
inf(or any oversized value such as1e30) makes that task sleep effectively forever. Therecovered 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_tasksfor the life of the process.The key then survives until the unrelated rolling
stream_ttl_seconds(default 86400) expires, oruntil
_flush_recovered_stream_cleanups()cancels it at shutdown. The operator asked for a 60-secondgrace window and got "keep it around for a day".
trueis silently coerced to1.0, collapsing the reconnect grace window to one second — theopposite 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_secondsis now validated the wayheartbeat_interval_secondsalready is: a non-boolean number between 0 and 86400. A config thatsays
true,.inf, or a value above a day now fails at load withrecovered_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.0stays valid and still means "delete as soon as END is published" — pinned by a test, since thefield is documented as allowing it.
Scope notes, so the diff is not oversold:
-1,-infandnanwere already rejected onmain, because pydantic evaluatesge=0against them as false. The new
allow_inf_nan=Falsedoes not change what is rejected; it is thereso this field reads like its sibling instead of relying on a comparison side effect to exclude
non-finite values.
info.field_name, so the existingheartbeat 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
60and every value that works today keep working.Bug fix verification
backend/tests/test_stream_bridge.py::test_stream_bridge_config_rejects_invalid_recovered_stream_cleanup_delayand
…::test_stream_bridge_config_accepts_valid_recovered_stream_cleanup_delaymain, green on this branch: yesRed = the branch's test file against
main'sstream_bridge_config.py(only the src file reverted),using the same seven parametrized cases:
Green on this branch:
7 passed in 4.10sfor the same cases, and the committed versions of the twotests pass as part of
tests/test_stream_bridge.py(80 passed, 7 skipped). The scratch red-checkfile above was deleted; the committed tests import the new
MAX_RECOVERED_STREAM_CLEANUP_DELAY_SECONDS, so onmainthey 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 touchesStreamBridgeConfigor the recovered-stream cleanup path this value feeds.ruff checkon both changed Python files: all checks passed.ruff format: clean afterwards(ruff reflowed the
Field(description=…)I had lengthened).make test/make test-blocking-io: the change is one field's constraintsand 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
/contributorroutine autonomously.How you used it: the agent diffed this field against the guarded sibling to find the asymmetry,
measured the accepted/rejected inputs on
mainand 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.