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:
Orca Worker
2026-09-08 03:25:09 -07:00
parent bba68b1bdd
commit d8375db6b4
6 changed files with 399 additions and 126 deletions
+8
View File
@@ -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
+148
View File
@@ -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) } : {})
})
}
}
+170 -7
View File
@@ -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({
+36 -119
View File
@@ -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' }])
+14
View File
@@ -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