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: 11 additions & 4 deletions packages/core/src/mcp/mcp-runtime-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,16 +249,23 @@ export async function cancelManagedTree (
if (!(await waitFor(closed, deadline))) {
return { confirmed: false, error: 'npm process did not exit after SIGKILL' }
}
let probeError: string | undefined
while (Date.now() < deadline) {
try {
process.kill(groupId, 0)
await sleep(25) // group still exists (e.g. a grandchild) — keep polling
probeError = undefined
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ESRCH') return { confirmed: true }
return { confirmed: false, error: `process group check failed (${(err as NodeJS.ErrnoException).code})` }
const code = (err as NodeJS.ErrnoException).code
if (code === 'ESRCH') return { confirmed: true }
probeError = `process group check failed (${code})`
// Darwin skips zombies when probing a group and can return EPERM
// until they are reaped. Keep waiting for ESRCH within the same
// deadline; EPERM alone never proves that cleanup is safe.
if (platform !== 'darwin' || code !== 'EPERM') return { confirmed: false, error: probeError }
}
await sleep(25) // group still exists or its state is unconfirmed — keep polling
}
return { confirmed: false, error: 'managed npm process group still exists after the confirmation deadline' }
return { confirmed: false, error: probeError ?? 'managed npm process group still exists after the confirmation deadline' }
} catch (err) {
return { confirmed: false, error: (err as Error).message }
}
Expand Down
58 changes: 58 additions & 0 deletions packages/core/test/unit/mcp/mcp-runtime-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,64 @@ function killTree (pid: number): void {
}
}

describe('cancelManagedTree (Unix termination confirmation)', () => {
it('waits for a transient Darwin EPERM to become ESRCH before confirming termination', async (t) => {
let probes = 0
const calls: Array<[number, NodeJS.Signals | number]> = []
t.mock.method(process, 'kill', (pid: number, signal: NodeJS.Signals | number) => {
calls.push([pid, signal])
if (signal === 0) {
probes++
throw Object.assign(new Error('group probe'), { code: probes === 1 ? 'EPERM' : 'ESRCH' })
}
return true
})

const outcome = await cancelManagedTree({ pid: 1234 }, Promise.resolve(), {
platform: 'darwin',
confirmMs: 5_000,
})

assert.deepStrictEqual(outcome, { confirmed: true })
assert.strictEqual(probes, 2, 'confirmation requires a later ESRCH, not EPERM alone')
assert.deepStrictEqual(calls, [[-1234, 'SIGTERM'], [-1234, 'SIGKILL'], [-1234, 0], [-1234, 0]])
})

it('does not confirm termination when Darwin EPERM persists to the deadline', async (t) => {
t.mock.method(process, 'kill', (pid: number, signal: NodeJS.Signals | number) => {
assert.strictEqual(pid, -1234)
if (signal === 0) throw Object.assign(new Error('permission denied'), { code: 'EPERM' })
return true
})
const start = Date.now()
const outcome = await cancelManagedTree({ pid: 1234 }, Promise.resolve(), {
platform: 'darwin',
confirmMs: 50,
})

assert.strictEqual(outcome.confirmed, false)
assert.match(outcome.error ?? '', /process group check failed \(EPERM\)/)
assert.ok(Date.now() - start < 5_000, 'confirmation remains bounded')
})

for (const { platform, code } of [
{ platform: 'linux', code: 'EPERM' },
{ platform: 'darwin', code: 'EACCES' },
] as const) {
it(`does not reinterpret ${code} on ${platform} as confirmed termination`, async (t) => {
t.mock.method(process, 'kill', (_pid: number, signal: NodeJS.Signals | number) => {
if (signal === 0) throw Object.assign(new Error('probe failed'), { code })
return true
})

const outcome = await cancelManagedTree({ pid: 1234 }, Promise.resolve(), { platform })

assert.strictEqual(outcome.confirmed, false)
assert.match(outcome.error ?? '', new RegExp(`process group check failed \\(${code}\\)`))
})
}
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion — packages/core/test/unit/mcp/mcp-runtime-runner.test.ts:104

The rewritten poll loop has one branch the new suite never reaches: a group probe that keeps succeeding (a live member survives) right up to the deadline, which exits through the probeError ?? '…still exists…' fallback at packages/core/src/mcp/mcp-runtime-runner.ts:268. Every new test throws on signal === 0, so the success path and its operator-facing diagnostic are unverified — a future edit could drop that classification without CI noticing. Optional and non-blocking; the case below is verified to pass on this head.

Suggested change
})
it('keeps polling a surviving group and reports it as still existing at the deadline', async (t) => {
t.mock.method(process, 'kill', () => true) // the group keeps answering: a member survives
const outcome = await cancelManagedTree({ pid: 1234 }, Promise.resolve(), {
platform: 'darwin',
confirmMs: 50,
})
assert.deepStrictEqual(outcome, {
confirmed: false,
error: 'managed npm process group still exists after the confirmation deadline',
})
})
})
🤖 Ask ns-control-tower to fix this

@ns-control-tower please fix: add the surviving-group probe case so the still exists after the confirmation deadline fallback in cancelManagedTree has test coverage.


describe('cancelManagedTree (Windows termination branch)', () => {
it('returns a bounded terminationError when taskkill never exits', async () => {
const killer = fakeKiller()
Expand Down
Loading