chore: delete unused util/abort module + orphaned leak test (#27230)
This commit is contained in:
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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<string, () => 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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user