Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 9 additions & 6 deletions src/backends/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { hostSpawner } from '../executors/host.js'
import type { Spawner } from '../executors/types.js'
import { readProcessLines, waitForProcessClose } from './process-lines.js'
import { writeStdinPayload } from './stdin-payload.js'
import { killTree } from '../executors/process-tree.js'

interface ClaudeStreamInit {
type: 'system'
Expand Down Expand Up @@ -228,11 +229,10 @@ export class ClaudeBackend implements Backend {
const earlySpawnError = spawned.spawnError?.()
if (earlySpawnError) spawnErrorMessage = earlySpawnError.message

const timeoutHandle = setTimeout(() => {
child.kill('SIGTERM')
}, this.timeoutMs)

const onAbort = () => child.kill('SIGTERM')
// Tear down the whole process group (claude + every MCP/tool fork
// it owns). See backends/opencode.ts for rationale.
const timeoutHandle = setTimeout(() => { void killTree(child) }, this.timeoutMs)
const onAbort = (): void => { void killTree(child) }
signal.addEventListener('abort', onAbort, { once: true })

let emittedAnyToolCall = false
Expand Down Expand Up @@ -339,7 +339,10 @@ export class ClaudeBackend implements Backend {
} finally {
clearTimeout(timeoutHandle)
signal.removeEventListener('abort', onAbort)
if (child.exitCode === null) child.kill('SIGTERM')
// Always tear down the whole subtree before releasing the slot.
// Reaps MCP servers and tool sub-processes claude spawned. Pre-fix
// this was `child.kill('SIGTERM')` which leaked grand-children.
await killTree(child)
releaseSpawner()
mcpMaterialised?.cleanup()
}
Expand Down
10 changes: 7 additions & 3 deletions src/backends/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { contentToText } from './content.js'
import { hostSpawner } from '../executors/host.js'
import type { Spawner } from '../executors/types.js'
import { readProcessLines, waitForProcessClose } from './process-lines.js'
import { killTree } from '../executors/process-tree.js'

export interface CodexBackendOptions {
bin: string
Expand Down Expand Up @@ -154,8 +155,9 @@ export class CodexBackend implements Backend {
const earlySpawnError = spawned.spawnError?.()
if (earlySpawnError) spawnErrorMessage = earlySpawnError.message

const timeoutHandle = setTimeout(() => child.kill('SIGTERM'), this.opts.timeoutMs)
const onAbort = () => child.kill('SIGTERM')
// Group-kill on timeout/abort — see backends/opencode.ts.
const timeoutHandle = setTimeout(() => { void killTree(child) }, this.opts.timeoutMs)
const onAbort = (): void => { void killTree(child) }
signal.addEventListener('abort', onAbort, { once: true })

let emittedToolCall = false
Expand Down Expand Up @@ -223,7 +225,9 @@ export class CodexBackend implements Backend {
} finally {
clearTimeout(timeoutHandle)
signal.removeEventListener('abort', onAbort)
if (child.exitCode === null) child.kill('SIGTERM')
// Reap the whole subtree — codex spawns sub-processes for MCP
// tool calls, model HTTP I/O, etc. and we owe them a clean exit.
await killTree(child)
releaseSpawner()
codexHome?.cleanup()
}
Expand Down
11 changes: 8 additions & 3 deletions src/backends/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { hostSpawner } from '../executors/host.js'
import type { Spawner } from '../executors/types.js'
import { readProcessLines, waitForProcessClose } from './process-lines.js'
import { writeStdinPayload } from './stdin-payload.js'
import { killTree } from '../executors/process-tree.js'

export interface KimiBackendOptions {
bin: string
Expand Down Expand Up @@ -166,8 +167,10 @@ export class KimiBackend implements Backend {
const earlySpawnError = spawned.spawnError?.()
if (earlySpawnError) spawnErrorMessage = earlySpawnError.message

const timeoutHandle = setTimeout(() => child.kill('SIGTERM'), this.opts.timeoutMs)
const onAbort = () => child.kill('SIGTERM')
// Tear down the whole process group (kimi + every tool/MCP subprocess
// it forks). See backends/opencode.ts for the rationale.
const timeoutHandle = setTimeout(() => { void killTree(child) }, this.opts.timeoutMs)
const onAbort = (): void => { void killTree(child) }
signal.addEventListener('abort', onAbort, { once: true })

try {
Expand Down Expand Up @@ -332,7 +335,9 @@ export class KimiBackend implements Backend {
} finally {
clearTimeout(timeoutHandle)
signal.removeEventListener('abort', onAbort)
if (child.exitCode === null) child.kill('SIGTERM')
// Always tear down the whole subtree (kimi + any MCP/tool forks)
// before releasing the slot. Idempotent; waits for actual exit.
await killTree(child)
if (configFile) await cleanupConfigFile(configFile)
mcpMaterialised?.cleanup()
releaseSpawner()
Expand Down
16 changes: 13 additions & 3 deletions src/backends/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { hostSpawner } from '../executors/host.js'
import type { Spawner } from '../executors/types.js'
import { readProcessLines, waitForProcessClose } from './process-lines.js'
import { writeStdinPayload } from './stdin-payload.js'
import { killTree } from '../executors/process-tree.js'

export interface OpencodeBackendOptions {
bin: string
Expand Down Expand Up @@ -140,8 +141,12 @@ export class OpencodeBackend implements Backend {
const earlySpawnError = spawned.spawnError?.()
if (earlySpawnError) spawnErrorMessage = earlySpawnError.message

const timeoutHandle = setTimeout(() => child.kill('SIGTERM'), this.opts.timeoutMs)
const onAbort = () => child.kill('SIGTERM')
// killTree kicks off the SIGTERM→grace→SIGKILL ladder against the
// ENTIRE process group (opencode + everything it forked). We fire
// and forget here — the actual await happens in the outer finally
// so the generator can still emit a clean final delta.
const timeoutHandle = setTimeout(() => { void killTree(child) }, this.opts.timeoutMs)
const onAbort = (): void => { void killTree(child) }
signal.addEventListener('abort', onAbort, { once: true })

try {
Expand Down Expand Up @@ -251,7 +256,12 @@ export class OpencodeBackend implements Backend {
} finally {
clearTimeout(timeoutHandle)
signal.removeEventListener('abort', onAbort)
if (child.exitCode === null) child.kill('SIGTERM')
// Always tear down the whole subtree before releasing the slot.
// killTree is idempotent and waits up to gracefulMs+500 for the
// process to actually exit, so by the time we hit releaseSpawner
// there's no orphan to leak. Pre-fix this was `child.kill('SIGTERM')`
// which left opencode's HTTP-client + MCP children alive.
await killTree(child)
releaseSpawner()
mcpMaterialised?.cleanup()
}
Expand Down
9 changes: 6 additions & 3 deletions src/backends/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { contentToText } from './content.js'
import { hostSpawner } from '../executors/host.js'
import type { Spawner } from '../executors/types.js'
import { readProcessLines, waitForProcessClose } from './process-lines.js'
import { killTree } from '../executors/process-tree.js'

export interface PiBackendOptions {
bin: string
Expand Down Expand Up @@ -162,8 +163,9 @@ export class PiBackend implements Backend {
const earlySpawnError = spawned.spawnError?.()
if (earlySpawnError) spawnErrorMessage = earlySpawnError.message

const timeoutHandle = setTimeout(() => child.kill('SIGTERM'), this.opts.timeoutMs)
const onAbort = (): void => { child.kill('SIGTERM') }
// Group-kill on timeout/abort — see backends/opencode.ts.
const timeoutHandle = setTimeout(() => { void killTree(child) }, this.opts.timeoutMs)
const onAbort = (): void => { void killTree(child) }
signal.addEventListener('abort', onAbort, { once: true })

try {
Expand Down Expand Up @@ -293,8 +295,9 @@ export class PiBackend implements Backend {
} finally {
clearTimeout(timeoutHandle)
signal.removeEventListener('abort', onAbort)
// Reap the whole subtree before releasing the slot.
await killTree(child)
try { releaseSpawner() } catch { /* best effort */ }
if (!child.killed) child.kill('SIGTERM')
}
}

Expand Down
Loading