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 β by default ~300 seconds β leaving the client UI stuck in "Running" with no feedback.
There are two distinct failure classes, each with a different root cause and fix:
- Early execution failure β stream never receives a terminal event (addressed in PR #36973 and PR #37100)
- Post-resume streaming block β
ResponseStreamFilterstate lost across a Human Input pause (addressed in PR #38540)
SSE Stream Loop and Terminal Events#
stream_topic_events() in api/core/app/apps/streaming_utils.py drives the SSE loop. It subscribes to a Redis pub/sub topic and yields events until it sees a terminal event type . By default, only workflow_finished and workflow_paused are recognized as terminal . If neither is ever published, the loop falls through to the idle_timeout check.
Failure Class 1: Early Execution Errors Drop Terminal Events#
Symptom: A workflow starts, fails due to validation or initialization error, but the client receives no error event and hangs for 300 s.
Root causes addressed by two PRs:
-
PR #36973 β When an async worker raises a validation error (e.g., a URL passed to a file-type input),
StreamEvent.ERRORwas not included in the default terminal events set. The error event was published to the SSE topic butstream_topic_events()did not treat it as a terminator, so the loop kept waiting. The fix addsStreamEvent.ERROR.valueto the recognized terminal events set. The error payload was also updated to includeworkflow_run_idand to derive the HTTP status code fromexc.coderather than always using 500. -
PR #37100 β When
WorkflowAppGenerator.generate()raises before thequeue_manageris initialized,_AppRunner.run()published anerrorevent but nothing else. The SSE loop never saw aworkflow_finished, so it waited out the 300 s idle timeout. The fix publishes a minimalworkflow_finishedevent immediately after the error so the stream closes cleanly. The current code generalizes this into_publish_failed_terminal_event(), which always emits aWorkflowFinishStreamResponsewithstatus=FAILEDwhen the stream ends without a terminal event β either after catching an exception or after the generator exhausts without a terminal event .
Issue reference: #38238 reports workflow_finish never arriving for LLM-node workflows in v1.13.2. A related ordering fix (PR #32985) ensures message_end is emitted before workflow_finished in advanced chat apps.
Failure Class 2: ResponseStreamFilter State Lost on Pause/Resume#
Symptom: After a Human Input (manual intervention) node pauses a chatflow and the user resumes it, all downstream nodes execute successfully β node_finished and workflow_finished events contain the correct output β but the frontend receives no token stream and the final message is empty.
Affected versions: Dify 1.15.0 (graphon v0.5.x). Confirmed fixed in PR #38540.
Root Cause#
iter_dify_graph_engine_events() wraps GraphEngine.run() with a ResponseStreamFilter (from graphon.filters). This filter maintains a paths_map of blocking edges that must be traversed before each downstream Answer node is unlocked. As edges are taken pre-pause, they are removed from the map.
Before PR #38540, on resume, iter_dify_graph_engine_events() always instantiated a fresh ResponseStreamFilter with no prior state. If an upstream conditional branch edge was traversed before the pause, that edge would not be re-emitted on resume. The new filter had no record of it, so the downstream Answer node's paths_map entry never emptied β the node was permanently blocked from streaming.
Trigger conditions: Any of the following upstream of the Human Input node will trigger the bug:
- A conditional branch (if-else) 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 referenced by the post-resume Answer node
The Fix (PR #38540)#
PauseStatePersistenceLayer now calls ResponseStreamFilter.dumps() on every GraphRunPausedEvent and stores the serialized state in WorkflowResumptionContext alongside GraphRuntimeState. On resume, WorkflowResumptionContext.get_response_stream_filter() calls ResponseStreamFilter.loads() to restore the filter β including paths_map, active_session, waiting_sessions, pending_sessions, stream_buffers, stream_positions, closed_streams, and node_execution_ids.
The serialized field is optional for backward compatibility: runs paused before the fix resume with fresh-filter behavior for that one stale run.
Workaround (pre-fix): Place Human Input nodes before any conditional branching in the workflow graph.
Related Issues and Patterns#
The same ResponseStreamFilter state-loss mechanism underlies several reported issues:
| Issue | Description |
|---|---|
| #38315 | Manual Intervention Node + Conditional Branch β no frontend reply (v1.15.0) |
| #38238 | workflow_finish never received for LLM-node workflows (v1.13.2) |
| #38432 | Answer node output not shown after Human Input resume |
| #38614 | Human Input node does not send message after Question Classifier or Condition Branch |
| #38525 | After human_input, no content output on page |
| #38824 | Two Human Input nodes β abnormal output content |
A separate but related failure mode (fixed in PR #22771) affects workflows where multiple if-else branches converge to a shared join β LLM β Answer node. Static dependency analysis in ResponseStreamFilter cannot model mutually exclusive branches at runtime, causing Answer nodes to block indefinitely. That fix introduced _has_complex_branch_merge_topology() and a runtime AnswerStreamProcessor for dynamic path tracing.
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() β fallback terminal event on stream failure |
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/advanced_chat/generate_task_pipeline.py | Event dispatch loop; _handle_workflow_paused_event(); message_end / workflow_finished ordering |