mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
fix(windows): ship the process-table addon to the relocated daemon host
The relocated daemon host mirrors the install dir, but the manifest copied only node-pty out of resources/node_modules. `@vscode/windows-process-tree` was never copied, so `require` failed inside orca-terminal-daemon.exe, `readOneSnapshot` took the absent-module branch, and every process-table read fell through to `powershell.exe Get-CimInstance Win32_Process`. That is #16905. The transcript pasted in the issue is byte-identical to POWERSHELL_PROCESS_QUERY, not to the memory collector's query -- which is also why toggling Resource Manager off changed nothing. The foreground tracker refreshes on the renderer's 2s agent-completion poll, so it ran about once per agent pane per two seconds, indefinitely. Copy the addon's runtime surface (package.json, lib/, the .node) into the mirror, and hash the addon into the materialization marker so a host missing or carrying a stale one is re-materialized rather than used: a host that cannot load it still runs, and silently forks a shell per snapshot, which is the failure this is fixing. Check the addon exists before the copy, not after -- the marker hashes it, so otherwise a ~260MB copy runs and is discarded when the hash throws, on every launch. Absence still fails open to the install-dir host, which has it. Finally, warn once when the CIM fallback engages. Absence is legitimate on a relay; being quiet about it is how this lasted months. Fixes #16905. Related to #15209 and #15749.
This commit is contained in:
@@ -26,6 +26,7 @@ import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-syst
|
||||
import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity'
|
||||
import { publishDaemonPidFile } from './daemon-spawner'
|
||||
import { isNativePtyException } from './daemon-native-pty-exception'
|
||||
import { isWindowsProcessTableAvailable } from '../windows/windows-process-table'
|
||||
|
||||
export type ParsedDaemonArgs = {
|
||||
socketPath: string
|
||||
@@ -318,6 +319,13 @@ async function main(): Promise<void> {
|
||||
daemonLog.log('ready')
|
||||
|
||||
warmWindowsConptyOnce()
|
||||
// Whether the addon loads is fixed for this process, and a detached daemon has
|
||||
// no stderr, so the module's own warn cannot report it here. Both answers, so a
|
||||
// bundle can tell "native" from "never asked" (#16905). Loading it now also pays
|
||||
// the dlopen off the first teardown.
|
||||
if (process.platform === 'win32') {
|
||||
daemonLog.log('windows-process-table', { native: isWindowsProcessTableAvailable() })
|
||||
}
|
||||
}
|
||||
|
||||
// Only auto-run when executed directly (not imported for testing, or for the build guard's
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { cpSync, existsSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join, win32 as winPath } from 'node:path'
|
||||
|
||||
// What the relocated host is made of: which files are mirrored, where each lands,
|
||||
// and which of a package's files are runtime rather than bulk. The lifecycle
|
||||
// around it -- when to materialize, what keeps a host valid, pruning -- is in
|
||||
// daemon-host-relocation.ts.
|
||||
|
||||
/**
|
||||
* The host exe keeps the app exe's own file name, so the relocated image is a byte-for-byte,
|
||||
* name-included copy of a signed binary — nothing for EDR to read as a renamed image (MITRE T1036).
|
||||
* Survival comes from the path (see daemon-host-relocation.ts). The one name-sensitive updater path is the
|
||||
* no-PowerShell `taskkill /IM` fallback, where the daemon is killed and terminals cold-restore —
|
||||
* the documented pre-relocation outcome, not a failure.
|
||||
*/
|
||||
export const daemonHostExeName = (execPath: string): string => winPath.basename(execPath)
|
||||
|
||||
// V8 snapshots + ICU data the Electron bootstrap reads even under ELECTRON_RUN_AS_NODE; siblings of Orca.exe.
|
||||
const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin']
|
||||
|
||||
/**
|
||||
* Everything `require('@vscode/windows-process-tree')` walks, package-relative.
|
||||
*
|
||||
* One list, read three times: the copy plan mirrors the package containing them,
|
||||
* materialization refuses to start without them, and a materialized host is only
|
||||
* valid while it still has them. Keeping those three in one place is what stops
|
||||
* "what we copy" and "what we accept" from drifting apart -- drift there means a
|
||||
* host that is copied, published, refused, and copied again on every launch.
|
||||
*/
|
||||
export const WINDOWS_PROCESS_TREE_REQUIRED = [
|
||||
'package.json',
|
||||
'lib/index.js',
|
||||
'build/Release/windows_process_tree.node'
|
||||
] as const
|
||||
|
||||
export type CopyOp = {
|
||||
sourcePath: string
|
||||
/** Destination path relative to the host root, posix-separated. */
|
||||
destRel: string
|
||||
kind: 'file' | 'dir'
|
||||
/** When true, a missing source is skipped rather than failing the copy. */
|
||||
optional?: boolean
|
||||
/** Per-source-path predicate for dir copies: return false to skip a path. */
|
||||
filter?: (sourcePath: string) => boolean
|
||||
}
|
||||
|
||||
export type DaemonHostSources = {
|
||||
appDir: string
|
||||
execPath: string
|
||||
resourcesPath: string
|
||||
entrySourcePath: string
|
||||
entryRelPath: string
|
||||
/** The addon's package; without it the daemon forks a shell per poll (#16905). */
|
||||
windowsProcessTreeDir: string
|
||||
}
|
||||
|
||||
// win32 path semantics so Windows paths decompose correctly off-win32 in cross-platform unit tests; production runs on win32 only.
|
||||
export function toPosixRelative(fromDir: string, absPath: string): string {
|
||||
return winPath.relative(fromDir, absPath).split(winPath.sep).join('/')
|
||||
}
|
||||
|
||||
export function destPath(root: string, destRel: string): string {
|
||||
return join(root, ...destRel.split('/'))
|
||||
}
|
||||
|
||||
// Drop node-pty's .pdb symbols and non-host-arch prebuilds (its bulk); keyed on host arch so a future win32-arm64 build keeps the prebuild it needs.
|
||||
const HOST_WIN_PREBUILD_DIR = `win32-${process.arch}`.toLowerCase()
|
||||
|
||||
function isRuntimeNodePtyPath(sourcePath: string): boolean {
|
||||
const p = sourcePath.toLowerCase()
|
||||
if (p.endsWith('.pdb')) {
|
||||
return false
|
||||
}
|
||||
// Keep only the host arch's win32 prebuild; drop any other win32-<arch> dir.
|
||||
const prebuild = p.match(/prebuilds[\\/](win32-[^\\/]+)/)
|
||||
return !prebuild || prebuild[1] === HOST_WIN_PREBUILD_DIR
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered copy plan. Every destRel mirrors the source's win-unpacked relative path so require()
|
||||
* and node-pty's loader resolve the mirror identically to the packaged app. Pure so tests can assert layout.
|
||||
*/
|
||||
export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] {
|
||||
const { appDir, execPath, resourcesPath, entrySourcePath, entryRelPath } = sources
|
||||
const ops: CopyOp[] = []
|
||||
|
||||
// Host exe (verbatim name) + V8/ICU blobs at dest root. Top-level DLLs omitted: GPU/media libs a windowless run-as-node host never loads (~48MB saved).
|
||||
ops.push({ sourcePath: execPath, destRel: daemonHostExeName(execPath), kind: 'file' })
|
||||
for (const name of RUNTIME_DATA_FILES) {
|
||||
ops.push({ sourcePath: join(appDir, name), destRel: name, kind: 'file', optional: true })
|
||||
}
|
||||
|
||||
// Daemon bundle: entry + sibling chunks/ + out/package.json (CJS/ESM loader resolution), mirrored verbatim.
|
||||
ops.push({ sourcePath: entrySourcePath, destRel: entryRelPath, kind: 'file' })
|
||||
const chunksDir = join(winPath.dirname(entrySourcePath), 'chunks')
|
||||
ops.push({
|
||||
sourcePath: chunksDir,
|
||||
destRel: toPosixRelative(appDir, chunksDir),
|
||||
kind: 'dir',
|
||||
optional: true
|
||||
})
|
||||
const pkgJson = join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json')
|
||||
ops.push({
|
||||
sourcePath: pkgJson,
|
||||
destRel: toPosixRelative(appDir, pkgJson),
|
||||
kind: 'file',
|
||||
optional: true
|
||||
})
|
||||
|
||||
// @vscode/windows-process-tree, mirrored so the daemon's require() resolves it; without it every snapshot forks a powershell.exe (#16905).
|
||||
const { windowsProcessTreeDir } = sources
|
||||
ops.push({
|
||||
sourcePath: windowsProcessTreeDir,
|
||||
destRel: toPosixRelative(appDir, windowsProcessTreeDir),
|
||||
kind: 'dir'
|
||||
})
|
||||
// node-pty tree, mirrored so require('node-pty') resolves it; filtered to drop unused .pdb/other-arch prebuilds.
|
||||
const nodePtyDir = join(resourcesPath, 'node_modules', 'node-pty')
|
||||
ops.push({
|
||||
sourcePath: nodePtyDir,
|
||||
destRel: toPosixRelative(appDir, nodePtyDir),
|
||||
kind: 'dir',
|
||||
filter: isRuntimeNodePtyPath
|
||||
})
|
||||
|
||||
return ops
|
||||
}
|
||||
|
||||
export function executeManifest(ops: CopyOp[], stagingRoot: string): void {
|
||||
for (const op of ops) {
|
||||
if (!existsSync(op.sourcePath)) {
|
||||
if (op.optional) {
|
||||
continue
|
||||
}
|
||||
throw new Error(`daemon-host relocation: missing required input ${op.sourcePath}`)
|
||||
}
|
||||
const dest = destPath(stagingRoot, op.destRel)
|
||||
mkdirSync(dirname(dest), { recursive: true })
|
||||
const { filter } = op
|
||||
// Dereference symlinks so the copy holds no link back into the install dir.
|
||||
cpSync(op.sourcePath, dest, {
|
||||
recursive: op.kind === 'dir',
|
||||
dereference: true,
|
||||
force: true,
|
||||
...(filter ? { filter: (src: string) => filter(src) } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import { createRequire } from 'node:module'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -37,8 +38,8 @@ function installHostApp(): void {
|
||||
} as AppEnvironment)
|
||||
}
|
||||
|
||||
import { buildDaemonHostManifest } from './daemon-host-manifest'
|
||||
import {
|
||||
buildDaemonHostManifest,
|
||||
collectPinnedDaemonVersions,
|
||||
getRelocatedDaemonHost,
|
||||
materializeRelocatedDaemonHost,
|
||||
@@ -88,8 +89,37 @@ function buildInstallFixture(root: string): void {
|
||||
mkdirSync(join(prebuildsRoot, arch), { recursive: true })
|
||||
writeFileSync(join(prebuildsRoot, arch, 'pty.node'), `${arch}-prebuild`)
|
||||
}
|
||||
const processTreeDir = join(root, 'resources', 'node_modules', '@vscode', 'windows-process-tree')
|
||||
mkdirSync(join(processTreeDir, 'build', 'Release'), { recursive: true })
|
||||
mkdirSync(join(processTreeDir, 'lib'), { recursive: true })
|
||||
mkdirSync(join(processTreeDir, 'src'), { recursive: true })
|
||||
writeFileSync(join(processTreeDir, 'package.json'), '{"main":"lib/index.js"}')
|
||||
writeFileSync(join(processTreeDir, 'lib', 'index.js'), 'module.exports = {}')
|
||||
writeFileSync(
|
||||
join(processTreeDir, 'build', 'Release', 'windows_process_tree.node'),
|
||||
'process-tree-native'
|
||||
)
|
||||
writeFileSync(join(processTreeDir, 'build', 'Release', 'windows_process_tree.pdb'), 'symbols')
|
||||
writeFileSync(join(processTreeDir, 'src', 'process.cc'), 'source')
|
||||
}
|
||||
|
||||
const PROCESS_TREE_DIR_REL = join(
|
||||
'resources',
|
||||
'node_modules',
|
||||
'@vscode',
|
||||
'windows-process-tree'
|
||||
)
|
||||
|
||||
const PROCESS_TREE_ADDON_REL = join(
|
||||
'resources',
|
||||
'node_modules',
|
||||
'@vscode',
|
||||
'windows-process-tree',
|
||||
'build',
|
||||
'Release',
|
||||
'windows_process_tree.node'
|
||||
)
|
||||
|
||||
// The win32 prebuild dir the running host arch loads vs. the one that is pruned.
|
||||
const HOST_PREBUILD = `win32-${process.arch}`
|
||||
const OTHER_PREBUILD = HOST_PREBUILD === 'win32-arm64' ? 'win32-x64' : 'win32-arm64'
|
||||
@@ -139,14 +169,13 @@ describe('buildDaemonHostManifest', () => {
|
||||
execPath: 'C:\\app\\Orca.exe',
|
||||
resourcesPath: 'C:\\app\\resources',
|
||||
entrySourcePath: 'C:\\app\\resources\\app.asar.unpacked\\out\\main\\daemon-entry.js',
|
||||
entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js'
|
||||
entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js',
|
||||
windowsProcessTreeDir: 'C:\\app\\resources\\node_modules\\@vscode\\windows-process-tree'
|
||||
})
|
||||
const byDest = new Map(ops.map((op) => [op.destRel, op]))
|
||||
// The host exe keeps the source basename: a verbatim, signature-preserving copy with no
|
||||
// image-name mismatch. What escapes the updater's sweep is the path, not the name.
|
||||
expect(byDest.get('Orca.exe')?.kind).toBe('file')
|
||||
const exeOp = ops.find((op) => op.sourcePath === 'C:\\app\\Orca.exe')
|
||||
expect(exeOp?.destRel).toBe('Orca.exe')
|
||||
// Without this op the relocated daemon cannot resolve the native process
|
||||
// table and falls back to a powershell.exe scan per snapshot (#16905).
|
||||
expect(byDest.get('resources/node_modules/@vscode/windows-process-tree')?.kind).toBe('dir')
|
||||
// V8/ICU data blobs are read by the Electron bootstrap and kept.
|
||||
expect(byDest.has('icudtl.dat')).toBe(true)
|
||||
// GPU/graphics DLLs are never loaded by the windowless host, so not copied.
|
||||
@@ -201,6 +230,19 @@ describe('materializeRelocatedDaemonHost', () => {
|
||||
const marker = JSON.parse(readFileSync(join(dest, '.materialized.json'), 'utf8'))
|
||||
expect(marker.version).toBe('9.9.9')
|
||||
expect(marker.entryRelPath).toBe('resources/app.asar.unpacked/out/main/daemon-entry.js')
|
||||
// The whole package is mirrored, so require() finds every hop it walks.
|
||||
const processTreeDest = join(dest, 'resources', 'node_modules', '@vscode', 'windows-process-tree')
|
||||
expect(existsSync(join(processTreeDest, 'build', 'Release', 'windows_process_tree.node'))).toBe(true)
|
||||
expect(existsSync(join(processTreeDest, 'lib', 'index.js'))).toBe(true)
|
||||
// The loader requires the BARE package, so resolution runs through the copied
|
||||
// package.json's `main`. Asserting the .node subpath instead would still pass
|
||||
// with package.json dropped from the filter, while the daemon's own require
|
||||
// failed and it silently went back to the CIM scan.
|
||||
expect(
|
||||
createRequire(
|
||||
join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'chunks', 'a.js')
|
||||
).resolve('@vscode/windows-process-tree')
|
||||
).toBe(join(processTreeDest, 'lib', 'index.js'))
|
||||
})
|
||||
|
||||
it('copies the exe verbatim: same file name and same bytes as the install-dir exe', () => {
|
||||
@@ -225,6 +267,120 @@ describe('materializeRelocatedDaemonHost', () => {
|
||||
expect(getRelocatedDaemonHost()?.execPath).toBe(join(dest, 'Orca Nightly.exe'))
|
||||
})
|
||||
|
||||
it('refuses a mirror that lost the addon, so a stale host is never handed out', () => {
|
||||
// A host without it still RUNS -- the daemon just forks a shell per snapshot --
|
||||
// which is why absence has to read as unmaterialized. This is also what retires
|
||||
// every host built before this shipped: they have no addon at all.
|
||||
materializeRelocatedDaemonHost()
|
||||
const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9')
|
||||
expect(getRelocatedDaemonHost()).not.toBeNull()
|
||||
|
||||
rmSync(join(dest, PROCESS_TREE_ADDON_REL))
|
||||
|
||||
expect(getRelocatedDaemonHost()).toBeNull()
|
||||
})
|
||||
|
||||
it('rematerializes a host whose copied addon went missing', () => {
|
||||
materializeRelocatedDaemonHost()
|
||||
const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9')
|
||||
const relocatedAddon = join(dest, PROCESS_TREE_ADDON_REL)
|
||||
|
||||
rmSync(relocatedAddon)
|
||||
// A sentinel proves the host was rebuilt rather than reused.
|
||||
const sentinel = join(dest, 'sentinel.txt')
|
||||
writeFileSync(sentinel, 'remove')
|
||||
|
||||
expect(materializeRelocatedDaemonHost()).not.toBeNull()
|
||||
expect(readFileSync(relocatedAddon, 'utf8')).toBe('process-tree-native')
|
||||
expect(existsSync(sentinel)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not read the install dir to decide an existing host is still good', () => {
|
||||
// Relocation exists to outlive the install dir, so validity cannot depend on
|
||||
// it: an updater mid-copy would otherwise condemn an intact host. A real
|
||||
// upgrade changes the version keying this directory, which already forces a
|
||||
// rebuild, and nothing else in the mirror is source-verified either.
|
||||
materializeRelocatedDaemonHost()
|
||||
const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9')
|
||||
const sentinel = join(dest, 'sentinel.txt')
|
||||
writeFileSync(sentinel, 'keep')
|
||||
|
||||
rmSync(join(installDir, 'resources', 'node_modules', '@vscode'), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
|
||||
expect(getRelocatedDaemonHost()).not.toBeNull()
|
||||
expect(existsSync(sentinel)).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
it.each([['the binary', PROCESS_TREE_ADDON_REL], ['package.json', join(PROCESS_TREE_DIR_REL, 'package.json')], ['lib/index.js', join(PROCESS_TREE_DIR_REL, 'lib', 'index.js')]])(
|
||||
'refuses a mirror that lost %s',
|
||||
(_label, relativePath) => {
|
||||
// Any one of them missing means require() cannot reach the addon, and a host
|
||||
// that cannot load it runs anyway -- forking a shell per snapshot (#16905).
|
||||
materializeRelocatedDaemonHost()
|
||||
const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9')
|
||||
expect(getRelocatedDaemonHost()).not.toBeNull()
|
||||
|
||||
rmSync(join(dest, relativePath))
|
||||
|
||||
expect(getRelocatedDaemonHost()).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('rematerializes a host whose copied addon went missing', () => {
|
||||
materializeRelocatedDaemonHost()
|
||||
const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9')
|
||||
const relocatedAddon = join(dest, PROCESS_TREE_ADDON_REL)
|
||||
|
||||
rmSync(relocatedAddon)
|
||||
// A sentinel proves the host was rebuilt rather than reused.
|
||||
const sentinel = join(dest, 'sentinel.txt')
|
||||
writeFileSync(sentinel, 'remove')
|
||||
|
||||
expect(materializeRelocatedDaemonHost()).not.toBeNull()
|
||||
expect(readFileSync(relocatedAddon, 'utf8')).toBe('process-tree-native')
|
||||
expect(existsSync(sentinel)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not read the install dir to decide an existing host is still good', () => {
|
||||
// Relocation exists to outlive the install dir, so validity cannot depend on
|
||||
// it: an updater mid-copy would otherwise condemn an intact host. A real
|
||||
// upgrade changes the version keying this directory, which already forces a
|
||||
// rebuild, and nothing else in the mirror is source-verified either.
|
||||
materializeRelocatedDaemonHost()
|
||||
const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9')
|
||||
const sentinel = join(dest, 'sentinel.txt')
|
||||
writeFileSync(sentinel, 'keep')
|
||||
|
||||
rmSync(join(installDir, 'resources', 'node_modules', '@vscode'), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
|
||||
expect(getRelocatedDaemonHost()).not.toBeNull()
|
||||
expect(existsSync(sentinel)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the binary', PROCESS_TREE_ADDON_REL],
|
||||
['package.json', join(PROCESS_TREE_DIR_REL, 'package.json')],
|
||||
['lib/index.js', join(PROCESS_TREE_DIR_REL, 'lib', 'index.js')]
|
||||
])('refuses to copy anything when the install lost %s', (_label, relativePath) => {
|
||||
// Each of these would be accepted by the copy plan and then refused by the
|
||||
// mirror check, which is a ~260MB copy per launch to reach a verdict the
|
||||
// source could have given for free. Sharing one list is what prevents that.
|
||||
rmSync(join(installDir, relativePath))
|
||||
|
||||
expect(materializeRelocatedDaemonHost()).toBeNull()
|
||||
// Not even the host root: the source is checked before any directory is made.
|
||||
expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('is idempotent: a valid marker short-circuits without recopying', () => {
|
||||
materializeRelocatedDaemonHost()
|
||||
const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9')
|
||||
@@ -278,6 +434,13 @@ describe('getRelocatedDaemonHost', () => {
|
||||
join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js'),
|
||||
'e'
|
||||
)
|
||||
// The process-table files too, or this passes because the mirror has no addon
|
||||
// and never exercises the version comparison it is named for.
|
||||
mkdirSync(join(dest, PROCESS_TREE_DIR_REL, 'lib'), { recursive: true })
|
||||
mkdirSync(dirname(join(dest, PROCESS_TREE_ADDON_REL)), { recursive: true })
|
||||
writeFileSync(join(dest, PROCESS_TREE_DIR_REL, 'package.json'), '{}')
|
||||
writeFileSync(join(dest, PROCESS_TREE_DIR_REL, 'lib', 'index.js'), 'm')
|
||||
writeFileSync(join(dest, PROCESS_TREE_ADDON_REL), 'n')
|
||||
writeFileSync(
|
||||
join(dest, '.materialized.json'),
|
||||
JSON.stringify({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
@@ -9,8 +8,17 @@ import {
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { dirname, join, win32 as winPath } from 'node:path'
|
||||
import { join, win32 as winPath } from 'node:path'
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import {
|
||||
buildDaemonHostManifest,
|
||||
daemonHostExeName,
|
||||
destPath,
|
||||
executeManifest,
|
||||
toPosixRelative,
|
||||
WINDOWS_PROCESS_TREE_REQUIRED,
|
||||
type DaemonHostSources
|
||||
} from './daemon-host-manifest'
|
||||
import type { ProcessLivenessVerdict } from './daemon-incarnation-evidence-types'
|
||||
import { parseDaemonPidFile } from './daemon-pid-file-parse'
|
||||
import { quarantineCorruptDaemonPidRecord } from './daemon-pid-record-quarantine'
|
||||
@@ -41,52 +49,12 @@ const MARKER_NAME = '.materialized.json'
|
||||
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync.
|
||||
const LOCAL_HOST_ROOT_NAME = 'Orca'
|
||||
|
||||
/**
|
||||
* The host exe keeps the app exe's own file name, so the relocated image is a byte-for-byte,
|
||||
* name-included copy of a signed binary — nothing for EDR to read as a renamed image (MITRE T1036).
|
||||
* Survival comes from the path (see the module header). The one name-sensitive updater path is the
|
||||
* no-PowerShell `taskkill /IM` fallback, where the daemon is killed and terminals cold-restore —
|
||||
* the documented pre-relocation outcome, not a failure.
|
||||
*/
|
||||
const daemonHostExeName = (execPath: string): string => winPath.basename(execPath)
|
||||
|
||||
// V8 snapshots + ICU data the Electron bootstrap reads even under ELECTRON_RUN_AS_NODE; siblings of Orca.exe.
|
||||
const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin']
|
||||
|
||||
type CopyOp = {
|
||||
sourcePath: string
|
||||
/** Destination path relative to the host root, posix-separated. */
|
||||
destRel: string
|
||||
kind: 'file' | 'dir'
|
||||
/** When true, a missing source is skipped rather than failing the copy. */
|
||||
optional?: boolean
|
||||
/** Per-source-path predicate for dir copies: return false to skip a path. */
|
||||
filter?: (sourcePath: string) => boolean
|
||||
}
|
||||
|
||||
type DaemonHostSources = {
|
||||
appDir: string
|
||||
execPath: string
|
||||
resourcesPath: string
|
||||
entrySourcePath: string
|
||||
entryRelPath: string
|
||||
}
|
||||
|
||||
type MaterializeMarker = {
|
||||
version: string
|
||||
completedAt: string
|
||||
entryRelPath: string
|
||||
}
|
||||
|
||||
// win32 path semantics so Windows paths decompose correctly off-win32 in cross-platform unit tests; production runs on win32 only.
|
||||
function toPosixRelative(fromDir: string, absPath: string): string {
|
||||
return winPath.relative(fromDir, absPath).split(winPath.sep).join('/')
|
||||
}
|
||||
|
||||
function destPath(root: string, destRel: string): string {
|
||||
return join(root, ...destRel.split('/'))
|
||||
}
|
||||
|
||||
// Mirror getDaemonEntryPath()'s resolution order so the copied entry is the exact file the in-dir fork would run.
|
||||
function resolveEntrySourcePath(resourcesPath: string): string {
|
||||
const unpackedRoot = join(resourcesPath, 'app.asar.unpacked')
|
||||
@@ -132,83 +100,8 @@ function collectDaemonHostSources(): DaemonHostSources | null {
|
||||
execPath,
|
||||
resourcesPath,
|
||||
entrySourcePath,
|
||||
entryRelPath: toPosixRelative(appDir, entrySourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop node-pty's .pdb symbols and non-host-arch prebuilds (its bulk); keyed on host arch so a future win32-arm64 build keeps the prebuild it needs.
|
||||
const HOST_WIN_PREBUILD_DIR = `win32-${process.arch}`.toLowerCase()
|
||||
function isRuntimeNodePtyPath(sourcePath: string): boolean {
|
||||
const p = sourcePath.toLowerCase()
|
||||
if (p.endsWith('.pdb')) {
|
||||
return false
|
||||
}
|
||||
// Keep only the host arch's win32 prebuild; drop any other win32-<arch> dir.
|
||||
const prebuild = p.match(/prebuilds[\\/](win32-[^\\/]+)/)
|
||||
return !prebuild || prebuild[1] === HOST_WIN_PREBUILD_DIR
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered copy plan. Every destRel mirrors the source's win-unpacked relative path so require()
|
||||
* and node-pty's loader resolve the mirror identically to the packaged app. Pure so tests can assert layout.
|
||||
*/
|
||||
export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] {
|
||||
const { appDir, execPath, resourcesPath, entrySourcePath, entryRelPath } = sources
|
||||
const ops: CopyOp[] = []
|
||||
|
||||
// Host exe (verbatim name) + V8/ICU blobs at dest root. Top-level DLLs omitted: GPU/media libs a windowless run-as-node host never loads (~48MB saved).
|
||||
ops.push({ sourcePath: execPath, destRel: daemonHostExeName(execPath), kind: 'file' })
|
||||
for (const name of RUNTIME_DATA_FILES) {
|
||||
ops.push({ sourcePath: join(appDir, name), destRel: name, kind: 'file', optional: true })
|
||||
}
|
||||
|
||||
// Daemon bundle: entry + sibling chunks/ + out/package.json (CJS/ESM loader resolution), mirrored verbatim.
|
||||
ops.push({ sourcePath: entrySourcePath, destRel: entryRelPath, kind: 'file' })
|
||||
const chunksDir = join(winPath.dirname(entrySourcePath), 'chunks')
|
||||
ops.push({
|
||||
sourcePath: chunksDir,
|
||||
destRel: toPosixRelative(appDir, chunksDir),
|
||||
kind: 'dir',
|
||||
optional: true
|
||||
})
|
||||
const pkgJson = join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json')
|
||||
ops.push({
|
||||
sourcePath: pkgJson,
|
||||
destRel: toPosixRelative(appDir, pkgJson),
|
||||
kind: 'file',
|
||||
optional: true
|
||||
})
|
||||
|
||||
// node-pty tree, mirrored so require('node-pty') resolves it; filtered to drop unused .pdb/other-arch prebuilds.
|
||||
const nodePtyDir = join(resourcesPath, 'node_modules', 'node-pty')
|
||||
ops.push({
|
||||
sourcePath: nodePtyDir,
|
||||
destRel: toPosixRelative(appDir, nodePtyDir),
|
||||
kind: 'dir',
|
||||
filter: isRuntimeNodePtyPath
|
||||
})
|
||||
|
||||
return ops
|
||||
}
|
||||
|
||||
function executeManifest(ops: CopyOp[], stagingRoot: string): void {
|
||||
for (const op of ops) {
|
||||
if (!existsSync(op.sourcePath)) {
|
||||
if (op.optional) {
|
||||
continue
|
||||
}
|
||||
throw new Error(`daemon-host relocation: missing required input ${op.sourcePath}`)
|
||||
}
|
||||
const dest = destPath(stagingRoot, op.destRel)
|
||||
mkdirSync(dirname(dest), { recursive: true })
|
||||
const { filter } = op
|
||||
// Dereference symlinks so the copy holds no link back into the install dir.
|
||||
cpSync(op.sourcePath, dest, {
|
||||
recursive: op.kind === 'dir',
|
||||
dereference: true,
|
||||
force: true,
|
||||
...(filter ? { filter: (src: string) => filter(src) } : {})
|
||||
})
|
||||
entryRelPath: toPosixRelative(appDir, entrySourcePath),
|
||||
windowsProcessTreeDir: join(resourcesPath, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +123,17 @@ function readMarker(dir: string): MaterializeMarker | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function processTreeRelDir(sources: DaemonHostSources): string {
|
||||
return toPosixRelative(sources.appDir, sources.windowsProcessTreeDir)
|
||||
}
|
||||
|
||||
/** True when any file require() needs is absent from a copy of the package. */
|
||||
function missingProcessTreeFiles(packageDir: string): boolean {
|
||||
return WINDOWS_PROCESS_TREE_REQUIRED.some(
|
||||
(relative) => !existsSync(join(packageDir, ...relative.split('/')))
|
||||
)
|
||||
}
|
||||
|
||||
function hostRootDir(): string {
|
||||
// Prefer LOCAL appData (see LOCAL_HOST_ROOT_NAME); fall back to userData only if LOCALAPPDATA is unset.
|
||||
const localAppData = process.env.LOCALAPPDATA
|
||||
@@ -260,6 +164,13 @@ export function getRelocatedDaemonHost(): RelocatedDaemonHost | null {
|
||||
if (!existsSync(execPath) || !existsSync(entryPath)) {
|
||||
return null
|
||||
}
|
||||
// A mirror the daemon cannot load the addon from still runs -- it just forks a
|
||||
// shell per snapshot (#16905) -- so treat it as unmaterialized and rebuild. Hosts
|
||||
// from before this shipped have none of these files. Checked in the mirror, never
|
||||
// in the install dir, which is the thing relocation exists to outlive.
|
||||
if (missingProcessTreeFiles(destPath(dest, processTreeRelDir(sources)))) {
|
||||
return null
|
||||
}
|
||||
return { execPath, entryPath }
|
||||
}
|
||||
|
||||
@@ -276,6 +187,12 @@ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null {
|
||||
if (!sources) {
|
||||
return null
|
||||
}
|
||||
// Checked against the source before copying: the mirror check below would refuse
|
||||
// the result anyway, and re-copying ~260MB on every launch to reach that verdict
|
||||
// is the loop this shares its list with the copy plan to prevent.
|
||||
if (missingProcessTreeFiles(sources.windowsProcessTreeDir)) {
|
||||
return null
|
||||
}
|
||||
const version = getAppEnvironment().getVersion()
|
||||
const root = hostRootDir()
|
||||
const dest = join(root, version)
|
||||
|
||||
@@ -481,6 +481,29 @@ describe('PowerShell fallback when the native binding is absent', () => {
|
||||
expect(cimScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('says so in the log, once, rather than falling back silently', async () => {
|
||||
// #16905 was this path running as the daemon's steady state with nothing to
|
||||
// notice it. Absence is legitimate on a relay; being quiet about it is not.
|
||||
const fallbackWarnings = (): number =>
|
||||
warn.mock.calls.filter((call) =>
|
||||
String(call[0]).includes('falling back to a powershell.exe CIM scan')
|
||||
).length
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
__setWindowsProcessTreeLoaderForTests(() => null)
|
||||
|
||||
await readWindowsProcessTableFresh()
|
||||
await readWindowsProcessTableFresh()
|
||||
expect(fallbackWarnings()).toBe(1)
|
||||
|
||||
// Re-injecting resets the reader, so the next process-equivalent warns again.
|
||||
// Asserting only the first count passes even with that reset removed, as long
|
||||
// as an earlier test in this file happened to trip the fallback first.
|
||||
__setWindowsProcessTreeLoaderForTests(() => null)
|
||||
await readWindowsProcessTableFresh()
|
||||
expect(fallbackWarnings()).toBe(2)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects a scan missing our own pid instead of reporting an idle machine', async () => {
|
||||
__setWindowsProcessTreeLoaderForTests(() => null)
|
||||
cimScan.mockResolvedValue([{ pid: 200, ppid: 4, name: 'claude.exe', command: 'claude' }])
|
||||
|
||||
@@ -168,6 +168,13 @@ function stagedRelayAddonIsUnpatched(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Once per process, for the main and relay processes whose console is real. The
|
||||
* daemon's stderr is destroyed once it reports ready, so it logs this capability
|
||||
* to daemonLog at startup instead (daemon-entry.ts).
|
||||
*/
|
||||
let warnedAboutCimFallback = false
|
||||
|
||||
let cachedModule: WindowsProcessTreeModule | null | undefined
|
||||
let moduleLoader: () => WindowsProcessTreeModule | null = loadWindowsProcessTree
|
||||
let cimScan: () => Promise<WindowsProcessRow[]> = readWindowsProcessRowsWithCim
|
||||
@@ -293,6 +300,7 @@ let nativeReadGate: Promise<unknown> = Promise.resolve()
|
||||
|
||||
function resetNativeReaderState(): void {
|
||||
nativeReaderEpoch += 1
|
||||
warnedAboutCimFallback = false
|
||||
unreturnedReads.clear()
|
||||
// Chain, never replace. Dropping the old chain lets a waiter still holding it
|
||||
// run against a read queued on the new one -- two concurrent calls into one
|
||||
@@ -367,6 +375,12 @@ function readOneSnapshot<Row>(projection: ProcessRowProjection<Row>): Promise<Ro
|
||||
// never silently start forking shells at the caller's poll rate. Absence
|
||||
// is the one condition that can never resolve itself — see
|
||||
// docs/reference/windows-process-enumeration.md.
|
||||
if (!warnedAboutCimFallback) {
|
||||
warnedAboutCimFallback = true
|
||||
console.warn(
|
||||
'[windows-process-table] no native binding; falling back to a powershell.exe CIM scan at each caller poll. See docs/reference/windows-process-enumeration.md.'
|
||||
)
|
||||
}
|
||||
return projection.cimFallback()
|
||||
}
|
||||
// Reject rather than resolve empty: an empty table is a claim that nothing
|
||||
|
||||
Reference in New Issue
Block a user