Files
orca/src/main/refused-tree-kill-root-termination.test.ts
T
Jinwoo Hong 8ba7f829ac feat(ipynb): run notebook cells in a persistent Jupyter kernel (#22581)
* feat(ipynb): run notebook cells in a persistent Jupyter kernel

Replaces the fresh-process runner (which silently re-ran every earlier cell)
with the user's own ipykernel, driven by a small bundled Python bridge over
line-framed JSON. One kernel per open notebook: started on first Run, shut
down when its tab closes or Orca exits (stdin EOF), and the kernel's own
parent poller reaps it if the bridge dies.

The header gains a kernel pill (workspace .venv/.conda recommended, PATH
interpreters, Browse), Interrupt/Restart/Run all/Clear all, and a one-time
missing-ipykernel dialog with Install. Outputs stream live per cell and are
written into the document when the run finishes.

* test(ipynb): cover the stalled-interrupt restart offer

* refactor(ipynb): disable the kernel pill while settling; merge its classes with cn

* refactor(ipynb): tie kernels to their renderer document and simplify the run flow

- Main keys kernels per renderer, so a reload, renderer crash or closed
  window shuts them down, and two windows never share one notebook kernel.
  One close-driven cleanup replaces the separate exit and start-failure
  deletions.
- The first run uses the nearest workspace env, else the first Python on
  PATH; the picker no longer opens itself, so its open state stays in the
  toolbar. Closing the picker brings the missing-ipykernel dialog back
  instead of dropping the queue, which also keeps a Browse pick's run.
- Discovery marks the kernel starting, so a second run during it queues
  instead of starting a second kernel, and a tab closed mid-discovery no
  longer leaks one.
- Running an nbformat 4.4 notebook gives its cells ids (upgrading to 4.5),
  so moving a cell mid-run cannot misroute its output.
- The death notice drops stderr from before the kernel was ready (the
  unencrypted-TCP warning).
- SSH and non-Python runs toast instead of writing a notice into the cell.
- Windows conda envs are named after their folder.

* fix(ipynb): install ipykernel into envs without pip

uv-created venvs ship without pip, so Install failed with 'No module named
pip' there. When pip is missing, bootstrap it with the stdlib's ensurepip
and retry. The install moves beside the other interpreter probes, and the
copyable install command comes from one helper.

* fix(ipynb): address PR review comments on stream errors, old jupyter_client and the Windows venv hint

- Swallow stdout/stderr stream errors on the bridge child, as spawnProcess
  requires, so a broken pipe cannot crash main.
- The bridge exits (reporting the death) even when cleanup_resources is
  missing (jupyter_client < 6.1.5) or raises.
- The install-failure hint suggests `py -m venv .venv` on Windows.

* feat(ipynb): add Cancel to the missing-ipykernel dialog

It does what Esc does: drops the cells waiting on the kernel.

* fix(ipynb): recover from a rejected kernel start; quote the install command per shell

- Discovery moves into start, so one catch turns a rejected
  listPythonEnvironments or startKernel into the usual failed start: the
  session returns to off with the error in the cell, instead of sticking
  at starting.
- The copyable ipykernel command quotes the interpreter only when its path
  has whitespace, prefixing PowerShell's call operator on Windows. Install
  itself still spawns without a shell.

* fix(ipynb): always shell-quote the copyable ipykernel install command

Quote the interpreter path for every path, not only ones with whitespace,
so paths with shell metacharacters like & copy as a working command.
Single quotes are literal in POSIX shells and PowerShell; embedded quotes
are escaped per shell, and PowerShell keeps its & call operator.
2026-09-23 23:38:33 -04:00

210 lines
7.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { spawnMock, execFileMock, queryWindowsProcessDescendantsMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
execFileMock: vi.fn(),
queryWindowsProcessDescendantsMock: vi.fn()
}))
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
spawn: spawnMock,
execFile: execFileMock
}))
vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), on: vi.fn() } }))
vi.mock('./providers/windows-foreground-process-rows', () => ({
queryWindowsProcessDescendants: queryWindowsProcessDescendantsMock
}))
import {
getAppEnvironment,
hasAppEnvironment,
setAppEnvironment,
type AppEnvironment
} from '../shared/app-environment'
import { installMainProcessTreeKillGate } from './own-chromium-tree-kill-guard'
import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kill-gate'
import { resetSelfInitiatedTreeKillLogForTest } from './crash-reporting/self-initiated-tree-kill-log'
import {
clearCrashBreadcrumbsForTest,
getCrashBreadcrumbSnapshot
} from './crash-reporting/crash-breadcrumb-store'
import { _resetTracerForTests, setActiveSink } from './observability/tracer'
import { killLocalPrecheckProcessTree } from './automations/precheck-runner'
import { killRecipeProcess } from '../shared/ephemeral-vm-recipe-process'
import { killSpawnedCommandTree } from './git/command-runner/spawned-command-tree-kill'
import { killCodexAppServerProcessTree } from './codex/codex-app-server-process-tree-kill'
import { signalProcessTree } from '../shared/child-process/process-tree-termination'
import { killSourceControlAgentProcess } from './text-generation/source-control-local-process'
import { terminateCodexTurnProcesses } from './codex/codex-structured-turn-processes'
/** A pid Electron reports as one of ours: every gate below must refuse it. */
const RENDERER_PID = 1001
function appEnvironment(): AppEnvironment {
return {
getPath: () => process.cwd(),
getAppPath: () => process.cwd(),
getVersion: () => '0.0.0-test',
isPackaged: () => false,
onWillQuit: () => {},
exit: () => {},
getAppMetrics: (() => [
{ pid: RENDERER_PID, type: 'Tab' }
]) as unknown as AppEnvironment['getAppMetrics']
}
}
let previousEnvironment: AppEnvironment | null = null
let previousPlatform: PropertyDescriptor | undefined
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
}
beforeEach(() => {
previousEnvironment = hasAppEnvironment() ? getAppEnvironment() : null
previousPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
setAppEnvironment(appEnvironment())
setActiveSink(null)
clearCrashBreadcrumbsForTest()
resetSelfInitiatedTreeKillLogForTest()
installMainProcessTreeKillGate()
spawnMock.mockReset()
execFileMock.mockReset()
queryWindowsProcessDescendantsMock.mockReset()
spawnMock.mockReturnValue({ on: vi.fn(), once: vi.fn(), unref: vi.fn(), kill: vi.fn() })
})
afterEach(() => {
setProcessTreeKillGate(null)
if (previousPlatform) {
Object.defineProperty(process, 'platform', previousPlatform)
}
if (previousEnvironment) {
setAppEnvironment(previousEnvironment)
}
_resetTracerForTests()
})
/**
* A refusal must never become a process leak. The gate only blocks the
* pid-addressed tree walk; the root kill is addressed by the child handle, so it
* cannot reach the recycled pid we refused, and skipping it would report a
* timed-out command as stopped while its tree keeps running.
*/
describe('a refused tree-kill still terminates the root it owns', () => {
it('kills the git command root when the tree walk is refused', async () => {
setPlatform('win32')
const child = { pid: RENDERER_PID, kill: vi.fn() }
await killSpawnedCommandTree(child as never)
expect(spawnMock).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalledTimes(1)
})
it('kills the automation precheck root when the tree walk is refused', () => {
setPlatform('win32')
const child = { pid: RENDERER_PID, kill: vi.fn() }
expect(killLocalPrecheckProcessTree(child as never)).toBeNull()
expect(spawnMock).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalledTimes(1)
})
it('kills the ephemeral-VM recipe root when the tree walk is refused', () => {
setPlatform('win32')
const child = { pid: RENDERER_PID, kill: vi.fn() }
killRecipeProcess(child as never, true)
expect(spawnMock).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
it('kills the codex app-server root when the deadline tree walk is refused', () => {
const child = { pid: RENDERER_PID, kill: vi.fn() }
killCodexAppServerProcessTree(child as never, {
platform: 'win32',
spawnImpl: spawnMock as never
})
expect(spawnMock).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
it('kills the commit-message agent root when the tree walk is refused', async () => {
setPlatform('win32')
const child = { pid: RENDERER_PID, kill: vi.fn() }
await killSourceControlAgentProcess(child as never)
expect(execFileMock).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
it('kills the runProcess root when the Windows arm of the shared choke point is refused', async () => {
setPlatform('win32')
const windowsChild = { pid: RENDERER_PID, kill: vi.fn(), exitCode: null, signalCode: null }
await expect(signalProcessTree(windowsChild as never, 'SIGKILL')).resolves.toBe(false)
expect(spawnMock).not.toHaveBeenCalled()
expect(windowsChild.kill).toHaveBeenCalledWith('SIGKILL')
})
it('still signals the POSIX process group: a group only holds what Orca put in it', async () => {
// Same contract as main and as the other three POSIX group arms in main
// (claude-login, codex teardown, PTY sweep): record, never refuse. A stale
// `getAppMetrics()` entry must not orphan a macOS/Linux tree.
setPlatform('linux')
const posixChild = { pid: RENDERER_PID, kill: vi.fn(), exitCode: null, signalCode: null }
const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true)
await expect(signalProcessTree(posixChild as never, 'SIGKILL')).resolves.toBe(true)
expect(processKill).toHaveBeenCalledWith(-RENDERER_PID, 'SIGKILL')
expect(posixChild.kill).not.toHaveBeenCalled()
expect(getCrashBreadcrumbSnapshot()).toEqual([
expect.objectContaining({
name: 'self_tree_kill',
data: expect.objectContaining({ pid: RENDERER_PID, scope: 'posix-process-group' })
})
])
processKill.mockRestore()
})
})
/**
* The one gated site with nothing to fall back to: the roots it kills are found
* by a process-table walk, not spawned here, so there is no child handle. A
* refusal must then be visible — the refusal crumb is written and the turn is
* reported as not cancelled — rather than resolving as if the tree had gone.
*/
describe('a refused tree-kill with no handle to fall back to', () => {
it('reports the codex turn as not cancelled and records the refused added root', async () => {
const appServerPid = 500
const addedRoot = {
pid: RENDERER_PID,
ppid: appServerPid,
name: 'node.exe',
command: 'node',
depth: 1
}
queryWindowsProcessDescendantsMock.mockResolvedValue([addedRoot])
await expect(
terminateCodexTurnProcesses(appServerPid, { platform: 'win32', identities: new Map() })
).resolves.toBe(false)
expect(execFileMock).not.toHaveBeenCalled()
expect(getCrashBreadcrumbSnapshot()).toEqual([
expect.objectContaining({
name: 'self_tree_kill_refused_own_chromium',
data: expect.objectContaining({ pid: RENDERER_PID, site: 'codex-turn-added-roots' })
})
])
})
})