SSE Stream Lifecycle#
Dify's streaming APIs use Server-Sent Events (SSE) over a Redis channel (pub/sub or streams) to deliver workflow execution events to clients. The lifecycle spans five stages: stream startup, event delivery, idle/ping, pause handling, and execution cancellation.
Architecture Overview#
1. Stream Startup#
The SSE endpoint is exposed at:
- Web app:
GET /api/workflow/<task_id>/events - Service API:
GET /workflow/<task_id>/events
Both controllers instantiate a MessageGenerator and call retrieve_events() , which derives a Redis topic key from app_mode + workflow_run_id and prepares a subscription. For Redis Streams channels (SupportsPreparedSubscription), retrieve_events() calls prepare_subscription() to read the current stream tail and create a subscription that will resume from that checkpoint β ensuring events published after the checkpoint are delivered even if they arrive before the subscriber starts iterating. For other transports, it calls subscribe() directly. The subscription is then passed to stream_topic_events(). If the workflow run is already finished, the controller skips live streaming and immediately emits a single workflow_finished event .
stream_topic_events() accepts a subscription parameter (an AsyncIterator of events) instead of deriving one from a topic β this allows the caller to fix the delivery boundary before passing it in. The function sends an immediate PING event on connection to prevent the SSE request from appearing stuck in browser DevTools , then fires the optional on_subscribe callback once the subscription context manager is entered β this callback gates Celery task startup for all transport types to prevent race conditions.
2. Event Loop, Idle Timeout, and Pings#
The core event loop polls Redis with a 1-second timeout per iteration :
- Message received β yield it; if the event type matches a terminal event, return and close the stream.
- No message (
None) β ifcurrent_time - last_msg_time > idle_timeout(default 300 s), return. Otherwise emit aPINGifping_intervalhas elapsed (default 10 s).
Terminal events are normalized by _normalize_terminal_events(). By default, workflow_finished and workflow_paused are the only terminal values; receiving either closes the stream .
3. close_on_pause and continue_on_pause#
The default behavior closes the SSE stream when the workflow pauses at a Human Input (HITL) node. Clients that need to stay subscribed across multiple sequential Human Input nodes can pass continue_on_pause=true as a query parameter :
continue_on_pause=false(default):terminal_events=Noneβ resolves to{workflow_finished, workflow_paused}β stream closes on first pause.continue_on_pause=true:terminal_events=[](empty set) β stream only closes onworkflow_finishedor idle timeout.
When include_state_snapshot=true is also set, close_on_pause=not continue_on_pause is threaded into build_workflow_event_stream(), which passes it to an internal _is_terminal_event() check during snapshot replay .
4. Execution Cancellation and Listener Lifecycle#
Execution cancellation is managed by AppExecutionCoordinator, which owns the cancellation policy for one execution attempt. The coordinator is scoped to a single attempt: resumed workflows create a new coordinator even when reusing the stable task ID.
Cancellation Paths#
Two conditions trigger execution cancellation:
- Timeout: An independent watchdog timer runs for
APP_MAX_EXECUTION_TIMEseconds. If the timer expires, the coordinator callsrequest_abort()and publishes aQueueStopEventvia the queue manager's_publish_timeout_stop()callback. - Manual stop:
listen()polls_is_stopped()on every iteration, reading agenerate_task_stopped:{task_id}key from Redis with a 1-second TTL cache. If the stop flag is detected, the listener callsAppExecutionCoordinator.request_abort().
When request_abort() is called, it:
- Calls
set_app_task_stop_flag(task_id)to write the Redis stop key (TTL 600 s). - Calls
GraphEngineManager(redis_client).send_stop_command(task_id, reason=...)to signal the graphonGraphEnginedirectly.
Both the legacy Redis stop flag and the GraphEngine command path are updated to ensure cancellation propagates to all execution backends. The _lock and _abort_sent guards ensure the abort logic runs at most once per coordinator.
User-initiated stops call AppQueueManager.set_stop_flag(), which performs a user ownership check before writing the same Redis key that _is_stopped() reads.
Listener Detachment Is Not Cancellation#
When the listen() generator's finally block runs (stream closed by the client or terminal event), it calls AppExecutionCoordinator.listener_closed() with a segment_completed flag derived from _listener_segment_completed. This is an observation, not an implicit cancellation signal β streaming workflow execution may continue in a Celery worker and publish durable events that a later subscriber can retrieve via the /workflow/<task_id>/events endpoint. For Redis Streams, the durable nature of the transport allows late subscribers to replay events from a known checkpoint, ensuring no events are lost when the subscriber reconnects.
The coordinator logs listener detachment but does not issue stop commands unless the detachment was preceded by an explicit timeout or manual stop. This decouples HTTP transport lifecycle from execution policy.
State Transitions#
AppQueueManager.stop_listen() accepts an execution_state parameter to distinguish between PAUSED and TERMINAL listener completions:
AppExecutionState.PAUSED: The workflow reached a Human Input node. The coordinator transitions toPAUSEDstate and cancels the watchdog timer. A subsequent resume creates a fresh coordinator and does not inherit cancellation from the earlier segment.AppExecutionState.TERMINAL: The workflow finished, failed, or encountered an error. The coordinator transitions toTERMINALstate and cancels the watchdog timer.
The _listener_segment_completed event is set when stop_listen() is called, gating the listener_closed() logic.
5. Pause/Resume and ResponseStreamFilter State#
On pause, PauseStatePersistenceLayer intercepts GraphRunPausedEvent and serializes two state components into WorkflowResumptionContext (persisted to the database) :
| Component | Purpose |
|---|---|
GraphRuntimeState | Variable pool, LLM usage counters, execution metadata |
ResponseStreamFilter | Edge path tracking (paths_map) gating Answer node streaming |
On resume, WorkflowResumptionContext.get_response_stream_filter() calls ResponseStreamFilter.loads() and the restored filter is threaded through iter_dify_graph_engine_events() in workflow_entry.py .
Before PR #38540 (Dify β€ 1.15.0): Resume always instantiated a fresh ResponseStreamFilter. If a conditional-branch edge was traversed before the pause, the new filter had no record of it; the downstream Answer node's paths_map entry never emptied, permanently blocking token streaming β even though workflow_finished reported succeeded . Known affected issue reports: #38432, #38315, #38614, #38525.
6. Terminal Event Safety#
If the Celery worker fails before the generator emits any events, _publish_failed_terminal_event() synthesizes a workflow_finished(FAILED) so the SSE loop closes rather than hanging for 300 s . Similarly, if the generator exhausts without emitting a terminal event, a fallback failed event is published .
Key Source Files#
| File | Role |
|---|---|
api/core/app/apps/streaming_utils.py | Core SSE event loop; stream_topic_events(), _normalize_terminal_events() |
api/core/app/apps/message_generator.py | Derives Redis topic key; prepares subscription; calls stream_topic_events() |
api/core/app/apps/base_app_queue_manager.py | AppQueueManager: listen(), stop_listen(), Redis stop flag read |
api/core/app/apps/execution_coordinator.py | AppExecutionCoordinator: execution cancellation policy, watchdog timer, state transitions |
api/controllers/service_api/app/workflow_events.py | SSE endpoint with continue_on_pause parameter |
api/controllers/web/workflow_events.py | Web app SSE endpoint; post-pause event replay |
api/core/app/layers/pause_state_persist_layer.py | Serializes GraphRuntimeState + ResponseStreamFilter on pause |
api/core/workflow/workflow_entry.py | iter_dify_graph_engine_events() β accepts restored filter on resume |
api/tasks/app_generate/workflow_execute_task.py | _publish_streaming_response(), fallback terminal event safety |
api/services/workflow_event_snapshot_service.py | build_workflow_event_stream() with close_on_pause gating |