Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
797a700
feat: Add memory leak tests to reproduce the bug
alex-alecu Feb 24, 2026
32ddfe4
fix: prevent MCP child process orphaning on close/dispose
alex-alecu Feb 25, 2026
779121f
fix: resolve /exit hang caused by undelivered worker RPC response
alex-alecu Feb 25, 2026
424f3d8
fix: cleanup
alex-alecu Feb 25, 2026
0b635fd
Merge branch 'main' into fix/memory-leak
alex-alecu Feb 25, 2026
ee6325d
fix: guard terminateWorker against multiple invocations
alex-alecu Feb 25, 2026
5d22ab6
fix: document SDK version for private _process field access
alex-alecu Feb 25, 2026
bc63efd
fix: close existing MCP client before spawning replacement in connect()
alex-alecu Feb 25, 2026
3ab63cf
Merge branch 'fix/memory-leak' of https://github.com/Kilo-Org/kilocod…
alex-alecu Feb 25, 2026
5fe6b1e
fix: close existing MCP client before create() in add() to prevent pr…
alex-alecu Feb 25, 2026
7e38aaa
fix: replace empty catch blocks in memory test helpers with isAlive/t…
alex-alecu Feb 25, 2026
a2d182a
fix: unref shutdown timer in TUI worker to avoid keeping process alive
alex-alecu Feb 25, 2026
748cee0
fix: ensure MCP process tracking cleanup runs even if close rejects
alex-alecu Feb 25, 2026
7182d4c
fix: Clear RPC channel for faster shutdown
alex-alecu Feb 25, 2026
db3cec1
fix(tui): terminate orphaned CLI when terminal parent exits
alex-alecu Feb 26, 2026
d76d563
Merge branch 'main' into fix/memory-leak
alex-alecu Feb 26, 2026
6cd5a4c
test(memory): make isAlive errno-aware
alex-alecu Feb 26, 2026
05c0847
test(memory): remove unsafe LSP any casts
alex-alecu Feb 26, 2026
ed4e139
test(memory): isolate MCP baseline per test
alex-alecu Feb 26, 2026
f1932fc
test(memory): scope orphan baselines per test
alex-alecu Feb 26, 2026
d905480
test(memory): wait for MCP process exit before counting
alex-alecu Feb 26, 2026
9ee536c
test(memory): replace orphan sleeps with exit waits
alex-alecu Feb 26, 2026
48533a7
fix: restore accidental AgentManagerApp deletion
alex-alecu Feb 26, 2026
e54479d
fix: drop MCP transport monkey patching
alex-alecu Feb 26, 2026
cd3bee9
test(memory): remove MCP-specific docs and lifecycle tests
alex-alecu Feb 26, 2026
97fccc0
test(memory): remove broad memory regression suite
alex-alecu Feb 26, 2026
996598e
fix(tui): allow terminateWorker retries after failure
alex-alecu Feb 26, 2026
ecfea37
Merge branch 'main' into fix/memory-leak
alex-alecu Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 97 additions & 7 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ export const TuiThreadCommand = cmd({
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
// (Important when running under `bun run` wrappers on Windows.)
const unguard = win32InstallCtrlCGuard()
const shutdown = {
pending: undefined as Promise<void> | undefined,
exiting: false,
}
try {
// Must be the very first thing — disables CTRL_C_EVENT before any Worker
// spawn or async work so the OS cannot kill the process group.
Expand Down Expand Up @@ -130,11 +134,98 @@ export const TuiThreadCommand = cmd({
await client.call("reload", undefined)
})
// kilocode_change start - graceful shutdown on external signals
const shutdown = async () => {
await client.call("shutdown", undefined).catch(() => {})
// The worker's postMessage for the RPC result may never be delivered
// after shutdown because the worker's event loop drains. Send the
// shutdown request without awaiting the response, wait for the worker
// to exit naturally or force-terminate after a timeout.
// Guard against multiple invocations (SIGHUP + SIGTERM + onExit).
const terminateWorker = () => {
if (shutdown.pending) return shutdown.pending
const state = {
closed: false,
}
const result = new Promise<void>((resolve) => {
worker.addEventListener(
"close",
() => {
state.closed = true
resolve()
},
{ once: true },
)
setTimeout(resolve, 5000).unref()
client.call("shutdown", undefined).catch((error) => {
Log.Default.debug("worker shutdown RPC failed", { error })
})
}).then(async () => {
if (state.closed) return
await Promise.resolve()
.then(() => worker.terminate())
.catch((error) => {
shutdown.pending = undefined
Log.Default.debug("worker terminate failed", { error })
})
})
shutdown.pending = result
return result
}
const shutdownAndExit = (input: { reason: string; code: number; signal?: NodeJS.Signals }) => {
if (shutdown.exiting) return
shutdown.exiting = true
Log.Default.info("shutting down tui thread", {
reason: input.reason,
signal: input.signal,
code: input.code,
pid: process.pid,
ppid: process.ppid,
})
terminateWorker()
.catch((error) => {
Log.Default.error("failed to terminate worker during shutdown", {
reason: input.reason,
signal: input.signal,
error,
})
})
.finally(() => {
unguard?.()
process.exit(input.code)
})
}
process.on("SIGHUP", shutdown)
process.on("SIGTERM", shutdown)
process.once("SIGHUP", () => shutdownAndExit({ reason: "signal", signal: "SIGHUP", code: 129 }))
process.once("SIGTERM", () => shutdownAndExit({ reason: "signal", signal: "SIGTERM", code: 143 }))
// In some terminal/tab-close paths the parent shell is terminated without
// forwarding a signal to this process, leaving the TUI orphaned. Detect
// parent PID re-parenting and exit explicitly.
const parent = process.ppid
const orphanWatch = setInterval(() => {
const orphaned = (() => {
if (process.ppid !== parent) return true
if (parent === 1) return false
try {
process.kill(parent, 0)
return false
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== "ESRCH") {
Log.Default.debug("parent liveness check failed", {
parent,
code,
error,
})
return false
}
Log.Default.debug("detected dead parent process", {
parent,
error,
})
return true
}
})()
if (!orphaned) return
shutdownAndExit({ reason: "parent-exit", code: 0 })
}, 1000)
orphanWatch.unref()
// kilocode_change end

const prompt = await iife(async () => {
Expand Down Expand Up @@ -180,9 +271,7 @@ export const TuiThreadCommand = cmd({
prompt,
fork: args.fork,
},
onExit: async () => {
await client.call("shutdown", undefined)
},
onExit: () => terminateWorker(),
})

setTimeout(() => {
Expand All @@ -193,6 +282,7 @@ export const TuiThreadCommand = cmd({
} finally {
unguard?.()
}
if (shutdown.exiting) return
process.exit(0)
},
})
6 changes: 5 additions & 1 deletion packages/opencode/src/cli/cmd/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,14 @@ export const rpc = {
await Promise.race([
Instance.disposeAll(),
new Promise((resolve) => {
setTimeout(resolve, 5000)
setTimeout(resolve, 5000).unref()
}),
])
if (server) server.stop(true)
// Clear the Rpc message channel so the worker's event loop can drain and
// exit naturally. Without this, the active onmessage handle keeps the
// worker alive even after all async work is done.
onmessage = null
},
}

Expand Down
Loading