Relocate node-pty ConPTY runtime outside the Windows install dir (fixes update-time terminal loss) (#7421)

This commit is contained in:
Jinwoo Hong 2026-07-05 12:50:27 -07:00 committed by GitHub
parent 4b2e4735d7
commit 509c41e2bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 330 additions and 3 deletions

View File

@ -384,3 +384,27 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
}
}
#endif
diff --git a/lib/utils.js b/lib/utils.js
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -19,7 +19,20 @@ function loadNativeModule(name) {
var dirs = ['build/Release', 'build/Debug', "prebuilds/" + process.platform + "-" + process.arch];
// Check relative to the parent dir for unbundled and then the current dir for bundled
var relative = ['..', '.'];
var lastError;
+ // Why (Orca): the Windows updater force-closes every process whose image
+ // lives under the app install dir; this env var relocates the native
+ // binaries (conpty.dll spawns OpenConsole.exe from beside itself) so PTY
+ // console hosts run outside it and survive updates.
+ var overrideDir = process.env.ORCA_NODE_PTY_NATIVE_DIR;
+ if (overrideDir) {
+ try {
+ return { dir: overrideDir, module: require(overrideDir + "/" + name + ".node") };
+ }
+ catch (e) {
+ lastError = e;
+ }
+ }
for (var _i = 0, dirs_1 = dirs; _i < dirs_1.length; _i++) {
var d = dirs_1[_i];
for (var _a = 0, relative_1 = relative; _a < relative_1.length; _a++) {

View File

@ -12,7 +12,7 @@ patchedDependencies:
hash: 296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
node-pty@1.1.0:
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
hash: b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88
path: config/patches/node-pty@1.1.0.patch
importers:
@ -54,7 +54,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282)
version: 1.1.0(patch_hash=b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@ -11747,7 +11747,7 @@ snapshots:
undici: 6.27.0
which: 6.0.1
node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282):
node-pty@1.1.0(patch_hash=b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88):
dependencies:
node-addon-api: 7.1.1

View File

@ -20,6 +20,7 @@ import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store'
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store'
import { killAllPty } from './ipc/pty'
import { installRelocatedNodePtyNativeRuntime } from './pty/node-pty-runtime-relocation'
import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init'
import { closeAllWatchers } from './ipc/filesystem-watcher'
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
@ -575,6 +576,9 @@ if (hasSingleInstanceLock) {
initClaudeUsagePath()
initCodexUsagePath()
initOpenCodeUsagePath()
// Why: must run before anything loads node-pty (first PTY spawn) so main
// and the daemon it forks both pick up ORCA_NODE_PTY_NATIVE_DIR.
installRelocatedNodePtyNativeRuntime()
crashReports = CrashReportStore.fromUserData()
recordCrashBreadcrumb('app_started', {
packaged: app.isPackaged,

View File

@ -0,0 +1,162 @@
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 {
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')
}
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('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' })
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' })
writeFileSync(join(sourceDir, 'conpty.node'), 'changed-after-first-copy')
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
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' })
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
})
it('removes stale version dirs but keeps the current one', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0' })
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '2.0.0' })
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('fails open when the source dir is missing', () => {
expect(
ensureRelocatedNodePtyNativeRuntime({
sourceDir: join(tempDir, 'does-not-exist'),
destRoot: join(tempDir, 'dest'),
version: '1.2.3'
})
).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'
})
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
}
}
})
})

View File

@ -0,0 +1,137 @@
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
renameSync,
rmSync,
writeFileSync
} from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { app } from 'electron'
/**
* Relocates node-pty's Windows native runtime (conpty.node, conpty.dll,
* OpenConsole.exe, winpty binaries) from the install directory to userData.
*
* Why: the NSIS update installer force-closes every process whose image path
* is under the install directory before replacing files. conpty.dll spawns
* OpenConsole.exe from beside itself, so while these binaries live in the
* install dir every live terminal's console host is killed mid-update.
* Loading them from userData (via the ORCA_NODE_PTY_NATIVE_DIR override
* patched into node-pty's loader) takes them out of the installer's kill
* zone, and the detached daemon inherits the env var so its PTYs are covered.
*/
export const NODE_PTY_NATIVE_DIR_ENV_VAR = 'ORCA_NODE_PTY_NATIVE_DIR'
const RELOCATION_COMPLETE_MARKER = '.relocation-complete'
export function resolveNodePtyNativeSourceDir(nodePtyPackageDir: string): string | null {
// Packaged builds carry the rebuilt binding in build/Release; dev installs
// (and the forced-relocation test path) load from prebuilds.
const candidates = [
join(nodePtyPackageDir, 'build', 'Release'),
join(nodePtyPackageDir, 'prebuilds', `win32-${process.arch}`)
]
for (const candidate of candidates) {
if (existsSync(join(candidate, 'conpty.node'))) {
return candidate
}
}
return null
}
function copyRuntimeTree(sourceDir: string, destDir: string): void {
mkdirSync(destDir, { recursive: true })
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = join(sourceDir, entry.name)
const destPath = join(destDir, entry.name)
if (entry.isDirectory()) {
copyRuntimeTree(sourcePath, destPath)
} else if (entry.isFile() && !/\.pdb$/i.test(entry.name)) {
copyFileSync(sourcePath, destPath)
}
}
}
function removeStaleRuntimeVersions(destRoot: string, keepVersion: string): void {
let entries
try {
entries = readdirSync(destRoot, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === keepVersion) {
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`)
try {
renameSync(join(destRoot, entry.name), doomedPath)
rmSync(doomedPath, { recursive: true, force: true })
} catch {
// Still in use (or already being cleaned) — retry on a future launch.
}
}
}
export function ensureRelocatedNodePtyNativeRuntime(options: {
sourceDir: string
destRoot: string
version: string
}): string | null {
const { sourceDir, destRoot, version } = options
const destDir = join(destRoot, version)
try {
// Why: the marker is written only after a full copy, so a crash mid-copy
// leaves no marker and the next launch redoes the copy from scratch.
if (!existsSync(join(destDir, RELOCATION_COMPLETE_MARKER))) {
rmSync(destDir, { recursive: true, force: true })
copyRuntimeTree(sourceDir, destDir)
writeFileSync(join(destDir, RELOCATION_COMPLETE_MARKER), '')
}
removeStaleRuntimeVersions(destRoot, version)
return destDir
} catch {
// Fail open: node-pty keeps loading from the install dir, which is the
// pre-relocation behavior (sessions then don't survive updates).
return null
}
}
export function installRelocatedNodePtyNativeRuntime(): void {
if (process.platform !== 'win32') {
return
}
if (!app.isPackaged && process.env.ORCA_FORCE_CONPTY_RELOCATION !== '1') {
return
}
// Note: a pre-set env var is deliberately NOT honored here. The update
// relaunch chain (old app -> installer -> new app) inherits the previous
// version's value, which would pin the new process to stale binaries and
// skip copying the current version.
let nodePtyPackageDir: string
try {
const requireFromHere = createRequire(import.meta.url)
nodePtyPackageDir = dirname(requireFromHere.resolve('node-pty/package.json'))
} catch {
return
}
const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir)
if (!sourceDir) {
return
}
const destDir = ensureRelocatedNodePtyNativeRuntime({
sourceDir,
destRoot: join(app.getPath('userData'), 'node-pty-runtime'),
version: app.getVersion()
})
if (destDir) {
process.env[NODE_PTY_NATIVE_DIR_ENV_VAR] = destDir
}
}