fix(pty): keep runtime dirs a surviving daemon still uses (#7463)

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.

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Brennan Benson 2026-07-05 17:21:42 -07:00 committed by GitHub
parent f3f67a51f5
commit 3cd23a13a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 241 additions and 24 deletions

View File

@ -12,7 +12,10 @@ vi.mock('electron', () => ({
}
}))
import { getDaemonPidPath, serializeDaemonPidFile } from '../daemon/daemon-spawner'
import type { DaemonPidFile } from '../daemon/daemon-spawner'
import {
collectInUseRuntimeVersions,
ensureRelocatedNodePtyNativeRuntime,
resolveNodePtyNativeSourceDir
} from './node-pty-runtime-relocation'
@ -42,6 +45,23 @@ function seedSourceDir(dir: string): void {
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')
@ -66,13 +86,98 @@ describe('resolveNodePtyNativeSourceDir', () => {
})
})
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' })
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')
@ -85,10 +190,20 @@ describe('ensureRelocatedNodePtyNativeRuntime', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
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' })
const destDir = ensureRelocatedNodePtyNativeRuntime({
sourceDir,
destRoot,
version: '1.2.3',
daemonRuntimeDir: join(tempDir, 'daemon')
})
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
})
@ -100,30 +215,70 @@ describe('ensureRelocatedNodePtyNativeRuntime', () => {
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' })
const destDir = ensureRelocatedNodePtyNativeRuntime({
sourceDir,
destRoot,
version: '1.2.3',
daemonRuntimeDir: join(tempDir, 'daemon')
})
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
})
it('removes stale version dirs but keeps the current one', () => {
it('reclaims a stale version dir once no live daemon pins it', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0' })
const daemonRuntimeDir = join(tempDir, 'daemon')
ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0', daemonRuntimeDir })
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '2.0.0' })
// 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'
version: '1.2.3',
daemonRuntimeDir: join(tempDir, 'daemon')
})
).toBeNull()
})
@ -140,7 +295,8 @@ describe.runIf(process.platform === 'win32')('patched node-pty loader override',
const destDir = ensureRelocatedNodePtyNativeRuntime({
sourceDir: sourceDir!,
destRoot: join(tempDir, 'relocated-runtime'),
version: 'loader-test'
version: 'loader-test',
daemonRuntimeDir: join(tempDir, 'daemon')
})
expect(destDir).not.toBeNull()

View File

@ -3,13 +3,14 @@ import {
existsSync,
mkdirSync,
readdirSync,
renameSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { app } from 'electron'
import { parseDaemonPidFile, startTimeMatches } from '../daemon/daemon-health'
/**
* Relocates node-pty's Windows native runtime (conpty.node, conpty.dll,
@ -55,7 +56,61 @@ function copyRuntimeTree(sourceDir: string, destDir: string): void {
}
}
function removeStaleRuntimeVersions(destRoot: string, keepVersion: string): void {
type IsDaemonPidAlive = (pid: number, startedAtMs: number | null) => boolean
function isDaemonPidAliveDefault(pid: number, startedAtMs: number | null): boolean {
try {
process.kill(pid, 0)
} catch {
return false
}
return startTimeMatches(pid, startedAtMs)
}
/**
* Runtime version dirs still claimed by a live daemon, read from the daemon
* pid files under `daemonRuntimeDir` (`daemon-v<N>.pid`).
*
* Why: a daemon deliberately survives app updates, and it was forked with
* ORCA_NODE_PTY_NATIVE_DIR pinned to its own app-version runtime dir which
* it reloads conpty.dll from on every new spawn. Each pid file records that
* `appVersion`, so a live daemon's version dir must never be reclaimed.
*/
export function collectInUseRuntimeVersions(
daemonRuntimeDir: string,
isPidAlive: IsDaemonPidAlive = isDaemonPidAliveDefault
): Set<string> {
const inUse = new Set<string>()
let entries
try {
entries = readdirSync(daemonRuntimeDir, { withFileTypes: true })
} catch {
return inUse
}
for (const entry of entries) {
if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) {
continue
}
let parsed
try {
parsed = parseDaemonPidFile(readFileSync(join(daemonRuntimeDir, entry.name), 'utf8'))
} catch {
continue
}
// appVersion null => a pre-relocation daemon that loads from the install
// dir and thus pins no version dir here.
if (parsed && parsed.appVersion !== null && isPidAlive(parsed.pid, parsed.startedAtMs)) {
inUse.add(parsed.appVersion)
}
}
return inUse
}
function removeStaleRuntimeVersions(
destRoot: string,
keepVersion: string,
inUseVersions: ReadonlySet<string>
): void {
let entries
try {
entries = readdirSync(destRoot, { withFileTypes: true })
@ -63,19 +118,18 @@ function removeStaleRuntimeVersions(destRoot: string, keepVersion: string): void
return
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === keepVersion) {
if (!entry.isDirectory() || entry.name === keepVersion || inUseVersions.has(entry.name)) {
continue
}
// Why: an adopted daemon from a previous version may still run (or later
// respawn) binaries out of its own version dir. Renaming a directory
// fails on Windows while anything inside is open, so a successful rename
// proves the dir is unused and safe to delete.
const doomedPath = join(destRoot, `${entry.name}.stale`)
// Why: Windows maps the daemon's conpty.dll/OpenConsole.exe with
// FILE_SHARE_DELETE, so a rename/delete succeeds even while a surviving
// daemon still needs them — the old rename heuristic deleted in-use dirs
// and stranded the daemon (new spawn -> conpty.dll ERROR_PATH_NOT_FOUND).
// Only dirs no live daemon claims via its pid file are safe to remove.
try {
renameSync(join(destRoot, entry.name), doomedPath)
rmSync(doomedPath, { recursive: true, force: true })
rmSync(join(destRoot, entry.name), { recursive: true, force: true })
} catch {
// Still in use (or already being cleaned) — retry on a future launch.
// Still locked or already gone — retry on a future launch.
}
}
}
@ -84,8 +138,10 @@ export function ensureRelocatedNodePtyNativeRuntime(options: {
sourceDir: string
destRoot: string
version: string
daemonRuntimeDir: string
isDaemonPidAlive?: IsDaemonPidAlive
}): string | null {
const { sourceDir, destRoot, version } = options
const { sourceDir, destRoot, version, daemonRuntimeDir, isDaemonPidAlive } = options
const destDir = join(destRoot, version)
try {
// Why: the marker is written only after a full copy, so a crash mid-copy
@ -95,7 +151,8 @@ export function ensureRelocatedNodePtyNativeRuntime(options: {
copyRuntimeTree(sourceDir, destDir)
writeFileSync(join(destDir, RELOCATION_COMPLETE_MARKER), '')
}
removeStaleRuntimeVersions(destRoot, version)
const inUseVersions = collectInUseRuntimeVersions(daemonRuntimeDir, isDaemonPidAlive)
removeStaleRuntimeVersions(destRoot, version, inUseVersions)
return destDir
} catch {
// Fail open: node-pty keeps loading from the install dir, which is the
@ -126,10 +183,14 @@ export function installRelocatedNodePtyNativeRuntime(): void {
if (!sourceDir) {
return
}
const userData = app.getPath('userData')
const destDir = ensureRelocatedNodePtyNativeRuntime({
sourceDir,
destRoot: join(app.getPath('userData'), 'node-pty-runtime'),
version: app.getVersion()
destRoot: join(userData, 'node-pty-runtime'),
version: app.getVersion(),
// Keyed to daemon-init's runtimeDir (userData/daemon), where surviving
// daemons write daemon-v<N>.pid recording the runtime version they pin.
daemonRuntimeDir: join(userData, 'daemon')
})
if (destDir) {
process.env[NODE_PTY_NATIVE_DIR_ENV_VAR] = destDir