mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
test(memory): remove broad memory regression suite
This commit is contained in:
@@ -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,
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user