Skip to content

Do not block a second consumer on an already-drained halt signal - #937

Merged
Quim Muntal (qmuntal) merged 1 commit into
microsoft:mainfrom
PratikDhanaveFork:fix-offthread-stream-hang-after-halt
Aug 28, 2026
Merged

Do not block a second consumer on an already-drained halt signal#937
Quim Muntal (qmuntal) merged 1 commit into
microsoft:mainfrom
PratikDhanaveFork:fix-offthread-stream-hang-after-halt

Conversation

@PratikDhanave

Copy link
Copy Markdown
Contributor

Problem

The off-thread streaming run loop (workflow/internal/execution/eventstream.go) emits a single one-shot internalHaltSignal per completion epoch, then parks in waitForInput. In TakeEventStream, a consumer that isn't expecting fresh work targets that already-emitted halt (myEpoch = currentEpoch). If a prior consumer already dequeued that one-shot signal, a later consumer blocks in nextEvent forever — the run loop is parked and never re-emits it.

This is a deadlock reachable through the public API:

  • Environment.Run already drives one RunToNextHalt internally, so calling Run.RunToNextHalt(ctx) again on the returned (already-Idle) run hangs.
  • Draining StreamingRun.WatchUntilHalt once (to Idle) then again with no new input hangs.

The sibling lockstep RunEventStream returns promptly in the exact same scenario; only the off-thread/default stream hangs. The stream is explicitly designed to be re-taken (RunHandle resets isEventStreamTaken after each consumer), so re-consuming a terminal state is a supported operation.

Fix

When TakeEventStream is not expecting fresh work (the run has already halted), drain any still-queued events non-blockingly and stop at the terminal (Idle/Ended) halt — or, when blockOnPendingRequest is false, the pending-request halt — instead of blocking on a signal that will never be re-emitted. The blockOnPendingRequest path still falls through to the blocking read so it can await serviced input.

Test

TestRun_SecondRunToNextHaltOnHaltedRunReturns calls RunToNextHalt a second time on an already-Idle run under a watchdog; it hangs before the fix and returns promptly after. Validated against the full ./workflow/... suite under -race -shuffle (no regressions).

The off-thread streaming run loop emits a single one-shot internalHaltSignal
per completion epoch, then parks awaiting input. TakeEventStream, when it is
not expecting fresh work, targets that already-emitted halt (myEpoch =
currentEpoch). If a prior consumer already drained the signal, a later
consumer blocks in nextEvent forever - the run loop is parked and never
re-emits it.

This is reachable through the public API: Environment.Run already drives one
RunToNextHalt internally, so a caller that calls Run.RunToNextHalt again (or
drains StreamingRun.WatchUntilHalt twice) on an already-Idle run deadlocks.
The lockstep stream returns promptly in the same situation.

When not expecting fresh work, drain any still-queued events non-blockingly
and stop at the terminal (Idle/Ended) or - for RunToNextHalt semantics -
pending-request halt, instead of blocking on a signal that will never be
re-emitted. The blockOnPendingRequest path still falls through to block for
serviced input. Verified against the full workflow suite under -race.
@PratikDhanave
PratikDhanave (PratikDhanave) requested a review from a team as a code owner August 28, 2026 05:52
Copilot AI lite review requested due to automatic review settings August 28, 2026 05:52
@github-actions github-actions Bot added area:workflow Changes files in the workflow area size:medium At most 100 changed lines across at most 5 files pending-auto-risk Automatic risk classification is in progress labels Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Generated by Go API Consistency Review Agent · sonnet46 · 15.8 AIC · ⌖ 3.96 AIC · ⊞ 6.4K ·

@github-actions github-actions Bot added risk:medium Contained production impact requiring normal review depth and removed pending-auto-risk Automatic risk classification is in progress labels Aug 28, 2026

Copilot AI 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.

Pull request overview

Fixes a deadlock in the off-thread (streaming) execution event stream where taking/consuming the event stream a second time on an already-halted run could block forever because the per-epoch halt signal is a one-shot queue item that may have been consumed by a prior consumer.

Changes:

  • Add a non-blocking “already halted/no fresh work” path in streamingRunEventStream.TakeEventStream that drains queued events and terminates based on current status (Idle/Ended, or PendingRequests when not blocking).
  • Add a regression test ensuring a second RunToNextHalt on an already-Idle run returns promptly.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
workflow/internal/execution/eventstream.go Adds a fast path to avoid blocking on an already-drained one-shot halt signal when re-consuming a halted stream.
workflow/inproc/binding_test.go Adds a regression test covering the previously hanging “second RunToNextHalt on halted run” scenario.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +1172 to +1182

done := make(chan struct{})
go func() {
_, _ = run.RunToNextHalt(context.Background())
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("second RunToNextHalt on an already-halted run blocked")
}
@qmuntal
Quim Muntal (qmuntal) added this pull request to the merge queue Aug 28, 2026
Merged via the queue into microsoft:main with commit 6aabdc7 Aug 28, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:workflow Changes files in the workflow area risk:medium Contained production impact requiring normal review depth size:medium At most 100 changed lines across at most 5 files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants