Files
OmniRoute/src/lib/cli-helper/log-streamer.ts
anhtahaylove d61b1727cd fix(cli-helper): clear the log stream timeout on the abort path (#13114)
`stop()` is the normal lifecycle for a `follow: true` stream, not an edge case, so the `signal.aborted` early return skipping `clearTimeout` leaked one armed timer per stop. Cancelling the reader on the early loop exit closes the second half.


I reformatted the changelog fragment to the `changelog.d` convention (`- **fix(scope):** …`) before merging — `check:changelog-integrity` rejects a fragment that does not start with a markdown bullet, which is the same gate your #13158 was about. Wording is yours, unchanged in substance.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:30:38 -03:00

86 lines
2.4 KiB
TypeScript

export interface LogStreamOptions {
baseUrl?: string;
filters?: string[];
follow?: boolean;
timeout?: number;
headers?: HeadersInit;
}
export interface LogStream {
stream: ReadableStream<Uint8Array>;
stop: () => void;
}
export function createLogStream(options: LogStreamOptions = {}): LogStream {
const baseUrl = options.baseUrl || "http://localhost:20128";
const filters = options.filters || [];
const follow = options.follow ?? false;
const timeout = options.timeout || 30000;
const headers = options.headers;
const controller = new AbortController();
const { signal } = controller;
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
let url = `${baseUrl}/api/cli-tools/logs?follow=${follow}`;
if (filters.length > 0) {
url += `&filter=${encodeURIComponent(filters.join(","))}`;
}
const timeoutId = setTimeout(() => {
if (follow) return; // Don't timeout follow mode
controller.error(new Error(`Log stream timed out after ${timeout}ms`));
}, timeout);
try {
const response = await fetch(url, { signal, headers });
if (!response.ok) {
controller.error(new Error(`HTTP ${response.status}: ${response.statusText}`));
return;
}
if (!response.body) {
controller.error(new Error("Response body is null"));
return;
}
const reader = response.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (signal.aborted) break;
controller.enqueue(value);
}
} finally {
// Leaving the loop early (abort/throw) otherwise keeps the body locked
// and its socket held until GC.
await reader.cancel().catch(() => {});
}
controller.close();
} catch (err) {
if (signal.aborted) return; // Expected stop
controller.error(err instanceof Error ? err : new Error(String(err)));
} finally {
// `stop()` aborts mid-fetch and returns through the `signal.aborted`
// branch above, so clearing the timer on the individual exit paths
// misses the one path stop() is built to take.
clearTimeout(timeoutId);
}
},
cancel() {
controller.abort();
},
});
return {
stream,
stop: () => controller.abort(),
};
}