fix(ai): handled cursor tls session errors

Attached the Cursor HTTP/2 session error path to the provider stream completion before starting the request.

Added a subprocess regression that resets the TLS handshake and verifies the turn surfaces a stream error without terminating the process.

Fixes #5593
This commit is contained in:
roboomp
2026-07-15 15:46:34 +00:00
parent 5ff277349c
commit ed5ec3ebdc
4 changed files with 112 additions and 51 deletions
+1
View File
@@ -4,6 +4,7 @@
### Fixed
- Fixed Cursor TLS connection resets escaping the provider stream as process-fatal uncaught exceptions, allowing the active turn to fail or retry without terminating the session. ([#5593](https://github.com/can1357/oh-my-pi/issues/5593))
- Parsed Ollama NDJSON response bytes directly instead of decoding and buffering every network chunk as text. ([#5542](https://github.com/can1357/oh-my-pi/issues/5542))
- Fixed Amazon Bedrock stream error handling for non-`Error` values that `JSON.stringify` cannot serialize ([#5539](https://github.com/can1357/oh-my-pi/issues/5539)).
+48 -51
View File
@@ -351,6 +351,8 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
let h2Request: http2.ClientHttp2Stream | null = null;
let heartbeatTimer: NodeJS.Timeout | null = null;
let debugResponseLogPromise: Promise<RequestDebugResponseLog | undefined> | undefined;
const h2Completion = Promise.withResolvers<void>();
let resolveH2: (() => void) | undefined = h2Completion.resolve;
try {
const apiKey = options?.apiKey;
@@ -406,6 +408,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
} else {
h2Client = http2.connect(baseUrl);
}
h2Client.on("error", h2Completion.reject);
h2Request = h2Client.request(requestHeaders);
@@ -449,8 +452,6 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
conversationStateCache.set(conversationId, checkpoint);
};
let resolveH2: (() => void) | undefined;
h2Request.on("response", headers => {
debugResponseLogPromise = debugSession?.openResponseLog(
`HTTP/2 ${headers[":status"] ?? ""}`.trim(),
@@ -517,8 +518,6 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
}
});
h2Request.write(frameConnectMessage(requestBytes));
const sendHeartbeat = () => {
if (!h2Request || h2Request.closed) {
return;
@@ -530,58 +529,56 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
h2Request.write(frameConnectMessage(heartbeatBytes));
};
heartbeatTimer = setInterval(sendHeartbeat, 5000);
const closeDebugLog = async (): Promise<void> => {
const log = await debugResponseLogPromise;
await log?.close();
};
await new Promise<void>((resolve, reject) => {
resolveH2 = resolve;
const closeDebugLog = async (): Promise<void> => {
const log = await debugResponseLogPromise;
await log?.close();
};
h2Request!.on("trailers", trailers => {
const status = trailers["grpc-status"];
const msg = trailers["grpc-message"];
if (status && status !== "0") {
void closeDebugLog().finally(() => {
reject(
new AIError.ProviderResponseError(
`gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`,
{ kind: "envelope" },
),
);
});
}
});
h2Request!.on("end", () => {
resolveH2 = undefined;
void closeDebugLog()
.then(() => {
if (endStreamError) {
reject(endStreamError);
return;
}
resolve();
})
.catch(reject);
});
h2Request!.on("error", error => {
void closeDebugLog().finally(() => reject(error));
});
if (options?.signal) {
options.signal.addEventListener("abort", () => {
h2Request?.close();
void closeDebugLog().finally(() => {
reject(new AIError.AbortError());
});
h2Request.on("trailers", trailers => {
const status = trailers["grpc-status"];
const msg = trailers["grpc-message"];
if (status && status !== "0") {
void closeDebugLog().finally(() => {
h2Completion.reject(
new AIError.ProviderResponseError(
`gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`,
{ kind: "envelope" },
),
);
});
}
});
h2Request.on("end", () => {
resolveH2 = undefined;
void closeDebugLog()
.then(() => {
if (endStreamError) {
h2Completion.reject(endStreamError);
return;
}
h2Completion.resolve();
})
.catch(h2Completion.reject);
});
h2Request.on("error", error => {
void closeDebugLog().finally(() => h2Completion.reject(error));
});
if (options?.signal) {
options.signal.addEventListener("abort", () => {
h2Request?.close();
void closeDebugLog().finally(() => {
h2Completion.reject(new AIError.AbortError());
});
});
}
h2Request.write(frameConnectMessage(requestBytes));
heartbeatTimer = setInterval(sendHeartbeat, 5000);
await h2Completion.promise;
endCurrentTextBlock(output, stream, state);
endCurrentThinkingBlock(output, stream, state);
if (state.currentToolCall) {
@@ -0,0 +1,24 @@
import { describe, expect, it } from "bun:test";
import * as path from "node:path";
describe("Cursor transport errors", () => {
it("surfaces a TLS handshake reset as a stream error", async () => {
const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixtures/cursor-tls-reset.ts")], {
cwd: path.resolve(import.meta.dir, "../../.."),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
]);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(JSON.parse(stdout)).toEqual({
eventTypes: ["start", "error"],
stopReason: "error",
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import * as net from "node:net";
import { streamCursor } from "@oh-my-pi/pi-ai/providers/cursor";
import type { Context, Model } from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
const server = net.createServer(socket => socket.resetAndDestroy());
const listening = Promise.withResolvers<void>();
server.once("error", listening.reject);
server.listen(0, "127.0.0.1", listening.resolve);
await listening.promise;
const address = server.address();
if (!address || typeof address === "string") throw new Error("TCP server did not bind");
const model: Model<"cursor-agent"> = buildModel({
id: "cursor-reset-fixture",
name: "Cursor reset fixture",
api: "cursor-agent",
provider: "cursor",
baseUrl: `https://127.0.0.1:${address.port}`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1,
maxTokens: 1,
});
const context: Context = {
messages: [{ role: "user", content: "trigger TLS reset", timestamp: Date.now() }],
};
try {
const stream = streamCursor(model, context, { apiKey: "test-token" });
const eventTypes: string[] = [];
for await (const event of stream) eventTypes.push(event.type);
const result = await stream.result();
process.stdout.write(`${JSON.stringify({ eventTypes, stopReason: result.stopReason })}\n`);
} finally {
server.close();
}