Skip to content

Commit 75718ab

Browse files
authored
fix(execution): stop a cancelled run reporting success when its wait swallows the cancellation (#6775)
* fix(execution): stop a cancelled run reporting success when its wait swallows the cancellation Cancellation reaches a running execution over Redis pub/sub, which is at-most-once. The engine turns that into `status: 'cancelled'` via `signalCancelled`. But the wait handler also polled the durable Redis cancellation key itself, and on a hit it broke out of its sleep and returned an ordinary successful block output. The engine's `cancelledFlag` stayed false, so a cancelled run finished as `success: true` — and with a block after the wait, kept executing. Whichever detector fired first won. The engine's pub/sub path normally wins by about one round trip; when the wait's own 500ms poll landed inside that window the cancellation was lost. Consolidate detection in the engine, which is the only component that can project run status: extend the once-at-start durable backstop into a poll that runs for the life of the run and routes through `signalCancelled`. The wait handler and loop orchestrator now observe only `ctx.abortSignal`, which the engine aborts, so no leaf can observe a cancellation the engine has not seen. The loop orchestrator additionally used to ignore `abortSignal.aborted` whenever Redis was enabled, so a mid-loop timeout or client disconnect was invisible to it, and it awaited a Redis round trip on every iteration. Handlers that abort their own I/O off `ctx.abortSignal` are unaffected: that surfaces as a throw, which the cancelled branch of `run` already classifies. * docs(wait): correct the in-line wait ceiling to 5 minutes The Wait page claimed a 10-minute cap for a synchronous wait in three places. `MAX_INPROCESS_WAIT_MS`, the block description, the sub-block hint, and the validation error all say 5 minutes.
1 parent b38e4e2 commit 75718ab

6 files changed

Lines changed: 160 additions & 78 deletions

File tree

apps/docs/content/docs/en/workflows/blocks/wait.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ The time unit. For a short, in-line wait, choose **Seconds** or **Minutes**. Wit
2323

2424
### Async
2525

26-
Off, the run sleeps in line for the duration (up to 10 minutes). On, the run **suspends** and resumes after the delay, which is what lets a wait run for hours or days without holding the execution open. A suspended wait records when it will resume in `<wait.resumeAt>`.
26+
Off, the run sleeps in line for the duration (up to 5 minutes). On, the run **suspends** and resumes after the delay, which is what lets a wait run for hours or days without holding the execution open. A suspended wait records when it will resume in `<wait.resumeAt>`.
2727

2828
## Outputs
2929

@@ -54,9 +54,9 @@ With Async on, the run suspends for two days and resumes to send the follow-up,
5454
- **A wait is cancellable.** Stopping the run cancels an active wait, and `status` reports `cancelled`.
5555

5656
<FAQ items={[
57-
{ question: "What is the maximum wait time?", answer: "An in-line (synchronous) wait is capped at 10 minutes. For longer delays, turn on Async: the run suspends and resumes after the delay, so a wait can run for minutes, hours, or days." },
57+
{ question: "What is the maximum wait time?", answer: "An in-line (synchronous) wait is capped at 5 minutes. For longer delays, turn on Async: the run suspends and resumes after the delay, so a wait can run for minutes, hours, or days." },
5858
{ question: "Can a Wait block be cancelled?", answer: "Yes. Waits are interruptible by workflow cancellation. If the run is stopped while a Wait is active, the wait is cancelled and the status output reads 'cancelled'." },
59-
{ question: "What is the difference between a sync and an async wait?", answer: "A sync wait (Async off) sleeps in line for up to 10 minutes while the execution stays open. An async wait (Async on) suspends the run and resumes after minutes, hours, or days, recording the resume time in <wait.resumeAt>." },
59+
{ question: "What is the difference between a sync and an async wait?", answer: "A sync wait (Async off) sleeps in line for up to 5 minutes while the execution stays open. An async wait (Async on) suspends the run and resumes after minutes, hours, or days, recording the resume time in <wait.resumeAt>." },
6060
{ question: "Does the Wait block consume resources while paused?", answer: "An in-line wait performs a simple sleep and does not actively use compute, though the execution stays open. An async wait suspends the run entirely, so nothing is held open until it resumes." },
6161
{ question: "What outputs does the Wait block provide?", answer: "waitDuration (the wait in milliseconds), status ('waiting', 'completed', or 'cancelled'), and resumeAt (the ISO timestamp an async wait resumes at)." },
6262
]} />

apps/sim/executor/execution/engine.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,7 @@ describe('ExecutionEngine', () => {
767767
expect(context.abortSignal?.aborted).toBe(true)
768768
})
769769

770-
it('calls isExecutionCancelled once as the startup backstop check', async () => {
770+
it('calls isExecutionCancelled once for a run that finishes before the first poll', async () => {
771771
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
772772
;(isExecutionCancelled as Mock).mockResolvedValue(false)
773773

@@ -782,6 +782,66 @@ describe('ExecutionEngine', () => {
782782

783783
expect((isExecutionCancelled as Mock).mock.calls.length).toBe(1)
784784
})
785+
786+
it('cancels a long-running node when only the durable flag reports it', async () => {
787+
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
788+
;(isExecutionCancelled as Mock).mockResolvedValue(false)
789+
790+
let releaseNode = () => {}
791+
const nodeReleased = new Promise<void>((resolve) => {
792+
releaseNode = resolve
793+
})
794+
795+
const startNode = createMockNode('start', 'starter')
796+
const slowNode = createMockNode('slow', 'wait')
797+
startNode.outgoingEdges.set('edge1', { target: 'slow' })
798+
799+
const dag = createMockDAG([startNode, slowNode])
800+
const context = createMockContext({ executionId: 'redis-poll-execution' })
801+
const edgeManager = createMockEdgeManager((node) => (node.id === 'start' ? ['slow'] : []))
802+
const nodeOrchestrator = createMockNodeOrchestrator()
803+
;(nodeOrchestrator.executeNode as Mock).mockImplementation(
804+
async (_ctx: ExecutionContext, nodeId: string) => {
805+
if (nodeId === 'slow') {
806+
// Cancel durably with no pub/sub event, mirroring a cancel served by another replica
807+
// whose published event never reaches this engine.
808+
;(isExecutionCancelled as Mock).mockResolvedValue(true)
809+
await nodeReleased
810+
}
811+
return { nodeId, output: {}, isFinalOutput: false }
812+
}
813+
)
814+
815+
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
816+
const runPromise = engine.run('start')
817+
818+
await vi.waitFor(() => expect(context.abortSignal?.aborted).toBe(true), { timeout: 3000 })
819+
releaseNode()
820+
821+
await expect(runPromise).resolves.toMatchObject({ success: false, status: 'cancelled' })
822+
})
823+
824+
it('leaves no polling timer behind once the run settles', async () => {
825+
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
826+
;(isExecutionCancelled as Mock).mockResolvedValue(false)
827+
vi.useFakeTimers()
828+
829+
const startNode = createMockNode('start', 'starter')
830+
const dag = createMockDAG([startNode])
831+
const context = createMockContext({ executionId: 'poll-cleanup-execution' })
832+
const edgeManager = createMockEdgeManager()
833+
const nodeOrchestrator = createMockNodeOrchestrator()
834+
835+
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
836+
await engine.run('start')
837+
838+
const callsAtCompletion = (isExecutionCancelled as Mock).mock.calls.length
839+
// Well past several poll intervals: a surviving timer would add calls here.
840+
await vi.advanceTimersByTimeAsync(5_000)
841+
842+
expect(vi.getTimerCount()).toBe(0)
843+
expect((isExecutionCancelled as Mock).mock.calls.length).toBe(callsAtCompletion)
844+
})
785845
})
786846

787847
describe('Loop execution with cancellation', () => {

apps/sim/executor/execution/engine.ts

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-
2525

2626
const logger = createLogger('ExecutionEngine')
2727

28+
/** Cadence of the Redis fallback poll that covers a lost pub/sub cancellation. */
29+
const CANCELLATION_POLL_INTERVAL_MS = 500
30+
2831
export class ExecutionEngine {
2932
private readyQueue: string[] = []
3033
private executing = new Set<Promise<void>>()
@@ -42,6 +45,8 @@ export class ExecutionEngine {
4245
private cancellationController = new AbortController()
4346
private abortSignalListener: (() => void) | null = null
4447
private cancellationUnsubscribe: (() => void) | null = null
48+
private cancellationPollTimer: ReturnType<typeof setInterval> | null = null
49+
private cancellationPollInFlight = false
4550
private execLogger: Logger
4651

4752
constructor(
@@ -97,6 +102,7 @@ export class ExecutionEngine {
97102
private signalCancelled(reason: unknown = new DOMException('user', 'AbortError')): void {
98103
if (this.cancelledFlag) return
99104
this.cancelledFlag = true
105+
this.stopCancellationPolling()
100106
if (!this.cancellationController.signal.aborted) {
101107
this.cancellationController.abort(reason)
102108
}
@@ -107,23 +113,77 @@ export class ExecutionEngine {
107113
return this.cancelledFlag
108114
}
109115

116+
/** Reads the durable cancellation flag; false when Redis is not the cancellation store. */
117+
private async readDurableCancellation(): Promise<boolean> {
118+
const executionId = this.context.executionId
119+
if (!executionId || !isRedisCancellationEnabled()) return false
120+
return isExecutionCancelled(executionId)
121+
}
122+
110123
/** Catches cancellations published before this engine subscribed (e.g. resume from snapshot). */
111124
private async checkCancellationBackstop(): Promise<void> {
112-
if (!this.context.executionId || !isRedisCancellationEnabled()) return
113-
const cancelled = await isExecutionCancelled(this.context.executionId)
114-
if (cancelled) {
115-
this.execLogger.info('Execution already cancelled at engine start (Redis backstop)', {
116-
executionId: this.context.executionId,
117-
})
118-
this.signalCancelled()
119-
}
125+
if (!(await this.readDurableCancellation())) return
126+
this.execLogger.info('Execution already cancelled at engine start (Redis backstop)', {
127+
executionId: this.context.executionId,
128+
})
129+
this.signalCancelled()
130+
}
131+
132+
/**
133+
* Polls the durable flag for the life of the run.
134+
*
135+
* Pub/sub delivery is the fast path but is at-most-once: a dropped subscriber connection, or a
136+
* publish that races this engine reaching its own last block, would otherwise let a cancelled
137+
* run finish as successful. `markExecutionCancelled` writes the durable key before publishing
138+
* precisely so a reader that misses the event can still observe the cancellation.
139+
*
140+
* This and {@link checkCancellationBackstop} are the only places a durable cancellation becomes
141+
* run status. A block handler or orchestrator that reads the flag itself and then returns
142+
* normally leaves `cancelledFlag` false, which reports a cancelled run as successful. Handlers
143+
* that abort their own I/O off `ctx.abortSignal` are fine: that surfaces as a throw, which the
144+
* cancelled branch of `run` classifies.
145+
*/
146+
private startCancellationPolling(): void {
147+
if (this.cancelledFlag || !this.context.executionId || !isRedisCancellationEnabled()) return
148+
this.cancellationPollTimer = setInterval(() => {
149+
if (this.cancellationPollInFlight) return
150+
this.cancellationPollInFlight = true
151+
void this.pollDurableCancellation()
152+
.catch((error) => {
153+
this.execLogger.warn('Durable cancellation poll failed', {
154+
executionId: this.context.executionId,
155+
error: toError(error).message,
156+
})
157+
})
158+
.finally(() => {
159+
this.cancellationPollInFlight = false
160+
})
161+
}, CANCELLATION_POLL_INTERVAL_MS)
162+
}
163+
164+
private async pollDurableCancellation(): Promise<void> {
165+
const cancelled = await this.readDurableCancellation()
166+
// `signalCancelled` and `cleanup` both null the timer, so it doubles as "polling is still
167+
// live" — a run that settled while this read was in flight must not be cancelled after.
168+
if (!cancelled || !this.cancellationPollTimer) return
169+
this.execLogger.info('Execution cancelled via Redis poll', {
170+
executionId: this.context.executionId,
171+
})
172+
this.signalCancelled()
173+
}
174+
175+
private stopCancellationPolling(): void {
176+
if (!this.cancellationPollTimer) return
177+
clearInterval(this.cancellationPollTimer)
178+
this.cancellationPollTimer = null
120179
}
121180

122181
async run(triggerBlockId?: string): Promise<ExecutionResult> {
123182
const startTime = performance.now()
124183
try {
125184
this.initializeQueue(triggerBlockId)
126185
await this.checkCancellationBackstop()
186+
this.startCancellationPolling()
127187

128188
while (this.hasWork()) {
129189
if (this.checkCancellation() || this.errorFlag || this.stoppedEarlyFlag) {
@@ -213,6 +273,7 @@ export class ExecutionEngine {
213273
}
214274

215275
private cleanup(): void {
276+
this.stopCancellationPolling()
216277
if (this.abortSignalListener && this.context.abortSignal) {
217278
this.context.abortSignal.removeEventListener('abort', this.abortSignalListener)
218279
this.abortSignalListener = null

apps/sim/executor/handlers/wait/wait-handler.ts

Lines changed: 18 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
21
import type { BlockOutput } from '@/blocks/types'
32
import { BlockType } from '@/executor/constants'
43
import {
@@ -8,72 +7,38 @@ import {
87
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
98
import type { SerializedBlock } from '@/serializer/types'
109

11-
const CANCELLATION_CHECK_INTERVAL_MS = 500
12-
1310
/** Hard ceiling for in-process (synchronous) waits. */
1411
const MAX_INPROCESS_WAIT_MS = 5 * 60 * 1000
1512

1613
/** Hard ceiling for async waits. */
1714
const MAX_ASYNC_WAIT_MS = 30 * 24 * 60 * 60 * 1000
1815

19-
interface SleepOptions {
20-
signal?: AbortSignal
21-
executionId?: string
22-
}
23-
24-
const sleep = async (ms: number, options: SleepOptions = {}): Promise<boolean> => {
25-
const { signal, executionId } = options
26-
const useRedis = isRedisCancellationEnabled() && !!executionId
27-
28-
if (signal?.aborted) {
29-
return false
30-
}
31-
32-
return new Promise((resolve) => {
33-
// biome-ignore lint/style/useConst: needs to be declared before cleanup() but assigned later
34-
let mainTimeoutId: NodeJS.Timeout | undefined
35-
let checkIntervalId: NodeJS.Timeout | undefined
36-
let resolved = false
37-
38-
const cleanup = () => {
39-
if (mainTimeoutId) clearTimeout(mainTimeoutId)
40-
if (checkIntervalId) clearInterval(checkIntervalId)
41-
if (signal) signal.removeEventListener('abort', onAbort)
16+
/**
17+
* Resolves `true` when the full delay elapsed and `false` when the execution was aborted.
18+
*
19+
* The abort signal is the only cancellation input. The engine owns cancellation detection —
20+
* including the durable Redis flag — and aborts this signal, so a wait never has to read
21+
* cancellation state itself.
22+
*/
23+
const sleepUntilAborted = (ms: number, signal?: AbortSignal): Promise<boolean> =>
24+
new Promise((resolve) => {
25+
if (signal?.aborted) {
26+
resolve(false)
27+
return
4228
}
4329

4430
const onAbort = () => {
45-
if (resolved) return
46-
resolved = true
47-
cleanup()
31+
clearTimeout(timeoutId)
4832
resolve(false)
4933
}
5034

51-
if (signal) {
52-
signal.addEventListener('abort', onAbort, { once: true })
53-
}
54-
55-
if (useRedis) {
56-
checkIntervalId = setInterval(async () => {
57-
if (resolved) return
58-
try {
59-
const cancelled = await isExecutionCancelled(executionId!)
60-
if (cancelled) {
61-
resolved = true
62-
cleanup()
63-
resolve(false)
64-
}
65-
} catch {}
66-
}, CANCELLATION_CHECK_INTERVAL_MS)
67-
}
68-
69-
mainTimeoutId = setTimeout(() => {
70-
if (resolved) return
71-
resolved = true
72-
cleanup()
35+
const timeoutId = setTimeout(() => {
36+
signal?.removeEventListener('abort', onAbort)
7337
resolve(true)
7438
}, ms)
39+
40+
signal?.addEventListener('abort', onAbort, { once: true })
7541
})
76-
}
7742

7843
const UNIT_TO_MS = {
7944
seconds: 1000,
@@ -153,10 +118,7 @@ export class WaitBlockHandler implements BlockHandler {
153118
}
154119

155120
if (!isAsync) {
156-
const completed = await sleep(waitMs, {
157-
signal: ctx.abortSignal,
158-
executionId: ctx.executionId,
159-
})
121+
const completed = await sleepUntilAborted(waitMs, ctx.abortSignal)
160122

161123
if (!completed) {
162124
return {

apps/sim/executor/orchestrators/loop.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { generateRequestId } from '@/lib/core/utils/request'
4-
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
54
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
65
import { compactSubflowResults } from '@/lib/execution/payloads/serializer'
76
import { isLikelyReferenceSegment } from '@/lib/workflows/sanitization/references'
@@ -273,14 +272,11 @@ export class LoopOrchestrator {
273272
}
274273
}
275274

276-
const useRedis = isRedisCancellationEnabled() && !!ctx.executionId
277-
let isCancelled = false
278-
if (useRedis) {
279-
isCancelled = await isExecutionCancelled(ctx.executionId!)
280-
} else {
281-
isCancelled = ctx.abortSignal?.aborted ?? false
282-
}
283-
if (isCancelled) {
275+
// Exiting normally is safe only because the engine aborts this signal exclusively via
276+
// `signalCancelled`, so the run is already flagged cancelled. Never read the durable
277+
// cancellation flag here instead — the engine would not have seen it, and this clean exit
278+
// would then complete the run successfully.
279+
if (ctx.abortSignal?.aborted) {
284280
logger.info('Loop execution cancelled', { loopId, iteration: scope.iteration })
285281
return await this.createExitResult(ctx, loopId, scope)
286282
}

apps/sim/lib/workflows/custom-blocks/child-execution.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,10 @@ export async function createChildCancellationSignal(params: {
125125
// caught by the subscription, and one published earlier — while the child's
126126
// session and admission were still being set up — by the read itself. The
127127
// child's own engine backstop cannot cover this, since it checks the CHILD's
128-
// execution id, which is never the one marked cancelled.
128+
// execution id, which is never the one marked cancelled. For the same reason
129+
// the child's steady-state coverage is transitive: a cancel published after
130+
// setup reaches the child only via this subscription, or via the PARENT
131+
// engine's durable poll aborting `parentSignal`.
129132
unsubscribe = getCancellationChannel().subscribe((event) => {
130133
if (event.executionId === parentExecutionId) abort()
131134
})

0 commit comments

Comments
 (0)