test(memory): remove broad memory regression suite

This commit is contained in:
Alex Alecu
2026-02-26 13:33:36 +02:00
parent cd3bee9e05
commit 97fccc0abb
6 changed files with 0 additions and 994 deletions
@@ -1,180 +0,0 @@
import { describe, test, expect } from "bun:test"
import { Instance } from "../../src/project/instance"
import { State } from "../../src/project/state"
import { tmpdir } from "../fixture/fixture"
describe("memory: disposal lifecycle", () => {
test(
"Instance.dispose calls State.dispose",
async () => {
await using tmp = await tmpdir({ git: true })
let disposeCalled = false
await Instance.provide({
directory: tmp.path,
fn: async () => {
const getState = Instance.state(
() => ({ value: "test" }),
async () => {
disposeCalled = true
},
)
// Materialize the state
getState()
await Instance.dispose()
},
})
expect(disposeCalled).toBe(true)
},
30_000,
)
test(
"disposeAll disposes multiple instances",
async () => {
await using tmp1 = await tmpdir({ git: true })
await using tmp2 = await tmpdir({ git: true })
let dispose1Called = false
let dispose2Called = false
await Instance.provide({
directory: tmp1.path,
fn: async () => {
const getState = Instance.state(
() => ({ value: "inst1" }),
async () => {
dispose1Called = true
},
)
getState()
},
})
await Instance.provide({
directory: tmp2.path,
fn: async () => {
const getState = Instance.state(
() => ({ value: "inst2" }),
async () => {
dispose2Called = true
},
)
getState()
},
})
await Instance.disposeAll()
expect(dispose1Called).toBe(true)
expect(dispose2Called).toBe(true)
},
30_000,
)
test(
"disposeAll is idempotent",
async () => {
await using tmp = await tmpdir({ git: true })
let disposeCount = 0
await Instance.provide({
directory: tmp.path,
fn: async () => {
const getState = Instance.state(
() => ({ value: "idem" }),
async () => {
disposeCount++
},
)
getState()
},
})
await Instance.disposeAll()
await Instance.disposeAll()
// Dispose should only be called once — second disposeAll is a no-op
// because the cache is already cleared
expect(disposeCount).toBe(1)
},
30_000,
)
test(
"State.dispose removes entries from recordsByKey",
async () => {
await using tmp = await tmpdir({ git: true })
let firstValue: any
let secondValue: any
await Instance.provide({
directory: tmp.path,
fn: async () => {
const getState = Instance.state(
() => ({ createdAt: Date.now() }),
async () => {},
)
firstValue = getState()
expect(firstValue.createdAt).toBeGreaterThan(0)
await Instance.dispose()
},
})
// Re-provide and re-materialize — should create fresh state
await Instance.provide({
directory: tmp.path,
fn: async () => {
const getState = Instance.state(
() => ({ createdAt: Date.now() }),
async () => {},
)
secondValue = getState()
// Fresh state should have a different or later timestamp
// (the exact time may be the same if fast, but the object reference must differ)
expect(secondValue).not.toBe(firstValue)
await Instance.dispose()
},
})
},
30_000,
)
test(
"Slow disposal completes without error",
async () => {
await using tmp = await tmpdir({ git: true })
let disposed = false
await Instance.provide({
directory: tmp.path,
fn: async () => {
const getState = Instance.state(
() => ({ value: "slow" }),
async () => {
await Bun.sleep(200)
disposed = true
},
)
getState()
await Instance.dispose()
},
})
expect(disposed).toBe(true)
},
30_000,
)
})
@@ -1,124 +0,0 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import { Instance } from "../../src/project/instance"
import { MCP } from "../../src/mcp"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { tmpdir } from "../fixture/fixture"
import { PROJECT_ROOT, measureGrowth } from "./helper"
import z from "zod"
const FAKE_MCP_SERVER = path.join(PROJECT_ROOT, "test/fixture/mcp/fake-mcp-server.js")
describe("memory: heap growth detection", () => {
test(
"MCP add/disconnect cycle does not leak",
async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { growth } = await measureGrowth(3, async (i) => {
await MCP.add("heap-test-server", {
type: "local",
command: ["bun", FAKE_MCP_SERVER],
})
await MCP.disconnect("heap-test-server")
await Bun.sleep(200)
})
console.log(` MCP add/disconnect growth: ${growth.toFixed(2)} MB`)
expect(growth).toBeLessThan(10)
await Instance.dispose()
},
})
},
180_000,
)
test(
"Instance provide/dispose cycle does not leak",
async () => {
const { growth } = await measureGrowth(20, async (i) => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Create some state to exercise lifecycle
const getState = Instance.state(
() => ({ data: new Uint8Array(1024) }),
async () => {},
)
getState()
await Instance.dispose()
},
})
})
console.log(` Instance provide/dispose growth: ${growth.toFixed(2)} MB`)
expect(growth).toBeLessThan(10)
},
60_000,
)
test(
"Bus subscriptions cleaned on dispose",
async () => {
const TestEvent = BusEvent.define("test.heap.event", z.object({ i: z.number() }))
const { growth } = await measureGrowth(100, async (i) => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Subscribe to events (creates entries in subscriptions Map)
const unsub1 = Bus.subscribe(TestEvent, () => {})
const unsub2 = Bus.subscribe(TestEvent, () => {})
const unsub3 = Bus.subscribeAll(() => {})
// Publish some events
await Bus.publish(TestEvent, { i })
// Dispose should clear subscription state
await Instance.dispose()
},
})
})
console.log(` Bus subscription growth: ${growth.toFixed(2)} MB`)
expect(growth).toBeLessThan(10)
},
60_000,
)
test(
"State entries cleaned on dispose",
async () => {
const { growth } = await measureGrowth(100, async (i) => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Create state with a buffer to make leaks detectable
const getState = Instance.state(
() => ({ buffer: new Uint8Array(10 * 1024) }),
async () => {},
)
getState()
await Instance.dispose()
},
})
})
console.log(` State entry growth: ${growth.toFixed(2)} MB`)
expect(growth).toBeLessThan(1)
},
60_000,
)
})
-173
View File
@@ -1,173 +0,0 @@
import path from "path"
const MB = 1024 * 1024
export const PROJECT_ROOT = path.join(__dirname, "../..")
/**
* Check if a process is alive by sending signal 0.
* Returns false if the process has already exited.
*/
export function isAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === "ESRCH") return false
if (code === "EPERM") return true
throw err
}
}
/**
* Send a signal to a process, ignoring errors (e.g. process already exited).
*/
function trySendSignal(pid: number, signal: NodeJS.Signals): void {
try {
process.kill(pid, signal)
} catch (err) {
// Expected when process already exited before signal delivery
console.log(` signal ${signal} to PID ${pid} failed (likely already exited): ${err}`)
}
}
/**
* Force GC multiple times and return stable heap usage in MB.
* Multiple passes + sleeps allow GC to finalize weak refs and sweep.
*/
export async function stableHeapMB(): Promise<number> {
for (let i = 0; i < 3; i++) {
Bun.gc(true)
await Bun.sleep(50)
}
return process.memoryUsage().heapUsed / MB
}
/**
* Measure heap growth over repeated iterations of a function.
* Includes a warm-up iteration (excluded from measurement) to fill caches/JIT.
*/
export async function measureGrowth(
iterations: number,
fn: (i: number) => Promise<void>,
): Promise<{ baseline: number; after: number; growth: number }> {
// Warm-up
await fn(-1)
const baseline = await stableHeapMB()
for (let i = 0; i < iterations; i++) {
await fn(i)
}
const after = await stableHeapMB()
const growth = after - baseline
console.log(` Baseline: ${baseline.toFixed(2)} MB`)
console.log(` After ${iterations} iterations: ${after.toFixed(2)} MB`)
console.log(` Growth: ${growth.toFixed(2)} MB`)
return { baseline, after, growth }
}
/**
* Recursively find all descendant PIDs of a root PID using pgrep -P.
* Works on macOS and Linux.
*/
export async function snapshotDescendants(rootPid: number): Promise<Set<number>> {
const descendants = new Set<number>()
const queue = [rootPid]
while (queue.length > 0) {
const pid = queue.shift()!
const proc = Bun.spawn(["pgrep", "-P", String(pid)], {
stdout: "pipe",
stderr: "pipe",
})
const text = await new Response(proc.stdout).text()
await proc.exited
for (const line of text.trim().split("\n")) {
const child = parseInt(line, 10)
if (!isNaN(child) && !descendants.has(child)) {
descendants.add(child)
queue.push(child)
}
}
}
return descendants
}
/**
* Compare before/after process snapshots and assert no orphans remain.
* Logs orphan details via `ps` and force-kills them before throwing.
*/
export async function assertNoOrphans(before: Set<number>, after: Set<number>): Promise<void> {
const orphans = new Set<number>()
for (const pid of after) {
if (!before.has(pid) && isAlive(pid)) {
orphans.add(pid)
}
}
if (orphans.size === 0) return
// Log details about orphan processes
const details: string[] = []
for (const pid of orphans) {
try {
const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "pid,ppid,command"], {
stdout: "pipe",
stderr: "pipe",
})
const text = await new Response(proc.stdout).text()
await proc.exited
const lines = text.trim().split("\n")
if (lines.length > 1) {
details.push(lines[1].trim())
}
} catch {
details.push(`PID ${pid} (could not get details)`)
}
}
// Force-kill orphans to prevent cascading test failures
for (const pid of orphans) {
trySendSignal(pid, "SIGKILL")
}
throw new Error(`Found ${orphans.size} orphan process(es):\n${details.map((d) => ` ${d}`).join("\n")}`)
}
/**
* Wait for all given PIDs to exit, polling with kill -0.
* Returns true if all exited within timeout, false otherwise.
*/
export async function waitForExit(pids: number[], timeoutMs = 3000): Promise<boolean> {
const start = Date.now()
const remaining = new Set(pids)
while (remaining.size > 0 && Date.now() - start < timeoutMs) {
for (const pid of remaining) {
if (!isAlive(pid)) {
remaining.delete(pid)
}
}
if (remaining.size > 0) {
await Bun.sleep(100)
}
}
return remaining.size === 0
}
/**
* Force-kill a set of PIDs (best-effort, ignores errors).
*/
export function forceKillAll(pids: Set<number> | number[]): void {
for (const pid of pids) {
trySendSignal(pid, "SIGKILL")
}
}
@@ -1,135 +0,0 @@
import { describe, test, expect, afterEach } from "bun:test"
import path from "path"
import { Instance } from "../../src/project/instance"
import { LSPClient } from "../../src/lsp/client"
import type { LSPServer } from "../../src/lsp/server"
import { spawn } from "child_process"
import { tmpdir } from "../fixture/fixture"
import { PROJECT_ROOT, snapshotDescendants, assertNoOrphans, forceKillAll, stableHeapMB, isAlive } from "./helper"
const FAKE_LSP_SERVER = path.join(PROJECT_ROOT, "test/fixture/lsp/fake-lsp-server.js")
let beforePids: Set<number> = new Set()
function spawnLSP(cwd: string): LSPServer.Handle["process"] {
return spawn("bun", [FAKE_LSP_SERVER], {
stdio: ["pipe", "pipe", "pipe"],
cwd,
})
}
describe("memory: LSP lifecycle", () => {
afterEach(async () => {
try {
const afterPids = await snapshotDescendants(process.pid)
const orphans: number[] = []
for (const pid of afterPids) {
if (!beforePids.has(pid)) orphans.push(pid)
}
forceKillAll(orphans)
} catch {
// Best-effort cleanup
}
})
test("LSPClient.shutdown kills server process", async () => {
await using tmp = await tmpdir({ git: true })
beforePids = await snapshotDescendants(process.pid)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const serverProcess = spawnLSP(tmp.path)
const pid = serverProcess.pid!
const client = await LSPClient.create({
serverID: "test-lsp",
server: { process: serverProcess },
root: tmp.path,
})
expect(client).toBeTruthy()
// Verify process is running
expect(isAlive(pid)).toBe(true)
// Shutdown
await client!.shutdown()
await Bun.sleep(300)
// Verify process is gone
expect(isAlive(pid)).toBe(false)
await Instance.dispose()
},
})
}, 30_000)
test("Multiple create/shutdown cycles don't leak processes", async () => {
await using tmp = await tmpdir({ git: true })
beforePids = await snapshotDescendants(process.pid)
await Instance.provide({
directory: tmp.path,
fn: async () => {
for (let i = 0; i < 5; i++) {
const serverProcess = spawnLSP(tmp.path)
const client = await LSPClient.create({
serverID: `test-lsp-${i}`,
server: { process: serverProcess },
root: tmp.path,
})
await client!.shutdown()
await Bun.sleep(100)
}
await Bun.sleep(300)
const afterPids = await snapshotDescendants(process.pid)
await assertNoOrphans(beforePids, afterPids)
await Instance.dispose()
},
})
}, 60_000)
test("LSP create/shutdown doesn't leak memory", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Warm-up
const warmProc = spawnLSP(tmp.path)
const warmClient = await LSPClient.create({
serverID: "test-lsp-warm",
server: { process: warmProc },
root: tmp.path,
})
await warmClient!.shutdown()
await Bun.sleep(100)
const baseline = await stableHeapMB()
for (let i = 0; i < 10; i++) {
const serverProcess = spawnLSP(tmp.path)
const client = await LSPClient.create({
serverID: `test-lsp-mem-${i}`,
server: { process: serverProcess },
root: tmp.path,
})
await client!.shutdown()
await Bun.sleep(50)
}
const after = await stableHeapMB()
const growth = after - baseline
console.log(` LSP create/shutdown 10x growth: ${growth.toFixed(2)} MB`)
expect(growth).toBeLessThan(5)
await Instance.dispose()
},
})
}, 60_000)
})
@@ -1,161 +0,0 @@
import { describe, test, expect } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { Bus } from "../../src/bus"
import { tmpdir } from "../fixture/fixture"
import { measureGrowth, stableHeapMB } from "./helper"
describe("memory: session heap growth", () => {
test("Session create/remove cycle doesn't leak", async () => {
// Exercise the full session lifecycle: create → subscribe to events → remove
// This tests Database + Bus + State interactions without needing LLM mocks.
const { growth } = await measureGrowth(10, async (i) => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Subscribe to session events (like bootstrap does)
const unsub1 = Bus.subscribe(Session.Event.Created, () => {})
const unsub2 = Bus.subscribe(Session.Event.Updated, () => {})
const unsub3 = Bus.subscribe(Session.Event.Deleted, () => {})
// Create a session
const session = await Session.create(undefined)
// Touch the session (updates timestamp, publishes events)
await Session.touch(session.id)
// Create another session
const session2 = await Session.create({ title: `Test session ${i}` })
// List sessions
const sessions = [...Session.list()]
expect(sessions.length).toBeGreaterThanOrEqual(2)
// Remove sessions
await Session.remove(session.id)
await Session.remove(session2.id)
// Dispose instance (clears Bus state, State entries)
await Instance.dispose()
},
})
})
console.log(` Session create/remove 10x growth: ${growth.toFixed(2)} MB`)
expect(growth).toBeLessThan(10)
}, 60_000)
test("Session message update/query cycle doesn't leak", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create(undefined)
// Warm up
await Session.updateMessage({
id: "message_warm",
sessionID: session.id,
role: "user",
time: { created: Date.now() },
agent: "code",
model: { providerID: "test", modelID: "test-model" },
})
const baseline = await stableHeapMB()
// Create and query many messages
for (let i = 0; i < 50; i++) {
const msgId = `message_${String(i).padStart(6, "0")}`
await Session.updateMessage({
id: msgId,
sessionID: session.id,
role: "user",
time: { created: Date.now() },
agent: "code",
model: { providerID: "test", modelID: "test-model" },
})
await Session.updatePart({
id: `part_${String(i).padStart(6, "0")}`,
messageID: msgId,
sessionID: session.id,
type: "text",
text: `Test message content ${i} with some padding to make it larger`.repeat(10),
})
}
// Query messages
const msgs = await Session.messages({ sessionID: session.id })
expect(msgs.length).toBeGreaterThan(0)
const after = await stableHeapMB()
const growth = after - baseline
console.log(` Message update/query 50x growth: ${growth.toFixed(2)} MB`)
// DB operations should be bounded — data is in SQLite, not in JS heap
expect(growth).toBeLessThan(10)
await Session.remove(session.id)
await Instance.dispose()
},
})
}, 60_000)
test("Multiple session lifecycles with events don't accumulate", async () => {
// This is the closest to the real scenario: multiple instances, each with
// sessions, events, and cleanup — similar to what happens when Kilo CLI
// is restarted multiple times.
const { growth } = await measureGrowth(5, async (i) => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Simulate bootstrap-like subscriptions
Bus.subscribe(Session.Event.Created, () => {})
Bus.subscribe(Session.Event.Updated, () => {})
Bus.subscribe(Session.Event.Deleted, () => {})
Bus.subscribe(Session.Event.Error, () => {})
Bus.subscribe(Session.Event.TurnOpen, () => {})
Bus.subscribe(Session.Event.TurnClose, () => {})
Bus.subscribeAll(() => {})
// Create sessions with messages
for (let j = 0; j < 5; j++) {
const session = await Session.create({ title: `Iter ${i} Session ${j}` })
for (let k = 0; k < 5; k++) {
const msgId = `message_${i}_${j}_${String(k).padStart(4, "0")}`
await Session.updateMessage({
id: msgId,
sessionID: session.id,
role: "user",
time: { created: Date.now() },
agent: "code",
model: { providerID: "test", modelID: "test-model" },
})
await Session.updatePart({
id: `part_${i}_${j}_${String(k).padStart(4, "0")}`,
messageID: msgId,
sessionID: session.id,
type: "text",
text: `Content for iteration ${i}, session ${j}, message ${k}`,
})
}
await Session.remove(session.id)
}
await Instance.dispose()
},
})
})
console.log(` Multiple session lifecycles growth: ${growth.toFixed(2)} MB`)
expect(growth).toBeLessThan(10)
}, 120_000)
})
@@ -1,221 +0,0 @@
import { describe, test, expect } from "bun:test"
import { Instance } from "../../src/project/instance"
import { State } from "../../src/project/state"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { GlobalBus } from "../../src/bus/global"
import { tmpdir } from "../fixture/fixture"
import { stableHeapMB } from "./helper"
import z from "zod"
describe("memory: state and bus leaks", () => {
test(
"State.dispose fully clears recordsByKey",
async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Create state with large buffers
for (let i = 0; i < 100; i++) {
const getState = Instance.state(
() => ({ buffer: new Uint8Array(10 * 1024), index: i }),
async () => {},
)
// Each call with a different init function creates a separate entry
getState()
}
const beforeDispose = await stableHeapMB()
await Instance.dispose()
const afterDispose = await stableHeapMB()
// Memory should drop (or at least not grow) after disposing 100 x 10KB entries
console.log(
` Before dispose: ${beforeDispose.toFixed(2)} MB, After: ${afterDispose.toFixed(2)} MB`,
)
// afterDispose should be less than beforeDispose + small margin
// (GC may not reclaim everything immediately, but it shouldn't grow)
expect(afterDispose).toBeLessThan(beforeDispose + 2)
},
})
},
30_000,
)
test(
"Instance cache cleared on disposeAll",
async () => {
// Create many instances with state
const dirs: Awaited<ReturnType<typeof tmpdir>>[] = []
for (let i = 0; i < 20; i++) {
const tmp = await tmpdir({ git: true })
dirs.push(tmp)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const getState = Instance.state(
() => ({ buffer: new Uint8Array(50 * 1024) }),
async () => {},
)
getState()
},
})
}
const beforeDispose = await stableHeapMB()
await Instance.disposeAll()
const afterDispose = await stableHeapMB()
const growth = afterDispose - beforeDispose
console.log(
` 20 instances - Before disposeAll: ${beforeDispose.toFixed(2)} MB, After: ${afterDispose.toFixed(2)} MB, Growth: ${growth.toFixed(2)} MB`,
)
// After disposing 20 instances with 50KB each (1MB total), memory shouldn't grow
expect(growth).toBeLessThan(5)
// Clean up tmpdirs
for (const d of dirs) {
await d[Symbol.asyncDispose]()
}
},
60_000,
)
test(
"GlobalBus listener count bounded",
async () => {
const initialListenerCount = GlobalBus.listenerCount("event")
for (let i = 0; i < 10; i++) {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const TestEvent = BusEvent.define(`test.global.${i}`, z.object({}))
// Subscribe and publish — this emits to GlobalBus
Bus.subscribe(TestEvent, () => {})
await Bus.publish(TestEvent, {})
await Instance.dispose()
},
})
}
const finalListenerCount = GlobalBus.listenerCount("event")
console.log(
` GlobalBus listeners: initial=${initialListenerCount}, final=${finalListenerCount}`,
)
// GlobalBus listener count should not grow unboundedly
// Bus itself doesn't add/remove GlobalBus listeners — it just emits to it.
// But if something else adds listeners, we'd catch it here.
expect(finalListenerCount).toBeLessThanOrEqual(initialListenerCount + 5)
},
30_000,
)
test(
"Bus subscriptions cleared on Instance dispose",
async () => {
await using tmp = await tmpdir({ git: true })
const TestEvent = BusEvent.define("test.bus.clear", z.object({ v: z.number() }))
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Register several subscriptions
Bus.subscribe(TestEvent, () => {})
Bus.subscribe(TestEvent, () => {})
Bus.subscribe(TestEvent, () => {})
Bus.subscribeAll(() => {})
// Verify subscriptions are active by publishing
await Bus.publish(TestEvent, { v: 1 })
// Dispose instance — should clear Bus state (including subscriptions map)
await Instance.dispose()
},
})
// After dispose, re-provide and verify subscriptions are fresh (empty)
await Instance.provide({
directory: tmp.path,
fn: async () => {
let callCount = 0
Bus.subscribe(TestEvent, () => {
callCount++
})
await Bus.publish(TestEvent, { v: 2 })
// Only our new subscription should fire, not the old ones
expect(callCount).toBe(1)
await Instance.dispose()
},
})
},
30_000,
)
test(
"Bootstrap init/dispose cycle doesn't leak subscriptions",
async () => {
// This tests the pattern from bootstrap.ts where multiple modules
// register Bus subscriptions during init. After dispose, the Bus state
// (subscriptions Map) should be cleared since it's per-instance via Instance.state().
const TestEvent1 = BusEvent.define("test.bootstrap.a", z.object({}))
const TestEvent2 = BusEvent.define("test.bootstrap.b", z.object({}))
const baseline = await stableHeapMB()
for (let i = 0; i < 10; i++) {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Simulate bootstrap-like subscription pattern:
// Multiple modules subscribing to various events
Bus.subscribe(TestEvent1, () => {})
Bus.subscribe(TestEvent1, () => {})
Bus.subscribe(TestEvent2, () => {})
Bus.subscribeAll(() => {})
Bus.subscribe(TestEvent1, () => {})
Bus.subscribe(TestEvent2, () => {})
Bus.subscribe(TestEvent2, () => {})
Bus.subscribeAll(() => {})
Bus.subscribe(TestEvent1, () => {})
Bus.subscribe(TestEvent2, () => {})
// 10 subscriptions per instance, like real bootstrap
await Instance.dispose()
},
})
}
const after = await stableHeapMB()
const growth = after - baseline
console.log(` 10 bootstrap cycles growth: ${growth.toFixed(2)} MB`)
// 10 cycles × 10 subscriptions each should not accumulate
// since Bus state is per-instance and cleared on dispose
expect(growth).toBeLessThan(5)
},
60_000,
)
})