@botbuddy/cli 1.13.0 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/wait.mjs +70 -7
package/package.json
CHANGED
package/src/wait.mjs
CHANGED
|
@@ -399,6 +399,14 @@ function makeConnect(opts) {
|
|
|
399
399
|
// the agent (and its locks) aren't reaped during a long wait.
|
|
400
400
|
if (opts.heartbeat) url.searchParams.set("heartbeat", "1");
|
|
401
401
|
|
|
402
|
+
// BOT-1565: abort the fetch itself on idle. A silently half-open SSE body
|
|
403
|
+
// already has an `iterator.next()` pending, and `iterator.return()` queues
|
|
404
|
+
// BEHIND that read — it cannot cancel it until bytes/EOF eventually arrive,
|
|
405
|
+
// so it would leave the response locked and the socket open while we
|
|
406
|
+
// reconnect, leaking a connection per stall (Codex P2). Aborting the fetch's
|
|
407
|
+
// signal tears the stalled socket down immediately, then the read rejects and
|
|
408
|
+
// the stream ends so runWaitLoop reconnects.
|
|
409
|
+
const ac = new AbortController();
|
|
402
410
|
const res = await fetch(url, {
|
|
403
411
|
headers: {
|
|
404
412
|
Authorization: `Bearer ${opts.token}`,
|
|
@@ -407,12 +415,13 @@ function makeConnect(opts) {
|
|
|
407
415
|
// BOT-741: never let a proxy gzip-buffer an SSE stream.
|
|
408
416
|
"Accept-Encoding": "identity",
|
|
409
417
|
},
|
|
418
|
+
signal: ac.signal,
|
|
410
419
|
});
|
|
411
420
|
if (res.status === 401) return errorStream("unauthorized");
|
|
412
421
|
if (res.status === 403) return errorStream("forbidden");
|
|
413
422
|
if (!res.ok || !res.body) throw new Error(`relay responded ${res.status}`);
|
|
414
423
|
|
|
415
|
-
return sseFrameStream(res.body);
|
|
424
|
+
return sseFrameStream(res.body, { onIdle: () => ac.abort() });
|
|
416
425
|
};
|
|
417
426
|
}
|
|
418
427
|
|
|
@@ -468,14 +477,68 @@ function makeFeedLagProbe(opts) {
|
|
|
468
477
|
};
|
|
469
478
|
}
|
|
470
479
|
|
|
471
|
-
|
|
480
|
+
// BOT-1565: a silently half-open SSE socket (a network blip, or a gateway/edge
|
|
481
|
+
// connection-lifetime ceiling ~45–51 min) delivers no bytes AND no end-of-stream,
|
|
482
|
+
// so a bare `for await` here would block forever and never reconnect. Meanwhile
|
|
483
|
+
// the relay's last_seen_at keepalive stops bumping and the wait reaper abandons
|
|
484
|
+
// the still-parked wait after its grace → /waits goes blank (BOT-1565). Guard
|
|
485
|
+
// every read with an IDLE watchdog: the relay sends a keepalive comment every
|
|
486
|
+
// ~30s (EVENT_STREAM_KEEPALIVE_MS), and every chunk (comment or frame) resets
|
|
487
|
+
// the timer, so a healthy-but-quiet connection never trips it. If NOTHING
|
|
488
|
+
// arrives for idleMs the connection is presumed dead: end the stream so
|
|
489
|
+
// runWaitLoop reconnects from the cursor, which re-bumps wait_sessions.last_seen_at
|
|
490
|
+
// (and, with --heartbeat, agents.last_heartbeat) before the reaper's grace.
|
|
491
|
+
// 70s ≈ 2.3 missed keepalives — long enough to never false-trip on jitter, short
|
|
492
|
+
// enough that the reconnect lands inside the 90s last_seen reap window.
|
|
493
|
+
export const DEFAULT_SSE_IDLE_TIMEOUT_MS = 70_000;
|
|
494
|
+
|
|
495
|
+
function sseIdleTimeoutMs() {
|
|
496
|
+
const raw = Number(process.env.BOTBUDDY_WAIT_IDLE_TIMEOUT_MS);
|
|
497
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SSE_IDLE_TIMEOUT_MS;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const SSE_IDLE = Symbol("sse_idle");
|
|
501
|
+
|
|
502
|
+
export async function* sseFrameStream(body, { idleMs = sseIdleTimeoutMs(), onIdle = null } = {}) {
|
|
472
503
|
const decoder = new TextDecoder();
|
|
473
504
|
let buf = "";
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
505
|
+
const iterator = body[Symbol.asyncIterator]();
|
|
506
|
+
try {
|
|
507
|
+
while (true) {
|
|
508
|
+
const nextP = iterator.next();
|
|
509
|
+
let timer;
|
|
510
|
+
const idle = new Promise((resolve) => { timer = setTimeout(() => resolve(SSE_IDLE), idleMs); });
|
|
511
|
+
let result;
|
|
512
|
+
try {
|
|
513
|
+
result = await Promise.race([nextP, idle]);
|
|
514
|
+
} finally {
|
|
515
|
+
clearTimeout(timer);
|
|
516
|
+
}
|
|
517
|
+
if (result === SSE_IDLE) {
|
|
518
|
+
// Presumed dead. onIdle() aborts the underlying fetch (makeConnect wires
|
|
519
|
+
// it to the request's AbortController) — the ONLY thing that actually
|
|
520
|
+
// tears a stalled socket down, since iterator.return() would queue behind
|
|
521
|
+
// the pending read and never fire until bytes/EOF arrive (Codex P2). The
|
|
522
|
+
// abort settles the pending read (rejects with AbortError); swallow it so
|
|
523
|
+
// it isn't an unhandled rejection, then end the stream to force a reconnect.
|
|
524
|
+
nextP.then(() => {}, () => {});
|
|
525
|
+
if (onIdle) onIdle();
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const { value: chunk, done } = result;
|
|
529
|
+
if (done) return;
|
|
530
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
531
|
+
const { frames, rest } = parseSseFrames(buf);
|
|
532
|
+
buf = rest;
|
|
533
|
+
for (const f of frames) yield f;
|
|
534
|
+
}
|
|
535
|
+
} finally {
|
|
536
|
+
// Belt-and-braces release for the non-idle early-stop path (the consumer
|
|
537
|
+
// stopped iterating, e.g. runWaitLoop matched). On the idle path the fetch
|
|
538
|
+
// has already been aborted above, so the body is torn down regardless; this
|
|
539
|
+
// return() then resolves promptly instead of queuing behind a live read.
|
|
540
|
+
// Fire-and-forget + swallow: never let teardown block the generator's exit.
|
|
541
|
+
Promise.resolve(iterator.return?.()).then(() => {}, () => {});
|
|
479
542
|
}
|
|
480
543
|
}
|
|
481
544
|
|