diff --git a/Documentation/commands.md b/Documentation/commands.md index f3d0559b4b..016c09299a 100644 --- a/Documentation/commands.md +++ b/Documentation/commands.md @@ -136,6 +136,10 @@ Note: Each VS Code window gets its own extension host log folder, so the returne This command is only applicable to Linux machines. It attempts to ensure that .NET dependencies are present and, if they are not, installs them or prompts the user to do so. It accepts a [IDotnetEnsureDependenciesContext](https://github.com/dotnet/vscode-dotnet-runtime/blob/main/vscode-dotnet-runtime-library/src/IDotnetEnsureDependenciesContext.ts) object and has a void return type. It is no longer supported but remains to support legacy behavior. +The intended probe shape is `command: ` with `arguments` set to a `string[]` containing the .NET DLL payload to load and run. For example, the C# extension calls this command with the acquired `dotnet` path and an argument array containing its language server DLL. This lets the command test whether the specific .NET payload needed by the caller can start, and if it fails with a Linux dependency signal, the user is prompted to install missing dependencies. + +Passing CLI-only arguments such as `['--info']` runs the .NET CLI information path instead of the caller's payload and can exercise different runtime dependencies. That can be useful for diagnosis, but it is not the intended contract for this legacy command. + ### dotnet.reportIssue This is a **user-facing** command that opens a pre-populated GitHub issue in the browser and copies the issue body to the clipboard. It does not accept parameters and has a void return type. diff --git a/sample/HelloWorldConsoleApp/HelloWorldConsoleApp.deps.json b/sample/HelloWorldConsoleApp/HelloWorldConsoleApp.deps.json index 03e76bb60e..d62d848700 100644 --- a/sample/HelloWorldConsoleApp/HelloWorldConsoleApp.deps.json +++ b/sample/HelloWorldConsoleApp/HelloWorldConsoleApp.deps.json @@ -1,11 +1,11 @@ { "runtimeTarget": { - "name": ".NETCoreApp,Version=v2.2", - "signature": "da39a3ee5e6b4b0d3255bfef95601890afd80709" + "name": ".NETCoreApp,Version=v10.0", + "signature": "" }, "compilationOptions": {}, "targets": { - ".NETCoreApp,Version=v2.2": { + ".NETCoreApp,Version=v10.0": { "HelloWorldConsoleApp/1.0.0": { "runtime": { "HelloWorldConsoleApp.dll": {} diff --git a/sample/HelloWorldConsoleApp/HelloWorldConsoleApp.dll b/sample/HelloWorldConsoleApp/HelloWorldConsoleApp.dll index cc63cd6f20..2c98800735 100644 Binary files a/sample/HelloWorldConsoleApp/HelloWorldConsoleApp.dll and b/sample/HelloWorldConsoleApp/HelloWorldConsoleApp.dll differ diff --git a/sample/HelloWorldConsoleApp/HelloWorldConsoleApp.runtimeconfig.json b/sample/HelloWorldConsoleApp/HelloWorldConsoleApp.runtimeconfig.json index 49dbda4c5c..f730443ccd 100644 --- a/sample/HelloWorldConsoleApp/HelloWorldConsoleApp.runtimeconfig.json +++ b/sample/HelloWorldConsoleApp/HelloWorldConsoleApp.runtimeconfig.json @@ -1,9 +1,13 @@ { "runtimeOptions": { - "tfm": "netcoreapp2.2", + "tfm": "net10.0", "framework": { "name": "Microsoft.NETCore.App", - "version": "2.2.0" + "version": "10.0.0" + }, + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false } } } \ No newline at end of file diff --git a/sample/package.json b/sample/package.json index a3c7443375..dcb87281ac 100644 --- a/sample/package.json +++ b/sample/package.json @@ -70,6 +70,11 @@ "title": "Get the .NET runtime acquisition log file path", "category": "Sample" }, + { + "command": "sample.dotnet.ensureDependencies", + "title": "Call ensureDotnetDependencies with custom dotnet arguments", + "category": "Sample" + }, { "command": "sample.dotnet.acquireGlobalSDK", "title": "Install .NET SDK Globally via .NET Install Tool (Former Runtime Extension)", diff --git a/sample/src/extension.ts b/sample/src/extension.ts index 2573b3e0d2..686bbee6a8 100644 --- a/sample/src/extension.ts +++ b/sample/src/extension.ts @@ -13,6 +13,7 @@ import DotnetVersionSpecRequirement, IDotnetAcquireContext, IDotnetAcquireResult, + IDotnetEnsureDependenciesContext, IDotnetFindPathContext, IDotnetListVersionsResult, IDotnetLogResult, @@ -48,8 +49,8 @@ export function activate(context: vscode.ExtensionContext) { await vscode.commands.executeCommand('dotnet.showAcquisitionLog'); - // Console app requires .NET Core 2.2.0 - const commandRes = await vscode.commands.executeCommand('dotnet.acquire', { version: '2.2', requestingExtensionId }); + // Console app requires .NET 10. + const commandRes = await vscode.commands.executeCommand('dotnet.acquire', { version: '10.0', requestingExtensionId }); const dotnetPath = commandRes!.dotnetPath; if (!dotnetPath) { @@ -236,6 +237,96 @@ ${stderr}`); } }); + // Accept either whitespace-separated args (e.g. "--info") or a JSON string array for values that contain spaces. + function parseEnsureDependenciesArguments(input: string): string[] + { + const trimmed = input.trim(); + if (trimmed.startsWith('[')) + { + const parsed = JSON.parse(trimmed); + if (!Array.isArray(parsed) || parsed.some((arg: unknown) => typeof arg !== 'string')) + { + throw new Error('Custom arguments JSON must be an array of strings.'); + } + return parsed; + } + + return trimmed.length === 0 ? [] : trimmed.split(/\s+/); + } + + const sampleEnsureDependenciesRegistration = vscode.commands.registerCommand('sample.dotnet.ensureDependencies', async () => + { + const dotnetPath = await vscode.window.showInputBox({ + placeHolder: process.platform === 'win32' ? 'C:\\Program Files\\dotnet\\dotnet.exe' : '/usr/bin/dotnet', + value: 'dotnet', + prompt: 'The dotnet command or executable path to run.', + }); + + if (!dotnetPath) + { + return; + } + + const argumentMode = await vscode.window.showQuickPick(['DLL path', 'Custom arguments'], { + placeHolder: 'Choose the argument shape to pass to dotnet.ensureDotnetDependencies.' + }); + + if (!argumentMode) + { + return; + } + + let args: string[]; + if (argumentMode === 'DLL path') + { + const dllPath = await vscode.window.showInputBox({ + placeHolder: '/path/to/LanguageServer.dll', + prompt: 'The DLL path to pass as the single dotnet argument.', + }); + + if (!dllPath) + { + return; + } + args = [dllPath]; + } + else + { + const customArgs = await vscode.window.showInputBox({ + placeHolder: '--info or ["/path/to/app.dll", "--flag"]', + value: '--info', + prompt: 'Arguments to pass to dotnet. Use JSON array syntax if an argument contains spaces.', + }); + + if (customArgs === undefined) + { + return; + } + + try + { + args = parseEnsureDependenciesArguments(customArgs); + } + catch (error) + { + vscode.window.showErrorMessage(`Invalid custom arguments: ${(error as Error).message}`); + return; + } + } + + try + { + await vscode.commands.executeCommand('dotnet.showAcquisitionLog'); + const commandContext: IDotnetEnsureDependenciesContext = { command: dotnetPath, arguments: args }; + await vscode.commands.executeCommand('dotnet.ensureDotnetDependencies', commandContext); + vscode.window.showInformationMessage(`dotnet.ensureDotnetDependencies completed for: ${dotnetPath} ${args.join(' ')}`); + } + catch (error) + { + vscode.window.showErrorMessage((error as Error).toString()); + } + }); + const sampleGlobalSDKFromRuntimeRegistration = vscode.commands.registerCommand('sample.dotnet.acquireGlobalSDK', async (version: string | undefined) => { if (!version) @@ -354,6 +445,7 @@ ${JSON.stringify(result) ?? 'undefined'}`); sampleConcurrentASPNETTest, sampleShowAcquisitionLogRegistration, sampleGetAcquisitionLogRegistration, + sampleEnsureDependenciesRegistration, sampleFindPathRegistration, sampleAvailableInstallsRegistration ); diff --git a/vscode-dotnet-runtime-extension/src/extension.ts b/vscode-dotnet-runtime-extension/src/extension.ts index b5d7a0e043..f81c180cc7 100644 --- a/vscode-dotnet-runtime-extension/src/extension.ts +++ b/vscode-dotnet-runtime-extension/src/extension.ts @@ -911,7 +911,11 @@ ${JSON.stringify(commandContext)}`)); return; } - const result = cp.spawnSync(commandContext.command, commandContext.arguments); + // commandContext.arguments is either the dotnet process args (string[]) or a SpawnSync options object. + // Use the 3-arg overload (empty args + options) for the options case so the two paths are distinct. + const result = Array.isArray(commandContext.arguments) + ? cp.spawnSync(commandContext.command, commandContext.arguments) + : cp.spawnSync(commandContext.command, [], commandContext.arguments); const installer = new DotnetCoreDependencyInstaller(); if (installer.signalIndicatesMissingLinuxDependencies(result.signal!)) { diff --git a/vscode-dotnet-runtime-extension/src/test/functional/DotnetCoreAcquisitionExtension.test.ts b/vscode-dotnet-runtime-extension/src/test/functional/DotnetCoreAcquisitionExtension.test.ts index e09344f60b..74ad7798cd 100644 --- a/vscode-dotnet-runtime-extension/src/test/functional/DotnetCoreAcquisitionExtension.test.ts +++ b/vscode-dotnet-runtime-extension/src/test/functional/DotnetCoreAcquisitionExtension.test.ts @@ -3,6 +3,7 @@ * The .NET Foundation licenses this file to you under the MIT license. *--------------------------------------------------------------------------------------------*/ import * as chai from 'chai'; +import * as cp from 'child_process'; import { warn } from 'console'; import * as fs from 'fs'; import * as os from 'os'; @@ -10,6 +11,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { + DotnetCoreDependencyInstaller, DotnetInstallMode, DotnetInstallType, DotnetVersionSpecRequirement, @@ -58,6 +60,7 @@ suite('DotnetCoreAcquisitionExtension End to End', function () const requestingExtensionId = 'fake.extension'; const mockDisplayWorker = new MockWindowDisplayWorker(); let extensionContext: vscode.ExtensionContext; + let skipInstallCleanupAfterTest = false; const environmentVariableCollection = new MockEnvironmentVariableCollection(); const existingPathVersionToFake = '5.0.1~x64' @@ -116,7 +119,11 @@ suite('DotnetCoreAcquisitionExtension End to End', function () process.env.PATH = originalPATH; LocalMemoryCacheSingleton.getInstance().invalidate(); - await vscode.commands.executeCommand('dotnet.uninstallAll'); + if (!skipInstallCleanupAfterTest) + { + await vscode.commands.executeCommand('dotnet.uninstallAll'); + } + skipInstallCleanupAfterTest = false; mockState.clear(); MockTelemetryReporter.telemetryEvents = []; await new FileUtilities().wipeDirectory(storagePath); @@ -160,6 +167,96 @@ suite('DotnetCoreAcquisitionExtension End to End', function () assert.isTrue(logContents.length > 0, 'Log file is non-empty after activation'); }).timeout(standardTimeoutTime); + test('dotnet.ensureDotnetDependencies prompts when dotnet --info fails with a Linux dependency signal', async () => + { + const originalPlatform = os.platform; + const originalSpawnSync = cp.spawnSync; + const originalSignalCheck = DotnetCoreDependencyInstaller.prototype.signalIndicatesMissingLinuxDependencies; + const originalPromptLinuxDependencyInstall = DotnetCoreDependencyInstaller.prototype.promptLinuxDependencyInstall; + let promptCount = 0; + + try + { + skipInstallCleanupAfterTest = true; + Object.defineProperty(os, 'platform', { value: () => 'linux', configurable: true, writable: true }); + // Stub the platform-gated signal check rather than mutating the read-only process.platform, so this runs on any OS. + DotnetCoreDependencyInstaller.prototype.signalIndicatesMissingLinuxDependencies = (signal: string) => signal === 'SIGABRT'; + Object.defineProperty(cp, 'spawnSync', { + configurable: true, + writable: true, + value: (command: string, args?: string[]) => + { + assert.equal(command, 'dotnet'); + assert.deepEqual(args, ['--info']); + return { signal: 'SIGABRT', stderr: Buffer.from('Couldn\'t find a valid ICU package installed on the system.') }; + } + }); + DotnetCoreDependencyInstaller.prototype.promptLinuxDependencyInstall = async (message: string) => + { + assert.equal(message, 'Failed to run .NET runtime.'); + promptCount++; + return false; + }; + + await vscode.commands.executeCommand('dotnet.ensureDotnetDependencies', { command: 'dotnet', arguments: ['--info'] }); + + assert.equal(promptCount, 1, 'Missing Linux dependency prompt should be shown when dotnet --info aborts.'); + } + finally + { + Object.defineProperty(os, 'platform', { value: originalPlatform, configurable: true, writable: true }); + Object.defineProperty(cp, 'spawnSync', { value: originalSpawnSync, configurable: true, writable: true }); + DotnetCoreDependencyInstaller.prototype.signalIndicatesMissingLinuxDependencies = originalSignalCheck; + DotnetCoreDependencyInstaller.prototype.promptLinuxDependencyInstall = originalPromptLinuxDependencyInstall; + } + }).timeout(standardTimeoutTime); + + test('dotnet.ensureDotnetDependencies does not prompt when a dotnet dll payload starts successfully', async () => + { + const originalPlatform = os.platform; + const originalSpawnSync = cp.spawnSync; + const originalSignalCheck = DotnetCoreDependencyInstaller.prototype.signalIndicatesMissingLinuxDependencies; + const originalPromptLinuxDependencyInstall = DotnetCoreDependencyInstaller.prototype.promptLinuxDependencyInstall; + let promptCount = 0; + + try + { + skipInstallCleanupAfterTest = true; + Object.defineProperty(os, 'platform', { value: () => 'linux', configurable: true, writable: true }); + // Stub the platform-gated signal check rather than mutating the read-only process.platform, so this runs on any OS. + DotnetCoreDependencyInstaller.prototype.signalIndicatesMissingLinuxDependencies = (signal: string) => signal === 'SIGABRT'; + Object.defineProperty(cp, 'spawnSync', { + configurable: true, + writable: true, + value: (command: string, args?: string[]) => + { + assert.equal(command, 'dotnet'); + assert.deepEqual(args, [path.join('server', 'Microsoft.CodeAnalysis.LanguageServer.dll')]); + return { signal: null }; + } + }); + DotnetCoreDependencyInstaller.prototype.promptLinuxDependencyInstall = async () => + { + promptCount++; + return false; + }; + + await vscode.commands.executeCommand('dotnet.ensureDotnetDependencies', { + command: 'dotnet', + arguments: [path.join('server', 'Microsoft.CodeAnalysis.LanguageServer.dll')] + }); + + assert.equal(promptCount, 0, 'Missing Linux dependency prompt should not be shown when the dotnet dll payload starts.'); + } + finally + { + Object.defineProperty(os, 'platform', { value: originalPlatform, configurable: true, writable: true }); + Object.defineProperty(cp, 'spawnSync', { value: originalSpawnSync, configurable: true, writable: true }); + DotnetCoreDependencyInstaller.prototype.signalIndicatesMissingLinuxDependencies = originalSignalCheck; + DotnetCoreDependencyInstaller.prototype.promptLinuxDependencyInstall = originalPromptLinuxDependencyInstall; + } + }).timeout(standardTimeoutTime); + async function installRuntime(dotnetVersion: string, installMode: DotnetInstallMode, arch?: string) { let context: IDotnetAcquireContext = { version: dotnetVersion, requestingExtensionId, mode: installMode }; diff --git a/vscode-dotnet-runtime-library/install scripts/install-linux-prereqs.sh b/vscode-dotnet-runtime-library/install scripts/install-linux-prereqs.sh index 38ef73405d..e19107287f 100644 --- a/vscode-dotnet-runtime-library/install scripts/install-linux-prereqs.sh +++ b/vscode-dotnet-runtime-library/install scripts/install-linux-prereqs.sh @@ -44,7 +44,7 @@ sudoIf() # Utility function that waits for any existing installation operations to complete # on Debian/Ubuntu based distributions and then calls apt-get -aptSudoIf() +aptSudoIf() { while sudoIf fuser /var/lib/dpkg/lock >/dev/null 2>&1; do echo -ne "(*) Waiting for other package operations to complete.\r" @@ -69,7 +69,7 @@ checkNetCoreDeps(){ } checkAdditionalDeps(){ - if [ "$ADDITIONAL_DEPS" -ne "" ]; then + if [ "$ADDITIONAL_DEPS" != "" ]; then # Install additional dependencies if ! "$1" "$2 $ADDITIONAL_DEPS"; then echo "(!) Failed to install additional dependencies!" @@ -125,13 +125,13 @@ fi #openSUSE - Has to be first since apt-get is available but package names different if [ "$DISTRO" = "SUSE" ]; then echo "(*) Detected SUSE (unoffically/community supported)" - installAdditionalDeps sudoIf "zypper -n in" + checkAdditionalDeps sudoIf "zypper -n in" checkNetCoreDeps sudoIf "zypper -n in libopenssl1_0_0 libicu krb5 libz1" # Debian / Ubuntu elif [ "$DISTRO" = "Debian" ]; then echo "(*) Detected Debian / Ubuntu" - + # Get latest package data echo -e "\n(*) Updating package lists..." if ! aptSudoIf "update"; then @@ -139,9 +139,9 @@ elif [ "$DISTRO" = "Debian" ]; then exitScript 1 fi - installAdditionalDeps aptSudoIf "install -yq" - checkNetCoreDeps aptSudoIf "install -yq libicu[0-9][0-9] libkrb5-3 zlib1g $ADDITIONAL_DEPS" - if [ $SKIPDOTNETCORE -eq 0 ]; then + checkAdditionalDeps aptSudoIf "install -yq" + checkNetCoreDeps aptSudoIf "install -yq ^libicu[0-9][0-9]*$ libkrb5-3 zlib1g" + if [ $SKIPDOTNETCORE -eq 0 ]; then # Determine which version of libssl to install # dpkg-query can return "1" in some distros if the package is not found. "2" is an unexpected error LIBSSL=$(dpkg-query -f '${db:Status-Abbrev}\t${binary:Package}\n' -W 'libssl1\.0\.?' 2>&1) @@ -151,18 +151,20 @@ elif [ "$DISTRO" = "Debian" ]; then fi if [ "$(echo "$LIBSSL" | grep -o 'libssl1\.0\.[0-9]:' | uniq | sort | wc -l)" -eq 0 ]; then # No libssl install 1.0.2 for Debian, 1.0.0 for Ubuntu - if [[ ! -z $(apt-cache --names-only search ^libssl1.0.2$) ]]; then + if [[ ! -z $(apt-cache --names-only search '^libssl1\.0\.2$') ]]; then if ! aptSudoIf "install -yq libssl1.0.2"; then echo "(!) libssl1.0.2 installation failed!" exitScript 1 fi - else + elif [[ ! -z $(apt-cache --names-only search '^libssl1\.0\.0$') ]]; then if ! aptSudoIf "install -yq libssl1.0.0"; then echo "(!) libssl1.0.0 installation failed!" exitScript 1 fi + else + echo "(*) libssl1.0.x is not available. Skipping legacy dependency." fi - else + else echo "(*) libssl1.0.x already installed." fi fi @@ -180,9 +182,9 @@ elif [ "$DISTRO" = "RedHat" ]; then exitScript 1 fi - installAdditionalDeps sudoIf "yum -y install" - checkNetCoreDeps sudoIf "yum -y install openssl-libs krb5-libs libicu zlib" - # Install openssl-compat10 for Fedora 29. Does not exist in + checkAdditionalDeps sudoIf "yum -y install" + checkNetCoreDeps sudoIf "yum -y install openssl-libs krb5-libs libicu zlib" + # Install openssl-compat10 for Fedora 29. Does not exist in # CentOS, so validate package exists first. if [ $SKIPDOTNETCORE -eq 0 ]; then if ! sudoIf "yum -q list compat-openssl10" >/dev/null 2>&1; then @@ -198,20 +200,20 @@ elif [ "$DISTRO" = "RedHat" ]; then #ArchLinux elif [ "$DISTRO" = "ArchLinux" ]; then echo "(*) Detected Arch Linux (unoffically/community supported)" - installAdditionalDeps sudoIf "pacman -Sq --noconfirm --needed" + checkAdditionalDeps sudoIf "pacman -Sq --noconfirm --needed" checkNetCoreDeps sudoIf "pacman -Sq --noconfirm --needed gcr liburcu openssl-1.0 krb5 icu zlib" #Solus elif [ "$DISTRO" = "Solus" ]; then echo "(*) Detected Solus (unoffically/community supported)" - installAdditionalDeps sudoIf "eopkg -y it" + checkAdditionalDeps sudoIf "eopkg -y it" checkNetCoreDeps sudoIf "eopkg -y it libicu openssl zlib kerberos" #Alpine Linux elif [ "$DISTRO" = "Alpine" ]; then echo "(*) Detected Alpine Linux" - - # Update package repo indexes + + # Update package repo indexes echo -e "\n(*) Updating and upgrading..." if ! sudoIf "apk update --wait 30"; then echo "(!) Failed to update package lists." @@ -223,7 +225,7 @@ elif [ "$DISTRO" = "Alpine" ]; then exitScript 1 fi - installAdditionalDeps sudoIf "apk add --no-cache" + checkAdditionalDeps sudoIf "apk add --no-cache" sudoIf "apk add --no-cache libssl1.0 icu krb5 zlib" # Unknown distro diff --git a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreDependencyInstaller.ts b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreDependencyInstaller.ts index bdffbb7aa0..dc6afadfbc 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreDependencyInstaller.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreDependencyInstaller.ts @@ -21,6 +21,8 @@ export interface IAdditionalLibs export class DotnetCoreDependencyInstaller { private readonly platform = process.platform; + private lastTerminalCommandOutput = ''; + private lastTerminalCommandOutputFile = ''; public signalIndicatesMissingLinuxDependencies(signal: string): boolean { @@ -46,15 +48,21 @@ export class DotnetCoreDependencyInstaller public async installLinuxDependencies(additionalLibs: IAdditionalLibs = {}, skipDotNetCore = false): Promise { - const scriptRoot = path.join(__dirname, '..', 'install scripts'); + const scriptRoot = this.getInstallScriptsRoot(); const shellCommand = this.getShellCommand(); + const distroScript = path.join(scriptRoot, 'determine-linux-distro.sh'); // Determine the distro - const result = cp.spawnSync(shellCommand, [path.join(scriptRoot, 'determine-linux-distro.sh')]); - if (result.status !== 0) + const result = cp.spawnSync(shellCommand, [distroScript]); + if (result.status !== 0 || result.error) { - console.log(`Failed to determine distro. Exit code: ${result.status}`); - return result.status!; + // This early-return path never reaches the terminal capture below, so build the diagnostics here + // and stash them so the failure popup can surface a concrete reason instead of a bare exit code. + this.lastTerminalCommandOutput = this.describeDistroDetectionFailure(shellCommand, scriptRoot, distroScript, result); + this.lastTerminalCommandOutputFile = distroScript; + console.log(this.lastTerminalCommandOutput); + // Normalize a missing-binary spawn error (status === null) to 127 so callers report "command not found" consistently. + return result.status ?? 127; } const distro = result.stdout.toString().trim(); console.log(`Found distro ${distro}`); @@ -71,6 +79,40 @@ export class DotnetCoreDependencyInstaller moreInfoUrl]); } + /** + * Builds a human-readable diagnostic for a failed distro-detection spawn so the failure popup can explain + * exactly what went wrong (e.g. the shell or script could not be found, which surfaces as exit code 127). + */ + private describeDistroDetectionFailure(shellCommand: string, scriptRoot: string, scriptPath: string, result: cp.SpawnSyncReturns): string + { + const lines: string[] = []; + lines.push('Failed to detect the Linux distribution before installing dependencies.'); + lines.push(`Shell: ${shellCommand} (exists: ${fs.existsSync(shellCommand)})`); + lines.push(`Script root: ${scriptRoot}`); + lines.push(`Script: ${scriptPath} (exists: ${fs.existsSync(scriptPath)})`); + lines.push(`Exit code: ${result.status ?? 'null'}`); + if (result.signal) + { + lines.push(`Signal: ${result.signal}`); + } + if (result.error) + { + const errno = (result.error as NodeJS.ErrnoException).code; + lines.push(`Spawn error: ${result.error.message}${errno ? ` (${errno})` : ''}`); + } + const stderr = result.stderr?.toString().trim(); + if (stderr) + { + lines.push(`stderr: ${stderr}`); + } + const stdout = result.stdout?.toString().trim(); + if (stdout) + { + lines.push(`stdout: ${stdout}`); + } + return lines.join('\n'); + } + public async promptLinuxDependencyInstall(message: string, additionalLibs: IAdditionalLibs = {}, skipDotNetCore = false): Promise { while (true) @@ -94,10 +136,11 @@ export class DotnetCoreDependencyInstaller { const msg = (exitCode === 4 ? 'Your Linux distribution is not supported by the automated installer' : - 'The dependency installer failed.'); + `The dependency installer failed with exit code ${exitCode}.`); + const outputDetails = this.getLastTerminalCommandOutputDetails(); // Terminal will pause for input on error so this is just an info message with a more info button const failResponse = await vscode.window.showErrorMessage( - `${msg} Try installing dependencies manually.`, + `${msg} Try installing dependencies manually.${outputDetails}`, 'More Info'); if (failResponse === 'More Info') { @@ -130,6 +173,7 @@ export class DotnetCoreDependencyInstaller { const fullCommand = `"${command}" ${((args?.length ?? 0) > 0 ? ` "${args.join('" "')}"` : '')}`; const exitCodeFile = path.join(__dirname, '..', `terminal-exit-code-${Math.floor(Math.random() * 1000000)}`); + const outputFile = path.join(__dirname, '..', `terminal-output-${Math.floor(Math.random() * 1000000)}`); const commandList = new Array(); if (this.platform === 'win32') { @@ -149,10 +193,13 @@ export class DotnetCoreDependencyInstaller { // Note that "|| echo $? >" in this command sequence is a hack to get the exit code from the // executed command given VS Code terminal does not return it. + // The exit code is captured inside the subshell before the pipe, so appending "|| true" to the + // tee step keeps a missing/failing tee from breaking the "&&" chain before the final "exit 0" + // (which lets the terminal auto-close). If tee is unavailable, output capture is simply skipped. commandList.push( 'clear', `echo 0 > "${exitCodeFile}"`, - `${fullCommand} || echo $? > "${exitCodeFile}"`, + `(${fullCommand}; echo $? > "${exitCodeFile}") 2>&1 | tee "${outputFile}" || true`, ); if (promptAfterRun) { @@ -177,6 +224,14 @@ export class DotnetCoreDependencyInstaller // Hack to get exit code - VS Code terminal does not return it try { + this.lastTerminalCommandOutput = ''; + this.lastTerminalCommandOutputFile = outputFile; + if (fs.existsSync(outputFile)) + { + this.lastTerminalCommandOutput = fs.readFileSync(outputFile).toString().trim(); + fs.unlinkSync(outputFile); + } + if (fs.existsSync(exitCodeFile)) { const exitFile = fs.readFileSync(exitCodeFile).toString().trim(); @@ -227,4 +282,44 @@ export class DotnetCoreDependencyInstaller // shellCommand will be null if bash is not found return shellCommand ? shellCommand.toString() : which('sh')?.toString() ?? 'sh'; } + + private getInstallScriptsRoot(): string + { + // The 'install scripts' folder ships next to the compiled code, but its exact location depends on how this + // module is loaded: + // - Unbundled library (e.g. unit tests): this file is in dist/Acquisition, so the scripts are one level up in dist/install scripts. + // - Webpacked extension: this file is bundled into dist/extension.js (__dirname === dist, since node.__dirname is false), + // so the scripts are in dist/install scripts (no parent traversal). + // Resolving the wrong one made spawnSync run a non-existent script and return exit code 127. + const candidates = [ + path.join(__dirname, 'install scripts'), + path.join(__dirname, '..', 'install scripts'), + ]; + for (const candidate of candidates) + { + if (fs.existsSync(candidate)) + { + return candidate; + } + } + // Preserve prior behavior when neither candidate exists (unusual packaging); callers will surface the resulting error. + return path.join(__dirname, '..', 'install scripts'); + } + + private getLastTerminalCommandOutputDetails(): string + { + if (!this.lastTerminalCommandOutput) + { + return this.lastTerminalCommandOutputFile + ? ` No installer output was captured. Check the Linux dependency installer terminal for details.` + : ''; + } + + const maxOutputLength = 1200; + const output = this.lastTerminalCommandOutput.length > maxOutputLength + ? `...${this.lastTerminalCommandOutput.slice(-maxOutputLength)}` + : this.lastTerminalCommandOutput; + + return `\n\nDetails:\n${output}`; + } } diff --git a/vscode-dotnet-runtime-library/src/IDotnetEnsureDependenciesContext.ts b/vscode-dotnet-runtime-library/src/IDotnetEnsureDependenciesContext.ts index 6444999f3e..63686ad5e1 100644 --- a/vscode-dotnet-runtime-library/src/IDotnetEnsureDependenciesContext.ts +++ b/vscode-dotnet-runtime-library/src/IDotnetEnsureDependenciesContext.ts @@ -5,8 +5,9 @@ import * as cp from 'child_process'; import { EnsureDependenciesErrorConfiguration } from './Utils/ErrorHandler'; -export interface IDotnetEnsureDependenciesContext { +export interface IDotnetEnsureDependenciesContext +{ command: string; - arguments: cp.SpawnSyncOptionsWithStringEncoding; + arguments: string[] | cp.SpawnSyncOptionsWithStringEncoding; errorConfiguration?: EnsureDependenciesErrorConfiguration; } diff --git a/vscode-dotnet-runtime-library/src/test/unit/LinuxPrereqsInstallerScript.test.ts b/vscode-dotnet-runtime-library/src/test/unit/LinuxPrereqsInstallerScript.test.ts new file mode 100644 index 0000000000..20e851a6f4 --- /dev/null +++ b/vscode-dotnet-runtime-library/src/test/unit/LinuxPrereqsInstallerScript.test.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- +* Licensed to the .NET Foundation under one or more agreements. +* The .NET Foundation licenses this file to you under the MIT license. +*--------------------------------------------------------------------------------------------*/ +import * as chai from 'chai'; +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const assert = chai.assert; + +function writeExecutable(filePath: string, content: string): void +{ + fs.writeFileSync(filePath, content); + fs.chmodSync(filePath, 0o755); +} + +suite('Linux Prereqs Installer Script Unit Tests', function () +{ + test('Debian install uses a libicu package pattern that supports newer package versions', function () + { + if (os.platform() !== 'linux') + { + this.skip(); + } + + const scriptPath = path.resolve(__dirname, '../../../install scripts/install-linux-prereqs.sh'); + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dotnet-prereqs-script-')); + const fakeBin = path.join(testRoot, 'bin'); + const aptGetLog = path.join(testRoot, 'apt-get.log'); + fs.mkdirSync(fakeBin); + + try + { + writeExecutable(path.join(fakeBin, 'id'), '#!/usr/bin/env bash\nif [ "$1" = "-u" ]; then echo 0; exit 0; fi\nexit 0\n'); + writeExecutable(path.join(fakeBin, 'fuser'), '#!/usr/bin/env bash\nexit 1\n'); + writeExecutable(path.join(fakeBin, 'apt-get'), '#!/usr/bin/env bash\necho "$*" >> "$APT_GET_LOG"\nif [[ "$*" == *libssl1.0* ]]; then exit 1; fi\nexit 0\n'); + writeExecutable(path.join(fakeBin, 'apt-cache'), '#!/usr/bin/env bash\nexit 0\n'); + writeExecutable(path.join(fakeBin, 'dpkg-query'), '#!/usr/bin/env bash\necho "dpkg-query: no packages found matching libssl1.0.?"\nexit 1\n'); + + const result = cp.spawnSync('bash', [scriptPath, 'Debian', '', 'false', ''], { + encoding: 'utf8', + env: { + ...process.env, + APT_GET_LOG: aptGetLog, + PATH: `${fakeBin}:${process.env.PATH ?? ''}` + } + }); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.notInclude(result.stderr, 'command not found'); + assert.notInclude(result.stderr, 'integer expected'); + + const aptGetCalls = fs.readFileSync(aptGetLog, 'utf8'); + assert.include(aptGetCalls, 'update'); + assert.include(aptGetCalls, 'install -yq ^libicu[0-9][0-9]*$ libkrb5-3 zlib1g'); + assert.notInclude(aptGetCalls, 'libssl1.0'); + } + finally + { + fs.rmSync(testRoot, { recursive: true, force: true }); + } + }); +}); \ No newline at end of file