From 5892ebf8077da1e68ae097361d02c377d59e04e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Nerl=C3=B8e?= <32075361+NERLOE@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:18:58 +0200 Subject: [PATCH 1/4] fix(core): accept IPC acks for void callbacks under zod 4.4+ FLUSH and CANCEL declare `callback: z.void()`, so the child acks with `message: undefined`. Node's JSON IPC serialization drops that key, and zod 4.4+ treats a `z.any()` key as required, so the ACK packet failed to parse and `sendWithAck` waited out its full timeout: ~6s on every run completion and ~31s on cancel. Make the ACK packet's `message` optional. Fixes #4979 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/void-ipc-acks.md | 5 ++ packages/core/src/v3/zodIpc.ts | 4 +- packages/core/test/zodIpc.test.ts | 83 +++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 .changeset/void-ipc-acks.md create mode 100644 packages/core/test/zodIpc.test.ts diff --git a/.changeset/void-ipc-acks.md b/.changeset/void-ipc-acks.md new file mode 100644 index 00000000000..5d1e1b0c7d6 --- /dev/null +++ b/.changeset/void-ipc-acks.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Fix a ~6 second delay between a task finishing and its run completing (and a ~31 second delay when cancelling a run) in projects that use zod 4.4 or newer. diff --git a/packages/core/src/v3/zodIpc.ts b/packages/core/src/v3/zodIpc.ts index ebd5a685df7..e2b5028b376 100644 --- a/packages/core/src/v3/zodIpc.ts +++ b/packages/core/src/v3/zodIpc.ts @@ -124,7 +124,9 @@ const Packet = discriminatedUnion("type", [ }), z.object({ type: z.literal("ACK"), - message: z.any(), + // Optional: a void callback acks with `message: undefined`, and `process.send` + // drops undefined keys when serializing, so the key is absent on arrival. + message: z.any().optional(), id: z.number(), }), z.object({ diff --git a/packages/core/test/zodIpc.test.ts b/packages/core/test/zodIpc.test.ts new file mode 100644 index 00000000000..5e0a35b6d6a --- /dev/null +++ b/packages/core/test/zodIpc.test.ts @@ -0,0 +1,83 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "vitest"; +import { z } from "zod/v4"; +import { ZodIpcConnection } from "../src/v3/zodIpc.js"; + +// A pair of in-memory endpoints that serialize packets the same way Node's default +// ("json") IPC serialization does, so keys with `undefined` values are dropped in transit. +function createIpcPair() { + const parentEvents = new EventEmitter(); + const childEvents = new EventEmitter(); + + const endpoint = (inbox: EventEmitter, outbox: EventEmitter) => ({ + connected: true, + send: (message: unknown) => { + const serialized = JSON.stringify(message); + setImmediate(() => outbox.emit("message", JSON.parse(serialized))); + return true; + }, + on: (event: "message", listener: (message: any) => void) => { + inbox.on(event, listener); + }, + }); + + return { + parent: endpoint(parentEvents, childEvents), + child: endpoint(childEvents, parentEvents), + }; +} + +const ParentToChild = { + FLUSH: { + message: z.object({ timeoutInMs: z.number() }), + callback: z.void(), + }, + PING: { + message: z.object({ value: z.string() }), + callback: z.object({ echoed: z.string() }), + }, +}; + +const ChildToParent = {}; + +function createConnections() { + const { parent, child } = createIpcPair(); + + const parentConnection = new ZodIpcConnection({ + listenSchema: ChildToParent, + emitSchema: ParentToChild, + process: parent, + }); + + new ZodIpcConnection({ + listenSchema: ParentToChild, + emitSchema: ChildToParent, + process: child, + handlers: { + FLUSH: async () => {}, + PING: async ({ value }) => ({ echoed: value }), + }, + }); + + return parentConnection; +} + +describe("ZodIpcConnection", () => { + it("resolves sendWithAck for a void callback after the ack crosses a JSON boundary", async () => { + const connection = createConnections(); + + const start = Date.now(); + await expect(connection.sendWithAck("FLUSH", { timeoutInMs: 1000 }, 1000)).resolves.toBe( + undefined + ); + expect(Date.now() - start).toBeLessThan(500); + }); + + it("resolves sendWithAck with the callback payload", async () => { + const connection = createConnections(); + + await expect(connection.sendWithAck("PING", { value: "hello" }, 1000)).resolves.toEqual({ + echoed: "hello", + }); + }); +}); From d098714842cd2891c7098962ac6030d75f1be27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Nerl=C3=B8e?= <32075361+NERLOE@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:40:15 +0200 Subject: [PATCH 2/4] test(core): drop the timing assertion from the zodIpc ack test The `resolves` assertion already fails when the ack is rejected, and a wall-clock bound could flake on a slow runner. Also hyphenate the changeset's compound adjectives. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/void-ipc-acks.md | 2 +- packages/core/test/zodIpc.test.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.changeset/void-ipc-acks.md b/.changeset/void-ipc-acks.md index 5d1e1b0c7d6..0c823e271db 100644 --- a/.changeset/void-ipc-acks.md +++ b/.changeset/void-ipc-acks.md @@ -2,4 +2,4 @@ "@trigger.dev/core": patch --- -Fix a ~6 second delay between a task finishing and its run completing (and a ~31 second delay when cancelling a run) in projects that use zod 4.4 or newer. +Fix a ~6-second delay between a task finishing and its run completing (and a ~31-second delay when cancelling a run) in projects that use zod 4.4 or newer. diff --git a/packages/core/test/zodIpc.test.ts b/packages/core/test/zodIpc.test.ts index 5e0a35b6d6a..07357d4b183 100644 --- a/packages/core/test/zodIpc.test.ts +++ b/packages/core/test/zodIpc.test.ts @@ -66,11 +66,9 @@ describe("ZodIpcConnection", () => { it("resolves sendWithAck for a void callback after the ack crosses a JSON boundary", async () => { const connection = createConnections(); - const start = Date.now(); await expect(connection.sendWithAck("FLUSH", { timeoutInMs: 1000 }, 1000)).resolves.toBe( undefined ); - expect(Date.now() - start).toBeLessThan(500); }); it("resolves sendWithAck with the callback payload", async () => { From 41287e70c1346d29e85dbd1ef80ee5110ab6e9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Nerl=C3=B8e?= <32075361+NERLOE@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:48:16 +0200 Subject: [PATCH 3/4] test(core): exercise zodIpc acks over a real forked child Replace the in-memory JSON channel with a forked child process so the ack goes through Node's actual IPC serialization. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/test/fixtures/zodIpcCatalog.ts | 14 ++++ packages/core/test/fixtures/zodIpcChild.ts | 13 ++++ packages/core/test/zodIpc.test.ts | 77 +++++--------------- 3 files changed, 47 insertions(+), 57 deletions(-) create mode 100644 packages/core/test/fixtures/zodIpcCatalog.ts create mode 100644 packages/core/test/fixtures/zodIpcChild.ts diff --git a/packages/core/test/fixtures/zodIpcCatalog.ts b/packages/core/test/fixtures/zodIpcCatalog.ts new file mode 100644 index 00000000000..6f24288b049 --- /dev/null +++ b/packages/core/test/fixtures/zodIpcCatalog.ts @@ -0,0 +1,14 @@ +import { z } from "zod/v4"; + +export const ParentToChild = { + FLUSH: { + message: z.object({ timeoutInMs: z.number() }), + callback: z.void(), + }, + PING: { + message: z.object({ value: z.string() }), + callback: z.object({ echoed: z.string() }), + }, +}; + +export const ChildToParent = {}; diff --git a/packages/core/test/fixtures/zodIpcChild.ts b/packages/core/test/fixtures/zodIpcChild.ts new file mode 100644 index 00000000000..721b534ddb9 --- /dev/null +++ b/packages/core/test/fixtures/zodIpcChild.ts @@ -0,0 +1,13 @@ +import { ZodIpcConnection } from "../../src/v3/zodIpc.js"; +import { ChildToParent, ParentToChild } from "./zodIpcCatalog.js"; + +// Forked by zodIpc.test.ts: answers the parent's messages over the real IPC channel. +new ZodIpcConnection({ + listenSchema: ParentToChild, + emitSchema: ChildToParent, + process, + handlers: { + FLUSH: async () => {}, + PING: async ({ value }) => ({ echoed: value }), + }, +}); diff --git a/packages/core/test/zodIpc.test.ts b/packages/core/test/zodIpc.test.ts index 07357d4b183..7b5d2669723 100644 --- a/packages/core/test/zodIpc.test.ts +++ b/packages/core/test/zodIpc.test.ts @@ -1,80 +1,43 @@ -import { EventEmitter } from "node:events"; -import { describe, expect, it } from "vitest"; -import { z } from "zod/v4"; +import { fork, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; import { ZodIpcConnection } from "../src/v3/zodIpc.js"; +import { ChildToParent, ParentToChild } from "./fixtures/zodIpcCatalog.js"; -// A pair of in-memory endpoints that serialize packets the same way Node's default -// ("json") IPC serialization does, so keys with `undefined` values are dropped in transit. -function createIpcPair() { - const parentEvents = new EventEmitter(); - const childEvents = new EventEmitter(); +const childPath = fileURLToPath(new URL("./fixtures/zodIpcChild.ts", import.meta.url)); - const endpoint = (inbox: EventEmitter, outbox: EventEmitter) => ({ - connected: true, - send: (message: unknown) => { - const serialized = JSON.stringify(message); - setImmediate(() => outbox.emit("message", JSON.parse(serialized))); - return true; - }, - on: (event: "message", listener: (message: any) => void) => { - inbox.on(event, listener); - }, - }); - - return { - parent: endpoint(parentEvents, childEvents), - child: endpoint(childEvents, parentEvents), - }; -} - -const ParentToChild = { - FLUSH: { - message: z.object({ timeoutInMs: z.number() }), - callback: z.void(), - }, - PING: { - message: z.object({ value: z.string() }), - callback: z.object({ echoed: z.string() }), - }, -}; +let child: ChildProcess | undefined; -const ChildToParent = {}; +afterEach(() => { + child?.kill(); + child = undefined; +}); -function createConnections() { - const { parent, child } = createIpcPair(); +// Node's default IPC serialization is JSON, so an ack with `message: undefined` +// arrives without the `message` key, exactly as it does between a worker and its task run process. +function forkChild() { + child = fork(childPath, { execArgv: ["--import", "tsx"], stdio: "inherit" }); - const parentConnection = new ZodIpcConnection({ + return new ZodIpcConnection({ listenSchema: ChildToParent, emitSchema: ParentToChild, - process: parent, - }); - - new ZodIpcConnection({ - listenSchema: ParentToChild, - emitSchema: ChildToParent, process: child, - handlers: { - FLUSH: async () => {}, - PING: async ({ value }) => ({ echoed: value }), - }, }); - - return parentConnection; } describe("ZodIpcConnection", () => { - it("resolves sendWithAck for a void callback after the ack crosses a JSON boundary", async () => { - const connection = createConnections(); + it("resolves sendWithAck for a void callback", async () => { + const connection = forkChild(); - await expect(connection.sendWithAck("FLUSH", { timeoutInMs: 1000 }, 1000)).resolves.toBe( + await expect(connection.sendWithAck("FLUSH", { timeoutInMs: 1000 }, 2000)).resolves.toBe( undefined ); }); it("resolves sendWithAck with the callback payload", async () => { - const connection = createConnections(); + const connection = forkChild(); - await expect(connection.sendWithAck("PING", { value: "hello" }, 1000)).resolves.toEqual({ + await expect(connection.sendWithAck("PING", { value: "hello" }, 2000)).resolves.toEqual({ echoed: "hello", }); }); From 42f254fa41e3737eb2e062fcc23db875f1b3c4eb Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 25 Sep 2026 15:25:15 +0100 Subject: [PATCH 4/4] Make it clear billed usage wasn't impacted for the release notes --- .changeset/void-ipc-acks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/void-ipc-acks.md b/.changeset/void-ipc-acks.md index 0c823e271db..85caba20d35 100644 --- a/.changeset/void-ipc-acks.md +++ b/.changeset/void-ipc-acks.md @@ -2,4 +2,4 @@ "@trigger.dev/core": patch --- -Fix a ~6-second delay between a task finishing and its run completing (and a ~31-second delay when cancelling a run) in projects that use zod 4.4 or newer. +Fix a ~6-second delay between a task finishing and its run completing (and a ~31-second delay when cancelling a run) in projects that use zod 4.4 or newer. Run cost and billed usage was not impacted by this issue.