Files
orca/src/main/pty/node-pty-runtime-relocation.test.ts
T
Neil 0a2ac16f22 fix(pty): keep runtime dirs a surviving daemon still uses
The node-pty native runtime is relocated to a per-app-version dir under
userData so live terminals survive NSIS updates. On every launch,
removeStaleRuntimeVersions() deleted every *other* version's dir, gated
on a "renameSync succeeds => unused => safe to delete" heuristic.

That heuristic is false on Windows: running .exe images and mapped .dll
files are opened with FILE_SHARE_DELETE, so both the directory rename and
the recursive delete succeed while a daemon adopted across the update is
still using them. Existing PTYs keep streaming via deferred-delete
handles, but the next spawn re-loads conpty.dll from the now-unlinked
version dir and fails with ERROR_PATH_NOT_FOUND (code 3) -- the
"Cannot find conpty.dll ... error code: 3" new-tab failure.

Gate cleanup on real daemon liveness instead: collectInUseRuntimeVersions()
reads the daemon-v<N>.pid files under userData/daemon and, for each live
daemon, protects the appVersion (runtime dir) it recorded. Deletion now
skips the current version and any in-use version, dropping the rename
dance. Every failure mode leaks a dir rather than deleting one in use.

Rewrites the misleading cleanup test (which modeled no live daemon) and
adds coverage for surviving/dead daemons, appVersion:null pin files,
multiple protocol versions, and malformed/non-pid files.
2026-07-05 17:09:14 -07:00

319 lines
12 KiB
TypeScript

import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: {
isPackaged: false,
getPath: vi.fn(() => '/unused'),
getVersion: vi.fn(() => '0.0.0-test')
}
}))
import { getDaemonPidPath, serializeDaemonPidFile } from '../daemon/daemon-spawner'
import type { DaemonPidFile } from '../daemon/daemon-spawner'
import {
collectInUseRuntimeVersions,
ensureRelocatedNodePtyNativeRuntime,
resolveNodePtyNativeSourceDir
} from './node-pty-runtime-relocation'
let tempDir: string
beforeEach(() => {
tempDir = mkdtempSync(join(os.tmpdir(), 'node-pty-relocation-'))
})
afterEach(() => {
// Why: the loader-override test dlopens conpty.node from tempDir, and a
// loaded native module stays image-locked until the process exits.
try {
rmSync(tempDir, { recursive: true, force: true })
} catch {}
})
function seedSourceDir(dir: string): void {
mkdirSync(join(dir, 'conpty'), { recursive: true })
writeFileSync(join(dir, 'conpty.node'), 'binding')
writeFileSync(join(dir, 'conpty_console_list.node'), 'listing')
writeFileSync(join(dir, 'pty.node'), 'winpty-binding')
writeFileSync(join(dir, 'winpty-agent.exe'), 'agent')
writeFileSync(join(dir, 'conpty.pdb'), 'symbols')
writeFileSync(join(dir, 'conpty', 'conpty.dll'), 'dll')
writeFileSync(join(dir, 'conpty', 'OpenConsole.exe'), 'console-host')
}
// Writes a daemon pid file exactly as the daemon does (userData/daemon/daemon-v<N>.pid).
function writeDaemonPidFile(
daemonRuntimeDir: string,
protocolVersion: number,
pidFile: DaemonPidFile
): void {
mkdirSync(daemonRuntimeDir, { recursive: true })
writeFileSync(getDaemonPidPath(daemonRuntimeDir, protocolVersion), serializeDaemonPidFile(pidFile))
}
// Deterministic liveness that treats the given pids as running, so tests never
// depend on the host's real process table.
function aliveFor(...alivePids: number[]): (pid: number) => boolean {
const set = new Set(alivePids)
return (pid) => set.has(pid)
}
describe('resolveNodePtyNativeSourceDir', () => {
it('prefers the rebuilt build/Release binding over prebuilds', () => {
const pkg = join(tempDir, 'node-pty')
mkdirSync(join(pkg, 'build', 'Release'), { recursive: true })
mkdirSync(join(pkg, 'prebuilds', `win32-${process.arch}`), { recursive: true })
writeFileSync(join(pkg, 'build', 'Release', 'conpty.node'), 'x')
writeFileSync(join(pkg, 'prebuilds', `win32-${process.arch}`, 'conpty.node'), 'x')
expect(resolveNodePtyNativeSourceDir(pkg)).toBe(join(pkg, 'build', 'Release'))
})
it('falls back to the platform prebuild dir', () => {
const pkg = join(tempDir, 'node-pty')
mkdirSync(join(pkg, 'prebuilds', `win32-${process.arch}`), { recursive: true })
writeFileSync(join(pkg, 'prebuilds', `win32-${process.arch}`, 'conpty.node'), 'x')
expect(resolveNodePtyNativeSourceDir(pkg)).toBe(join(pkg, 'prebuilds', `win32-${process.arch}`))
})
it('returns null when no conpty binding exists', () => {
const pkg = join(tempDir, 'node-pty')
mkdirSync(join(pkg, 'build', 'Release'), { recursive: true })
expect(resolveNodePtyNativeSourceDir(pkg)).toBeNull()
})
})
describe('collectInUseRuntimeVersions', () => {
it('returns an empty set when the daemon dir does not exist', () => {
expect(collectInUseRuntimeVersions(join(tempDir, 'no-daemon-dir')).size).toBe(0)
})
it('collects the app version a live daemon pins', () => {
const daemonDir = join(tempDir, 'daemon')
writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.4.124-rc.1' })
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(4321))
expect([...inUse]).toEqual(['1.4.124-rc.1'])
})
it('omits the version of a daemon whose pid is dead', () => {
const daemonDir = join(tempDir, 'daemon')
writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.4.124-rc.1' })
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(/* nobody */))
expect(inUse.size).toBe(0)
})
it('collects one version per live daemon across protocol versions', () => {
const daemonDir = join(tempDir, 'daemon')
writeDaemonPidFile(daemonDir, 18, { pid: 100, startedAtMs: null, appVersion: '1.4.124-rc.1' })
writeDaemonPidFile(daemonDir, 19, { pid: 200, startedAtMs: null, appVersion: '1.4.124-rc.2' })
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(100, 200))
expect([...inUse].sort()).toEqual(['1.4.124-rc.1', '1.4.124-rc.2'])
})
it('ignores a daemon whose pid file records no app version', () => {
const daemonDir = join(tempDir, 'daemon')
// Pre-relocation daemon: JSON pid file without appVersion.
writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null })
// Legacy daemon: bare-integer pid file (appVersion parses to null).
mkdirSync(daemonDir, { recursive: true })
writeFileSync(getDaemonPidPath(daemonDir, 17), '9999')
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(4321, 9999))
expect(inUse.size).toBe(0)
})
it('ignores non-pid files in the daemon dir', () => {
const daemonDir = join(tempDir, 'daemon')
mkdirSync(daemonDir, { recursive: true })
writeFileSync(join(daemonDir, 'daemon-v18.token'), 'secret')
writeFileSync(join(daemonDir, 'daemon-v18.sock'), 'x')
writeFileSync(join(daemonDir, 'notes.txt'), 'x')
expect(collectInUseRuntimeVersions(daemonDir, aliveFor(1, 2, 3)).size).toBe(0)
})
it('skips a malformed pid file without throwing', () => {
const daemonDir = join(tempDir, 'daemon')
mkdirSync(daemonDir, { recursive: true })
writeFileSync(getDaemonPidPath(daemonDir, 18), 'not-a-pid-at-all')
writeDaemonPidFile(daemonDir, 19, { pid: 200, startedAtMs: null, appVersion: '2.0.0' })
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(200))
expect([...inUse]).toEqual(['2.0.0'])
})
it('treats the current process as alive under the default liveness probe', () => {
const daemonDir = join(tempDir, 'daemon')
// startedAtMs null so startTimeMatches short-circuits true on every platform.
writeDaemonPidFile(daemonDir, 18, {
pid: process.pid,
startedAtMs: null,
appVersion: '3.0.0'
})
expect([...collectInUseRuntimeVersions(daemonDir)]).toEqual(['3.0.0'])
})
})
describe('ensureRelocatedNodePtyNativeRuntime', () => {
it('copies the runtime tree (without symbols) and returns the version dir', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
const destDir = ensureRelocatedNodePtyNativeRuntime({
sourceDir,
destRoot,
version: '1.2.3',
daemonRuntimeDir: join(tempDir, 'daemon')
})
expect(destDir).toBe(join(destRoot, '1.2.3'))
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
expect(readFileSync(join(destDir!, 'conpty', 'OpenConsole.exe'), 'utf8')).toBe('console-host')
expect(readFileSync(join(destDir!, 'conpty', 'conpty.dll'), 'utf8')).toBe('dll')
expect(existsSync(join(destDir!, 'conpty.pdb'))).toBe(false)
})
it('skips recopying once the completion marker exists', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
ensureRelocatedNodePtyNativeRuntime({
sourceDir,
destRoot,
version: '1.2.3',
daemonRuntimeDir: join(tempDir, 'daemon')
})
writeFileSync(join(sourceDir, 'conpty.node'), 'changed-after-first-copy')
const destDir = ensureRelocatedNodePtyNativeRuntime({
sourceDir,
destRoot,
version: '1.2.3',
daemonRuntimeDir: join(tempDir, 'daemon')
})
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
})
it('redoes an interrupted copy that has no completion marker', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
mkdirSync(join(destRoot, '1.2.3'), { recursive: true })
writeFileSync(join(destRoot, '1.2.3', 'conpty.node'), 'torn partial copy')
const destDir = ensureRelocatedNodePtyNativeRuntime({
sourceDir,
destRoot,
version: '1.2.3',
daemonRuntimeDir: join(tempDir, 'daemon')
})
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
})
it('reclaims a stale version dir once no live daemon pins it', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
const daemonRuntimeDir = join(tempDir, 'daemon')
ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0', daemonRuntimeDir })
// The 1.0.0 daemon exited, so its pid is dead — nothing pins 1.0.0.
writeDaemonPidFile(daemonRuntimeDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.0.0' })
const destDir = ensureRelocatedNodePtyNativeRuntime({
sourceDir,
destRoot,
version: '2.0.0',
daemonRuntimeDir,
isDaemonPidAlive: aliveFor(/* 4321 is dead */)
})
expect(destDir).toBe(join(destRoot, '2.0.0'))
expect(existsSync(join(destRoot, '1.0.0'))).toBe(false)
expect(existsSync(join(destRoot, '2.0.0', 'conpty.node'))).toBe(true)
})
it('preserves a stale version dir a surviving daemon still pins', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
const daemonRuntimeDir = join(tempDir, 'daemon')
ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0', daemonRuntimeDir })
// The 1.0.0 daemon survived the update and still loads conpty.dll from its
// 1.0.0 runtime dir on every new spawn — deleting it would strand it.
writeDaemonPidFile(daemonRuntimeDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.0.0' })
const destDir = ensureRelocatedNodePtyNativeRuntime({
sourceDir,
destRoot,
version: '2.0.0',
daemonRuntimeDir,
isDaemonPidAlive: aliveFor(4321)
})
expect(destDir).toBe(join(destRoot, '2.0.0'))
expect(existsSync(join(destRoot, '1.0.0', 'conpty.node'))).toBe(true)
expect(existsSync(join(destRoot, '2.0.0', 'conpty.node'))).toBe(true)
})
it('fails open when the source dir is missing', () => {
expect(
ensureRelocatedNodePtyNativeRuntime({
sourceDir: join(tempDir, 'does-not-exist'),
destRoot: join(tempDir, 'dest'),
version: '1.2.3',
daemonRuntimeDir: join(tempDir, 'daemon')
})
).toBeNull()
})
})
// Loads the real win32 conpty binding, so it cannot run on other platforms.
describe.runIf(process.platform === 'win32')('patched node-pty loader override', () => {
it('loads the conpty binding from ORCA_NODE_PTY_NATIVE_DIR', () => {
const requireFromHere = createRequire(import.meta.url)
const nodePtyPackageDir = dirname(requireFromHere.resolve('node-pty/package.json'))
const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir)
expect(sourceDir).not.toBeNull()
const destDir = ensureRelocatedNodePtyNativeRuntime({
sourceDir: sourceDir!,
destRoot: join(tempDir, 'relocated-runtime'),
version: 'loader-test',
daemonRuntimeDir: join(tempDir, 'daemon')
})
expect(destDir).not.toBeNull()
const previousNativeDir = process.env.ORCA_NODE_PTY_NATIVE_DIR
process.env.ORCA_NODE_PTY_NATIVE_DIR = destDir!
try {
const utils = requireFromHere('node-pty/lib/utils.js')
const loaded = utils.loadNativeModule('conpty')
expect(loaded.dir).toBe(destDir)
expect(typeof loaded.module.startProcess).toBe('function')
} finally {
if (previousNativeDir === undefined) {
delete process.env.ORCA_NODE_PTY_NATIVE_DIR
} else {
process.env.ORCA_NODE_PTY_NATIVE_DIR = previousNativeDir
}
}
})
})