From 1bb15c657031eda792a60918fbbf3c0c1cc192ef Mon Sep 17 00:00:00 2001 From: Kakumanu Rupesh Kumar Date: Wed, 15 Apr 2026 10:42:32 +0530 Subject: [PATCH 1/2] Fix #305740: avoid sync subprocess wait in TS node path resolution - Add resolveNodeExecutableFromPath() to resolve node via PATH/PATHEXT - Remove execFileSync usage from node path detection - Add helper functions for cross-platform support (Windows PATHEXT, Unix PATH) - Add comprehensive unit tests for all platforms (Windows, Unix, fallback) - Maintain existing warning behavior when Node cannot be detected - Handles cross-platform differences in PATH environment variables Fixes: #305740 --- .../configuration/configuration.electron.ts | 68 ++++++++++++++++--- .../test/unit/configuration.electron.test.ts | 43 ++++++++++++ 2 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 extensions/typescript-language-features/src/test/unit/configuration.electron.test.ts diff --git a/extensions/typescript-language-features/src/configuration/configuration.electron.ts b/extensions/typescript-language-features/src/configuration/configuration.electron.ts index 6f5bbd356ce23..e5b333d72438e 100644 --- a/extensions/typescript-language-features/src/configuration/configuration.electron.ts +++ b/extensions/typescript-language-features/src/configuration/configuration.electron.ts @@ -6,11 +6,63 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; -import * as child_process from 'child_process'; import * as fs from 'fs'; import { BaseServiceConfigurationProvider } from './configuration'; import { RelativeWorkspacePathResolver } from '../utils/relativePathResolver'; +type IsExecutableFile = (candidate: string) => boolean; + +function defaultIsExecutableFile(candidate: string): boolean { + try { + return fs.existsSync(candidate) && !fs.lstatSync(candidate).isDirectory(); + } catch { + return false; + } +} + +function getCaseInsensitiveEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined { + const foundKey = Object.keys(env).find(k => k.toUpperCase() === key.toUpperCase()); + return foundKey ? env[foundKey] : undefined; +} + +export function resolveNodeExecutableFromPath( + env: NodeJS.ProcessEnv, + cwd: string, + isExecutableFile: IsExecutableFile = defaultIsExecutableFile, + platform: NodeJS.Platform = process.platform, +): string | null { + const pathLib = platform === 'win32' ? path.win32 : path.posix; + const pathValue = getCaseInsensitiveEnvValue(env, 'PATH'); + if (!pathValue) { + return null; + } + + const searchPaths = pathValue.split(pathLib.delimiter).filter(Boolean); + const windowsExecutableSuffixes = platform === 'win32' + ? (getCaseInsensitiveEnvValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) + : []; + + for (const pathEntry of searchPaths) { + const baseDir = pathLib.isAbsolute(pathEntry) ? pathEntry : pathLib.join(cwd, pathEntry); + + if (platform === 'win32') { + for (const ext of windowsExecutableSuffixes) { + const candidate = pathLib.join(baseDir, `node${ext}`); + if (isExecutableFile(candidate)) { + return candidate; + } + } + } + + const candidate = pathLib.join(baseDir, 'node'); + if (isExecutableFile(candidate)) { + return candidate; + } + } + + return null; +} + export class ElectronServiceConfigurationProvider extends BaseServiceConfigurationProvider { private fixPathPrefixes(inspectValue: string): string { @@ -92,18 +144,12 @@ export class ElectronServiceConfigurationProvider extends BaseServiceConfigurati } private findNodePath(): string | null { - try { - const out = child_process.execFileSync('node', ['-e', 'console.log(process.execPath)'], { - windowsHide: true, - timeout: 2000, - cwd: vscode.workspace.workspaceFolders?.[0].uri.fsPath, - encoding: 'utf-8', - }); - return out.trim(); - } catch (error) { + const cwd = vscode.workspace.workspaceFolders?.[0].uri.fsPath ?? process.cwd(); + const resolvedNodePath = resolveNodeExecutableFromPath(process.env, cwd); + if (!resolvedNodePath) { vscode.window.showWarningMessage(vscode.l10n.t("Could not detect a Node installation to run TS Server.")); - return null; } + return resolvedNodePath; } private validatePath(nodePath: string | null): string | null { diff --git a/extensions/typescript-language-features/src/test/unit/configuration.electron.test.ts b/extensions/typescript-language-features/src/test/unit/configuration.electron.test.ts new file mode 100644 index 0000000000000..030b847167cd6 --- /dev/null +++ b/extensions/typescript-language-features/src/test/unit/configuration.electron.test.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import 'mocha'; +import { resolveNodeExecutableFromPath } from '../../configuration/configuration.electron'; + +suite('typescript.configuration.electron', () => { + test('resolves node from PATH on win32', () => { + const found = resolveNodeExecutableFromPath( + { PATH: 'C:\\Windows;C:\\Tools', PATHEXT: '.COM;.EXE;.BAT;.CMD' }, + 'C:\\workspace', + candidate => candidate.toLowerCase() === 'c:\\tools\\node.exe', + 'win32', + ); + + assert.strictEqual(found, 'C:\\Tools\\node.EXE'); + }); + + test('resolves node from PATH on non-win32', () => { + const found = resolveNodeExecutableFromPath( + { PATH: '/bin:/usr/local/bin' }, + '/workspace', + candidate => candidate === '/usr/local/bin/node', + 'linux', + ); + + assert.strictEqual(found, '/usr/local/bin/node'); + }); + + test('returns null when node is not found', () => { + const found = resolveNodeExecutableFromPath( + { PATH: '/bin:/usr/local/bin' }, + '/workspace', + () => false, + 'linux', + ); + + assert.strictEqual(found, null); + }); +}); From 1c3ce63cb4e1756ed3f17757f9b17c49598b9303 Mon Sep 17 00:00:00 2001 From: Kakumanu Rupesh Kumar Date: Fri, 17 Apr 2026 23:31:45 +0530 Subject: [PATCH 2/2] fix(typescript): apply Copilot review feedback on node path resolution - Use single lstatSync (instead of existsSync + lstatSync) in defaultIsExecutableFile - Add X_OK executable permission check on non-Windows - Add cwd fallback in resolveNodeExecutableFromPath when PATH is missing or exhausted - Add unit tests for cwd fallback on Linux and win32 platforms --- .../configuration/configuration.electron.ts | 33 +++++++++++++++---- .../test/unit/configuration.electron.test.ts | 33 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/extensions/typescript-language-features/src/configuration/configuration.electron.ts b/extensions/typescript-language-features/src/configuration/configuration.electron.ts index e5b333d72438e..e9da73dd47398 100644 --- a/extensions/typescript-language-features/src/configuration/configuration.electron.ts +++ b/extensions/typescript-language-features/src/configuration/configuration.electron.ts @@ -14,7 +14,14 @@ type IsExecutableFile = (candidate: string) => boolean; function defaultIsExecutableFile(candidate: string): boolean { try { - return fs.existsSync(candidate) && !fs.lstatSync(candidate).isDirectory(); + const stat = fs.lstatSync(candidate); + if (!stat.isFile()) { + return false; + } + if (process.platform !== 'win32') { + fs.accessSync(candidate, fs.constants.X_OK); + } + return true; } catch { return false; } @@ -32,16 +39,13 @@ export function resolveNodeExecutableFromPath( platform: NodeJS.Platform = process.platform, ): string | null { const pathLib = platform === 'win32' ? path.win32 : path.posix; - const pathValue = getCaseInsensitiveEnvValue(env, 'PATH'); - if (!pathValue) { - return null; - } - - const searchPaths = pathValue.split(pathLib.delimiter).filter(Boolean); const windowsExecutableSuffixes = platform === 'win32' ? (getCaseInsensitiveEnvValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) : []; + const pathValue = getCaseInsensitiveEnvValue(env, 'PATH'); + const searchPaths = pathValue ? pathValue.split(pathLib.delimiter).filter(Boolean) : []; + for (const pathEntry of searchPaths) { const baseDir = pathLib.isAbsolute(pathEntry) ? pathEntry : pathLib.join(cwd, pathEntry); @@ -60,6 +64,21 @@ export function resolveNodeExecutableFromPath( } } + // Fallback: check cwd directly when node is not found via PATH + if (platform === 'win32') { + for (const ext of windowsExecutableSuffixes) { + const candidate = pathLib.join(cwd, `node${ext}`); + if (isExecutableFile(candidate)) { + return candidate; + } + } + } + + const cwdCandidate = pathLib.join(cwd, 'node'); + if (isExecutableFile(cwdCandidate)) { + return cwdCandidate; + } + return null; } diff --git a/extensions/typescript-language-features/src/test/unit/configuration.electron.test.ts b/extensions/typescript-language-features/src/test/unit/configuration.electron.test.ts index 030b847167cd6..80dac70f4d17d 100644 --- a/extensions/typescript-language-features/src/test/unit/configuration.electron.test.ts +++ b/extensions/typescript-language-features/src/test/unit/configuration.electron.test.ts @@ -40,4 +40,37 @@ suite('typescript.configuration.electron', () => { assert.strictEqual(found, null); }); + + test('falls back to cwd when PATH is missing on non-win32', () => { + const found = resolveNodeExecutableFromPath( + {}, + '/workspace', + candidate => candidate === '/workspace/node', + 'linux', + ); + + assert.strictEqual(found, '/workspace/node'); + }); + + test('falls back to cwd with PATHEXT when PATH is missing on win32', () => { + const found = resolveNodeExecutableFromPath( + { PATHEXT: '.COM;.EXE;.BAT;.CMD' }, + 'C:\\workspace', + candidate => candidate.toLowerCase() === 'c:\\workspace\\node.exe', + 'win32', + ); + + assert.strictEqual(found, 'C:\\workspace\\node.EXE'); + }); + + test('returns null when node is not found in PATH or cwd', () => { + const found = resolveNodeExecutableFromPath( + {}, + '/workspace', + () => false, + 'linux', + ); + + assert.strictEqual(found, null); + }); });