Files
orca/src/main/daemon/daemon-spawner.test.ts
T
Jinwoo HongandOrca a638215729 fix(pty): release ptmx fd on natural exit + defuse SIGHUP-to-recycled-pid (#1327)
* fix(pty): release ptmx fd on natural exit + defuse SIGHUP-to-recycled-pid

Daemons accumulated ptmx fds over time because node-pty's UnixTerminal
only releases the master fd when destroy() runs. On the natural-exit
path (the common case — user closes a tab, shell runs `exit`) nothing
ever calls destroy(), so the fd leaks until GC. On macOS this
eventually hits kern.tty.ptmx_max=511 and all new terminals fail to
spawn.

Fix: release the fd synchronously on every teardown path (natural
exit, explicit kill, stale SSH spawn, daemon shutdown) and close the
concurrent SIGHUP-to-recycled-pid hazard inside node-pty's
UnixTerminal.destroy().

- src/main/daemon/pty-subprocess.ts: synchronous POSIX proc.kill
  neutralization inside proc.onExit; dead guards on forceKill/signal
  so they never target a reaped-and-possibly-recycled pid
- src/main/daemon/session.ts: new disposeSubprocess() for already-
  exited sessions (fd release only, no SIGKILL) — avoids sending
  SIGKILL to a recycled pid during daemon shutdown
- src/main/daemon/terminal-host.ts: dispose loop routes on isAlive —
  live sessions get forceKillAndDisposeSubprocess (SIGKILL + fd
  release), exited sessions get disposeSubprocess (fd release only)
- src/main/providers/local-pty-provider.ts: same POSIX kill
  neutralization at top of onExit for the legacy local path
- src/relay/pty-handler.ts: same neutralization in wireAndStore;
  disposed flag guards all public entry points; dispose() uses
  SIGKILL (not SIGTERM) before destroy since the relay is exiting;
  killTimer fallback + immediate-shutdown + stale-spawn cleanup all
  call disposeManagedPty + ptys.delete so wedged children (D-state,
  bad NFS) can't leak map entries against the 50-PTY cap

Windows is exempt everywhere — WindowsTerminal.destroy IS a kill()
call internally (closes the ConPTY agent), so neutralizing would
turn destroy into a no-op and leak the agent.

See docs/fix-pty-fd-leak.md for the full design.

Co-authored-by: Orca <help@stably.ai>

* fix(pty): patch node-pty native off-by-one leaking /dev/ptmx per spawn

node-pty 1.1.0's pty_posix_spawn on macOS walks low_fds[0..2] in an
allocation loop that breaks at the first fd >= STDERR_FILENO, then
cleans up via `for (; count > 0; count--) close(low_fds[count])`. In
the typical case (break at count=0) the cleanup body never runs and
low_fds[0] — a /dev/ptmx handle — leaks per spawn. Fixed upstream in
microsoft/node-pty af053f2 (PR #882), not in any 1.1.0 release.

Backport the 3-line cleanup-loop fix as a pnpm patch. E2E validated
against a dev daemon: 200 spawn/kill cycles kept the daemon's ptmx
fd count flat at baseline; prior runs reproduced linear 1-per-spawn
growth. Also documents the native root cause as a status addendum in
docs/fix-pty-fd-leak.md — the JS-side destroy() discipline previously
landed is still load-bearing for the SIGHUP-to-recycled-pid hazard and
for synchronous fd release on daemon shutdown.

Co-authored-by: Orca <help@stably.ai>

* fix(pty): capture stable kill spy ref in pty.test.ts

destroyPtyProcess reassigns proc.kill = () => {} on POSIX to defuse
the SIGHUP-to-recycled-pid hazard (see docs/fix-pty-fd-leak.md). After
that reassignment, proc.kill.mock is undefined and the assertions
crashed in CI. Capture a stable reference to the vi.fn() before it
gets reassigned.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-05-02 23:12:14 -07:00

170 lines
4.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'os'
import { join } from 'path'
import { mkdtempSync, rmSync } from 'fs'
import {
DaemonSpawner,
getDaemonPidPath,
getDaemonSocketPath,
getDaemonTokenPath
} from './daemon-spawner'
import { startDaemon, type DaemonHandle } from './daemon-main'
import { DaemonClient } from './client'
import type { SubprocessHandle } from './session'
import { PROTOCOL_VERSION } from './types'
function createTestDir(): string {
return mkdtempSync(join(tmpdir(), 'daemon-spawner-test-'))
}
function createMockSubprocess(): SubprocessHandle {
let onExitCb: ((code: number) => void) | null = null
return {
pid: 88888,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
forceKill: vi.fn(),
signal: vi.fn(),
onData(_cb: (data: string) => void) {},
onExit(cb: (code: number) => void) {
onExitCb = cb
},
dispose: vi.fn()
}
}
describe('DaemonSpawner', () => {
let dir: string
let spawner: DaemonSpawner
let activeDaemons: DaemonHandle[]
beforeEach(() => {
dir = createTestDir()
activeDaemons = []
})
afterEach(async () => {
await spawner?.shutdown()
for (const d of activeDaemons) {
await d.shutdown().catch(() => {})
}
rmSync(dir, { recursive: true, force: true })
})
function createSpawner(): DaemonSpawner {
spawner = new DaemonSpawner({
runtimeDir: dir,
launcher: async (socketPath, tokenPath) => {
const handle = await startDaemon({
socketPath,
tokenPath,
spawnSubprocess: () => createMockSubprocess()
})
activeDaemons.push(handle)
return { shutdown: () => handle.shutdown() }
}
})
return spawner
}
describe('ensureRunning', () => {
it('uses protocol-scoped socket and token paths', () => {
const socketPath = getDaemonSocketPath(dir)
const tokenPath = getDaemonTokenPath(dir)
const pidPath = getDaemonPidPath(dir)
if (process.platform === 'win32') {
expect(socketPath).toContain(`orca-terminal-host-v${PROTOCOL_VERSION}`)
} else {
expect(socketPath).toBe(join(dir, `daemon-v${PROTOCOL_VERSION}.sock`))
}
expect(tokenPath).toBe(join(dir, `daemon-v${PROTOCOL_VERSION}.token`))
expect(pidPath).toBe(join(dir, `daemon-v${PROTOCOL_VERSION}.pid`))
})
it('starts daemon and returns connection info', async () => {
const s = createSpawner()
const info = await s.ensureRunning()
expect(info.socketPath).toContain(dir)
expect(info.tokenPath).toContain(dir)
})
it('returns same info on subsequent calls', async () => {
const s = createSpawner()
const info1 = await s.ensureRunning()
const info2 = await s.ensureRunning()
expect(info1.socketPath).toBe(info2.socketPath)
expect(info1.tokenPath).toBe(info2.tokenPath)
})
it('daemon is connectable after ensureRunning', async () => {
const s = createSpawner()
const info = await s.ensureRunning()
const client = new DaemonClient({
socketPath: info.socketPath,
tokenPath: info.tokenPath
})
await client.ensureConnected()
expect(client.isConnected()).toBe(true)
client.disconnect()
})
it('daemon can create sessions', async () => {
const s = createSpawner()
const info = await s.ensureRunning()
const client = new DaemonClient({
socketPath: info.socketPath,
tokenPath: info.tokenPath
})
await client.ensureConnected()
const result = await client.request<{ isNew: boolean }>('createOrAttach', {
sessionId: 'test-session',
cols: 80,
rows: 24
})
expect(result.isNew).toBe(true)
client.disconnect()
})
})
describe('shutdown', () => {
it('stops the daemon', async () => {
const s = createSpawner()
const info = await s.ensureRunning()
await s.shutdown()
const client = new DaemonClient({
socketPath: info.socketPath,
tokenPath: info.tokenPath
})
await expect(client.ensureConnected()).rejects.toThrow()
})
it('can be called when daemon is not running', async () => {
const s = createSpawner()
await expect(s.shutdown()).resolves.toBeUndefined()
})
it('allows re-start after shutdown', async () => {
const s = createSpawner()
await s.ensureRunning()
await s.shutdown()
const info = await s.ensureRunning()
const client = new DaemonClient({
socketPath: info.socketPath,
tokenPath: info.tokenPath
})
await client.ensureConnected()
expect(client.isConnected()).toBe(true)
client.disconnect()
})
})
})