Skip to content

Commit 91b3671

Browse files
committed
fix: propagate websocket aborts to sidecars
1 parent ac5b5b7 commit 91b3671

7 files changed

Lines changed: 119 additions & 6 deletions

File tree

src/abort.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export interface LinkedAbortSignal {
2+
signal: AbortSignal;
3+
cleanup: () => void;
4+
}
5+
6+
export function signalWithTimeout(timeoutMs: number, parent?: AbortSignal): LinkedAbortSignal {
7+
const controller = new AbortController();
8+
const timeout = setTimeout(() => {
9+
if (!controller.signal.aborted) controller.abort(new DOMException("Timeout elapsed", "TimeoutError"));
10+
}, timeoutMs);
11+
12+
const abortFromParent = () => {
13+
if (!controller.signal.aborted) controller.abort(parent?.reason);
14+
};
15+
16+
if (parent?.aborted) {
17+
abortFromParent();
18+
} else {
19+
parent?.addEventListener("abort", abortFromParent, { once: true });
20+
}
21+
22+
return {
23+
signal: controller.signal,
24+
cleanup: () => {
25+
clearTimeout(timeout);
26+
parent?.removeEventListener("abort", abortFromParent);
27+
},
28+
};
29+
}

src/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ async function handleResponses(
150150
// with text BEFORE the main call, so the text-only model can reason about it.
151151
const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, req.headers);
152152
if (visionPlan) {
153-
await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings);
153+
await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings, options.abortSignal);
154154
}
155155

156156
const adapter = resolveAdapter(route.provider);
@@ -192,6 +192,7 @@ async function handleResponses(
192192
incomingHeaders: req.headers,
193193
settings: wsPlan.settings,
194194
maxSearches: wsPlan.maxSearches,
195+
abortSignal: options.abortSignal,
195196
});
196197
}
197198

src/vision/describe.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { OcxProviderConfig } from "../types";
22
import { FORWARD_HEADERS } from "../adapters/openai-responses";
3+
import { signalWithTimeout } from "../abort";
34
import { parseSidecarSSE } from "../web-search/parse";
45

56
export interface VisionSettings {
@@ -48,6 +49,7 @@ export async function describeImage(
4849
forwardProvider: OcxProviderConfig,
4950
incomingHeaders: Headers,
5051
settings: VisionSettings,
52+
abortSignal?: AbortSignal,
5153
): Promise<DescribeOutcome> {
5254
const invalid = validateImageUrl(imageUrl);
5355
if (invalid) return { text: "", error: invalid };
@@ -76,12 +78,13 @@ export async function describeImage(
7678
store: false,
7779
stream: true,
7880
};
81+
const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
7982
try {
8083
const res = await fetch(`${forwardProvider.baseUrl}/responses`, {
8184
method: "POST",
8285
headers,
8386
body: JSON.stringify(body),
84-
signal: AbortSignal.timeout(settings.timeoutMs),
87+
signal: linkedSignal.signal,
8588
});
8689
if (!res.ok) {
8790
const t = await res.text().catch(() => "");
@@ -94,5 +97,7 @@ export async function describeImage(
9497
return { text: parsed.text };
9598
} catch (e) {
9699
return { text: "", error: e instanceof Error ? e.message : String(e) };
100+
} finally {
101+
linkedSignal.cleanup();
97102
}
98103
}

src/vision/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export async function describeImagesInPlace(
107107
forwardProvider: OcxProviderConfig,
108108
incomingHeaders: Headers,
109109
settings: VisionSettings,
110+
abortSignal?: AbortSignal,
110111
): Promise<void> {
111112
// 1. Gather every image part across messages, each with its own message's text as context.
112113
const jobs: ImageJob[] = [];
@@ -129,7 +130,7 @@ export async function describeImagesInPlace(
129130

130131
// 2. Describe all images with bounded concurrency (order preserved).
131132
const outcomes = await runBounded(jobs, VISION_CONCURRENCY, j =>
132-
describeImage(j.imageUrl, j.detail, j.contextText, forwardProvider, incomingHeaders, settings));
133+
describeImage(j.imageUrl, j.detail, j.contextText, forwardProvider, incomingHeaders, settings, abortSignal));
133134

134135
// 3. Rebuild each message, replacing image parts with their descriptions in order.
135136
let oi = 0;

src/web-search/executor.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { OcxProviderConfig } from "../types";
22
import { FORWARD_HEADERS } from "../adapters/openai-responses";
3+
import { signalWithTimeout } from "../abort";
34
import { parseSidecarSSE, type WebSearchResult } from "./parse";
45

56
export interface SidecarSettings {
@@ -36,6 +37,7 @@ export async function runWebSearch(
3637
forwardProvider: OcxProviderConfig,
3738
incomingHeaders: Headers,
3839
settings: SidecarSettings,
40+
abortSignal?: AbortSignal,
3941
): Promise<SidecarOutcome> {
4042
const headers: Record<string, string> = { "Content-Type": "application/json" };
4143
if (forwardProvider.headers) Object.assign(headers, forwardProvider.headers);
@@ -57,12 +59,13 @@ export async function runWebSearch(
5759
stream: true,
5860
};
5961
const url = `${forwardProvider.baseUrl}/responses`;
62+
const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
6063
try {
6164
const res = await fetch(url, {
6265
method: "POST",
6366
headers,
6467
body: JSON.stringify(body),
65-
signal: AbortSignal.timeout(settings.timeoutMs),
68+
signal: linkedSignal.signal,
6669
});
6770
if (!res.ok) {
6871
const t = await res.text().catch(() => "");
@@ -71,5 +74,7 @@ export async function runWebSearch(
7174
return await parseSidecarSSE(res);
7275
} catch (e) {
7376
return { text: "", sources: [], error: e instanceof Error ? e.message : String(e) };
77+
} finally {
78+
linkedSignal.cleanup();
7479
}
7580
}

src/web-search/loop.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export interface WebSearchLoopDeps {
9595
incomingHeaders: Headers;
9696
settings: SidecarSettings;
9797
maxSearches: number;
98+
abortSignal?: AbortSignal;
9899
}
99100

100101
/**
@@ -104,7 +105,7 @@ export interface WebSearchLoopDeps {
104105
* streamed Responses SSE. web_search calls are executed internally and never relayed to Codex.
105106
*/
106107
export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Response> {
107-
const { parsed, adapter, incomingHeaders, forwardProvider, hostedTool, settings, maxSearches } = deps;
108+
const { parsed, adapter, incomingHeaders, forwardProvider, hostedTool, settings, maxSearches, abortSignal } = deps;
108109
if (!adapter.parseResponse) return jsonError(500, "web-search sidecar requires a non-streaming adapter");
109110

110111
const messages: OcxMessage[] = [...parsed.context.messages];
@@ -159,7 +160,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
159160
outcome = { text: "", sources: [], error: "the model called web_search with an empty query" };
160161
searchesExecuted++;
161162
} else {
162-
outcome = await runWebSearch(call.query, hostedTool, forwardProvider, incomingHeaders, settings);
163+
outcome = await runWebSearch(call.query, hostedTool, forwardProvider, incomingHeaders, settings, abortSignal);
163164
searchesExecuted++;
164165
if (outcome.error) failedQueries.add(normalizeQuery(call.query));
165166
}

tests/sidecar-abort.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { runWebSearch } from "../src/web-search/executor";
3+
import { describeImage } from "../src/vision/describe";
4+
import type { OcxProviderConfig } from "../src/types";
5+
6+
const originalFetch = globalThis.fetch;
7+
8+
const forwardProvider: OcxProviderConfig = {
9+
adapter: "openai-responses",
10+
baseUrl: "https://chatgpt.test",
11+
authMode: "forward",
12+
};
13+
14+
afterEach(() => {
15+
globalThis.fetch = originalFetch;
16+
});
17+
18+
function installAbortAwareFetch(): () => AbortSignal {
19+
let seenSignal: AbortSignal | undefined;
20+
globalThis.fetch = ((_, init) => {
21+
seenSignal = init?.signal as AbortSignal | undefined;
22+
return new Promise<Response>((_, reject) => {
23+
seenSignal?.addEventListener("abort", () => reject(new Error("aborted by turn")), { once: true });
24+
});
25+
}) as typeof fetch;
26+
return () => {
27+
if (!seenSignal) throw new Error("fetch was not called");
28+
return seenSignal;
29+
};
30+
}
31+
32+
describe("sidecar abort propagation", () => {
33+
test("web-search sidecar fetch observes the WebSocket turn abort signal", async () => {
34+
const getSignal = installAbortAwareFetch();
35+
const turn = new AbortController();
36+
const outcome = runWebSearch(
37+
"current docs",
38+
{ type: "web_search" },
39+
forwardProvider,
40+
new Headers({ authorization: "Bearer token" }),
41+
{ model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 },
42+
turn.signal,
43+
);
44+
45+
const signal = getSignal();
46+
expect(signal.aborted).toBe(false);
47+
turn.abort("replacement turn");
48+
expect(signal.aborted).toBe(true);
49+
expect((await outcome).error).toBe("aborted by turn");
50+
});
51+
52+
test("vision sidecar fetch observes the WebSocket turn abort signal", async () => {
53+
const getSignal = installAbortAwareFetch();
54+
const turn = new AbortController();
55+
const outcome = describeImage(
56+
"data:image/png;base64,iVBORw0KGgo=",
57+
"high",
58+
"inspect screenshot",
59+
forwardProvider,
60+
new Headers({ authorization: "Bearer token" }),
61+
{ model: "gpt-5.4-mini", timeoutMs: 30_000 },
62+
turn.signal,
63+
);
64+
65+
const signal = getSignal();
66+
expect(signal.aborted).toBe(false);
67+
turn.abort("replacement turn");
68+
expect(signal.aborted).toBe(true);
69+
expect((await outcome).error).toBe("aborted by turn");
70+
});
71+
});

0 commit comments

Comments
 (0)