mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-15 19:32:20 +03:00
`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.
86 lines
2.4 KiB
TypeScript
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(),
|
|
};
|
|
}
|