Skip to content

Commit 52f17c1

Browse files
Merging 7137b92 into trunk-temp/pr-4052/c44d4ee0-eaf4-42bd-a075-07016eb28a74
2 parents 436326e + 7137b92 commit 52f17c1

3 files changed

Lines changed: 179 additions & 2 deletions

File tree

packages/core/src/cloud-task/cloud-task-engine.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ const SSE_RECONNECT_BASE_DELAY_MS = 500;
3131
const SSE_RECONNECT_FLAT_ATTEMPTS = 3;
3232
const SSE_RECONNECT_MAX_DELAY_MS = 30_000;
3333
const SSE_HEALTHY_CONNECTION_MS = 60_000;
34+
// The backend emits a keepalive at least every ~25-30s (see SSE_KEEPALIVE_INTERVAL_MS in
35+
// packages/agent). A half-open socket (laptop sleep, unplugged NIC, NAT rebind) neither errors
36+
// nor EOFs, so `reader.read()` awaits forever with nothing to trigger reconnect. This timeout
37+
// treats "no bytes at all for a few keepalive intervals" as a disconnect so it flows into the
38+
// existing reconnect/backoff machinery instead of hanging the watcher indefinitely.
39+
const SSE_IDLE_TIMEOUT_MS = 90_000;
3440
const EVENT_BATCH_FLUSH_MS = 16;
3541
const EVENT_BATCH_MAX_SIZE = 50;
3642
const SESSION_LOG_PAGE_LIMIT = 5_000;
@@ -1344,6 +1350,24 @@ export class CloudTaskEngine extends TypedEventEmitter<CloudTaskEvents> {
13441350
let streamWasEstablished = false;
13451351
let bytesReceived = 0;
13461352
let eventsReceived = 0;
1353+
let idleTimedOut = false;
1354+
let idleTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
1355+
1356+
const clearIdleTimeout = () => {
1357+
if (idleTimeoutHandle) {
1358+
clearTimeout(idleTimeoutHandle);
1359+
idleTimeoutHandle = null;
1360+
}
1361+
};
1362+
// Re-armed on every read that returns a value (data or keepalive bytes), so it only fires
1363+
// when the transport has gone completely silent, not merely between infrequent events.
1364+
const armIdleTimeout = () => {
1365+
clearIdleTimeout();
1366+
idleTimeoutHandle = setTimeout(() => {
1367+
idleTimedOut = true;
1368+
controller.abort();
1369+
}, SSE_IDLE_TIMEOUT_MS);
1370+
};
13471371

13481372
try {
13491373
// The proxy authenticates with the run-scoped Bearer token; the Django leg uses the session.
@@ -1401,13 +1425,16 @@ export class CloudTaskEngine extends TypedEventEmitter<CloudTaskEvents> {
14011425
});
14021426

14031427
const reader = response.body.getReader();
1428+
armIdleTimeout();
14041429

14051430
while (true) {
14061431
const { done, value } = await reader.read();
14071432
if (done) {
14081433
break;
14091434
}
14101435

1436+
armIdleTimeout();
1437+
14111438
if (!value) {
14121439
continue;
14131440
}
@@ -1463,10 +1490,38 @@ export class CloudTaskEngine extends TypedEventEmitter<CloudTaskEvents> {
14631490
} catch (error) {
14641491
this.flushLogBatch(key);
14651492

1466-
if (controller.signal.aborted) {
1493+
// An idle-timeout abort must fall through to the reconnect machinery below rather than
1494+
// return here like a deliberate cancel (disconnectSse/stopWatching), since nothing else
1495+
// will ever notice this connection went silent.
1496+
if (controller.signal.aborted && !idleTimedOut) {
14671497
return;
14681498
}
14691499

1500+
if (idleTimedOut) {
1501+
const idleWatcher = this.watchers.get(key);
1502+
this.log.warn("Cloud task stream idle timeout, no bytes received", {
1503+
key,
1504+
leg,
1505+
streamUrl: url.toString(),
1506+
idleTimeoutMs: SSE_IDLE_TIMEOUT_MS,
1507+
bytesReceived,
1508+
eventsReceived,
1509+
connectionDurationMs: streamWasEstablished
1510+
? Date.now() - connectedAt
1511+
: 0,
1512+
});
1513+
if (idleWatcher) {
1514+
this.analytics.track(ANALYTICS_EVENTS.CLOUD_STREAM_IDLE_TIMEOUT, {
1515+
task_id: idleWatcher.taskId,
1516+
run_id: idleWatcher.runId,
1517+
team_id: idleWatcher.teamId,
1518+
idle_timeout_ms: SSE_IDLE_TIMEOUT_MS,
1519+
bytes_received: bytesReceived,
1520+
events_received: eventsReceived,
1521+
});
1522+
}
1523+
}
1524+
14701525
// Proxy-leg 401: the read token expired or its signing key rotated. Re-resolve to mint a
14711526
// fresh token (or route back to Django) instead of failing. Django-leg 401 stays fatal below.
14721527
const unauthorizedWatcher = this.watchers.get(key);
@@ -1548,6 +1603,7 @@ export class CloudTaskEngine extends TypedEventEmitter<CloudTaskEvents> {
15481603
countReconnectAttempt: !isBackendError && !wasHealthyStream,
15491604
});
15501605
} finally {
1606+
clearIdleTimeout();
15511607
const currentWatcher = this.watchers.get(key);
15521608
if (currentWatcher?.sseAbortController === controller) {
15531609
currentWatcher.sseAbortController = null;

packages/core/src/cloud-task/cloud-task.test.ts

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ async function waitFor(
9494

9595
describe("CloudTaskEngine", () => {
9696
let service: CloudTaskEngine;
97+
let analyticsMock: { track: ReturnType<typeof vi.fn> };
9798

9899
beforeEach(() => {
99100
const scopedLog = {
@@ -103,7 +104,7 @@ describe("CloudTaskEngine", () => {
103104
error: vi.fn(),
104105
};
105106
const loggerMock = { ...scopedLog, scope: vi.fn(() => scopedLog) };
106-
const analyticsMock = { track: vi.fn() };
107+
analyticsMock = { track: vi.fn() };
107108
service = createCloudTaskEngine({
108109
auth: mockAuthService as never,
109110
analytics: analyticsMock as never,
@@ -2404,6 +2405,115 @@ describe("CloudTaskEngine", () => {
24042405
).toBe(false);
24052406
});
24062407

2408+
it("aborts and reconnects a stream that goes silent with no bytes or keepalives", async () => {
2409+
vi.useFakeTimers();
2410+
2411+
const updates: unknown[] = [];
2412+
service.on(CloudTaskEvent.Update, (payload) => updates.push(payload));
2413+
2414+
const makeInProgressRun = () =>
2415+
createJsonResponse({
2416+
id: "run-1",
2417+
status: "in_progress",
2418+
stage: null,
2419+
output: null,
2420+
error_message: null,
2421+
branch: "main",
2422+
updated_at: "2026-01-01T00:00:00Z",
2423+
});
2424+
2425+
mockNetFetch
2426+
.mockResolvedValueOnce(makeInProgressRun())
2427+
.mockResolvedValueOnce(
2428+
createJsonResponse([], 200, { "X-Has-More": "false" }),
2429+
)
2430+
.mockImplementation(() => Promise.resolve(makeInProgressRun()));
2431+
2432+
// First connection hangs forever: no bytes, no error, no EOF, simulating a half-open
2433+
// socket (laptop sleep, NAT rebind). The second connection stays open and delivers a
2434+
// keepalive so recovery is observable once the idle watchdog aborts the first.
2435+
let streamCall = 0;
2436+
const encoder = new TextEncoder();
2437+
const abortedFirstConnection = { value: false };
2438+
mockStreamFetch.mockImplementation(
2439+
(_input: unknown, init?: RequestInit) => {
2440+
streamCall += 1;
2441+
if (streamCall === 1) {
2442+
const stream = new ReadableStream<Uint8Array>({
2443+
start(controller) {
2444+
// Never enqueue or close on our own; the read() promise awaits forever until the
2445+
// idle watchdog aborts it below, mirroring how a real fetch's reader rejects once
2446+
// its AbortSignal fires.
2447+
init?.signal?.addEventListener("abort", () => {
2448+
abortedFirstConnection.value = true;
2449+
controller.error(new DOMException("Aborted", "AbortError"));
2450+
});
2451+
},
2452+
});
2453+
return Promise.resolve(
2454+
new Response(stream, {
2455+
status: 200,
2456+
headers: { "Content-Type": "text/event-stream" },
2457+
}),
2458+
);
2459+
}
2460+
const stream = new ReadableStream<Uint8Array>({
2461+
start(controller) {
2462+
controller.enqueue(
2463+
encoder.encode(
2464+
'event: keepalive\ndata: {"type":"keepalive"}\n\n',
2465+
),
2466+
);
2467+
},
2468+
});
2469+
return Promise.resolve(
2470+
new Response(stream, {
2471+
status: 200,
2472+
headers: { "Content-Type": "text/event-stream" },
2473+
}),
2474+
);
2475+
},
2476+
);
2477+
2478+
service.watch({
2479+
taskId: "task-1",
2480+
runId: "run-1",
2481+
apiHost: "https://app.example.com",
2482+
teamId: 2,
2483+
});
2484+
2485+
await waitFor(() => mockStreamFetch.mock.calls.length === 1);
2486+
2487+
// Nothing throws or EOFs; without the idle watchdog this would hang forever.
2488+
await vi.advanceTimersByTimeAsync(60_000);
2489+
expect(abortedFirstConnection.value).toBe(false);
2490+
2491+
await vi.advanceTimersByTimeAsync(40_000);
2492+
await waitFor(() => abortedFirstConnection.value, 20_000);
2493+
await waitFor(() => mockStreamFetch.mock.calls.length >= 2, 20_000);
2494+
2495+
expect(
2496+
analyticsMock.track.mock.calls.some(
2497+
([eventName]) => eventName === "Cloud stream idle timeout",
2498+
),
2499+
).toBe(true);
2500+
2501+
const watcher = (
2502+
service as unknown as {
2503+
watchers: Map<string, { failed: boolean }>;
2504+
}
2505+
).watchers.get("task-1:run-1");
2506+
expect(watcher?.failed).toBe(false);
2507+
expect(
2508+
updates.some(
2509+
(u) =>
2510+
typeof u === "object" &&
2511+
u !== null &&
2512+
(u as { kind?: string }).kind === "error",
2513+
),
2514+
).toBe(false);
2515+
});
2516+
24072517
it("stops a cloud run through the run cancel endpoint", async () => {
24082518
mockNetFetch.mockResolvedValueOnce(
24092519
createJsonResponse({ id: "run-1", status: "in_progress" }, 202),

packages/shared/src/analytics-events.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,15 @@ export interface CloudStreamDisconnectedProperties {
327327
was_bootstrapping: boolean;
328328
}
329329

330+
export interface CloudStreamIdleTimeoutProperties {
331+
task_id: string;
332+
run_id: string;
333+
team_id: number;
334+
idle_timeout_ms: number;
335+
bytes_received: number;
336+
events_received: number;
337+
}
338+
330339
// Permission events
331340
export interface PermissionRespondedProperties {
332341
task_id: string;
@@ -1378,6 +1387,7 @@ export const ANALYTICS_EVENTS = {
13781387
TASK_CREATION_FAILED: "Task creation failed",
13791388
AGENT_SESSION_ERROR: "Agent session error",
13801389
CLOUD_STREAM_DISCONNECTED: "Cloud stream disconnected",
1390+
CLOUD_STREAM_IDLE_TIMEOUT: "Cloud stream idle timeout",
13811391

13821392
// Inbox events
13831393
INBOX_VIEWED: "Inbox viewed",
@@ -1556,6 +1566,7 @@ export type EventPropertyMap = {
15561566
[ANALYTICS_EVENTS.TASK_CREATION_FAILED]: TaskCreationFailedProperties;
15571567
[ANALYTICS_EVENTS.AGENT_SESSION_ERROR]: AgentSessionErrorProperties;
15581568
[ANALYTICS_EVENTS.CLOUD_STREAM_DISCONNECTED]: CloudStreamDisconnectedProperties;
1569+
[ANALYTICS_EVENTS.CLOUD_STREAM_IDLE_TIMEOUT]: CloudStreamIdleTimeoutProperties;
15591570

15601571
// Inbox events
15611572
[ANALYTICS_EVENTS.INBOX_VIEWED]: InboxViewedProperties;

0 commit comments

Comments
 (0)