abort-leak.test.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. import { describe, test, expect } from "bun:test"
  2. import path from "path"
  3. import { Instance } from "../../src/project/instance"
  4. import { WebFetchTool } from "../../src/tool/webfetch"
  5. const projectRoot = path.join(__dirname, "../..")
  6. const ctx = {
  7. sessionID: "test",
  8. messageID: "",
  9. callID: "",
  10. agent: "build",
  11. abort: new AbortController().signal,
  12. messages: [],
  13. metadata: () => {},
  14. ask: async () => {},
  15. }
  16. const MB = 1024 * 1024
  17. const ITERATIONS = 50
  18. const getHeapMB = () => {
  19. Bun.gc(true)
  20. return process.memoryUsage().heapUsed / MB
  21. }
  22. describe("memory: abort controller leak", () => {
  23. test("webfetch does not leak memory over many invocations", async () => {
  24. await Instance.provide({
  25. directory: projectRoot,
  26. fn: async () => {
  27. const tool = await WebFetchTool.init()
  28. // Warm up
  29. await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
  30. Bun.gc(true)
  31. const baseline = getHeapMB()
  32. // Run many fetches
  33. for (let i = 0; i < ITERATIONS; i++) {
  34. await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
  35. }
  36. Bun.gc(true)
  37. const after = getHeapMB()
  38. const growth = after - baseline
  39. console.log(`Baseline: ${baseline.toFixed(2)} MB`)
  40. console.log(`After ${ITERATIONS} fetches: ${after.toFixed(2)} MB`)
  41. console.log(`Growth: ${growth.toFixed(2)} MB`)
  42. // Memory growth should be minimal - less than 1MB per 10 requests
  43. // With the old closure pattern, this would grow ~0.5MB per request
  44. expect(growth).toBeLessThan(ITERATIONS / 10)
  45. },
  46. })
  47. }, 60000)
  48. test("compare closure vs bind pattern directly", async () => {
  49. const ITERATIONS = 500
  50. // Test OLD pattern: arrow function closure
  51. // Store closures in a map keyed by content to force retention
  52. const closureMap = new Map<string, () => void>()
  53. const timers: Timer[] = []
  54. const controllers: AbortController[] = []
  55. Bun.gc(true)
  56. Bun.sleepSync(100)
  57. const baseline = getHeapMB()
  58. for (let i = 0; i < ITERATIONS; i++) {
  59. // Simulate large response body like webfetch would have
  60. const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration
  61. const controller = new AbortController()
  62. controllers.push(controller)
  63. // OLD pattern - closure captures `content`
  64. const handler = () => {
  65. // Actually use content so it can't be optimized away
  66. if (content.length > 1000000000) controller.abort()
  67. }
  68. closureMap.set(content, handler)
  69. const timeoutId = setTimeout(handler, 30000)
  70. timers.push(timeoutId)
  71. }
  72. Bun.gc(true)
  73. Bun.sleepSync(100)
  74. const after = getHeapMB()
  75. const oldGrowth = after - baseline
  76. console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`)
  77. // Cleanup after measuring
  78. timers.forEach(clearTimeout)
  79. controllers.forEach((c) => c.abort())
  80. closureMap.clear()
  81. // Test NEW pattern: bind
  82. Bun.gc(true)
  83. Bun.sleepSync(100)
  84. const baseline2 = getHeapMB()
  85. const handlers2: (() => void)[] = []
  86. const timers2: Timer[] = []
  87. const controllers2: AbortController[] = []
  88. for (let i = 0; i < ITERATIONS; i++) {
  89. const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured
  90. const controller = new AbortController()
  91. controllers2.push(controller)
  92. // NEW pattern - bind doesn't capture surrounding scope
  93. const handler = controller.abort.bind(controller)
  94. handlers2.push(handler)
  95. const timeoutId = setTimeout(handler, 30000)
  96. timers2.push(timeoutId)
  97. }
  98. Bun.gc(true)
  99. Bun.sleepSync(100)
  100. const after2 = getHeapMB()
  101. const newGrowth = after2 - baseline2
  102. // Cleanup after measuring
  103. timers2.forEach(clearTimeout)
  104. controllers2.forEach((c) => c.abort())
  105. handlers2.length = 0
  106. console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`)
  107. console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`)
  108. expect(newGrowth).toBeLessThanOrEqual(oldGrowth)
  109. })
  110. })