Skip to content

Commit 8f15fa5

Browse files
NERLOEclaudematt-aitken
authored
fix(core): accept IPC acks for void callbacks under zod 4.4+ (#4980)
Closes #4979 Credit to @marcus-dk for finding and root-causing this in #4979, including the zod version bisect and the end-to-end confirmation with a patched worker. ## Summary `FLUSH` and `CANCEL` declare `callback: z.void()`, so the task run process acks them with `{ type: "ACK", id, message: undefined }`. Node's JSON IPC serialization drops the undefined `message` key. Under zod 4.4+ a `z.any()` object key is required at parse time (it was optional under zod 3 and 4.0–4.3), so `Packet.safeParse` rejects every such ack, `#handlePacket` returns silently, and `sendWithAck` waits out its full timeout. Because `zodIpc.ts` imports `zod/v4` and `@trigger.dev/core` resolves `zod` from the user's project, every project on zod ≥ 4.4 pays this on every run: ~6 s between the task finishing and the completion reaching the engine (FLUSH: 5 s + 1 s), and ~31 s on cancel. The fix makes the ACK packet's `message` optional again (`z.any().optional()`). Only the ACK packet can legitimately carry `undefined`, so the other packets are unchanged. ## ✅ Checklist - [x] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [x] The PR title follows the convention. - [x] I ran and tested the code works --- ## Testing - Added `packages/core/test/zodIpc.test.ts`: forks a real child process (`test/fixtures/zodIpcChild.ts`) and sends `FLUSH` (void callback) and `PING` (payload callback) over Node's IPC channel, so the ack goes through the same JSON serialization as between a worker and its task run process. On `main` (zod 4.5.4 in this repo) the void-callback test fails with `sendWithAck() timeout`; with the fix it resolves immediately. - `pnpm run build --filter @trigger.dev/core` passes. - Production evidence from our self-hosted instance (`completed_at - started_at - usage_duration_ms` on successful runs): | SDK | runs | p10 gap | p50 gap | |---|---|---|---| | 4.5.12 | 2.6M | 179 ms | 244 ms | | 4.5.15 | 5.0M | 176 ms | 251 ms | | 4.6.0 | 1.6M | 6,162 ms | 6,196 ms | | 4.6.2 | 4.0M | 6,180 ms | 6,240 ms | --- ## Changelog 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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Matt Aitken <matt@mattaitken.com>
1 parent ad005a4 commit 8f15fa5

5 files changed

Lines changed: 79 additions & 1 deletion

File tree

‎.changeset/void-ipc-acks.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
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.

‎packages/core/src/v3/zodIpc.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,9 @@ const Packet = discriminatedUnion("type", [
124124
}),
125125
z.object({
126126
type: z.literal("ACK"),
127-
message: z.any(),
127+
// Optional: a void callback acks with `message: undefined`, and `process.send`
128+
// drops undefined keys when serializing, so the key is absent on arrival.
129+
message: z.any().optional(),
128130
id: z.number(),
129131
}),
130132
z.object({
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { z } from "zod/v4";
2+
3+
export const ParentToChild = {
4+
FLUSH: {
5+
message: z.object({ timeoutInMs: z.number() }),
6+
callback: z.void(),
7+
},
8+
PING: {
9+
message: z.object({ value: z.string() }),
10+
callback: z.object({ echoed: z.string() }),
11+
},
12+
};
13+
14+
export const ChildToParent = {};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ZodIpcConnection } from "../../src/v3/zodIpc.js";
2+
import { ChildToParent, ParentToChild } from "./zodIpcCatalog.js";
3+
4+
// Forked by zodIpc.test.ts: answers the parent's messages over the real IPC channel.
5+
new ZodIpcConnection({
6+
listenSchema: ParentToChild,
7+
emitSchema: ChildToParent,
8+
process,
9+
handlers: {
10+
FLUSH: async () => {},
11+
PING: async ({ value }) => ({ echoed: value }),
12+
},
13+
});

‎packages/core/test/zodIpc.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { fork, type ChildProcess } from "node:child_process";
2+
import { fileURLToPath } from "node:url";
3+
import { afterEach, describe, expect, it } from "vitest";
4+
import { ZodIpcConnection } from "../src/v3/zodIpc.js";
5+
import { ChildToParent, ParentToChild } from "./fixtures/zodIpcCatalog.js";
6+
7+
const childPath = fileURLToPath(new URL("./fixtures/zodIpcChild.ts", import.meta.url));
8+
9+
let child: ChildProcess | undefined;
10+
11+
afterEach(() => {
12+
child?.kill();
13+
child = undefined;
14+
});
15+
16+
// Node's default IPC serialization is JSON, so an ack with `message: undefined`
17+
// arrives without the `message` key, exactly as it does between a worker and its task run process.
18+
function forkChild() {
19+
child = fork(childPath, { execArgv: ["--import", "tsx"], stdio: "inherit" });
20+
21+
return new ZodIpcConnection({
22+
listenSchema: ChildToParent,
23+
emitSchema: ParentToChild,
24+
process: child,
25+
});
26+
}
27+
28+
describe("ZodIpcConnection", () => {
29+
it("resolves sendWithAck for a void callback", async () => {
30+
const connection = forkChild();
31+
32+
await expect(connection.sendWithAck("FLUSH", { timeoutInMs: 1000 }, 2000)).resolves.toBe(
33+
undefined
34+
);
35+
});
36+
37+
it("resolves sendWithAck with the callback payload", async () => {
38+
const connection = forkChild();
39+
40+
await expect(connection.sendWithAck("PING", { value: "hello" }, 2000)).resolves.toEqual({
41+
echoed: "hello",
42+
});
43+
});
44+
});

0 commit comments

Comments
 (0)