Relocate node-pty ConPTY runtime outside the Windows install dir (fixes update-time terminal loss) (#7421)
This commit is contained in:
parent
4b2e4735d7
commit
509c41e2bf
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue