From e3684f36f9e0e011c93455f77c6ac2226b09413e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 22:46:41 -0400 Subject: [PATCH] chore: delete unused util/abort module + orphaned leak test (#27230) --- packages/opencode/src/util/abort.ts | 35 ----- .../test/memory/abort-leak-webfetch.ts | 49 ------- .../opencode/test/memory/abort-leak.test.ts | 127 ------------------ 3 files changed, 211 deletions(-) delete mode 100644 packages/opencode/src/util/abort.ts delete mode 100644 packages/opencode/test/memory/abort-leak-webfetch.ts delete mode 100644 packages/opencode/test/memory/abort-leak.test.ts diff --git a/packages/opencode/src/util/abort.ts b/packages/opencode/src/util/abort.ts deleted file mode 100644 index 3e7cfd8b28..0000000000 --- a/packages/opencode/src/util/abort.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Creates an AbortController that automatically aborts after a timeout. - * - * Uses bind() instead of arrow functions to avoid capturing the surrounding - * scope in closures. Arrow functions like `() => controller.abort()` capture - * request bodies and other large objects, preventing GC for the timer lifetime. - * - * @param ms Timeout in milliseconds - * @returns Object with controller, signal, and clearTimeout function - */ -export function abortAfter(ms: number) { - const controller = new AbortController() - const id = setTimeout(controller.abort.bind(controller), ms) - return { - controller, - signal: controller.signal, - clearTimeout: () => globalThis.clearTimeout(id), - } -} - -/** - * Combines multiple AbortSignals with a timeout. - * - * @param ms Timeout in milliseconds - * @param signals Additional signals to combine - * @returns Combined signal that aborts on timeout or when any input signal aborts - */ -export function abortAfterAny(ms: number, ...signals: AbortSignal[]) { - const timeout = abortAfter(ms) - const signal = AbortSignal.any([timeout.signal, ...signals]) - return { - signal, - clearTimeout: timeout.clearTimeout, - } -} diff --git a/packages/opencode/test/memory/abort-leak-webfetch.ts b/packages/opencode/test/memory/abort-leak-webfetch.ts deleted file mode 100644 index c3197f8dd5..0000000000 --- a/packages/opencode/test/memory/abort-leak-webfetch.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { abortAfterAny } from "../../src/util/abort" - -const MB = 1024 * 1024 -const ITERATIONS = 50 - -const heap = () => { - Bun.gc(true) - return process.memoryUsage().heapUsed / MB -} - -const server = Bun.serve({ - port: 0, - fetch() { - return new Response("hello from local", { - headers: { - "content-type": "text/plain", - }, - }) - }, -}) - -const url = `http://127.0.0.1:${server.port}` - -async function run() { - const { signal, clearTimeout } = abortAfterAny(30000, new AbortController().signal) - try { - const response = await fetch(url, { signal }) - await response.text() - } finally { - clearTimeout() - } -} - -try { - await run() - Bun.sleepSync(100) - const baseline = heap() - - for (let i = 0; i < ITERATIONS; i++) { - await run() - } - - Bun.sleepSync(100) - const after = heap() - process.stdout.write(JSON.stringify({ baseline, after, growth: after - baseline })) -} finally { - void server.stop(true) - process.exit(0) -} diff --git a/packages/opencode/test/memory/abort-leak.test.ts b/packages/opencode/test/memory/abort-leak.test.ts deleted file mode 100644 index d30ad45e46..0000000000 --- a/packages/opencode/test/memory/abort-leak.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, test, expect } from "bun:test" -import path from "path" - -const projectRoot = path.join(import.meta.dir, "../..") -const worker = path.join(import.meta.dir, "abort-leak-webfetch.ts") - -const MB = 1024 * 1024 -const ITERATIONS = 50 - -const getHeapMB = () => { - Bun.gc(true) - return process.memoryUsage().heapUsed / MB -} - -describe("memory: abort controller leak", () => { - test("webfetch does not leak memory over many invocations", async () => { - // Measure the abort-timed fetch path in a fresh process so shared tool - // runtime state does not dominate the heap signal. - const proc = Bun.spawn({ - cmd: [process.execPath, worker], - cwd: projectRoot, - stdout: "pipe", - stderr: "pipe", - env: process.env, - }) - - const [code, stdout, stderr] = await Promise.all([ - proc.exited, - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]) - - if (code !== 0) { - throw new Error(stderr.trim() || stdout.trim() || `worker exited with code ${code}`) - } - - const result = JSON.parse(stdout.trim()) as { - baseline: number - after: number - growth: number - } - - console.log(`Baseline: ${result.baseline.toFixed(2)} MB`) - console.log(`After ${ITERATIONS} fetches: ${result.after.toFixed(2)} MB`) - console.log(`Growth: ${result.growth.toFixed(2)} MB`) - - // Memory growth should be minimal - less than 1MB per 10 requests. - expect(result.growth).toBeLessThan(ITERATIONS / 10) - }, 60000) - - test("compare closure vs bind pattern directly", async () => { - const ITERATIONS = 500 - - // Test OLD pattern: arrow function closure - // Store closures in a map keyed by content to force retention - const closureMap = new Map void>() - const timers: Timer[] = [] - const controllers: AbortController[] = [] - - Bun.gc(true) - Bun.sleepSync(100) - const baseline = getHeapMB() - - for (let i = 0; i < ITERATIONS; i++) { - // Simulate large response body like webfetch would have - const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration - const controller = new AbortController() - controllers.push(controller) - - // OLD pattern - closure captures `content` - const handler = () => { - // Actually use content so it can't be optimized away - if (content.length > 1000000000) controller.abort() - } - closureMap.set(content, handler) - const timeoutId = setTimeout(handler, 30000) - timers.push(timeoutId) - } - - Bun.gc(true) - Bun.sleepSync(100) - const after = getHeapMB() - const oldGrowth = after - baseline - - console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`) - - // Cleanup after measuring - timers.forEach(clearTimeout) - controllers.forEach((c) => c.abort()) - closureMap.clear() - - // Test NEW pattern: bind - Bun.gc(true) - Bun.sleepSync(100) - const baseline2 = getHeapMB() - const handlers2: (() => void)[] = [] - const timers2: Timer[] = [] - const controllers2: AbortController[] = [] - - for (let i = 0; i < ITERATIONS; i++) { - const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured - const controller = new AbortController() - controllers2.push(controller) - - // NEW pattern - bind doesn't capture surrounding scope - const handler = controller.abort.bind(controller) - handlers2.push(handler) - const timeoutId = setTimeout(handler, 30000) - timers2.push(timeoutId) - } - - Bun.gc(true) - Bun.sleepSync(100) - const after2 = getHeapMB() - const newGrowth = after2 - baseline2 - - // Cleanup after measuring - timers2.forEach(clearTimeout) - controllers2.forEach((c) => c.abort()) - handlers2.length = 0 - - console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`) - console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`) - - expect(newGrowth).toBeLessThanOrEqual(oldGrowth) - }) -})