From eb85ccde78b22d76ff5c640960f9fe1adff77465 Mon Sep 17 00:00:00 2001 From: "n8n-assistant[bot]" <100856346+n8n-assistant[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:37:34 +0000 Subject: [PATCH] fix(core): Wrap a trigger's closeFunction in an expression isolate at creation time (backport to release-candidate/2.35.x) (#36698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Danny Martini Co-authored-by: Claude Fable 5 Co-authored-by: Mike Repeć --- .../__tests__/triggers-and-pollers.test.ts | 48 +++++++++++++++++++ .../__tests__/workflow-execute.test.ts | 8 ++-- .../execution-engine/triggers-and-pollers.ts | 21 +++++++- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/core/src/execution-engine/__tests__/triggers-and-pollers.test.ts b/packages/core/src/execution-engine/__tests__/triggers-and-pollers.test.ts index 3968bbf9613..19563ac6d46 100644 --- a/packages/core/src/execution-engine/__tests__/triggers-and-pollers.test.ts +++ b/packages/core/src/execution-engine/__tests__/triggers-and-pollers.test.ts @@ -62,6 +62,54 @@ describe('TriggersAndPollers', () => { expect(result).toEqual({ test: true }); }); + describe('closeFunction isolate wrapping', () => { + const originalClose = vi.fn(async () => {}); + const withIsolate = vi.fn(async (fn: () => Promise) => await fn()); + + beforeEach(() => { + nodeType.trigger = triggerFn; + workflow.expression = { withIsolate } as unknown as Workflow['expression']; + }); + + it('wraps closeFunction so teardown runs inside workflow.expression.withIsolate', async () => { + triggerFn.mockResolvedValue({ closeFunction: originalClose }); + + const response = await runTriggerHelper(); + + expect(response?.closeFunction).not.toBe(originalClose); + expect(originalClose).not.toHaveBeenCalled(); + + await response!.closeFunction!(); + + expect(withIsolate).toHaveBeenCalledTimes(1); + expect(originalClose).toHaveBeenCalledTimes(1); + const [isolateOrder] = withIsolate.mock.invocationCallOrder; + const [closeOrder] = originalClose.mock.invocationCallOrder; + expect(isolateOrder).toBeLessThan(closeOrder); + }); + + it('wraps closeFunction in manual mode too', async () => { + triggerFn.mockResolvedValue({ closeFunction: originalClose }); + + const response = await runTriggerHelper('manual'); + + expect(response?.closeFunction).not.toBe(originalClose); + await response!.closeFunction!(); + expect(withIsolate).toHaveBeenCalledTimes(1); + expect(originalClose).toHaveBeenCalledTimes(1); + }); + + it('propagates a closeFunction rejection through the wrapper', async () => { + const closeError = new Error('close failed'); + originalClose.mockRejectedValueOnce(closeError); + triggerFn.mockResolvedValue({ closeFunction: originalClose }); + + const response = await runTriggerHelper(); + + await expect(response!.closeFunction!()).rejects.toThrow(closeError); + }); + }); + describe('manual mode', () => { const getMockTriggerFunctions = () => getTriggerFunctions.mock.results[0]?.value; diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index 089ad0180ad..26ed16856ac 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -1562,8 +1562,9 @@ describe('WorkflowExecute', () => { describe('runNode', () => { const nodeTypes = mock(); const triggerNode = mock(); + const closeFunctionSpy = vi.fn(); const triggerResponse = mock({ - closeFunction: vi.fn(), + closeFunction: closeFunctionSpy, // This node should never trigger, or return manualTriggerFunction: async () => await new Promise(() => {}), }); @@ -1618,10 +1619,11 @@ describe('WorkflowExecute', () => { }); expect(isSettled).toBe(false); expect(abortController.signal.aborted).toBe(false); - expect(triggerResponse.closeFunction).not.toHaveBeenCalled(); + expect(closeFunctionSpy).not.toHaveBeenCalled(); abortController.abort(); - expect(triggerResponse.closeFunction).toHaveBeenCalled(); + await new Promise((resolve) => setImmediate(resolve)); + expect(closeFunctionSpy).toHaveBeenCalled(); }); }); diff --git a/packages/core/src/execution-engine/triggers-and-pollers.ts b/packages/core/src/execution-engine/triggers-and-pollers.ts index a19e2e2ac8a..68cc4733ff9 100644 --- a/packages/core/src/execution-engine/triggers-and-pollers.ts +++ b/packages/core/src/execution-engine/triggers-and-pollers.ts @@ -83,10 +83,27 @@ export class TriggersAndPollers { }; }); - return triggerResponse; + return this.wrapCloseFunctionInIsolate(workflow, triggerResponse); } // In all other modes simply start the trigger - return await nodeType.trigger.call(triggerFunctions); + return this.wrapCloseFunctionInIsolate(workflow, await nodeType.trigger.call(triggerFunctions)); + } + + /** + * Wraps a trigger's `closeFunction` so teardown holds an expression isolate: + * the closure evaluates expressions through this workflow's expression + * instance, which is no longer in scope at the eventual close call sites. + */ + private wrapCloseFunctionInIsolate( + workflow: Workflow, + response: ITriggerResponse | undefined, + ): ITriggerResponse | undefined { + const closeFunction = response?.closeFunction; + if (response && closeFunction) { + response.closeFunction = async () => + await workflow.expression.withIsolate(async () => await closeFunction.call(response)); + } + return response; } /**