From 97fccc0abbfe4305fedf73e97fe3fadfccc71ecb Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 26 Feb 2026 13:33:36 +0200 Subject: [PATCH] test(memory): remove broad memory regression suite --- .../opencode/test/memory/disposal.test.ts | 180 -------------- .../opencode/test/memory/heap-growth.test.ts | 124 ---------- packages/opencode/test/memory/helper.ts | 173 -------------- .../test/memory/lsp-lifecycle.test.ts | 135 ----------- .../test/memory/session-heap-growth.test.ts | 161 ------------- .../opencode/test/memory/state-leak.test.ts | 221 ------------------ 6 files changed, 994 deletions(-) delete mode 100644 packages/opencode/test/memory/disposal.test.ts delete mode 100644 packages/opencode/test/memory/heap-growth.test.ts delete mode 100644 packages/opencode/test/memory/helper.ts delete mode 100644 packages/opencode/test/memory/lsp-lifecycle.test.ts delete mode 100644 packages/opencode/test/memory/session-heap-growth.test.ts delete mode 100644 packages/opencode/test/memory/state-leak.test.ts diff --git a/packages/opencode/test/memory/disposal.test.ts b/packages/opencode/test/memory/disposal.test.ts deleted file mode 100644 index e182314f164..00000000000 --- a/packages/opencode/test/memory/disposal.test.ts +++ /dev/null @@ -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, - ) -}) diff --git a/packages/opencode/test/memory/heap-growth.test.ts b/packages/opencode/test/memory/heap-growth.test.ts deleted file mode 100644 index e752dc2f695..00000000000 --- a/packages/opencode/test/memory/heap-growth.test.ts +++ /dev/null @@ -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, - ) -}) diff --git a/packages/opencode/test/memory/helper.ts b/packages/opencode/test/memory/helper.ts deleted file mode 100644 index 7c02ff9f954..00000000000 --- a/packages/opencode/test/memory/helper.ts +++ /dev/null @@ -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 { - 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, -): 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> { - const descendants = new Set() - 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, after: Set): Promise { - const orphans = new Set() - 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 { - 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[]): void { - for (const pid of pids) { - trySendSignal(pid, "SIGKILL") - } -} diff --git a/packages/opencode/test/memory/lsp-lifecycle.test.ts b/packages/opencode/test/memory/lsp-lifecycle.test.ts deleted file mode 100644 index 99125adbe72..00000000000 --- a/packages/opencode/test/memory/lsp-lifecycle.test.ts +++ /dev/null @@ -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 = 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) -}) diff --git a/packages/opencode/test/memory/session-heap-growth.test.ts b/packages/opencode/test/memory/session-heap-growth.test.ts deleted file mode 100644 index 2e7aeee75b6..00000000000 --- a/packages/opencode/test/memory/session-heap-growth.test.ts +++ /dev/null @@ -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) -}) diff --git a/packages/opencode/test/memory/state-leak.test.ts b/packages/opencode/test/memory/state-leak.test.ts deleted file mode 100644 index 382b2d567cc..00000000000 --- a/packages/opencode/test/memory/state-leak.test.ts +++ /dev/null @@ -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>[] = [] - 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, - ) -})