diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 10ddff3b886..f9e7a215152 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -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 { 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 diff --git a/src/main/daemon/daemon-host-manifest.ts b/src/main/daemon/daemon-host-manifest.ts new file mode 100644 index 00000000000..6d930bf17cf --- /dev/null +++ b/src/main/daemon/daemon-host-manifest.ts @@ -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- 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) } : {}) + }) + } +} diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts index e899a67ed43..03647734f78 100644 --- a/src/main/daemon/daemon-host-relocation.test.ts +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -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({ diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 13aab9fd8f9..51fed83530a 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -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- 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) diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts index 16c411ceb71..959a0092ff0 100644 --- a/src/main/windows/windows-process-table.test.ts +++ b/src/main/windows/windows-process-table.test.ts @@ -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' }]) diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts index e3064560174..975517e0161 100644 --- a/src/main/windows/windows-process-table.ts +++ b/src/main/windows/windows-process-table.ts @@ -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 = readWindowsProcessRowsWithCim @@ -293,6 +300,7 @@ let nativeReadGate: Promise = 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(projection: ProcessRowProjection): Promise