Skip to content

Commit 2656803

Browse files
committed
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.
1 parent b38e4e2 commit 2656803

5 files changed

Lines changed: 157 additions & 75 deletions

File tree

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)