Skip to content

Commit a21275f

Browse files
authored
fix(serverless-hono): defer waitUntil cleanup to prevent tool crashes in Cloudflare Workers (#1191)
The `finally` block in toCloudflareWorker/toVercelEdge/toDeno calls cleanup() as soon as the Response object is returned — before streaming and tool execution complete. This clears the global ___voltagent_wait_until while tools are still using it, causing crashes. Fix: schedule cleanup through the platform's own waitUntil() so it runs only after all pending promises (streaming, tools, observability exports) have settled. Falls back to synchronous cleanup when waitUntil is unavailable (non-serverless environments). Fixes #1186
1 parent 19fa54b commit a21275f

3 files changed

Lines changed: 236 additions & 7 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@voltagent/serverless-hono": patch
3+
---
4+
5+
fix(serverless-hono): defer waitUntil cleanup to prevent tool crashes in Cloudflare Workers
6+
7+
The `finally` block in `toCloudflareWorker()`, `toVercelEdge()`, and `toDeno()` was calling `cleanup()` immediately when the Response was returned, before streaming and tool execution completed. This cleared the global `___voltagent_wait_until` while tools were still using it, causing crashes with time-consuming tools.
8+
9+
Cleanup is now deferred through the platform's own `waitUntil()` so it runs only after all pending background work has settled.

packages/serverless-hono/src/serverless-provider.ts

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,69 @@ import type { Hono } from "hono";
33
import { createServerlessApp } from "./app-factory";
44
import type { ServerlessConfig, ServerlessRuntime } from "./types";
55
import { detectServerlessRuntime } from "./utils/runtime-detection";
6-
import { withWaitUntil } from "./utils/wait-until-wrapper";
6+
import { type WaitUntilContext, withWaitUntil } from "./utils/wait-until-wrapper";
7+
8+
type VoltAgentGlobal = typeof globalThis & {
9+
___voltagent_wait_until?: (promise: Promise<unknown>) => void;
10+
};
11+
12+
/**
13+
* Defers the waitUntil cleanup so the global stays alive while streaming and
14+
* tool execution are still in progress.
15+
*
16+
* We wrap the global `___voltagent_wait_until` with a tracking proxy that
17+
* records every promise registered by tools and observability exporters.
18+
* Cleanup only runs after **all** tracked promises settle, guaranteeing the
19+
* global is available for the entire lifetime of the request.
20+
*
21+
* If the platform context has no `waitUntil` (non-serverless), we fall back
22+
* to immediate cleanup.
23+
*/
24+
export function deferCleanup(
25+
context: WaitUntilContext | null | undefined,
26+
cleanup: () => void,
27+
): void {
28+
const waitUntil = context?.waitUntil;
29+
if (!waitUntil || typeof waitUntil !== "function") {
30+
cleanup();
31+
return;
32+
}
33+
34+
try {
35+
const tracked: Promise<unknown>[] = [];
36+
const originalWaitUntil = waitUntil.bind(context);
37+
const globals = globalThis as VoltAgentGlobal;
38+
39+
// Replace the global with a tracking wrapper so every promise
40+
// registered by tools / observability is captured.
41+
const currentGlobal = globals.___voltagent_wait_until;
42+
if (currentGlobal) {
43+
globals.___voltagent_wait_until = (promise: Promise<unknown>) => {
44+
tracked.push(promise);
45+
originalWaitUntil(promise);
46+
};
47+
}
48+
49+
// Schedule cleanup to run only after every tracked promise settles.
50+
const cleanupWhenDone = Promise.resolve().then(async () => {
51+
// Wait in a loop — new promises may be registered while we wait.
52+
let settled = 0;
53+
while (settled < tracked.length) {
54+
const batch = tracked.slice(settled);
55+
await Promise.allSettled(batch);
56+
settled += batch.length;
57+
}
58+
cleanup();
59+
});
60+
61+
originalWaitUntil(cleanupWhenDone);
62+
} catch {
63+
// waitUntil can throw after the response is committed on some
64+
// platforms — fall through to synchronous cleanup.
65+
cleanup();
66+
}
67+
}
68+
769
export class HonoServerlessProvider implements IServerlessProvider {
870
private readonly deps: ServerProviderDeps;
971
private readonly config?: ServerlessConfig;
@@ -42,43 +104,43 @@ export class HonoServerlessProvider implements IServerlessProvider {
42104
env: Record<string, unknown>,
43105
executionCtx: unknown,
44106
): Promise<Response> => {
45-
const cleanup = withWaitUntil(executionCtx as any);
107+
const cleanup = withWaitUntil(executionCtx as WaitUntilContext | undefined);
46108

47109
try {
48110
await this.ensureEnvironmentTarget(env);
49111
const app = await this.getApp();
50112
return await app.fetch(request, env as Record<string, unknown>, executionCtx as any);
51113
} finally {
52-
cleanup();
114+
deferCleanup(executionCtx as WaitUntilContext | undefined, cleanup);
53115
}
54116
},
55117
};
56118
}
57119

58120
toVercelEdge(): (request: Request, context?: unknown) => Promise<Response> {
59121
return async (request: Request, context?: unknown) => {
60-
const cleanup = withWaitUntil(context as any);
122+
const cleanup = withWaitUntil(context as WaitUntilContext | undefined);
61123

62124
try {
63125
await this.ensureEnvironmentTarget(context as Record<string, unknown> | undefined);
64126
const app = await this.getApp();
65127
return await app.fetch(request, context as Record<string, unknown> | undefined);
66128
} finally {
67-
cleanup();
129+
deferCleanup(context as WaitUntilContext | undefined, cleanup);
68130
}
69131
};
70132
}
71133

72134
toDeno(): (request: Request, info?: unknown) => Promise<Response> {
73135
return async (request: Request, info?: unknown) => {
74-
const cleanup = withWaitUntil(info as any);
136+
const cleanup = withWaitUntil(info as WaitUntilContext | undefined);
75137

76138
try {
77139
await this.ensureEnvironmentTarget(info as Record<string, unknown> | undefined);
78140
const app = await this.getApp();
79141
return await app.fetch(request, info as Record<string, unknown> | undefined);
80142
} finally {
81-
cleanup();
143+
deferCleanup(info as WaitUntilContext | undefined, cleanup);
82144
}
83145
};
84146
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { deferCleanup } from "../serverless-provider";
3+
import type { WaitUntilContext } from "./wait-until-wrapper";
4+
5+
type VoltAgentGlobal = typeof globalThis & {
6+
___voltagent_wait_until?: (promise: Promise<unknown>) => void;
7+
};
8+
9+
describe("deferCleanup", () => {
10+
let originalWaitUntil: ((promise: Promise<unknown>) => void) | undefined;
11+
12+
beforeEach(() => {
13+
const globals = globalThis as VoltAgentGlobal;
14+
originalWaitUntil = globals.___voltagent_wait_until;
15+
});
16+
17+
afterEach(() => {
18+
const globals = globalThis as VoltAgentGlobal;
19+
globals.___voltagent_wait_until = originalWaitUntil;
20+
});
21+
22+
it("should defer cleanup until all tracked promises settle", async () => {
23+
const cleanup = vi.fn();
24+
const registeredPromises: Promise<unknown>[] = [];
25+
const context: WaitUntilContext = {
26+
waitUntil: vi.fn((p: Promise<unknown>) => {
27+
registeredPromises.push(p);
28+
}),
29+
};
30+
31+
// Set up a global so the tracking wrapper can intercept
32+
const globals = globalThis as VoltAgentGlobal;
33+
globals.___voltagent_wait_until = context.waitUntil?.bind(context);
34+
35+
// Simulate a background tool promise that takes time
36+
let resolveToolPromise!: () => void;
37+
const toolPromise = new Promise<void>((r) => {
38+
resolveToolPromise = r;
39+
});
40+
41+
deferCleanup(context, cleanup);
42+
43+
// Simulate tool registering via the global after deferCleanup
44+
globals.___voltagent_wait_until?.(toolPromise);
45+
46+
// cleanup should NOT have run yet — tool is still pending
47+
await Promise.resolve(); // flush microtasks
48+
expect(cleanup).not.toHaveBeenCalled();
49+
50+
// Now resolve the tool promise
51+
resolveToolPromise();
52+
await Promise.allSettled(registeredPromises);
53+
54+
expect(cleanup).toHaveBeenCalledTimes(1);
55+
});
56+
57+
it("should fall back to synchronous cleanup when context is null", () => {
58+
const cleanup = vi.fn();
59+
60+
deferCleanup(null, cleanup);
61+
62+
expect(cleanup).toHaveBeenCalledTimes(1);
63+
});
64+
65+
it("should fall back to synchronous cleanup when context is undefined", () => {
66+
const cleanup = vi.fn();
67+
68+
deferCleanup(undefined, cleanup);
69+
70+
expect(cleanup).toHaveBeenCalledTimes(1);
71+
});
72+
73+
it("should fall back to synchronous cleanup when context has no waitUntil", () => {
74+
const cleanup = vi.fn();
75+
76+
deferCleanup({} as unknown as WaitUntilContext, cleanup);
77+
78+
expect(cleanup).toHaveBeenCalledTimes(1);
79+
});
80+
81+
it("should fall back to synchronous cleanup when waitUntil throws", () => {
82+
const cleanup = vi.fn();
83+
const context: WaitUntilContext = {
84+
waitUntil: vi.fn(() => {
85+
throw new Error("Cannot call waitUntil after response committed");
86+
}),
87+
};
88+
89+
deferCleanup(context, cleanup);
90+
91+
expect(cleanup).toHaveBeenCalledTimes(1);
92+
});
93+
94+
it("should not throw when waitUntil throws", () => {
95+
const cleanup = vi.fn();
96+
const context: WaitUntilContext = {
97+
waitUntil: vi.fn(() => {
98+
throw new Error("platform error");
99+
}),
100+
};
101+
102+
expect(() => deferCleanup(context, cleanup)).not.toThrow();
103+
});
104+
105+
it("should handle context with non-function waitUntil", () => {
106+
const cleanup = vi.fn();
107+
const context = { waitUntil: "not a function" } as unknown as WaitUntilContext;
108+
109+
deferCleanup(context, cleanup);
110+
111+
expect(cleanup).toHaveBeenCalledTimes(1);
112+
});
113+
114+
it("should handle late-registered promises", async () => {
115+
const cleanup = vi.fn();
116+
const registeredPromises: Promise<unknown>[] = [];
117+
const context: WaitUntilContext = {
118+
waitUntil: vi.fn((p: Promise<unknown>) => {
119+
registeredPromises.push(p);
120+
}),
121+
};
122+
123+
const globals = globalThis as VoltAgentGlobal;
124+
globals.___voltagent_wait_until = context.waitUntil?.bind(context);
125+
126+
let resolveFirst!: () => void;
127+
const firstPromise = new Promise<void>((r) => {
128+
resolveFirst = r;
129+
});
130+
131+
let resolveSecond!: () => void;
132+
const secondPromise = new Promise<void>((r) => {
133+
resolveSecond = r;
134+
});
135+
136+
deferCleanup(context, cleanup);
137+
138+
// Register first background task
139+
globals.___voltagent_wait_until?.(firstPromise);
140+
141+
// Resolve first — but second hasn't been registered yet
142+
resolveFirst();
143+
await Promise.resolve();
144+
await Promise.resolve();
145+
146+
// Register second task AFTER first settled (late registration)
147+
globals.___voltagent_wait_until?.(secondPromise);
148+
149+
// cleanup should still NOT have run
150+
expect(cleanup).not.toHaveBeenCalled();
151+
152+
// Now resolve second
153+
resolveSecond();
154+
await Promise.allSettled(registeredPromises);
155+
156+
expect(cleanup).toHaveBeenCalledTimes(1);
157+
});
158+
});

0 commit comments

Comments
 (0)