Files
OmniRoute/tests/unit/logstream-timer-leak-13113.test.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

99 lines
2.9 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { createLogStream } from "../../src/lib/cli-helper/log-streamer.ts";
function armedTimers(): number {
return process.getActiveResourcesInfo().filter((r) => r === "Timeout").length;
}
async function startServer(): Promise<{ port: number; close: () => Promise<void> }> {
const open: http.ServerResponse[] = [];
const server = http.createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.write("log line\n");
// Deliberately left open: stop() must land while the stream is still live.
open.push(res);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return {
port,
close: async () => {
for (const res of open) res.end();
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
test("stop() clears the stream timeout timer", async () => {
const server = await startServer();
try {
const before = armedTimers();
const streams = Array.from({ length: 8 }, () =>
createLogStream({
baseUrl: `http://127.0.0.1:${server.port}`,
follow: true,
// Long enough that a leaked timer is still armed when we measure.
timeout: 120_000,
})
);
// Begin consuming so start() runs and the fetch is in flight.
for (const s of streams) {
void s.stream
.getReader()
.read()
.catch(() => {});
}
await new Promise((r) => setTimeout(r, 300));
for (const s of streams) s.stop();
await new Promise((r) => setTimeout(r, 500));
const after = armedTimers();
assert.ok(
after <= before,
`stopping 8 streams retained ${after - before} armed timer(s) ` +
`(before=${before} after=${after}); stop() must clear the timeout`
);
} finally {
await server.close();
}
});
test("a stream that ends normally still clears its timer", async () => {
const finished = http.createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("done\n");
});
await new Promise<void>((resolve) => finished.listen(0, "127.0.0.1", resolve));
const { port } = finished.address() as AddressInfo;
try {
const before = armedTimers();
const { stream } = createLogStream({
baseUrl: `http://127.0.0.1:${port}`,
follow: false,
timeout: 120_000,
});
const reader = stream.getReader();
while (true) {
const { done } = await reader.read();
if (done) break;
}
await new Promise((r) => setTimeout(r, 200));
assert.ok(
armedTimers() <= before,
"a normally-completed stream must not leave its timeout armed"
);
} finally {
await new Promise<void>((resolve) => finished.close(() => resolve()));
}
});