SSE Stream Terminal Event Delivery#
Dify's streaming APIs rely on two terminal SSE events to signal completion: workflow_finished (for workflow apps) and message_end (for chatflows). When either event is missing, the SSE connection stays open until idle_timeout expires β 300 seconds by default β leaving the client UI stuck in "Running" with no error feedback.
The SSE event loop is driven by stream_topic_events(), which subscribes to a Redis pub/sub topic and yields events until it sees a recognized terminal event type . By default, only workflow_finished and workflow_paused are terminal ; StreamEvent.ERROR was historically excluded from this set, allowing error events to pass through without closing the stream.
There are three distinct failure classes, each with a different root cause:
- Early execution failure β stream never receives a terminal event (PRs #36973, #37100)
ResponseStreamFilterstate lost on pause/resume β Answer nodes permanently blocked after Human Input + Conditional Branch (PR #38540)- Redis transport failures β broken pipe or
XADDerrors abort iteration before terminal events publish (PRs #41356, #41711)
Failure Class 1: Early Execution Errors Drop Terminal Events#
Symptom: A workflow starts, hits a validation or initialization error, but the client receives no error event and hangs for ~300 seconds. Reported in issue #38238 against v1.13.2.
Root causes (two separate fixes):
-
PR #36973:
StreamEvent.ERRORwas not in the default terminal events set. The error event was published to Redis butstream_topic_events()did not treat it as a terminator, so the loop kept waiting. The fix addsStreamEvent.ERRORto the recognized terminal set and enriches the error payload withworkflow_run_id. -
PR #37100: When
WorkflowAppGenerator.generate()raises before the queue manager is initialized, an error event was published but noworkflow_finishedfollowed, so the loop waited out the full idle timeout. The fix publishes a minimalworkflow_finished(FAILED)immediately after the error.
Current safety net: _publish_failed_terminal_event() is the general fallback β it always emits a WorkflowFinishStreamResponse with status=FAILED when the stream ends without a terminal event, covering both caught exceptions and generator exhaustion without a terminal event. For failures before the generator is returned, _publish_failed_workflow_terminal_events() synthesizes a workflow_started β workflow_finished(FAILED) pair from AppExecutionParams.
Failure Class 2: ResponseStreamFilter State Lost on Pause/Resume#
Symptom: After a Human Input (HITL) node pauses a chatflow and the user resumes, all downstream nodes execute and workflow_finished reports succeeded β but the frontend receives no token stream and the final message is empty. Reported in issue #38315 on Dify v1.15.0.
Root cause: iter_dify_graph_engine_events() wraps the GraphEngine with a ResponseStreamFilter (from graphon.filters) that tracks a paths_map β a set of blocking edges that must be traversed before downstream Answer nodes can emit tokens. Before PR #38540, resume always instantiated a fresh filter with no prior state. If a conditional-branch edge was taken before the pause, the fresh filter had no record of it, so the Answer node's paths_map entry never cleared, permanently blocking streaming.
Trigger conditions β any of these upstream of the Human Input node will reproduce the bug :
- A Conditional Branch (if-else) or Question Classifier node
- A second or later Human Input pause in the same run
- An Answer node that streamed before the pause
- A variable written before the pause that is referenced post-resume
Affected versions: Dify β€ 1.15.0 (graphon v0.5.x). Fixed in PR #38540.
Fix: PauseStatePersistenceLayer now calls ResponseStreamFilter.dumps() on every GraphRunPausedEvent and stores the serialized state in WorkflowResumptionContext. On resume, WorkflowResumptionContext.get_response_stream_filter() calls ResponseStreamFilter.loads() to restore paths_map, active_session, waiting_sessions, stream_buffers, and related fields. The serialized field is optional for backward compatibility β runs paused before the fix resume with fresh-filter behavior for that stale run only.
Pre-fix workaround: Place Human Input nodes before any conditional branching in the workflow graph.
Known affected issues: #38315, #38432, #38525, #38614, #38824
Failure Class 3: Redis Transport Failures#
Two Redis-level failures can prevent terminal events from reaching clients:
Stop-flag broken pipe (PR #41356, merged 2026-08-27): AppQueueManager._is_stopped() reads a Redis key on every poll iteration. A broken socket (EPIPE) previously escaped the runner loop and released the suspended workflow generator via GeneratorExit, leaving workflow_runs records stuck in running with no finished_at. The fix in _is_stopped() catches broken-pipe errors and returns False (fail-open).
Publish failure during streaming (PR #41711): A transient XADD failure mid-stream previously raised out of the generator loop, stopping iteration before terminal handlers could persist Message.answer. The fix separates delivery failures from iteration: once topic.publish() raises, the error is captured and generator iteration continues (without publishing) so terminal handlers can run; _publish_failed_terminal_event() is called as a fallback if no terminal event was successfully delivered.
Answer Node Streaming in Iterations#
A separate but related failure affects Answer nodes inside iteration loops. Issue #38867 reports that Answer nodes do not emit message SSE events when nested in an iteration.
Key Source Files#
| File | Relevance |
|---|---|
api/core/app/apps/streaming_utils.py | stream_topic_events() SSE loop; _normalize_terminal_events() β default terminal event set |
api/tasks/app_generate/workflow_execute_task.py | _publish_failed_terminal_event() and _publish_failed_workflow_terminal_events() β fallback terminal event safety net |
api/core/workflow/workflow_entry.py | iter_dify_graph_engine_events() β wraps engine with ResponseStreamFilter; accepts restored filter on resume |
api/core/app/layers/pause_state_persist_layer.py | Serializes ResponseStreamFilter + GraphRuntimeState on pause |
api/core/app/apps/base_app_queue_manager.py | _is_stopped() with fail-open broken-pipe handling |
api/core/app/apps/advanced_chat/generate_task_pipeline.py | Event dispatch loop; message_end / workflow_finished ordering |