diff --git a/.github/workflows/extension-e2e-tests.yml b/.github/workflows/extension-e2e-tests.yml index 9123b301f7d..c2b4a721394 100644 --- a/.github/workflows/extension-e2e-tests.yml +++ b/.github/workflows/extension-e2e-tests.yml @@ -282,6 +282,15 @@ jobs: archivePattern: aspire-cli-win-x64*.zip cliBinary: aspire.exe useXvfb: false + - name: Linux + shardName: resource-debug-tools + spec: out/test-e2e/test-e2e/resourceDebugTools.e2e.test.js + runner: ubuntu-latest + rid: linux-x64 + archivePattern: aspire-cli-linux-x64*.tar.gz + cliBinary: aspire + useXvfb: true + installResourceDebug: true - name: Linux shardName: java-apphost spec: out/test-e2e/test-e2e/javaAppHost.e2e.test.js @@ -346,6 +355,14 @@ jobs: archivePattern: aspire-cli-linux-x64*.tar.gz cliBinary: aspire useXvfb: true + - name: Windows + shardName: resource-debug-tools + spec: out/test-e2e/test-e2e/resourceDebugTools.e2e.test.js + runner: windows-latest + rid: win-x64 + archivePattern: aspire-cli-win-x64*.zip + cliBinary: aspire.exe + useXvfb: false - name: Windows shardName: cli-path-rejection spec: out/test-e2e/test-e2e/cliPathRejectionNotification.e2e.test.js @@ -554,6 +571,49 @@ jobs: echo 'FUNCTIONS_CORE_TOOLS_TELEMETRY_OPTOUT=1' } >> "$GITHUB_ENV" + - name: Install resource debug E2E prerequisites + if: ${{ matrix.installResourceDebug }} + shell: bash + run: | + set -euo pipefail + + # Ubuntu's Yama default allows ptrace only when the debugger is an ancestor of the target. + # DCP launches the Go resource independently from Delve, so packaged attach needs this test-only policy. + sudo sysctl --write kernel.yama.ptrace_scope=0 + test "$(cat /proc/sys/kernel/yama/ptrace_scope)" = "0" + + debugger_bin="$RUNNER_TEMP/resource-debug-bin" + mkdir -p "$debugger_bin" + GOBIN="$debugger_bin" GOTOOLCHAIN=local go install github.com/go-delve/delve/cmd/dlv@v1.25.2 + echo "$debugger_bin" >> "$GITHUB_PATH" + export PATH="$debugger_bin:$PATH" + dlv version + + dotnet_runtime_vsix="$RUNNER_TEMP/vscode-dotnet-runtime-3.1.0.vsix" + curl --fail --location --compressed --retry 3 --retry-all-errors \ + --output "$dotnet_runtime_vsix" \ + 'https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dotnettools/vsextensions/vscode-dotnet-runtime/3.1.0/vspackage' + echo '8e675ffe5f3674430d63e28d2dc05ab40f36c8494e9549e79d3995d721b13f5a '"$dotnet_runtime_vsix" | sha256sum --check - + + csharp_vsix="$RUNNER_TEMP/vscode-csharp-2.148.23-linux-x64.vsix" + curl --fail --location --compressed --retry 3 --retry-all-errors \ + --output "$csharp_vsix" \ + 'https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dotnettools/vsextensions/csharp/2.148.23/vspackage?targetPlatform=linux-x64' + echo '18b503e614a979212762683b35a4fa1806688ba773d5fe93bf62c9f9346db23f '"$csharp_vsix" | sha256sum --check - + + go_vsix="$RUNNER_TEMP/vscode-go-0.56.0.vsix" + curl --fail --location --compressed --retry 3 --retry-all-errors \ + --output "$go_vsix" \ + 'https://marketplace.visualstudio.com/_apis/public/gallery/publishers/golang/vsextensions/Go/0.56.0/vspackage' + echo '9f5959fb17ba0a8dbd804387ddda50975fcaa9dd5267aa33eaaa89912072aacb '"$go_vsix" | sha256sum --check - + + { + echo 'ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG=true' + echo "ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX=$dotnet_runtime_vsix" + echo "ASPIRE_EXTENSION_E2E_CSHARP_VSIX=$csharp_vsix" + echo "ASPIRE_EXTENSION_E2E_GO_VSIX=$go_vsix" + } >> "$GITHUB_ENV" + - name: Set up the JDK for the Java E2E specs if: ${{ matrix.installJava }} uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 diff --git a/docs/specs/cli-output-formats.md b/docs/specs/cli-output-formats.md index ffec04212ad..2292da2aafb 100644 --- a/docs/specs/cli-output-formats.md +++ b/docs/specs/cli-output-formats.md @@ -197,7 +197,7 @@ If discovery finds no AppHost candidates, the stream emits no lines. The stream | `relationships` | Related resources as `{ "type": "...", "resourceName": "..." }`. | | `urls` | Endpoint objects with `name`, `displayName`, `url`, and `isInternal`. | | `volumes` | Volume objects with `source`, `target`, `mountType`, and `isReadOnly`. | -| `properties` | Resource properties keyed by property name. | +| `properties` | Resource properties keyed by property name. Common debug-related keys include `project.path`, `project.launchProfile`, `project.launchCommand`, `project.configuration`, `project.targetFramework`, and `resource.launchConfigurationType`. For current AppHosts, `project.launchCommand` is `run`, `watch`, or `null` when a dotnet project launch command cannot be classified. | | `environment` | Environment variables keyed by variable name. | | `healthReports` | Health report objects keyed by report name. | | `commands` | Resource command metadata keyed by command name. | diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index ce86bc05a71..cff02caf6ca 100644 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -14,6 +14,10 @@ +### Features + +- Add an **Attach debugger** action for running .NET and Go resources in the Aspire pane, including installation guidance when the required C# or Go debugger extension is missing ([#18602](https://github.com/microsoft/aspire/pull/18602)). + ### Fixes - Prevent the Aspire view from stealing sidebar focus and reappearing in the Activity Bar when the window is reloaded ([#19746](https://github.com/microsoft/aspire/issues/19746), [#19754](https://github.com/microsoft/aspire/pull/19754)). diff --git a/extension/README.md b/extension/README.md index 16eab69d75f..894c4ab33d1 100644 --- a/extension/README.md +++ b/extension/README.md @@ -20,6 +20,7 @@ An **AppHost** defines your app in code: services, containers, databases, front - **Run your apps:** Start, debug, and stop an Aspire application, or use the available start, stop, and restart actions for individual resources, from the Aspire view. - **Debug across your stack:** Aspire debug sessions support C#, JavaScript, Python, Go, Java, and Rust application resources when the corresponding debugger is available. Java and Rust debugging require Aspire 13.6 or later. +- **Attach to running resources:** Attach to .NET and Go resources from the Aspire view. The action remains available when a required debugger extension is missing so VS Code can show installation guidance. - **See live health and analytics:** Open the Aspire dashboard for resource health, endpoints, console logs, structured logs, distributed traces, and metrics. - **Monitor without leaving VS Code:** See resource health summaries and quick actions beside resource definitions in your AppHost. - **Move toward production.** Deploy, publish artifacts, and run pipeline steps using the Aspire view. @@ -28,6 +29,10 @@ An **AppHost** defines your app in code: services, containers, databases, front Aspire can bring together C#, TypeScript and JavaScript, Python, Go, Java, containers, databases, cloud resources, and more. Browse the [integration gallery](https://aspire.dev/integrations/gallery/) to find the pieces your app needs. +## Chat tools for agents + +The extension contributes `aspire_apphost_start`, `aspire_apphost_stop`, and `aspire_resource_debug` language model tools. They only accept AppHosts and resources already discovered in a trusted workspace, reject arbitrary or absolute paths, and ask the user to confirm before acting. Resource debugging attaches to a running resource; it does not start or restart one. + ## Learn more - [Aspire extension for Visual Studio Code](https://aspire.dev/get-started/aspire-vscode-extension/) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index ab766716917..160378c5a58 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -10,6 +10,9 @@ A debug session is already active for id {0}. + + A debugger is already attached to {0}. + Add Aspire to this workspace @@ -88,18 +91,54 @@ Aspire terminal + + Aspire terminal closed before its process started. + Aspire terminal command arguments cannot contain control characters. Aspire terminal command syntax can only contain command names and flags. + + Aspire terminal process failed to start. + Aspire: Launch default AppHost Aspire: Launch default AppHost ({0}: {1}) + + Attach debugger + + + Attach debugger to Aspire resource + + + Attach debugger: {0} + + + Attach the VS Code debugger to a running Aspire resource that the extension has already discovered. Requires a workspace-relative AppHost path and the resource name. The default auto strategy currently attaches to the resource; start and restart under debug are not supported. + + + Attach the debugger to a running Aspire resource. + + + Attach the debugger to resource {0} from Aspire AppHost {1}? + + + Attach the debugger to the requested Aspire resource? + + + Attaching debugger to Aspire resource {0}... + + + Attaching debugger to the requested Aspire resource... + + + Attaching debugger to {0}... + Attempted to start unsupported resource type: {0}. @@ -253,12 +292,18 @@ Debug Aspire pipeline step + + Debug Aspire resource + Debug pipeline step Debug pipeline step + + Debug strategy. auto selects the available safe action, currently attach. attach only attaches a debugger; starting and restarting resources are not supported. + Debug with Chrome @@ -445,6 +490,12 @@ Install the Aspire CLI + + Install the C# extension to attach the debugger to .NET project resources. + + + Install {0} to attach the debugger to this resource. + Invalid launch configuration for {0}. @@ -535,6 +586,9 @@ Multiple AppHosts were found. Select the one to launch + + Name of a running resource from the selected AppHost. Resource names are limited to 256 characters. + New Aspire project @@ -973,6 +1027,9 @@ The selected Aspire CLI launch profile capability could not be verified. + + The selected resource is no longer available. Refresh the Aspire pane and try again. + This Aspire AppHost is already starting or running. The new debug session was cancelled so only one AppHost runs. @@ -985,6 +1042,9 @@ This field is required. + + This resource cannot be attached to a debugger. + This setting has been renamed to aspire.appHostsPollingInterval. @@ -1003,6 +1063,9 @@ Timeout in milliseconds for Aspire CLI commands that discover AppHost projects. Streaming discovery resets this timeout when output is received and has a maximum runtime of five minutes. Minimum: 1000. + + Trust this workspace before attaching a debugger to an Aspire resource. + Unable to add folder to workspace: {0} @@ -1039,6 +1102,9 @@ VS Code did not start the Aspire {0} session for {1}. + + VS Code did not start the debugger attach session for {0}. + Value missing @@ -1093,6 +1159,9 @@ Workspace-relative path of an AppHost that Aspire has already discovered in this workspace, for example 'AppHost/AppHost.csproj' or 'apphost.cs'. The value must match one of the discovered AppHosts exactly; arbitrary paths, absolute paths, and files Aspire did not discover are rejected. In a multi-root workspace, always prefix the path with the workspace folder name (for example 'backend/AppHost/AppHost.csproj'). + + Workspace-relative path of an AppHost that Aspire has already discovered. Absolute paths and paths Aspire did not discover are rejected. In a multi-root workspace, prefix the path with the workspace folder name. + Yes diff --git a/extension/package.json b/extension/package.json index 69ee9e2b93c..fd87c4c76f5 100644 --- a/extension/package.json +++ b/extension/package.json @@ -55,7 +55,8 @@ "onCommand:aspire-vscode.installCli", "onCommand:aspire-vscode.verifyCliInstalled", "onLanguageModelTool:aspire_apphost_start", - "onLanguageModelTool:aspire_apphost_stop" + "onLanguageModelTool:aspire_apphost_stop", + "onLanguageModelTool:aspire_resource_debug" ], "main": "./dist/extension.js", "l10n": "./l10n", @@ -133,6 +134,48 @@ ], "additionalProperties": false } + }, + { + "name": "aspire_resource_debug", + "toolReferenceName": "aspireDebugResource", + "displayName": "%languageModelTool.aspireResourceDebug.displayName%", + "modelDescription": "%languageModelTool.aspireResourceDebug.modelDescription%", + "userDescription": "%languageModelTool.aspireResourceDebug.userDescription%", + "icon": "$(debug-alt)", + "canBeReferencedInPrompt": true, + "when": "isWorkspaceTrusted", + "tags": [ + "aspire", + "debug", + "resource" + ], + "inputSchema": { + "type": "object", + "properties": { + "appHostPath": { + "type": "string", + "description": "%languageModelTool.aspireResourceDebug.appHostPath.description%" + }, + "resourceName": { + "type": "string", + "description": "%languageModelTool.aspireResourceDebug.resourceName.description%" + }, + "strategy": { + "type": "string", + "enum": [ + "auto", + "attach" + ], + "default": "auto", + "description": "%languageModelTool.aspireResourceDebug.strategy.description%" + } + }, + "required": [ + "appHostPath", + "resourceName" + ], + "additionalProperties": false + } } ], "mcpServerDefinitionProviders": [ @@ -499,6 +542,12 @@ "category": "Aspire", "icon": "$(debug-restart)" }, + { + "command": "aspire-vscode.attachDebuggerToResource", + "title": "%command.attachDebuggerToResource%", + "category": "Aspire", + "icon": "$(debug-alt)" + }, { "command": "aspire-vscode.viewResourceLogs", "title": "%command.viewResourceLogs%", @@ -764,6 +813,10 @@ "command": "aspire-vscode.restartResource", "when": "false" }, + { + "command": "aspire-vscode.attachDebuggerToResource", + "when": "false" + }, { "command": "aspire-vscode.viewResourceLogs", "when": "false" @@ -964,15 +1017,20 @@ "when": "view == aspire-vscode.appHosts && viewItem =~ /^resource.*:canRestart/", "group": "2_actions@3" }, + { + "command": "aspire-vscode.attachDebuggerToResource", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^resource.*:canAttachDebugger/", + "group": "2_actions@4" + }, { "command": "aspire-vscode.executeResourceCommand", "when": "view == aspire-vscode.appHosts && viewItem =~ /^resource(:|$)/", - "group": "2_actions@4" + "group": "2_actions@5" }, { "command": "aspire-vscode.executeResourceCommandItem", "when": "view == aspire-vscode.appHosts && viewItem == resourceCommand:enabled", - "group": "2_actions@4" + "group": "2_actions@5" }, { "command": "aspire-vscode.viewResourceLogs", diff --git a/extension/package.nls.json b/extension/package.nls.json index 0a25c81219a..c29a3b1367c 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -242,6 +242,7 @@ "command.stopResource": "Stop", "command.startResource": "Start", "command.restartResource": "Restart", + "command.attachDebuggerToResource": "Attach debugger", "command.viewResourceLogs": "View logs", "command.openResourceTerminal": "Open terminal", "command.executeResourceCommand": "Execute resource command", @@ -327,6 +328,15 @@ "aspire-vscode.strings.appHostDebugPipelineStepActionLabel": "Debug pipeline step", "aspire-vscode.strings.appHostPathLabel": "Path", "aspire-vscode.strings.appHostStartingDescription": "Starting...", + "aspire-vscode.strings.attachDebuggerConfigurationName": "Attach debugger: {0}", + "aspire-vscode.strings.attachingDebugger": "Attaching debugger to {0}...", + "aspire-vscode.strings.attachDebuggerAlreadyDebugging": "A debugger is already attached to {0}.", + "aspire-vscode.strings.attachDebuggerUnavailable": "This resource cannot be attached to a debugger.", + "aspire-vscode.strings.attachDebuggerResourceNotFound": "The selected resource is no longer available. Refresh the Aspire pane and try again.", + "aspire-vscode.strings.attachDebuggerCsharpExtensionRequired": "Install the C# extension to attach the debugger to .NET project resources.", + "aspire-vscode.strings.attachDebuggerExtensionsRequired": "Install {0} to attach the debugger to this resource.", + "aspire-vscode.strings.attachDebuggerDeclined": "VS Code did not start the debugger attach session for {0}.", + "aspire-vscode.strings.attachDebuggerWorkspaceNotTrusted": "Trust this workspace before attaching a debugger to an Aspire resource.", "aspire-vscode.strings.appHostDeployingDescription": "Deploying...", "aspire-vscode.strings.appHostPublishingDescription": "Publishing...", "aspire-vscode.strings.appHostRunningPipelineStepDescription": "Running pipeline step...", @@ -377,6 +387,11 @@ "aspire-vscode.strings.appHostLifecycleInvalidLaunchProfile": "an invalid launch profile", "aspire-vscode.strings.appHostLifecycleLaunchProfileRequiresRun": "Launch profiles are only supported for the run command.", "aspire-vscode.strings.appHostLifecycleLaunchAlreadyClaimed": "This Aspire AppHost is already starting or running. The new debug session was cancelled so only one AppHost runs.", + "aspire-vscode.strings.resourceDebugToolConfirmationTitle": "Attach debugger to Aspire resource", + "aspire-vscode.strings.resourceDebugToolConfirmationMessage": "Attach the debugger to resource {0} from Aspire AppHost {1}?", + "aspire-vscode.strings.resourceDebugToolUnresolvedConfirmationMessage": "Attach the debugger to the requested Aspire resource?", + "aspire-vscode.strings.resourceDebugToolInvocationMessage": "Attaching debugger to Aspire resource {0}...", + "aspire-vscode.strings.resourceDebugToolUnavailableInvocationMessage": "Attaching debugger to the requested Aspire resource...", "aspire-vscode.strings.appHostOperationAlreadyInProgress": "Another operation is already in progress for this Aspire AppHost. The new operation was cancelled.", "languageModelTool.aspireAppHostStart.displayName": "Start Aspire AppHost", "languageModelTool.aspireAppHostStart.modelDescription": "Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Start an Aspire AppHost that Aspire has already discovered in the current workspace, using the editor's own debug lifecycle. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. Also requires whether to start it in 'run' mode (no debugger attached) or 'debug' mode (debugger attached). Optional 'isolated' starts the AppHost with randomized ports and isolated user secrets; when omitted, linked git worktrees start isolated automatically so they do not collide with the primary checkout. Explicit true or false overrides that inference. Optional 'launchProfile' selects a profile from the AppHost launchSettings.json by its exact name. Does not create, pick, or guess an AppHost: if the path does not name a discovered AppHost, or names more than one, the call fails and the result lists the AppHosts you can pass. If the AppHost is already starting or already running, no second process is started. A successful new launch includes the verified effective 'isolated' value; idempotent or uncertain results omit it.", @@ -388,5 +403,11 @@ "languageModelTool.aspireAppHostStop.modelDescription": "Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Stop a running Aspire AppHost that Aspire has already discovered in the current workspace. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. AppHosts started by this editor stop through the coordinated debug lifecycle. AppHosts started outside the editor stop through 'aspire stop --apphost' for the same discovered path. The extension never kills arbitrary processes. If it cannot determine whether the AppHost is running, the call fails rather than reporting that nothing is running.", "languageModelTool.aspireAppHostStop.userDescription": "Stop a running Aspire AppHost from this workspace.", "languageModelTool.aspireAppHost.appHostPath.description": "Workspace-relative path of an AppHost that Aspire has already discovered in this workspace, for example 'AppHost/AppHost.csproj' or 'apphost.cs'. The value must match one of the discovered AppHosts exactly; arbitrary paths, absolute paths, and files Aspire did not discover are rejected. In a multi-root workspace, always prefix the path with the workspace folder name (for example 'backend/AppHost/AppHost.csproj').", + "languageModelTool.aspireResourceDebug.displayName": "Debug Aspire resource", + "languageModelTool.aspireResourceDebug.modelDescription": "Attach the VS Code debugger to a running Aspire resource that the extension has already discovered. Requires a workspace-relative AppHost path and the resource name. The default auto strategy currently attaches to the resource; start and restart under debug are not supported.", + "languageModelTool.aspireResourceDebug.userDescription": "Attach the debugger to a running Aspire resource.", + "languageModelTool.aspireResourceDebug.appHostPath.description": "Workspace-relative path of an AppHost that Aspire has already discovered. Absolute paths and paths Aspire did not discover are rejected. In a multi-root workspace, prefix the path with the workspace folder name.", + "languageModelTool.aspireResourceDebug.resourceName.description": "Name of a running resource from the selected AppHost. Resource names are limited to 256 characters.", + "languageModelTool.aspireResourceDebug.strategy.description": "Debug strategy. auto selects the available safe action, currently attach. attach only attaches a debugger; starting and restarting resources are not supported.", "command.openDashboardToSide": "Open Aspire Dashboard to the Side" } diff --git a/extension/scripts/run-e2e.js b/extension/scripts/run-e2e.js index 97e38f33a32..9b262b21351 100644 --- a/extension/scripts/run-e2e.js +++ b/extension/scripts/run-e2e.js @@ -68,6 +68,11 @@ const useJavaStarterWorkspace = enableJavaE2E && javaStarterTestSpecs.length > 0 // dependency of it. capabilities.ts only advertises the `java` capability - which is what makes the // CLI hand the AppHost launch back to the extension - when the first two are both installed. const REQUIRED_JAVA_EXTENSION_IDS = ['redhat.java', 'vscjava.vscode-java-debug', 'vscjava.vscode-java-dependency']; +const REQUIRED_RESOURCE_DEBUG_EXTENSION_IDS = [ + 'ms-dotnettools.vscode-dotnet-runtime', + 'ms-dotnettools.csharp', + 'golang.go', +]; const extesterVersion = extensionPackageJson.devDependencies?.['vscode-extension-tester']; if (!extesterVersion) { throw new Error('vscode-extension-tester must be pinned in extension/package.json devDependencies.'); @@ -142,6 +147,7 @@ const primaryAppHostProject = path.join(workspaceRoot, 'AspireE2E.AppHost', 'Asp const runRootNuGetConfigPath = path.join(shortRunRoot, 'NuGet.config'); const workspaceNuGetConfigPath = path.join(workspaceRoot, 'NuGet.config'); const enableAzureFunctionsE2E = process.env.ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS === 'true'; +const enableResourceDebugE2E = process.env.ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG === 'true'; const advisoryIssue = process.env.ASPIRE_EXTENSION_E2E_ADVISORY_ISSUE || ''; let cliPathForCleanup; const csharpFileHeader = `// Licensed to the .NET Foundation under one or more agreements. @@ -644,9 +650,13 @@ async function main() { } validateVsix(vsixPath); const azureFunctionsVsixPaths = resolveAzureFunctionsVsixPaths(); + const resourceDebugVsixPaths = resolveResourceDebugVsixPaths(); if (enableAzureFunctionsE2E) { validateAzureFunctionsCoreTools(); } + if (enableResourceDebugE2E) { + validateResourceDebugTools(); + } ensureExtester(); patchExtesterLaunchLocale(); @@ -672,6 +682,7 @@ async function main() { ASPIRE_EXTENSION_E2E_APPHOST_SDK_VERSION: appHostSdkVersion, ASPIRE_EXTENSION_E2E_EXTESTER_MODULE: extesterModule, ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS: enableAzureFunctionsE2E ? 'true' : 'false', + ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG: enableResourceDebugE2E ? 'true' : 'false', VSCODE_NLS_CONFIG: JSON.stringify({ locale: 'en', availableLanguages: {} }), LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', @@ -716,11 +727,12 @@ async function main() { logStep('Installing VSIX'); run(process.execPath, [extesterCli, 'install-vsix', '--storage', storageDir, '--extensions_dir', extensionsDir, '--vsix_file', vsixPath], extestEnv, { timeout: 300000 }); - for (const azureFunctionsVsix of azureFunctionsVsixPaths) { - logStep(`Installing ${azureFunctionsVsix.displayName} VSIX`); - run(process.execPath, [extesterCli, 'install-vsix', '--storage', storageDir, '--extensions_dir', extensionsDir, '--vsix_file', azureFunctionsVsix.path], extestEnv, { timeout: 300000 }); + for (const dependencyVsix of [...azureFunctionsVsixPaths, ...resourceDebugVsixPaths]) { + logStep(`Installing ${dependencyVsix.displayName} VSIX`); + run(process.execPath, [extesterCli, 'install-vsix', '--storage', storageDir, '--extensions_dir', extensionsDir, '--vsix_file', dependencyVsix.path], extestEnv, { timeout: 300000 }); } assertJavaExtensionsRegistered(); + assertResourceDebugExtensionsRegistered(); recording = startRecording(); try { @@ -878,23 +890,69 @@ function resolveAzureFunctionsVsixPaths() { return [ { displayName: '.NET Install Tool', - path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX'), + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS'), }, { displayName: 'C#', - path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_CSHARP_VSIX'), + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_CSHARP_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS'), }, { displayName: 'Azure Resource Groups', - path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_RESOURCE_GROUPS_VSIX'), + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_RESOURCE_GROUPS_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS'), }, { displayName: 'Azure Functions', - path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_FUNCTIONS_VSIX'), + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_FUNCTIONS_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS'), + }, + ]; +} + +function resolveResourceDebugVsixPaths() { + if (!enableResourceDebugE2E) { + return []; + } + + // The Extension Host runs offline. Install both debugger adapters and C#'s runtime dependency + // explicitly so this shard proves packaged attach behavior rather than a developer profile. + return [ + { + displayName: '.NET Install Tool', + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG'), + }, + { + displayName: 'C#', + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_CSHARP_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG'), + }, + { + displayName: 'Go', + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_GO_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG'), }, ]; } +function validateResourceDebugTools() { + const result = spawnSync('dlv', ['version'], { + cwd: extensionRoot, + env: getAspireCliEnvironment(), + shell: false, + encoding: 'utf8', + timeout: 60000, + }); + if (result.error || result.status !== 0) { + throw new Error(`The resource debug E2E shard requires dlv on PATH. ${result.error?.message ?? result.stderr ?? `exit code ${result.status}`}`); + } + + if (process.platform === 'linux') { + const ptraceScopePath = '/proc/sys/kernel/yama/ptrace_scope'; + if (fs.existsSync(ptraceScopePath)) { + const ptraceScope = fs.readFileSync(ptraceScopePath, 'utf8').trim(); + if (ptraceScope !== '0') { + throw new Error(`The resource debug E2E shard requires kernel.yama.ptrace_scope=0 so Delve can attach to DCP-launched Go processes, but ${ptraceScopePath} contains '${ptraceScope}'.`); + } + } + } +} + /** * Copies the Java Spring Boot playground into the run's workspace. * @@ -1213,6 +1271,18 @@ function assertJavaExtensionsRegistered() { return; } + assertExtensionsRegistered('Java', REQUIRED_JAVA_EXTENSION_IDS); +} + +function assertResourceDebugExtensionsRegistered() { + if (!enableResourceDebugE2E) { + return; + } + + assertExtensionsRegistered('Resource debug', REQUIRED_RESOURCE_DEBUG_EXTENSION_IDS); +} + +function assertExtensionsRegistered(label, requiredExtensionIds) { const manifestPath = path.join(extensionsDir, 'extensions.json'); if (!fs.existsSync(manifestPath)) { throw new Error(`VS Code did not write ${manifestPath}, so no extension is registered for the run.`); @@ -1230,7 +1300,7 @@ function assertJavaExtensionsRegistered() { .map(entry => entry?.identifier?.id) .filter(Boolean); - for (const identifier of REQUIRED_JAVA_EXTENSION_IDS) { + for (const identifier of requiredExtensionIds) { const entry = registered.find(candidate => candidate?.identifier?.id?.toLowerCase() === identifier.toLowerCase()); if (!entry) { throw new Error(`${identifier} is not registered in ${manifestPath}, so VS Code will not load it. Registered: ${registeredIds.join(', ') || '(none)'}`); @@ -1239,7 +1309,7 @@ function assertJavaExtensionsRegistered() { assertExtensionSupportsVsCodeVersion(path.join(extensionsDir, entry.relativeLocation), entry.relativeLocation); } - console.log(`Java extensions registered for the run: ${REQUIRED_JAVA_EXTENSION_IDS.join(', ')}.`); + console.log(`${label} extensions registered for the run: ${requiredExtensionIds.join(', ')}.`); } /** @@ -1273,14 +1343,15 @@ function assertExtensionSupportsVsCodeVersion(extensionDirectory, directoryName) } } -function resolveRequiredVsixPath(environmentVariable) { const configuredPath = process.env[environmentVariable]; +function resolveRequiredVsixPath(environmentVariable, selectingFeatureFlag) { + const configuredPath = process.env[environmentVariable]; if (!configuredPath) { - throw new Error(`${environmentVariable} is required when ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS=true.`); + throw new Error(`${environmentVariable} is required when ${selectingFeatureFlag}=true.`); } const resolvedPath = path.resolve(configuredPath); if (!fs.existsSync(resolvedPath)) { - throw new Error(`${environmentVariable} points to a missing file: ${resolvedPath}`); + throw new Error(`${environmentVariable} points to a missing file: ${resolvedPath}. It is required when ${selectingFeatureFlag}=true.`); } validateVsix(resolvedPath); @@ -1395,10 +1466,13 @@ function prepareWorkspaceFixture(resolvedCliPath, resolvedAppHostSdkVersion) { fs.mkdirSync(workspaceRoot, { recursive: true }); fs.writeFileSync(workspaceMarkerFile, `${runId}\n`); writeWorkerProject('AspireE2E.Worker'); + if (enableResourceDebugE2E) { + writeGoWorker('AspireE2E.Go'); + } if (enableAzureFunctionsE2E) { writeAzureFunctionsProject('AspireE2E.Functions'); } - writeAppHostProject('AspireE2E.AppHost', resolvedAppHostSdkVersion, enableAzureFunctionsE2E); + writeAppHostProject('AspireE2E.AppHost', resolvedAppHostSdkVersion, enableAzureFunctionsE2E, enableResourceDebugE2E); writeNuGetConfigIfLocalPackageSourcesExist(); const vscodeDirectory = path.join(workspaceRoot, '.vscode'); @@ -1451,12 +1525,15 @@ function restoreWorkspaceFixture() { } } -function writeAppHostProject(projectName, resolvedAppHostSdkVersion, includeAzureFunctions) { +function writeAppHostProject(projectName, resolvedAppHostSdkVersion, includeAzureFunctions, includeResourceDebug) { const projectDirectory = path.join(workspaceRoot, projectName); fs.mkdirSync(projectDirectory, { recursive: true }); const azureFunctionsPackageReference = includeAzureFunctions ? ` \n` : ''; + const goPackageReference = includeResourceDebug + ? ` \n` + : ''; fs.writeFileSync(path.join(projectDirectory, `${projectName}.csproj`), ` @@ -1468,7 +1545,7 @@ function writeAppHostProject(projectName, resolvedAppHostSdkVersion, includeAzur -${azureFunctionsPackageReference} +${azureFunctionsPackageReference}${goPackageReference} `); @@ -1476,6 +1553,13 @@ ${azureFunctionsPackageReference} const azureFunctionsResource = includeAzureFunctions ? `builder.AddAzureFunctionsProject("e2e-functions", "../AspireE2E.Functions/AspireE2E.Functions.csproj");\n\n` : ''; + const goResource = includeResourceDebug + ? `builder.AddGoApp("e2e-go", "../AspireE2E.Go", gcFlags: "all=-N -l") + .WithCommand("./test-tools/go") + .WithHttpEndpoint(name: "http", env: "PORT"); + +` + : ''; fs.writeFileSync(path.join(projectDirectory, 'AppHost.cs'), `${csharpFileHeader}#pragma warning disable ASPIREINTERACTION001 #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIRETERMINAL001 @@ -1558,7 +1642,7 @@ builder.AddProject("e2e-terminal") .WithHttpEndpoint(name: "http") .WithTerminal(); -${azureFunctionsResource}builder.Pipeline.AddStep("e2e-run-action-step", async context => +${azureFunctionsResource}${goResource}builder.Pipeline.AddStep("e2e-run-action-step", async context => { var task = await context.ReportingStep .CreateTaskAsync("Running E2E run action pipeline step", context.CancellationToken) @@ -1691,6 +1775,92 @@ app.Run(); `); } +function writeGoWorker(projectName) { + const projectDirectory = path.join(workspaceRoot, projectName); + fs.mkdirSync(projectDirectory, { recursive: true }); + fs.writeFileSync(path.join(projectDirectory, 'go.mod'), `module example.com/aspire-e2e-go + +go 1.24 +`); + fs.writeFileSync(path.join(projectDirectory, 'main.go'), `package main + +import ( + "fmt" + "log" + "net/http" + "os" +) + +func main() { + http.HandleFunc("/", func(writer http.ResponseWriter, _ *http.Request) { + message := "go-ok" + _, _ = fmt.Fprint(writer, message) + }) + log.Fatal(http.ListenAndServe(":"+os.Getenv("PORT"), nil)) +} +`); + + // `go run` intentionally links its temporary executable with `-s -w`, so Delve can attach to + // the process but cannot bind source breakpoints. Keep the supported AddGoApp/go-launcher shape, + // while using an unstripped child under a go-build*/exe path that the attach provider recognizes. + const debugExecutable = path.join(projectDirectory, 'go-build-debug', 'exe', isWindows ? 'e2e-go.exe' : 'e2e-go'); + fs.mkdirSync(path.dirname(debugExecutable), { recursive: true }); + buildGoE2EExecutable(projectDirectory, ['build', '-gcflags=all=-N -l', '-o', debugExecutable, '.'], 'debug target'); + + const launcherSourceDirectory = path.join(projectDirectory, 'debug-launcher'); + fs.mkdirSync(launcherSourceDirectory, { recursive: true }); + fs.writeFileSync(path.join(launcherSourceDirectory, 'main.go'), `package main + +import ( + "log" + "os" + "os/exec" + "os/signal" + "path/filepath" + "syscall" +) + +func main() { + target := filepath.Join(filepath.Dir(os.Args[0]), "..", "go-build-debug", "exe", "${isWindows ? 'e2e-go.exe' : 'e2e-go'}") + command := exec.Command(target) + command.Env = os.Environ() + command.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + log.Fatal(err) + } + + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + go func() { + _ = command.Process.Signal(<-signals) + }() + + if err := command.Wait(); err != nil { + log.Fatal(err) + } +} +`); + + const launcherExecutable = path.join(projectDirectory, 'test-tools', isWindows ? 'go.exe' : 'go'); + fs.mkdirSync(path.dirname(launcherExecutable), { recursive: true }); + buildGoE2EExecutable(projectDirectory, ['build', '-o', launcherExecutable, './debug-launcher'], 'debug launcher'); +} + +function buildGoE2EExecutable(projectDirectory, args, description) { + const result = spawnSync('go', args, { + cwd: projectDirectory, + env: getAspireCliEnvironment(), + shell: false, + encoding: 'utf8', + timeout: 120000, + }); + if (result.error || result.status !== 0) { + throw new Error(`Unable to build the Go E2E ${description}. ${result.error?.message ?? result.stderr ?? `exit code ${result.status}`}`); + } +} + function resolveAppHostSdkVersion(resolvedCliPath) { if (process.env.ASPIRE_EXTENSION_E2E_APPHOST_SDK_VERSION) { return process.env.ASPIRE_EXTENSION_E2E_APPHOST_SDK_VERSION; diff --git a/extension/src/activation/registerTreeViewCommands.ts b/extension/src/activation/registerTreeViewCommands.ts index 202bc52a2d8..68536d9ec4d 100644 --- a/extension/src/activation/registerTreeViewCommands.ts +++ b/extension/src/activation/registerTreeViewCommands.ts @@ -18,6 +18,7 @@ const treeElementCommands: ReadonlyArray p.stopResource(e)], ['aspire-vscode.startResource', (p, e) => p.startResource(e)], ['aspire-vscode.restartResource', (p, e) => p.restartResource(e)], + ['aspire-vscode.attachDebuggerToResource', (p, e) => p.attachDebuggerToResource(e)], ['aspire-vscode.viewResourceLogs', (p, e) => p.viewResourceLogs(e)], ['aspire-vscode.openResourceTerminal', (p, e) => p.openResourceTerminal(e)], ['aspire-vscode.executeResourceCommand', (p, e) => p.executeResourceCommand(e)], diff --git a/extension/src/data/AppHostDataRepository.ts b/extension/src/data/AppHostDataRepository.ts index 1663c31e860..ee77d20206b 100644 --- a/extension/src/data/AppHostDataRepository.ts +++ b/extension/src/data/AppHostDataRepository.ts @@ -7,7 +7,7 @@ import { extensionLogOutputChannel } from '../utils/logging'; import { appHostDescribeMayNotBeSupported, appHostDiscoveryProgress, appHostPathMustBeNonEmptyAbsolute, aspireCliDescribeNotSupported, aspireDescribeMinimumVersion, errorFetchingAppHosts, workspaceViewSelectedMultipleAppHosts, workspaceViewSelectedSingleAppHost } from '../loc/strings'; import { AppHostCandidate, AppHostDiscoveryService, CandidateAppHostDisplayInfo, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult, isBuildableAppHostCandidate } from '../utils/appHostDiscovery'; import { ConfigInfoProvider } from '../utils/configInfoProvider'; -import { describeIncludeDisabledCommandsCapability } from '../types/configInfo'; +import { describeAppHostPidCapability, describeIncludeDisabledCommandsCapability } from '../types/configInfo'; import { nonInteractiveCliEnvironment } from '../utils/environment'; import { getComparisonKey, isAppHostPathUnderFolder, isSameAppHostPath } from '../utils/paths/comparison'; import { FileSystemEntryDescriptor, FileSystemEntryDescriptorIndex, getFileSystemEntryDescriptor } from '../utils/paths/fileSystemIdentity'; @@ -482,7 +482,7 @@ export class AppHostDataRepository { const appHostList = await this.fetchRunningAppHostsOnce(); const appHostsWithResources = await Promise.allSettled(appHostList.map(async appHost => ({ ...appHost, - resources: await this._fetchAppHostResourcesOnce(appHost.appHostPath), + resources: await this.fetchAppHostResourcesOnce(appHost.appHostPath), }))); return appHostsWithResources.map((result, index) => { @@ -1327,11 +1327,30 @@ export class AppHostDataRepository { } } - private async _fetchAppHostResourcesOnce(appHostPath: string): Promise { + async fetchAppHostResourcesOnce(appHostPath: string, cancellationToken?: vscode.CancellationToken, appHostPid?: number): Promise { + const args = ['describe', '--format', 'json', '--apphost', appHostPath]; + const target = getCliPathTargetForUri(vscode.Uri.file(appHostPath)); + if (appHostPid !== undefined) { + if (!Number.isInteger(appHostPid) || appHostPid <= 0) { + throw new Error('The AppHost process ID must be a positive integer.'); + } + + const capability = await this._configInfoProvider.getCapabilityStatus(describeAppHostPidCapability, { + target, + cancellationToken, + suppressErrors: true, + }); + if (capability !== 'supported') { + throw new Error('The selected Aspire CLI cannot bind resource snapshots to an AppHost process.'); + } + + args.push('--apphost-pid', String(appHostPid)); + } + const snapshot = await this._runCliJson( 'aspire describe', - this._cliRunner.withNoLogo(['describe', '--format', 'json', '--apphost', appHostPath]), - { target: getCliPathTargetForUri(vscode.Uri.file(appHostPath)) }); + this._cliRunner.withNoLogo(args), + { cancellationToken, target }); return snapshot.resources ?? []; } diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index 35d9231de92..438ca06bfd5 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -169,6 +169,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche private _appHostDebugSession?: AspireResourceDebugSession = undefined; private _resourceDebugSessions: AspireResourceDebugSession[] = []; + private readonly _resourceDebugSessionProcessIds = new Map(); private _trackedDebugAdapters: string[] = []; private _rpcClient?: ICliRpcClient; private readonly _dashboardLauncher = new DashboardLauncher(this); @@ -292,6 +293,14 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche return this._cliProcess?.pid; } + // Already-started debugger integrations report the actual debuggee PID back to DCP. + // Resource attach must recognize that PID as editor-owned rather than treating it as a launcher. + hasResourceDebugSessionProcess(processId: number): boolean { + return [...this._resourceDebugSessionProcessIds.values()].includes(processId) || + this._resourceDebugSessions.some( + session => (session as Partial).processId === processId); + } + constructor(session: vscode.DebugSession, rpcServer: AspireRpcServer, dcpServer: AspireDcpServer, terminalProvider: AspireTerminalProvider, removeAspireDebugSession: (session: AspireDebugSession) => void, trackAppHostDebugSession: AppHostDebugSessionTracker = () => { }, debugSessionId: string = generateDcpIdPrefix(), operationKind?: AspireOperationKind) { this._session = session; this._rpcServer = rpcServer; @@ -303,6 +312,8 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche this.operationKind = operationKind ?? getOperationKind(this.configuration.command); this.debugSessionId = debugSessionId; + this._disposables.push(vscode.debug.onDidTerminateDebugSession( + terminatedSession => this._resourceDebugSessionProcessIds.delete(terminatedSession.id))); } /** @@ -1230,7 +1241,17 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche } this._trackedDebugAdapters.push(debugAdapter); - this._disposables.push(createDebugAdapterTracker(this._dcpServer, debugAdapter, appHostTracker)); + this._disposables.push(createDebugAdapterTracker( + this._dcpServer, + debugAdapter, + appHostTracker, + (session, processId) => { + if (processId === undefined) { + this._resourceDebugSessionProcessIds.delete(session.id); + } else { + this._resourceDebugSessionProcessIds.set(session.id, processId); + } + })); } private static readonly _nodeAppHostExtensions = ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts']; @@ -1508,6 +1529,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche } void resourceDebugSession.termination.then(exitCode => { + this._resourceDebugSessions = this._resourceDebugSessions.filter(session => session !== resourceDebugSession); if (debugConfig.debugSessionId === null) { extensionLogOutputChannel.warn(`Unable to report termination for run ${debugConfig.runId} because the DCP session ID is missing.`); return; @@ -1572,6 +1594,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche // stop that is still waiting for VS Code to confirm the same termination. terminated = true; this._resourceDebugSessions = this._resourceDebugSessions.filter(resourceSession => resourceSession.id !== session.id); + this._resourceDebugSessionProcessIds.delete(session.id); cleanupResource(); resolveTermination(); terminationDisposable.dispose(); @@ -1811,6 +1834,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche this.flushAppHostLogOutput(); this._appHostLogOutput.reset(); this._trackedDebugAdapters = []; + this._resourceDebugSessionProcessIds.clear(); this._onDidSendDebugConsoleOutput.dispose(); // Keep this disposed session tracked while its delayed CLI termination is pending, so // extension deactivation can still force-drain the process tree before VS Code exits. diff --git a/extension/src/debugger/adapterTracker.ts b/extension/src/debugger/adapterTracker.ts index 43f447e3d49..b9a9d03c73c 100644 --- a/extension/src/debugger/adapterTracker.ts +++ b/extension/src/debugger/adapterTracker.ts @@ -19,6 +19,7 @@ export type AppHostRestartHandler = (debugSessionId: string) => boolean; */ export type DapOutputCategory = 'console' | 'important' | 'stdout' | 'stderr' | 'debug' | 'telemetry' | (string & {}) | undefined; export type AppHostOutputHandler = (output: string, category: DapOutputCategory) => void; +export type DebuggeeProcessHandler = (session: vscode.DebugSession, processId: number | undefined) => void; export interface AppHostTrackerOptions { // VS Code invokes every factory registered for an adapter type for every matching @@ -29,7 +30,12 @@ export interface AppHostTrackerOptions { onOutput?: AppHostOutputHandler; } -export function createDebugAdapterTracker(dcpServer: AspireDcpServer, debugAdapter: string, appHostTracker?: AppHostTrackerOptions): vscode.Disposable { +export function createDebugAdapterTracker( + dcpServer: AspireDcpServer, + debugAdapter: string, + appHostTracker?: AppHostTrackerOptions, + onDebuggeeProcess?: DebuggeeProcessHandler, +): vscode.Disposable { return vscode.debug.registerDebugAdapterTrackerFactory(debugAdapter, { createDebugAdapterTracker(session: vscode.DebugSession) { const configuration = session.configuration; @@ -104,6 +110,13 @@ export function createDebugAdapterTracker(dcpServer: AspireDcpServer, debugAdapt // Reset before the PID guard: `systemProcessId` is optional in DAP, so a // restart reported without it must still clear the stale exit code. debuggeeExitCode = undefined; + if (!configuration.isApphost) { + onDebuggeeProcess?.( + session, + typeof message.body?.systemProcessId === 'number' + ? message.body.systemProcessId + : undefined); + } if (typeof message.body?.systemProcessId !== 'number') { extensionLogOutputChannel.warn(`Debug session ${session.id} does not have a valid system process ID.`); @@ -125,6 +138,9 @@ export function createDebugAdapterTracker(dcpServer: AspireDcpServer, debugAdapt } if (message.type === 'event' && message.event === 'exited' && typeof message.body?.exitCode === 'number') { + if (!configuration.isApphost) { + onDebuggeeProcess?.(session, undefined); + } debuggeeExitCode = message.body.exitCode; } }, diff --git a/extension/src/debugger/debuggerExtensions.ts b/extension/src/debugger/debuggerExtensions.ts index 2aab0b6dd6d..cb13143f5e1 100644 --- a/extension/src/debugger/debuggerExtensions.ts +++ b/extension/src/debugger/debuggerExtensions.ts @@ -1,4 +1,5 @@ import path from "path"; +import * as vscode from 'vscode'; import { ExecutableLaunchConfiguration, EnvVar, LaunchOptions, AspireResourceExtendedDebugConfiguration, AspireExtendedDebugConfiguration, AspireResourceDebugSession } from "../dcp/types"; import { debugProject, runProject } from "../loc/strings"; import { getEnvironmentForChildProcess, mergeEnvs } from "../utils/environment"; @@ -42,6 +43,24 @@ export async function createDebugSessionConfiguration(debugSessionConfig: Aspire return (await prepareDebugSession(debugSessionConfig, launchConfig, args, env, launchOptions, debuggerExtension)).debugConfiguration; } +export function applyDebuggerConfigurationOverrides( + configuration: vscode.DebugConfiguration, + debugSessionConfig: AspireExtendedDebugConfiguration | undefined, + launchConfigurationType: string, + isApphost: boolean): void { + if (!debugSessionConfig?.debuggers) { + return; + } + + if (isApphost && debugSessionConfig.debuggers['apphost']) { + Object.assign(configuration, debugSessionConfig.debuggers['apphost']); + } + + if (debugSessionConfig.debuggers[launchConfigurationType]) { + Object.assign(configuration, debugSessionConfig.debuggers[launchConfigurationType]); + } +} + export async function prepareDebugSession(debugSessionConfig: AspireExtendedDebugConfiguration, launchConfig: ExecutableLaunchConfiguration, args: string[] | undefined, env: EnvVar[], launchOptions: LaunchOptions, debuggerExtension: ResourceDebuggerExtension): Promise { if (debuggerExtension === null) { extensionLogOutputChannel.warn(`Unknown type: ${launchConfig.type}.`); @@ -67,17 +86,7 @@ export async function prepareDebugSession(debugSessionConfig: AspireExtendedDebu isApphost: launchOptions.isApphost }; - if (debugSessionConfig.debuggers) { - // 1. Check if this is the apphost - if (launchOptions.isApphost && debugSessionConfig.debuggers['apphost']) { - Object.assign(configuration, debugSessionConfig.debuggers['apphost']); - } - - // 2. Check for resource type specific debugger settings - if (debugSessionConfig.debuggers[launchConfig.type]) { - Object.assign(configuration, debugSessionConfig.debuggers[launchConfig.type]); - } - } + applyDebuggerConfigurationOverrides(configuration, debugSessionConfig, launchConfig.type, launchOptions.isApphost); let alreadyStartedSession: AlreadyStartedResourceDebugSession | undefined; diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index c4649dd0d22..961cb0d4213 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -1,16 +1,19 @@ import * as vscode from 'vscode'; import { extensionLogOutputChannel } from '../../utils/logging'; -import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, explicitLaunchProfileNotResolved, launchProfileUnsupportedCommandName, launchProfileHasInvalidProperties } from '../../loc/strings'; +import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, explicitLaunchProfileNotResolved, launchProfileUnsupportedCommandName, launchProfileHasInvalidProperties, attachDebuggerConfigurationName, attachDebuggerCsharpExtensionRequired, attachDebuggerUnavailable } from '../../loc/strings'; import { ChildProcessWithoutNullStreams, execFile, spawn } from 'child_process'; +import * as childProcess from 'child_process'; import * as util from 'util'; import * as path from 'path'; import * as readline from 'readline'; import * as os from 'os'; import * as fs from 'fs'; +import { LimitedOutputBuffer, oneShotOutputBufferLimit } from '../../data/appHostCliRunner'; import { csharpExtensionId } from '../../capabilities'; import { doesFileExist } from '../../utils/io'; import { AspireResourceExtendedDebugConfiguration, DebugConfigurationArguments, EnvVar, ExecutableLaunchConfiguration, isProjectLaunchConfiguration, LaunchOptions, ProjectLaunchConfiguration } from '../../dcp/types'; import { ResourceDebuggerExtension } from '../debuggerExtensions'; +import { ResourceAttachConfigurationError, type ResourceAttachProvider, type ResourceDebugResourceSnapshot } from '../resourceDebugContracts'; import { readLaunchSettings, determineBaseLaunchProfile, @@ -26,31 +29,81 @@ import { hasSdkCompatibleLaunchProfileProperties } from '../launchProfiles'; import { AspireDebugSession } from '../AspireDebugSession'; -import { createResolvedAspireCliPathProcessEnvironment } from '../../utils/cliPathEnvironment'; +import { createAspireCliPathProcessEnvironment, createResolvedAspireCliPathProcessEnvironment } from '../../utils/cliPathEnvironment'; import { resolveCliPath } from '../../utils/cliPath'; import { getCliPathTargetForUri } from '../../utils/cliPathVariables'; import { getHotReloadDiagnostics, logHotReloadDiagnostics, showHotReloadDisabledAdvisoryIfNeeded } from '../hotReload'; +import { terminateCliProcess } from '../../utils/process/cliProcess'; +import { + launchedChildProcessResolver, + type LaunchedChildProcess, + type LaunchedChildProcessIdentity, +} from '../launchedChildProcessDiscovery'; import { deleteEnvironmentVariable, getEnvironmentForChildProcess, setEnvironmentVariable } from '../../utils/environment'; import { getAppHostLaunchProfileOptions } from '../../utils/launchProfile'; interface IDotNetService { getAndActivateDevKit(): Promise buildDotNetProject(projectFile: string): Promise; + getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken, framework?: string): Promise; getDotNetTargetPath(projectFile: string): Promise; getDotNetRunApiOutput(projectFile: string, environment?: NodeJS.ProcessEnv): Promise; } +type DotNetLaunchCommand = 'run' | 'watch'; + +interface DotNetAttachTargetInfo { + targetPath: string; + targetName?: string; + useAppHost: boolean; +} + +interface DotNetAttachDebuggerResourceInfo { + configuration?: string; + framework?: string; + launchCommand?: DotNetLaunchCommand; + launcherPid: number; + projectPath: string; + resourceLabel: string; + useTargetNameFallback: boolean; +} + +interface LaunchedChildProcessResolver { + resolveProcessId( + launcherPid: number, + identity: LaunchedChildProcessIdentity, + cancellationToken?: vscode.CancellationToken, + ): Promise; +} + +interface DotNetAttachFileSystem { + realpath(path: string): Promise; +} + +const executableArgsPropertyName = 'executable.args'; +const executablePidPropertyName = 'executable.pid'; +const executablePathPropertyName = 'executable.path'; +const projectPathPropertyName = 'project.path'; +const projectConfigurationPropertyName = 'project.configuration'; +const projectLaunchCommandPropertyName = 'project.launchCommand'; +const projectTargetFrameworkPropertyName = 'project.targetFramework'; +const resourceParentNamePropertyName = 'resource.parentName'; +const resourceLaunchConfigurationTypePropertyName = 'resource.launchConfigurationType'; +const dotNetProjectFileExtensions = new Set(['.csproj', '.fsproj', '.vbproj']); + export class DotNetService implements IDotNetService { - private _debugSession: AspireDebugSession; + private static readonly _msbuildProbeTimeoutMs = 10_000; - constructor(debugSession: AspireDebugSession) { + private _debugSession: AspireDebugSession | undefined; + + constructor(debugSession: AspireDebugSession | undefined) { this._debugSession = debugSession; } - execFileAsync = util.promisify(execFile); + execFileAsync = util.promisify(childProcess.execFile); writeToDebugConsole(message: string, category: 'stdout' | 'stderr', addNewLine: boolean = false): void { - this._debugSession.sendMessage(message, addNewLine, category); + this._debugSession?.sendMessage(message, addNewLine, category); } async getAndActivateDevKit(): Promise { @@ -77,7 +130,7 @@ export class DotNetService implements IDotNetService { void (async () => { const { cliPath } = await resolveCliPath(getCliPathTargetForUri(vscode.Uri.file(projectFile))); - const buildProcess = spawn('dotnet', args, { + const buildProcess = childProcess.spawn('dotnet', args, { // The .NET SDK searches for global.json from the process working directory, not the // project argument. Run from the project directory so extension and CLI builds select // the same SDK and repository configuration. @@ -123,6 +176,61 @@ export class DotNetService implements IDotNetService { }); } + async getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken, framework?: string): Promise { + const args = [ + 'msbuild', + projectFile, + '-nologo', + '-getProperty:TargetPath', + '-getProperty:TargetName', + '-getProperty:UseAppHost', + '-v:q', + '-property:GenerateFullPaths=true' + ]; + if (configuration) { + args.push(`-property:Configuration=${configuration}`); + } + if (framework) { + args.push(`-property:TargetFramework=${framework}`); + } + + try { + const stdout = await this._runDotNetMsbuild(args, path.dirname(projectFile), cancellationToken); + // Multiple -getProperty switches return: + // { "Properties": { "TargetPath": "/repo/bin/Release/net10.0/Api.dll", "TargetName": "Api", "UseAppHost": "false" } } + const payload: unknown = JSON.parse(stdout); + const properties = typeof payload === 'object' && payload !== null && 'Properties' in payload + ? (payload as { Properties?: unknown }).Properties + : undefined; + const targetPath = typeof properties === 'object' && properties !== null && 'TargetPath' in properties + ? (properties as { TargetPath?: unknown }).TargetPath + : undefined; + const targetName = typeof properties === 'object' && properties !== null && 'TargetName' in properties + ? (properties as { TargetName?: unknown }).TargetName + : undefined; + const useAppHost = typeof properties === 'object' && properties !== null && 'UseAppHost' in properties + ? (properties as { UseAppHost?: unknown }).UseAppHost + : undefined; + if (typeof targetPath !== 'string' || targetPath.trim().length === 0) { + throw new Error(noOutputFromMsbuild); + } + + return { + targetPath: targetPath.trim(), + targetName: typeof targetName === 'string' && targetName.trim().length > 0 + ? targetName.trim() + : undefined, + useAppHost: typeof useAppHost === 'string' && useAppHost.trim().toLowerCase() === 'true', + }; + } catch (err) { + if (cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + throw new Error(failedToGetTargetPath(String(err))); + } + } + async getDotNetTargetPath(projectFile: string): Promise { const args = [ 'msbuild', @@ -152,32 +260,34 @@ export class DotNetService implements IDotNetService { async getDotNetRunApiOutput(projectPath: string, environment?: NodeJS.ProcessEnv): Promise { const { cliPath } = await resolveCliPath(getCliPathTargetForUri(vscode.Uri.file(projectPath))); - let childProcess: ChildProcessWithoutNullStreams | undefined; + // Named `runApiProcess` rather than `childProcess` because the module import of the same + // name is what spawns it below. + let runApiProcess: ChildProcessWithoutNullStreams | undefined; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { - childProcess?.kill(); + runApiProcess?.kill(); reject(new Error('Timeout while waiting for dotnet run-api response')); }, 10_000); try { extensionLogOutputChannel.info('dotnet run-api - starting process'); - childProcess = spawn('dotnet', ['run-api'], { + runApiProcess = childProcess.spawn('dotnet', ['run-api'], { cwd: path.dirname(projectPath), env: createResolvedAspireCliPathProcessEnvironment(cliPath, { ...process.env, ...environment }), stdio: ['pipe', 'pipe', 'pipe'] }); - childProcess.on('error', reject); - childProcess.on('exit', (code, signal) => { + runApiProcess.on('error', reject); + runApiProcess.on('exit', (code, signal) => { clearTimeout(timeout); if (code !== 0) { reject(new Error(processExitedWithCode(code?.toString() ?? "unknown"))); } }); - const rl = readline.createInterface(childProcess.stdout); + const rl = readline.createInterface(runApiProcess.stdout); rl.on('line', line => { clearTimeout(timeout); extensionLogOutputChannel.info(`dotnet run-api - received: ${line}`); @@ -186,13 +296,100 @@ export class DotNetService implements IDotNetService { const message = JSON.stringify({ ['$type']: 'GetRunCommand', ['EntryPointFileFullPath']: projectPath }); extensionLogOutputChannel.info(`dotnet run-api - sending: ${message}`); - childProcess.stdin.write(message + os.EOL); - childProcess.stdin.end(); + runApiProcess.stdin.write(message + os.EOL); + runApiProcess.stdin.end(); } catch (e) { clearTimeout(timeout); reject(e); } - }).finally(() => childProcess?.removeAllListeners()); + }).finally(() => runApiProcess?.removeAllListeners()); + } + + private _runDotNetMsbuild(args: string[], workingDirectory: string, cancellationToken: vscode.CancellationToken | undefined): Promise { + return new Promise((resolve, reject) => { + let completed = false; + let timeout: ReturnType | undefined; + let cancellationRegistration: vscode.Disposable | undefined; + const complete = (action: () => void) => { + if (completed) { + return; + } + + completed = true; + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + cancellationRegistration?.dispose(); + action(); + }; + const msbuildProcess = childProcess.spawn('dotnet', args, { + cwd: workingDirectory, + env: createAspireCliPathProcessEnvironment(), + stdio: 'pipe', + }); + const stdout = new LimitedOutputBuffer(oneShotOutputBufferLimit); + const stderr = new LimitedOutputBuffer(oneShotOutputBufferLimit); + + msbuildProcess.stdout.setEncoding('utf8'); + msbuildProcess.stdout.on('data', (data: string) => { + stdout.append(data); + }); + // The probe normally produces JSON only on stdout, but MSBuild can write enough failure + // detail to stderr to fill the pipe. Read both streams so a failed probe can always exit. + msbuildProcess.stderr.setEncoding('utf8'); + msbuildProcess.stderr.on('data', (data: string) => { + stderr.append(data); + }); + + msbuildProcess.on('error', error => { + complete(() => reject(createMsbuildProbeError(error.message, stdout.value, stderr.value))); + }); + msbuildProcess.on('close', code => { + if (cancellationToken?.isCancellationRequested) { + complete(() => reject(new vscode.CancellationError())); + } else if (code === 0) { + complete(() => resolve(stdout.value)); + } else { + complete(() => reject(createMsbuildProbeError( + `dotnet msbuild exited with code ${code ?? 'unknown'}`, + stdout.value, + stderr.value))); + } + }); + + const stopProbe = (error: Error) => { + if (completed) { + return; + } + + // This child is a short-lived metadata probe, not the resource or AppHost. Stop only + // its known process handle so cancellation or timeout cannot affect the workload being attached. + void terminateCliProcess(msbuildProcess, 'dotnet msbuild target discovery', { + force: true, + suppressTimeoutWarning: true, + }); + complete(() => reject(error)); + }; + const cancel = () => { + stopProbe(new vscode.CancellationError()); + }; + cancellationRegistration = cancellationToken?.onCancellationRequested(cancel); + if (completed) { + cancellationRegistration?.dispose(); + return; + } + + timeout = setTimeout(() => { + stopProbe(createMsbuildProbeError( + `dotnet msbuild target discovery timed out after ${DotNetService._msbuildProbeTimeoutMs}ms`, + stdout.value, + stderr.value)); + }, DotNetService._msbuildProbeTimeoutMs); + if (cancellationToken?.isCancellationRequested) { + cancel(); + } + }); } } @@ -377,6 +574,10 @@ function createErrorWithStreamedDebugConsoleOutput(message: string): Error { return error; } +function createMsbuildProbeError(reason: string, stdout: string, stderr: string): Error { + return new Error(`${reason}\nstdout:\n${stdout}\nstderr:\n${stderr}`); +} + async function shouldLaunchProjectWithDotNetRun(outputPath: string): Promise { if (path.extname(outputPath).toLowerCase() !== '.dll') { return false; @@ -712,6 +913,387 @@ function isDefaultLaunchProfileEnvironmentVariable( || (namesEqual('DOTNET_LAUNCH_PROFILE') && defaultProfileName === value); } +function getDotNetAttachDebuggerResourceInfo(resource: ResourceDebugResourceSnapshot): DotNetAttachDebuggerResourceInfo | undefined { + if (resource.state !== 'Running' || !canRecognizeDotNetAttachDebuggerResource(resource)) { + return undefined; + } + + const launcherPid = getAttachDebuggerProcessId(resource); + if (launcherPid === undefined) { + return undefined; + } + + const launchMetadata = getDotNetLaunchMetadata(resource); + if (launchMetadata === undefined) { + return undefined; + } + + const projectPath = resource.properties?.[projectPathPropertyName] as string; + return { + ...launchMetadata, + launcherPid, + projectPath, + resourceLabel: resource.displayName ?? resource.name, + }; +} + +function canRecognizeDotNetAttachDebuggerResource(resource: ResourceDebugResourceSnapshot): boolean { + if (resource.resourceType !== 'Project') { + return false; + } + + const launchConfigurationType = getLaunchConfigurationType(resource); + // Newer AppHosts identify MAUI platform resources explicitly. Older AppHosts do not emit this + // property, so retain the parent fallback there rather than risking a CoreCLR attach to a device + // or simulator process. Ordinary grouped projects from newer AppHosts remain attachable. + if (launchConfigurationType === 'maui' || + (launchConfigurationType === null && getResourceParentName(resource) !== null)) { + return false; + } + + if (!isDotNetExecutable(resource)) { + return false; + } + + const projectPath: unknown = resource.properties?.[projectPathPropertyName]; + if (typeof projectPath !== 'string' || projectPath.trim().length === 0) { + return false; + } + + if (!dotNetProjectFileExtensions.has(path.extname(projectPath).toLowerCase())) { + return false; + } + + return true; +} + +function getDotNetLaunchMetadata( + resource: ResourceDebugResourceSnapshot, +): Pick | undefined { + const properties = resource.properties; + const hasConfiguration = properties !== null && properties !== undefined && + Object.prototype.hasOwnProperty.call(properties, projectConfigurationPropertyName); + const hasFramework = properties !== null && properties !== undefined && + Object.prototype.hasOwnProperty.call(properties, projectTargetFrameworkPropertyName); + const configuration = getNonEmptyStringProperty(resource, projectConfigurationPropertyName); + const framework = getNonEmptyStringProperty(resource, projectTargetFrameworkPropertyName); + if ((hasConfiguration && configuration === undefined) || + (hasFramework && framework === undefined)) { + return undefined; + } + + const hasLaunchCommand = properties !== null && properties !== undefined && + Object.prototype.hasOwnProperty.call(properties, projectLaunchCommandPropertyName); + const launchCommandValue = properties?.[projectLaunchCommandPropertyName]; + if (hasLaunchCommand && launchCommandValue !== 'run' && launchCommandValue !== 'watch') { + return undefined; + } + + return { + configuration, + framework, + launchCommand: launchCommandValue as DotNetLaunchCommand | undefined, + useTargetNameFallback: !hasLaunchCommand && + properties?.[executableArgsPropertyName] === null && + !hasConfiguration && + !hasFramework, + }; +} + +function getNonEmptyStringProperty(resource: ResourceDebugResourceSnapshot, propertyName: string): string | undefined { + const value: unknown = resource.properties?.[propertyName]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function getResourceParentName(resource: ResourceDebugResourceSnapshot): string | null { + const value: unknown = resource.properties?.[resourceParentNamePropertyName]; + return typeof value === 'string' ? value : null; +} + +function getLaunchConfigurationType(resource: ResourceDebugResourceSnapshot): string | null { + const value: unknown = resource.properties?.[resourceLaunchConfigurationTypePropertyName]; + return typeof value === 'string' ? value.trim().toLowerCase() : null; +} + +function getAttachDebuggerProcessId(resource: ResourceDebugResourceSnapshot): number | undefined { + const value: unknown = resource.properties?.[executablePidPropertyName]; + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return value; + } + + if (typeof value !== 'string') { + return undefined; + } + + const processId = Number(value); + if (!Number.isInteger(processId) || processId <= 0) { + return undefined; + } + + return processId; +} + +function isDotNetExecutable(resource: ResourceDebugResourceSnapshot): boolean { + const executablePath: unknown = resource.properties?.[executablePathPropertyName]; + if (typeof executablePath !== 'string') { + return false; + } + + const executableName = executablePath.split(/[\\/]/).pop()?.toLowerCase(); + return executableName === 'dotnet' || executableName === 'dotnet.exe'; +} + +async function createDotNetProcessIdentity( + targetInfo: DotNetAttachTargetInfo, + attachInfo: DotNetAttachDebuggerResourceInfo, + fileSystem: DotNetAttachFileSystem, +): Promise { + const requiresDirectChild = attachInfo.launchCommand !== 'watch'; + if (attachInfo.useTargetNameFallback) { + const targetName = targetInfo.targetName; + if (targetName === undefined) { + throw new Error(attachDebuggerUnavailable); + } + + return { + requiresDirectChild, + isLauncher: process => isDotNetProcess(process), + isCandidate: process => targetInfo.useAppHost + ? isAppHostProcessForTargetName(process, targetName) + : isFrameworkDependentProcessForTargetName(process, targetName), + }; + } + + const appHostPaths = targetInfo.useAppHost + ? await getCanonicalAppHostPaths(targetInfo.targetPath, fileSystem) + : undefined; + return { + requiresDirectChild, + isLauncher: process => isDotNetProcess(process), + isCandidate: process => targetInfo.useAppHost + ? isAppHostProcessForTarget(process, appHostPaths!) + : isFrameworkDependentProcessForTarget(process, targetInfo.targetPath), + }; +} + +function isDotNetProcess(process: LaunchedChildProcess): boolean { + const executableName = process.executable.split(/[\\/]/).pop()?.toLowerCase(); + return executableName === 'dotnet' || executableName === 'dotnet.exe'; +} + +function isAppHostProcessForTarget(candidate: LaunchedChildProcess, appHostPaths: readonly string[]): boolean { + return appHostPaths.some(appHostPath => areProcessPathsEqual(candidate.executable, appHostPath)); +} + +function isAppHostProcessForTargetName(process: LaunchedChildProcess, targetName: string): boolean { + return doesProcessPathStemMatchTargetName(process.executable, targetName, '.exe'); +} + +function isFrameworkDependentProcessForTarget(process: LaunchedChildProcess, targetPath: string): boolean { + if (!isDotNetProcess(process)) { + return false; + } + + if (process.commandLineArguments) { + return commandLineArgumentsContainTargetPath(process.commandLineArguments, targetPath); + } + + return commandContainsPathArgumentAfterDotNetExec(process.command, targetPath); +} + +function isFrameworkDependentProcessForTargetName(process: LaunchedChildProcess, targetName: string): boolean { + if (!isDotNetProcess(process)) { + return false; + } + + const targetArgument = process.commandLineArguments + ? getFirstDllArgumentAfterExec(process.commandLineArguments) + : getFirstDllArgumentAfterDotNetExec(process.command); + return targetArgument !== undefined && + doesProcessPathStemMatchTargetName(targetArgument, targetName, '.dll'); +} + +function getFirstDllArgumentAfterExec(argumentsList: readonly string[]): string | undefined { + const execIndex = argumentsList.indexOf('exec'); + if (execIndex < 1) { + return undefined; + } + + return argumentsList.slice(execIndex + 1).find(argument => /\.dll$/i.test(argument)); +} + +function getFirstDllArgumentAfterDotNetExec(command: string): string | undefined { + const dotNetExec = /^\s*(?:"[^"]+"|'[^']+'|\S+)\s+exec(?:\s+|$)/.exec(command); + if (!dotNetExec) { + return undefined; + } + + const dllArgument = getFirstDllArgumentMatch(command.slice(dotNetExec[0].length)); + return dllArgument?.[1] ?? dllArgument?.[2] ?? dllArgument?.[3]; +} + +function getFirstDllArgumentMatch(command: string): RegExpExecArray | null { + // Raw process text has the shape: + // dotnet exec "/repo/bin/Release/net10.0/Api.dll" --flag /app/Other.dll + // Only the first DLL token is the host target; later DLL values are application arguments. + return /(?:^|\s)(?:"([^"]+\.dll)"|'([^']+\.dll)'|(\S+\.dll))(?=$|\s)/i.exec(command); +} + +function doesProcessPathStemMatchTargetName( + processPath: string, + targetName: string, + extension: '.dll' | '.exe', +): boolean { + const fileName = processPath.split(/[\\/]/).pop(); + if (fileName === undefined) { + return false; + } + + const stem = fileName.toLowerCase().endsWith(extension) + ? fileName.slice(0, -extension.length) + : fileName; + // Windows CIM can omit ExecutablePath and return only Name, such as `API.EXE`, so the + // executable suffix must also identify Windows semantics when no path is available. + const isWindowsIdentity = /^(?:[a-z]:[\\/]|\\\\)/i.test(processPath) || + processPath.includes('\\') || + /\.exe$/i.test(fileName); + return isWindowsIdentity + ? stem.toLowerCase() === targetName.toLowerCase() + : stem === targetName; +} + +function areProcessPathsEqual(left: string, right: string): boolean { + const normalizedLeft = left.replace(/\\/g, '/'); + const normalizedRight = right.replace(/\\/g, '/'); + const isWindowsPath = /^[a-z]:\//i.test(normalizedLeft) || /^[a-z]:\//i.test(normalizedRight); + return isWindowsPath + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +function getAppHostPaths(targetPath: string): readonly string[] { + if (path.extname(targetPath).toLowerCase() !== '.dll') { + return [targetPath]; + } + + const appHostPath = targetPath.slice(0, -'.dll'.length); + return [appHostPath, `${appHostPath}.exe`]; +} + +async function getCanonicalAppHostPaths( + targetPath: string, + fileSystem: DotNetAttachFileSystem, +): Promise { + const appHostPaths = getAppHostPaths(targetPath); + const canonicalAppHostPaths = await Promise.all(appHostPaths.map(async appHostPath => { + try { + return await fileSystem.realpath(appHostPath); + } + catch { + return undefined; + } + })); + + let canonicalTargetDirectory: string | undefined; + if (canonicalAppHostPaths.some(appHostPath => appHostPath === undefined)) { + try { + canonicalTargetDirectory = await fileSystem.realpath(path.dirname(targetPath)); + } + catch { + // `/proc//exe` resolves symlinked directories even after its final executable was + // unlinked. Retain the raw path if neither the file nor its parent directory survives. + } + } + + const directoryCanonicalizedAppHostPaths = canonicalAppHostPaths.map((appHostPath, index) => + appHostPath ?? (canonicalTargetDirectory + ? path.join(canonicalTargetDirectory, path.basename(appHostPaths[index])) + : appHostPaths[index])); + return [...new Set([...appHostPaths, ...directoryCanonicalizedAppHostPaths])]; +} + +function commandLineArgumentsContainTargetPath(argumentsList: readonly string[], targetPath: string): boolean { + const targetArgument = getFirstDllArgumentAfterExec(argumentsList); + return targetArgument !== undefined && areProcessPathsEqual(targetArgument, targetPath); +} + +function commandContainsPathArgumentAfterDotNetExec(command: string, targetPath: string): boolean { + const dotNetExec = /^\s*(?:"[^"]+"|'[^']+'|\S+)\s+exec(?:\s+|$)/.exec(command); + if (!dotNetExec) { + return false; + } + + const commandAfterExec = command.slice(dotNetExec[0].length); + const targetPathIndex = getPathArgumentIndex(commandAfterExec, targetPath); + const firstDllArgumentIndex = getFirstDllArgumentMatch(commandAfterExec)?.index; + return targetPathIndex !== undefined && + firstDllArgumentIndex !== undefined && + targetPathIndex <= firstDllArgumentIndex; +} + +function getPathArgumentIndex(command: string, targetPath: string): number | undefined { + const normalizedCommand = command.replace(/\\/g, '/'); + const normalizedTargetPath = targetPath.replace(/\\/g, '/'); + const isWindowsPath = /^[a-z]:\//i.test(normalizedCommand) || /^[a-z]:\//i.test(normalizedTargetPath); + const match = new RegExp( + `(?:^|\\s|["'])${escapeRegularExpression(normalizedTargetPath)}(?=$|\\s|["'])`, + isWindowsPath ? 'i' : undefined).exec(normalizedCommand); + return match?.index; +} + +function escapeRegularExpression(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export async function createDotNetAttachDebugSessionConfiguration( + resource: ResourceDebugResourceSnapshot, + dotNetService: IDotNetService, + childProcessResolver: LaunchedChildProcessResolver, + cancellationToken?: vscode.CancellationToken, + fileSystem: DotNetAttachFileSystem = systemDotNetAttachFileSystem, +): Promise { + const attachInfo = getDotNetAttachDebuggerResourceInfo(resource); + if (!attachInfo) { + throw new ResourceAttachConfigurationError('resourceNotAttachable', invalidLaunchConfiguration(resource.name)); + } + + let targetInfo: DotNetAttachTargetInfo; + try { + targetInfo = await dotNetService.getDotNetAttachTargetInfo(attachInfo.projectPath, attachInfo.configuration, cancellationToken, attachInfo.framework); + } + catch (error) { + throw new ResourceAttachConfigurationError( + 'resourceNotAttachable', + error instanceof Error ? error.message : String(error)); + } + + let applicationPid: number; + try { + applicationPid = await childProcessResolver.resolveProcessId( + attachInfo.launcherPid, + await createDotNetProcessIdentity(targetInfo, attachInfo, fileSystem), + cancellationToken); + } + catch (error) { + if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + throw new ResourceAttachConfigurationError('resourceNotAttachable', attachDebuggerUnavailable); + } + + if (!Number.isInteger(applicationPid) || applicationPid <= 0) { + throw new ResourceAttachConfigurationError('resourceNotAttachable', attachDebuggerUnavailable); + } + + return { + type: 'coreclr', + request: 'attach', + name: attachDebuggerConfigurationName(attachInfo.resourceLabel), + processId: applicationPid, + }; +} + function getEnvironmentVariable(environment: NodeJS.ProcessEnv, name: string): string | undefined { if (process.platform !== 'win32') { return environment[name]; @@ -734,12 +1316,12 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess return launchConfig.project_path; } - throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); + throw new Error(invalidLaunchConfiguration(launchConfig.type)); }, createDebugSessionConfigurationCallback: async (launchConfig, args, env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise => { if (!isProjectLaunchConfiguration(launchConfig)) { - extensionLogOutputChannel.info(`The resource type was not project for ${JSON.stringify(launchConfig)}`); - throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); + extensionLogOutputChannel.info(`The resource type was not project for ${launchConfig.type}`); + throw new Error(invalidLaunchConfiguration(launchConfig.type)); } const projectPath = launchConfig.project_path; @@ -1022,3 +1604,29 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess } export const projectDebuggerExtension: ResourceDebuggerExtension = createProjectDebuggerExtension(debugSession => new DotNetService(debugSession)); + +export function createProjectResourceAttachProvider( + dotNetServiceProducer: () => IDotNetService, + childProcessResolver: LaunchedChildProcessResolver = launchedChildProcessResolver, + fileSystem: DotNetAttachFileSystem = systemDotNetAttachFileSystem, +): ResourceAttachProvider { + return { + id: 'dotnet', + requiredDebuggerExtensions: [{ + id: 'ms-dotnettools.csharp', + label: 'C#', + installMessage: attachDebuggerCsharpExtensionRequired, + }], + canRecognizeResource: resource => canRecognizeDotNetAttachDebuggerResource(resource), + canAttachToResource: resource => getDotNetAttachDebuggerResourceInfo(resource) !== undefined, + createDebugConfiguration: async (resource, cancellationToken) => + await createDotNetAttachDebugSessionConfiguration(resource, dotNetServiceProducer(), childProcessResolver, cancellationToken, fileSystem), + }; +} + +const systemDotNetAttachFileSystem: DotNetAttachFileSystem = { + realpath: path => fs.promises.realpath(path), +}; + +export const projectResourceAttachProvider: ResourceAttachProvider = + createProjectResourceAttachProvider(() => new DotNetService(undefined)); diff --git a/extension/src/debugger/languages/go.ts b/extension/src/debugger/languages/go.ts index 96dc5e934d4..b02ad614e34 100644 --- a/extension/src/debugger/languages/go.ts +++ b/extension/src/debugger/languages/go.ts @@ -1,8 +1,29 @@ import * as vscode from 'vscode'; import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, isGoLaunchConfiguration } from "../../dcp/types"; -import { goDisplayName, goLabel, invalidLaunchConfiguration } from "../../loc/strings"; +import { attachDebuggerConfigurationName, attachDebuggerUnavailable, goDisplayName, goLabel, invalidLaunchConfiguration } from "../../loc/strings"; import { extensionLogOutputChannel } from "../../utils/logging"; import { ResourceDebuggerExtension } from "../debuggerExtensions"; +import { ResourceAttachConfigurationError, type ResourceAttachProvider, type ResourceDebugResourceSnapshot } from '../resourceDebugContracts'; +import { + launchedChildProcessResolver, + type LaunchedChildProcess, + type LaunchedChildProcessIdentity, +} from '../launchedChildProcessDiscovery'; + +const executablePidPropertyName = 'executable.pid'; +const executablePathPropertyName = 'executable.path'; +const resourceLaunchConfigurationTypePropertyName = 'resource.launchConfigurationType'; +const goBuildExecutablePattern = /(?:^|[\\/])go-build[^\\/\s]*(?:[\\/][^\\/\s]+)*[\\/]exe[\\/][^\\/\s]+(?:\.exe)?$/i; +const cachedGoRunExecutablePattern = /(?:^|[\\/])[0-9a-f]{2}[\\/][0-9a-f]{16,}-d[\\/][^\\/\s]+(?:\.exe)?$/i; + +interface GoAttachDebuggerResourceInfo { + readonly parentPid: number; + readonly resourceLabel: string; +} + +interface GoApplicationProcessResolver { + resolveApplicationPid(goProcessId: number, cancellationToken?: vscode.CancellationToken): Promise; +} function getProjectFile(launchConfig: ExecutableLaunchConfiguration): string { if (isGoLaunchConfiguration(launchConfig)) { @@ -54,3 +75,132 @@ export const goDebuggerExtension: ResourceDebuggerExtension = { debugConfiguration.args = args ?? []; } }; + +export function createGoResourceAttachProvider(processResolver: GoApplicationProcessResolver): ResourceAttachProvider { + return { + id: 'go', + requiredDebuggerExtensions: [{ + id: 'golang.go', + label: goLabel, + }], + canRecognizeResource: resource => canRecognizeGoAttachDebuggerResource(resource), + canAttachToResource: resource => getGoAttachDebuggerResourceInfo(resource) !== undefined, + createDebugConfiguration: async (resource, cancellationToken) => + await createGoAttachDebugConfiguration(resource, processResolver, cancellationToken), + }; +} + +export const goResourceAttachProvider: ResourceAttachProvider = + createGoResourceAttachProvider({ + resolveApplicationPid: async (goProcessId, cancellationToken) => + await launchedChildProcessResolver.resolveProcessId( + goProcessId, + createGoRunProcessIdentity(), + cancellationToken), + }); + +export function createGoRunProcessIdentity(): LaunchedChildProcessIdentity { + return { + isLauncher: process => isGoToolProcess(process), + isCandidate: process => isGoBuildApplication(process), + }; +} + +function canRecognizeGoAttachDebuggerResource(resource: ResourceDebugResourceSnapshot): boolean { + return getLaunchConfigurationType(resource) === 'go' && isGoExecutable(resource); +} + +function getGoAttachDebuggerResourceInfo(resource: ResourceDebugResourceSnapshot): GoAttachDebuggerResourceInfo | undefined { + if (resource.state !== 'Running' || !canRecognizeGoAttachDebuggerResource(resource)) { + return undefined; + } + + const parentPid = getProcessId(resource); + if (parentPid === undefined) { + return undefined; + } + + return { + parentPid, + resourceLabel: resource.displayName ?? resource.name, + }; +} + +async function createGoAttachDebugConfiguration( + resource: ResourceDebugResourceSnapshot, + processResolver: GoApplicationProcessResolver, + cancellationToken?: vscode.CancellationToken, +): Promise { + const attachInfo = getGoAttachDebuggerResourceInfo(resource); + if (!attachInfo) { + throw new ResourceAttachConfigurationError('resourceNotAttachable', attachDebuggerUnavailable); + } + + let applicationPid: number; + try { + applicationPid = await processResolver.resolveApplicationPid(attachInfo.parentPid, cancellationToken); + } + catch (error) { + if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + throw new ResourceAttachConfigurationError('resourceNotAttachable', attachDebuggerUnavailable); + } + + if (!Number.isInteger(applicationPid) || applicationPid <= 0) { + throw new ResourceAttachConfigurationError('resourceNotAttachable', attachDebuggerUnavailable); + } + + return { + type: 'go', + request: 'attach', + mode: 'local', + debugAdapter: 'dlv-dap', + name: attachDebuggerConfigurationName(attachInfo.resourceLabel), + processId: applicationPid, + }; +} + +function getLaunchConfigurationType(resource: ResourceDebugResourceSnapshot): string | undefined { + const value = resource.properties?.[resourceLaunchConfigurationTypePropertyName]; + return typeof value === 'string' ? value : undefined; +} + +function isGoExecutable(resource: ResourceDebugResourceSnapshot): boolean { + const executablePath = resource.properties?.[executablePathPropertyName]; + if (typeof executablePath !== 'string') { + return false; + } + + const executableName = executablePath.split(/[\\/]/).pop()?.toLowerCase(); + return executableName === 'go' || executableName === 'go.exe'; +} + +function getProcessId(resource: ResourceDebugResourceSnapshot): number | undefined { + const value = resource.properties?.[executablePidPropertyName]; + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return value; + } + + if (typeof value !== 'string') { + return undefined; + } + + const processId = Number(value); + return Number.isInteger(processId) && processId > 0 ? processId : undefined; +} + +function isGoBuildApplication(process: LaunchedChildProcess): boolean { + return isGoRunApplicationPath(process.executable); +} + +function isGoToolProcess(process: LaunchedChildProcess): boolean { + const executableName = process.executable.split(/[\\/]/).pop()?.toLowerCase(); + return executableName === 'go' || executableName === 'go.exe'; +} + +function isGoRunApplicationPath(path: string | undefined): boolean { + return path !== undefined && + (goBuildExecutablePattern.test(path) || cachedGoRunExecutablePattern.test(path)); +} diff --git a/extension/src/debugger/launchedChildProcessDiscovery.ts b/extension/src/debugger/launchedChildProcessDiscovery.ts new file mode 100644 index 00000000000..fd4700add48 --- /dev/null +++ b/extension/src/debugger/launchedChildProcessDiscovery.ts @@ -0,0 +1,748 @@ +import * as childProcess from 'child_process'; +import * as fs from 'fs'; +import * as vscode from 'vscode'; + +export interface LaunchedChildProcess { + readonly pid: number; + readonly parentPid: number; + readonly executable: string; + readonly command: string; + readonly commandLineArguments?: readonly string[]; +} + +export interface LaunchedChildProcessQuery { + readonly canTrustListedProcessIdentity?: boolean; + listProcesses(cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; + getProcess?(processId: number, cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; +} + +export interface LaunchedChildProcessClock { + now(): number; + sleep(milliseconds: number, cancellationToken?: vscode.CancellationToken): Promise; +} + +export interface LaunchedChildProcessIdentity { + readonly requiresDirectChild?: boolean; + isLauncher(process: LaunchedChildProcess): boolean; + isCandidate(process: LaunchedChildProcess): boolean; +} + +export interface LaunchedChildProcessCommandRunner { + run(command: string, args: readonly string[], cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; +} + +export interface LaunchedChildProcessFileSystem { + readlink(path: string): Promise; + readFile(path: string): Promise; +} + +export type LaunchedChildProcessSpawner = ( + command: string, + args: readonly string[], + options: childProcess.SpawnOptions, +) => childProcess.ChildProcessWithoutNullStreams; + +const maxProcessListingLength = 16 * 1024 * 1024; +const windowsProcessProperties = 'ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine'; +const windowsProcessQuery = `$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-CimInstance Win32_Process | Select-Object ${windowsProcessProperties} | ConvertTo-Json -Compress`; +const linuxDeletedExecutableMarker = ' (deleted)'; + +export function parsePosixProcessList(output: string): readonly LaunchedChildProcess[] { + const processes: LaunchedChildProcess[] = []; + + for (const line of output.split(/\r?\n/)) { + const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line); + if (!match) { + continue; + } + + processes.push({ + pid: Number(match[1]), + parentPid: Number(match[2]), + executable: '', + command: '', + }); + } + + return processes; +} + +export function parseWindowsProcessList(output: string): readonly LaunchedChildProcess[] { + let parsed: unknown; + try { + // Windows PowerShell can still prepend U+FEFF despite setting OutputEncoding. JSON.parse + // rejects that marker, so remove it before parsing the machine-readable response. + parsed = JSON.parse(output.replace(/^\uFEFF/, '')); + } + catch { + throw createProcessDiscoveryError(); + } + + const rows = Array.isArray(parsed) ? parsed : [parsed]; + const processes: LaunchedChildProcess[] = []; + for (const row of rows) { + if (typeof row !== 'object' || row === null) { + continue; + } + + const values = row as Record; + const executablePath = getNonEmptyString(values.ExecutablePath); + // CIM can omit ExecutablePath. Name plus CommandLine is still usable listed identity: + // exact-path matchers fail closed on Name, and selected ancestry is freshly queried. + const process = createProcessInfo( + values.ProcessId, + values.ParentProcessId, + executablePath ?? values.Name, + values.CommandLine); + if (process) { + processes.push(process); + } + } + + return processes; +} + +export function getProcessCommandProgram(command: string): string | undefined { + const match = /^\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(command); + return match?.[1] ?? match?.[2] ?? match?.[3]; +} + +export class LaunchedChildProcessResolver { + private static readonly _defaultTimeoutMs = 30_000; + private static readonly _defaultRetryDelayMs = 100; + private static readonly _maximumRetryDelayMs = 1_000; + + constructor( + private readonly _processQuery: LaunchedChildProcessQuery, + private readonly _clock: LaunchedChildProcessClock = systemLaunchedChildProcessClock, + options: { readonly timeoutMs?: number; readonly retryDelayMs?: number } = {}, + ) { + this._timeoutMs = options.timeoutMs ?? LaunchedChildProcessResolver._defaultTimeoutMs; + this._retryDelayMs = options.retryDelayMs ?? LaunchedChildProcessResolver._defaultRetryDelayMs; + } + + private readonly _timeoutMs: number; + private readonly _retryDelayMs: number; + + async resolveProcessId( + launcherPid: number, + identity: LaunchedChildProcessIdentity, + cancellationToken?: vscode.CancellationToken, + ): Promise { + if (!isValidPid(launcherPid)) { + throw createProcessDiscoveryError(); + } + + const timeoutMs = Math.max(1, this._timeoutMs); + const deadline = this._clock.now() + timeoutMs; + let previousCandidate: number | undefined; + let retryDelayMs = Math.max(1, this._retryDelayMs); + const maximumAttempts = Math.max(2, Math.ceil(timeoutMs / retryDelayMs) + 1); + let attempts = 0; + + while (this._clock.now() <= deadline && attempts++ < maximumAttempts) { + throwIfCancelled(cancellationToken); + + let processes: readonly LaunchedChildProcess[]; + try { + processes = await this._processQuery.listProcesses( + cancellationToken, + Math.max(1, deadline - this._clock.now())); + } + catch (error) { + if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + processes = []; + } + + throwIfCancelled(cancellationToken); + + let candidate: number | undefined; + try { + candidate = await this._findMatchingCandidate( + launcherPid, + identity, + processes, + cancellationToken, + deadline); + } + catch (error) { + if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + throw createProcessDiscoveryError(); + } + + if (candidate !== undefined && candidate === previousCandidate && + await this._verifyCandidateLineage(candidate, launcherPid, identity, cancellationToken, deadline)) { + return candidate; + } + + previousCandidate = candidate; + const remainingTimeMs = deadline - this._clock.now(); + if (remainingTimeMs <= 0) { + break; + } + + try { + await this._clock.sleep(Math.min(retryDelayMs, remainingTimeMs), cancellationToken); + retryDelayMs = Math.min( + LaunchedChildProcessResolver._maximumRetryDelayMs, + retryDelayMs * 2); + } + catch (error) { + if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + throw createProcessDiscoveryError(); + } + } + + throw createProcessDiscoveryError(); + } + + private async _findMatchingCandidate( + launcherPid: number, + identity: LaunchedChildProcessIdentity, + processes: readonly LaunchedChildProcess[], + cancellationToken: vscode.CancellationToken | undefined, + deadline: number, + ): Promise { + const candidatePids = findDescendantProcessIds(launcherPid, identity.requiresDirectChild === true, processes); + if (!candidatePids) { + return undefined; + } + + const launcher = await this._getProcess( + launcherPid, + processes.find(process => process.pid === launcherPid), + cancellationToken, + deadline); + if (!launcher || !identity.isLauncher(launcher)) { + return undefined; + } + + const candidates: number[] = []; + for (const candidatePid of candidatePids) { + const candidate = await this._getProcess( + candidatePid, + processes.find(process => process.pid === candidatePid), + cancellationToken, + deadline); + if (!candidate || + (identity.requiresDirectChild === true && candidate.parentPid !== launcherPid)) { + continue; + } + + if (identity.isCandidate(candidate)) { + candidates.push(candidate.pid); + } + } + + return candidates.length === 1 ? candidates[0] : undefined; + } + + private async _verifyCandidateLineage( + candidatePid: number, + launcherPid: number, + identity: LaunchedChildProcessIdentity, + cancellationToken: vscode.CancellationToken | undefined, + deadline: number, + ): Promise { + if (!this._processQuery.getProcess) { + return true; + } + + if (identity.requiresDirectChild === true) { + throwIfCancelled(cancellationToken); + if (this._clock.now() > deadline) { + return false; + } + + const candidate = await this._getProcess( + candidatePid, + undefined, + cancellationToken, + deadline, + true); + throwIfCancelled(cancellationToken); + if (this._clock.now() > deadline) { + return false; + } + + return candidate !== undefined && + candidate.parentPid === launcherPid && + identity.isCandidate(candidate); + } + + let processId = candidatePid; + const visited = new Set(); + + // POSIX process-list rows contain only PID/PPID topology, while Windows CIM rows can also + // carry trusted identity for candidate discovery. Regardless of the listing source, re-read + // every PID in the selected transitive ancestry from the OS immediately before returning. + while (true) { + if (visited.has(processId) || this._clock.now() > deadline) { + return false; + } + + visited.add(processId); + const process = await this._getProcess(processId, undefined, cancellationToken, deadline); + if (!process) { + return false; + } + + if (processId === candidatePid && !identity.isCandidate(process)) { + return false; + } + + if (processId === launcherPid) { + return identity.isLauncher(process); + } + + if (!isValidPid(process.parentPid)) { + return false; + } + + processId = process.parentPid; + } + } + + private async _getProcess( + processId: number, + topologyProcess: LaunchedChildProcess | undefined, + cancellationToken: vscode.CancellationToken | undefined, + deadline: number, + requireFresh = false, + ): Promise { + if (!this._processQuery.getProcess || + (!requireFresh && + this._processQuery.canTrustListedProcessIdentity === true && + topologyProcess !== undefined && + topologyProcess.executable.length > 0 && + topologyProcess.command.length > 0)) { + return topologyProcess; + } + + try { + const process = await this._processQuery.getProcess( + processId, + cancellationToken, + Math.max(1, deadline - this._clock.now())); + return process?.pid === processId ? process : undefined; + } + catch (error) { + if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + return undefined; + } + } +} + +export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuery { + readonly canTrustListedProcessIdentity: boolean; + + constructor( + private readonly _platform: NodeJS.Platform = process.platform, + private readonly _commandRunner: LaunchedChildProcessCommandRunner = new SystemLaunchedChildProcessCommandRunner(), + private readonly _fileSystem: LaunchedChildProcessFileSystem = systemLaunchedChildProcessFileSystem, + ) { + this.canTrustListedProcessIdentity = this._platform === 'win32'; + } + + async listProcesses(cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise { + const output = this._platform === 'win32' + ? await this._commandRunner.run( + 'powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', windowsProcessQuery], + cancellationToken, + timeoutMs) + : await this._commandRunner.run( + 'ps', + ['-axo', 'pid=,ppid='], + cancellationToken, + timeoutMs); + + return this._platform === 'win32' + ? parseWindowsProcessList(output) + : parsePosixProcessList(output); + } + + async getProcess(processId: number, cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise { + if (!isValidPid(processId)) { + return undefined; + } + + if (this._platform === 'win32') { + const output = await this._commandRunner.run( + 'powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', + `$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-CimInstance Win32_Process -Filter "ProcessId = ${processId}" | Select-Object ${windowsProcessProperties} | ConvertTo-Json -Compress`], + cancellationToken, + timeoutMs); + return parseWindowsProcessList(output).find(process => process.pid === processId); + } + + if (this._platform === 'linux') { + return this._getLinuxProcess(processId, cancellationToken, timeoutMs); + } + + if (this._platform === 'darwin') { + const [parentPidOutput, executableOutput, commandOutput] = await Promise.all([ + this._commandRunner.run('ps', ['-p', String(processId), '-o', 'ppid='], cancellationToken, timeoutMs), + this._commandRunner.run('lsof', ['-a', '-p', String(processId), '-d', 'txt', '-Fn'], cancellationToken, timeoutMs), + this._commandRunner.run('ps', ['-p', String(processId), '-o', 'args='], cancellationToken, timeoutMs), + ]); + const executablePath = parseMacOsTextExecutablePath(executableOutput, processId); + if (executablePath === undefined) { + return undefined; + } + + return createProcessInfo( + processId, + parentPidOutput.trim(), + executablePath, + commandOutput.trim()); + } + + const [parentPidOutput, executableOutput, commandOutput] = await Promise.all([ + this._commandRunner.run('ps', ['-p', String(processId), '-o', 'ppid='], cancellationToken, timeoutMs), + this._commandRunner.run('ps', ['-p', String(processId), '-o', 'comm='], cancellationToken, timeoutMs), + this._commandRunner.run('ps', ['-p', String(processId), '-o', 'args='], cancellationToken, timeoutMs), + ]); + return createProcessInfo( + processId, + parentPidOutput.trim(), + executableOutput.trim(), + commandOutput.trim()); + } + + private async _getLinuxProcess( + processId: number, + cancellationToken: vscode.CancellationToken | undefined, + timeoutMs: number | undefined, + ): Promise { + // Procfs exposes exact details as separate kernel-owned files. `cmdline` is a NUL-separated + // byte sequence such as `dotnet\0exec\0/repo/My Service.dll\0`; do not route it through a + // shell or flatten it before identity matching. + const [executable, commandLine, status] = await awaitProcessDetails( + Promise.all([ + this._fileSystem.readlink(`/proc/${processId}/exe`), + this._fileSystem.readFile(`/proc/${processId}/cmdline`), + this._fileSystem.readFile(`/proc/${processId}/status`), + ]), + cancellationToken, + timeoutMs); + const parentPid = parseLinuxParentPid(status); + if (parentPid === undefined) { + return undefined; + } + + const commandLineArguments = parseLinuxCommandLine(commandLine); + return createProcessInfo( + processId, + parentPid, + normalizeLinuxExecutablePath(executable), + commandLineArguments.join(' '), + commandLineArguments); + } +} + +export function parseMacOsTextExecutablePath(output: string, processId: number): string | undefined { + // `lsof -a -p 123 -d txt -Fn` reports the kernel-backed text executable as: + // p123 + // ftxt + // n/Applications/My Long App.app/Contents/MacOS/My Long App + // Require the requested PID record and the `txt` file descriptor before accepting its name. + const lines = output.split(/\r?\n/); + if (!lines.includes(`p${processId}`)) { + return undefined; + } + + const textDescriptorIndex = lines.indexOf('ftxt'); + const executableLine = textDescriptorIndex >= 0 ? lines[textDescriptorIndex + 1] : undefined; + return executableLine?.startsWith('n') && executableLine.length > 1 + ? executableLine.slice(1) + : undefined; +} + +export class SystemLaunchedChildProcessCommandRunner implements LaunchedChildProcessCommandRunner { + constructor( + private readonly _spawn: LaunchedChildProcessSpawner = + childProcess.spawn as unknown as LaunchedChildProcessSpawner, + ) { + } + + run(command: string, args: readonly string[], cancellationToken?: vscode.CancellationToken, timeoutMs = 1_000): Promise { + return new Promise((resolve, reject) => { + let completed = false; + let cancellationRegistration: vscode.Disposable | undefined; + let timeout: ReturnType | undefined; + let output = ''; + const process = this._spawn(command, args, { + stdio: 'pipe', + windowsHide: true, + }); + + const complete = (action: () => void) => { + if (completed) { + return; + } + + completed = true; + if (timeout) { + clearTimeout(timeout); + } + cancellationRegistration?.dispose(); + action(); + }; + const fail = () => { + // Discovery owns this short-lived `ps` or PowerShell child only. Never signal the + // launched workload or any descendant while resolving an attach target. + if (!process.killed) { + process.kill(); + } + complete(() => reject(createProcessDiscoveryError())); + }; + + process.stdout.setEncoding('utf8'); + process.stdout.on('data', (chunk: string) => { + if (output.length + chunk.length > maxProcessListingLength) { + fail(); + return; + } + + output += chunk; + }); + // Drain stderr so a failed fixed query cannot block on a full pipe. Its contents may + // include command text and are intentionally neither logged nor returned. + process.stderr.resume(); + process.on('error', fail); + process.on('close', exitCode => { + if (exitCode === 0) { + complete(() => resolve(output)); + } + else { + complete(() => reject(createProcessDiscoveryError())); + } + }); + + cancellationRegistration = cancellationToken?.onCancellationRequested(fail); + timeout = setTimeout(fail, Math.max(1, timeoutMs)); + if (cancellationToken?.isCancellationRequested) { + fail(); + } + }); + } +} + +const systemLaunchedChildProcessClock: LaunchedChildProcessClock = { + now: () => Date.now(), + sleep: (milliseconds, cancellationToken) => new Promise((resolve, reject) => { + if (cancellationToken?.isCancellationRequested) { + reject(new vscode.CancellationError()); + return; + } + + let cancellationRegistration: vscode.Disposable | undefined; + const timeout = setTimeout(() => { + cancellationRegistration?.dispose(); + resolve(); + }, milliseconds); + cancellationRegistration = cancellationToken?.onCancellationRequested(() => { + clearTimeout(timeout); + cancellationRegistration?.dispose(); + reject(new vscode.CancellationError()); + }); + }), +}; + +const systemLaunchedChildProcessFileSystem: LaunchedChildProcessFileSystem = { + readlink: path => fs.promises.readlink(path), + readFile: path => fs.promises.readFile(path), +}; + +export const launchedChildProcessResolver = new LaunchedChildProcessResolver( + new SystemLaunchedChildProcessQuery()); + +function getNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmedValue = value.trim(); + return trimmedValue.length > 0 ? trimmedValue : undefined; +} + +function createProcessInfo( + pidValue: unknown, + parentPidValue: unknown, + executableValue: unknown, + commandValue: unknown, + commandLineArguments?: readonly string[], +): LaunchedChildProcess | undefined { + const pid = parsePid(pidValue); + const parentPid = parseParentPid(parentPidValue); + const executable = typeof executableValue === 'string' ? executableValue.trim() : ''; + const command = typeof commandValue === 'string' ? commandValue.trim() : ''; + if (pid === undefined || parentPid === undefined || executable.length === 0) { + return undefined; + } + + return { + pid, + parentPid, + executable, + command, + ...(commandLineArguments ? { commandLineArguments } : {}), + }; +} + +function parseLinuxCommandLine(commandLine: Buffer): readonly string[] { + const argumentsList = commandLine.toString('utf8').split('\0'); + if (argumentsList.at(-1) === '') { + argumentsList.pop(); + } + + return argumentsList; +} + +function parseLinuxParentPid(status: Buffer): number | undefined { + const match = /^PPid:\s*(\d+)\s*$/m.exec(status.toString('utf8')); + return match ? parseParentPid(match[1]) : undefined; +} + +function normalizeLinuxExecutablePath(executable: string): string { + // `/proc//exe` reports an unlinked executable as `/path/app (deleted)`. Remove only + // the kernel's exact trailing marker so a filename that contains those characters elsewhere + // remains a distinct executable identity. + return executable.endsWith(linuxDeletedExecutableMarker) + ? executable.slice(0, -linuxDeletedExecutableMarker.length) + : executable; +} + +function awaitProcessDetails( + details: Promise, + cancellationToken: vscode.CancellationToken | undefined, + timeoutMs: number | undefined, +): Promise { + return new Promise((resolve, reject) => { + let completed = false; + let cancellationRegistration: vscode.Disposable | undefined; + const timeout = setTimeout( + () => complete(() => reject(createProcessDiscoveryError())), + Math.max(1, timeoutMs ?? 30_000)); + const complete = (action: () => void) => { + if (completed) { + return; + } + + completed = true; + clearTimeout(timeout); + cancellationRegistration?.dispose(); + action(); + }; + + // The procfs reads have already started. Observe both outcomes before checking + // cancellation so a cancelled caller cannot leave the aggregate promise unobserved. + details.then( + result => complete(() => resolve(result)), + error => complete(() => reject(error))); + + cancellationRegistration = cancellationToken?.onCancellationRequested( + () => complete(() => reject(new vscode.CancellationError()))); + if (cancellationToken?.isCancellationRequested) { + complete(() => reject(new vscode.CancellationError())); + return; + } + }); +} + +function findDescendantProcessIds( + launcherPid: number, + requiresDirectChild: boolean, + processes: readonly LaunchedChildProcess[], +): readonly number[] | undefined { + const processById = new Map(); + const childrenByParentId = new Map(); + for (const process of processes) { + if (!isValidPid(process.pid) || !Number.isInteger(process.parentPid) || process.parentPid < 0 || processById.has(process.pid)) { + return undefined; + } + + processById.set(process.pid, process); + const children = childrenByParentId.get(process.parentPid) ?? []; + children.push(process); + childrenByParentId.set(process.parentPid, children); + } + + if (!processById.has(launcherPid)) { + return undefined; + } + + const descendants = [...(childrenByParentId.get(launcherPid) ?? [])]; + if (requiresDirectChild) { + return descendants.map(descendant => descendant.pid); + } + + const descendantsIds: number[] = []; + const visitedProcessIds = new Set([launcherPid]); + for (let index = 0; index < descendants.length; index++) { + const descendant = descendants[index]; + if (visitedProcessIds.has(descendant.pid)) { + return undefined; + } + + visitedProcessIds.add(descendant.pid); + descendantsIds.push(descendant.pid); + descendants.push(...(childrenByParentId.get(descendant.pid) ?? [])); + } + + return descendantsIds; +} + +function parsePid(value: unknown): number | undefined { + if (typeof value === 'number' && isValidPid(value)) { + return value; + } + + if (typeof value !== 'string') { + return undefined; + } + + const pid = Number(value); + return isValidPid(pid) ? pid : undefined; +} + +function parseParentPid(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isInteger(value) && value >= 0) { + return value; + } + + if (typeof value !== 'string') { + return undefined; + } + + const pid = Number(value); + return Number.isInteger(pid) && pid >= 0 ? pid : undefined; +} + +function isValidPid(value: number): boolean { + return Number.isInteger(value) && value > 0; +} + +function throwIfCancelled(cancellationToken?: vscode.CancellationToken): void { + if (cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } +} + +function createProcessDiscoveryError(): Error { + return new Error('Unable to resolve the running application process.'); +} diff --git a/extension/src/debugger/resourceAttachProviders.ts b/extension/src/debugger/resourceAttachProviders.ts new file mode 100644 index 00000000000..fe52ed40b71 --- /dev/null +++ b/extension/src/debugger/resourceAttachProviders.ts @@ -0,0 +1,30 @@ +import { isExtensionInstalled } from '../capabilities'; +import { projectResourceAttachProvider } from './languages/dotnet'; +import { goResourceAttachProvider } from './languages/go'; +import { + type ResourceAttachProvider, + type ResourceDebugExtensionRequirement, + type ResourceDebugResourceSnapshot, +} from './resourceDebugContracts'; + +export const extensionResourceAttachProviders: readonly ResourceAttachProvider[] = [ + projectResourceAttachProvider, + goResourceAttachProvider, +]; + +export class ResourceAttachProviderRegistry { + constructor( + private readonly _knownProviders: readonly ResourceAttachProvider[], + private readonly _isDebuggerExtensionInstalled?: (extensionId: string) => boolean, + ) { + } + + getRecognizedProviderForResource(resource: ResourceDebugResourceSnapshot): ResourceAttachProvider | undefined { + return this._knownProviders.find(provider => provider.canRecognizeResource(resource)); + } + + getMissingDebuggerExtensions(provider: ResourceAttachProvider): readonly ResourceDebugExtensionRequirement[] { + return provider.requiredDebuggerExtensions.filter(requirement => + !(this._isDebuggerExtensionInstalled?.(requirement.id) ?? isExtensionInstalled(requirement.id))); + } +} diff --git a/extension/src/debugger/resourceDebugContracts.ts b/extension/src/debugger/resourceDebugContracts.ts new file mode 100644 index 00000000000..208bd9c4f96 --- /dev/null +++ b/extension/src/debugger/resourceDebugContracts.ts @@ -0,0 +1,116 @@ +import type * as vscode from 'vscode'; + +export type ResourceDebugSource = 'tree' | 'languageModelTool'; + +export type ResourceAttachProviderId = 'dotnet' | 'go'; + +/** + * The caller's requested behavior. `auto` is intentionally bounded to the same attach + * action as `attach` today; the debug service owns that selection so callers cannot + * introduce start or restart behavior by interpreting it themselves. + */ +export type ResourceDebugStrategy = 'auto' | 'attach'; + +/** + * An AppHost selected by a caller. The absolute path remains internal to the editor + * control plane; only the safe display path may be used by presentation layers. The + * optional process IDs preserve exact tree-item identity and select the owning editor + * session when one path has overlapping runs. + */ +export interface ResourceDebugAppHostTarget { + readonly absolutePath: string; + readonly displayPath: string; + readonly appHostPid?: number; + readonly cliPid?: number; +} + +export interface ResourceDebugRequest { + readonly source: ResourceDebugSource; + readonly strategy: ResourceDebugStrategy; + readonly appHost: ResourceDebugAppHostTarget; + readonly resourceName: string; + readonly cancellationToken?: vscode.CancellationToken; +} + +/** + * The CLI resource snapshot supplied to attach providers. This is internal-only: + * provider configuration may require process or project metadata that must never + * cross the resource-debug result boundary. + */ +export interface ResourceDebugResourceSnapshot { + readonly name: string; + readonly displayName: string | null; + readonly resourceType: string; + readonly state: string | null; + readonly properties: Record | null; +} + +export interface ResourceDebugExtensionRequirement { + readonly id: string; + readonly label: string; + readonly installMessage?: string; +} + +/** + * A language-specific debugger attach provider. The resource-debug orchestrator supplies a + * cancellation token because future providers may have cancellable configuration discovery. + * Existing providers that delegate to debugger APIs without cancellation support can omit it. + */ +export interface ResourceAttachProvider { + readonly id: ResourceAttachProviderId; + readonly requiredDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; + /** + * Identifies resources this provider supports independently of their current state. The service + * uses this before checking whether a resource is running so stopped supported resources get a + * bounded resourceNotRunning result instead of being reported as unsupported. + */ + canRecognizeResource(resource: ResourceDebugResourceSnapshot): boolean; + /** + * Determines whether a recognized resource is ready to attach now, including runtime metadata + * and any provider-specific attach prerequisites. + */ + canAttachToResource(resource: ResourceDebugResourceSnapshot): boolean; + createDebugConfiguration(resource: ResourceDebugResourceSnapshot, cancellationToken?: vscode.CancellationToken): Promise; +} + +/** + * The tree consumes only the extension-wide debug service. It must not create its own service + * because that would split session tracking and allow duplicate attach commands. + */ +export interface ResourceDebugger { + debug(request: ResourceDebugRequest): Promise; + canAttachToResource(resource: ResourceDebugResourceSnapshot): boolean; + /** + * Lets resource presentations refresh after attach sessions start or end without receiving + * internal process, path, or debugger configuration details. + */ + readonly onDidChangeDebugSessions?: vscode.Event; +} + +export type ResourceDebugErrorKind = + | 'resourceSnapshotFailed' + | 'providerResolutionFailed' + | 'configurationFailed' + | 'debuggerStartDeclined' + | 'debuggerStartFailed' + | 'unexpected'; + +export type ResourceDebugResult = + | { readonly outcome: 'started'; readonly providerId: ResourceAttachProviderId } + | { readonly outcome: 'alreadyDebugging' } + | { readonly outcome: 'appHostNotFound' } + | { readonly outcome: 'resourceNotFound' } + | { readonly outcome: 'unsupportedResource' } + | { readonly outcome: 'resourceNotRunning' } + | { readonly outcome: 'debuggerExtensionMissing'; readonly debuggerExtensions: readonly ResourceDebugExtensionRequirement[] } + | { readonly outcome: 'cancelled' } + | { readonly outcome: 'error'; readonly errorKind: ResourceDebugErrorKind }; + +export type ResourceAttachConfigurationErrorKind = 'resourceNotAttachable'; + +export class ResourceAttachConfigurationError extends Error { + constructor(public readonly errorKind: ResourceAttachConfigurationErrorKind, message: string) { + super(message); + this.name = 'ResourceAttachConfigurationError'; + } +} diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts new file mode 100644 index 00000000000..0efb571d795 --- /dev/null +++ b/extension/src/debugger/resourceDebugService.ts @@ -0,0 +1,588 @@ +import * as vscode from 'vscode'; +import type { AppHostDisplayInfo, ResourceJson } from '../data/AppHostDataRepository'; +import type { AspireExtendedDebugConfiguration } from '../dcp/types'; +import { compareAppHostIdentity, type AppHostIdentityRelation } from '../utils/appHostIdentity'; +import { extensionLogOutputChannel } from '../utils/logging'; +import { isCommandCancellation } from '../utils/telemetry'; +import { + ResourceAttachConfigurationError, + type ResourceAttachProvider, + type ResourceDebugAppHostTarget, + type ResourceDebugExtensionRequirement, + type ResourceDebugger, + type ResourceDebugRequest, + type ResourceDebugResult, + type ResourceDebugStrategy, +} from './resourceDebugContracts'; +import { ResourceAttachProviderRegistry } from './resourceAttachProviders'; +import { ResourceDebugSessionRegistry } from './resourceDebugSessionRegistry'; +import { + ExtensionResourceDebugTelemetry, + type ResourceDebugAttachSessionMetadata, + type ResourceDebugClock, + type ResourceDebugDebuggerRequirement, + type ResourceDebugResourceState, + type ResourceDebugResourceType, + type ResourceDebugRequestedStrategyTelemetryBucket, + type ResourceDebugResultTelemetryMeasurements, + type ResourceDebugTelemetry, + monotonicResourceDebugClock, +} from './resourceDebugTelemetry'; + +const safeAttachDebuggerOverrideProperties = { + dotnet: [ + 'name', + 'justMyCode', + 'requireExactSource', + 'suppressJITOptimizations', + 'enableStepFiltering', + 'sourceFileMap', + 'sourceLinkOptions', + 'symbolOptions', + 'logging', + 'stopAtEntry', + ], + go: [ + 'name', + 'stopOnEntry', + 'substitutePath', + 'showRegisters', + 'showGlobalVariables', + 'showLog', + 'logOutput', + 'hideSystemGoroutines', + 'stackTraceDepth', + 'showPprofLabels', + 'trace', + 'cwd', + ], +} as const; + +export interface ResourceDebugAppHostRepository { + fetchRunningAppHostsOnce(cancellationToken?: vscode.CancellationToken): Promise; + fetchAppHostResourcesOnce(appHostPath: string, cancellationToken?: vscode.CancellationToken, appHostPid?: number): Promise; +} + +export type ResourceDebugAppHostIdentityComparer = + (left: string | undefined, right: string | undefined) => AppHostIdentityRelation; + +export type ResourceDebugStartDebugging = + (workspaceFolder: vscode.WorkspaceFolder | undefined, configuration: vscode.DebugConfiguration) => Thenable; + +export interface ResourceDebugServiceDependencies { + readonly appHostRepository: ResourceDebugAppHostRepository; + readonly attachProviders: ResourceAttachProviderRegistry; + readonly sessionRegistry: ResourceDebugSessionRegistry; + readonly startDebugging: ResourceDebugStartDebugging; + readonly compareAppHostIdentity?: ResourceDebugAppHostIdentityComparer; + readonly isProcessAlreadyDebugged?: (processId: number) => boolean; + readonly getDebugSessionConfiguration?: (appHost: ResourceDebugAppHostTarget) => AspireExtendedDebugConfiguration | undefined; + readonly telemetry?: ResourceDebugTelemetry; + readonly clock?: ResourceDebugClock; +} + +/** + * Resolves and attaches to a resource using a fresh CLI snapshot. It deliberately returns only + * bounded, presentation-safe outcomes; tree and language-model callers own their own UX. + */ +export class ResourceDebugService implements vscode.Disposable, ResourceDebugger { + private readonly _compareAppHostIdentity: ResourceDebugAppHostIdentityComparer; + private readonly _telemetry: ResourceDebugTelemetry; + private readonly _clock: ResourceDebugClock; + readonly onDidChangeDebugSessions: vscode.Event; + + constructor(private readonly _dependencies: ResourceDebugServiceDependencies) { + this._compareAppHostIdentity = _dependencies.compareAppHostIdentity ?? compareAppHostIdentity; + this._telemetry = _dependencies.telemetry ?? new ExtensionResourceDebugTelemetry(); + this._clock = _dependencies.clock ?? monotonicResourceDebugClock; + this.onDidChangeDebugSessions = _dependencies.sessionRegistry.onDidChangeSessions; + } + + dispose(): void { + this._dependencies.sessionRegistry.dispose(); + } + + canAttachToResource(resource: ResourceJson): boolean { + try { + const provider = this._dependencies.attachProviders.getRecognizedProviderForResource(resource); + return provider !== undefined + && provider.canAttachToResource(resource); + } + catch (error) { + this._logFailure('checking whether a resource can be attached', error); + return false; + } + } + + async debug(request: ResourceDebugRequest): Promise { + const requestedStrategy = getRequestedStrategy(request.strategy); + const effectiveStrategy = selectEffectiveStrategy(requestedStrategy); + const telemetry = new ResourceDebugOperationTelemetry( + this._telemetry, + this._clock, + request.source, + requestedStrategy ?? 'invalid'); + telemetry.recordStart(); + let result: ResourceDebugResult = { outcome: 'error', errorKind: 'unexpected' }; + + try { + if (requestedStrategy === undefined || effectiveStrategy === undefined) { + result = { outcome: 'error', errorKind: 'unexpected' }; + return result; + } + + if (request.cancellationToken?.isCancellationRequested) { + result = { outcome: 'cancelled' }; + return result; + } + + const resolvedAppHost = await this._resolveAppHost(request); + if ('outcome' in resolvedAppHost) { + result = resolvedAppHost; + return result; + } + + const resolvedTarget: ResourceDebugAppHostTarget = { + absolutePath: resolvedAppHost.appHostPath, + displayPath: request.appHost.displayPath, + appHostPid: resolvedAppHost.appHostPid, + cliPid: resolvedAppHost.cliPid ?? undefined, + }; + result = await this._dependencies.sessionRegistry.runSerialized( + resolvedTarget, + request.resourceName, + request.cancellationToken, + async () => await this._debugSerialized(request, resolvedTarget, telemetry, requestedStrategy, effectiveStrategy), + () => ({ outcome: 'cancelled' })); + return result; + } + catch (error) { + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + result = { outcome: 'cancelled' }; + return result; + } + + this._logFailure('debugging the resource', error); + result = { outcome: 'error', errorKind: 'unexpected' }; + return result; + } + finally { + telemetry.recordResult(result); + } + } + + private async _resolveAppHost(request: ResourceDebugRequest): Promise { + let appHosts: readonly AppHostDisplayInfo[]; + try { + appHosts = await this._dependencies.appHostRepository.fetchRunningAppHostsOnce(request.cancellationToken); + } + catch (error) { + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + this._logFailure('resolving the running AppHost', error); + return { outcome: 'error', errorKind: 'resourceSnapshotFailed' }; + } + + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + const appHostMatches = appHosts.map(appHost => ({ + appHost, + relation: this._compareAppHostIdentity(request.appHost.absolutePath, appHost.appHostPath), + })); + if (appHostMatches.some(match => match.relation === 'ambiguous')) { + return { outcome: 'appHostNotFound' }; + } + + const matchingAppHosts = appHostMatches + .filter(match => match.relation === 'same') + .map(match => match.appHost) + .filter(appHost => request.appHost.appHostPid === undefined + || appHost.appHostPid === request.appHost.appHostPid); + if (matchingAppHosts.length !== 1) { + return { outcome: 'appHostNotFound' }; + } + + return matchingAppHosts[0]; + } + + private async _debugSerialized( + request: ResourceDebugRequest, + resolvedTarget: ResourceDebugAppHostTarget, + telemetry: ResourceDebugOperationTelemetry, + requestedStrategy: ResourceDebugStrategy, + effectiveStrategy: 'attach', + ): Promise { + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + let resources: readonly ResourceJson[]; + try { + resources = await this._dependencies.appHostRepository.fetchAppHostResourcesOnce( + resolvedTarget.absolutePath, + request.cancellationToken, + resolvedTarget.appHostPid); + } + catch (error) { + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + this._logFailure('fetching the selected AppHost resource snapshot', error); + return { outcome: 'error', errorKind: 'resourceSnapshotFailed' }; + } + + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + const matchingResources = resources.filter(resource => resource.name === request.resourceName); + if (matchingResources.length !== 1) { + return { outcome: 'resourceNotFound' }; + } + + const resource = matchingResources[0]; + telemetry.recordResource(resource); + let provider: ResourceAttachProvider | undefined; + try { + provider = this._dependencies.attachProviders.getRecognizedProviderForResource(resource); + } + catch (error) { + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + this._logFailure('resolving the resource attach provider', error); + return { outcome: 'error', errorKind: 'providerResolutionFailed' }; + } + + if (!provider) { + return { outcome: 'unsupportedResource' }; + } + + telemetry.recordProvider(provider); + if (resource.state !== 'Running') { + return { outcome: 'resourceNotRunning' }; + } + + return await this._attach(request, resolvedTarget, resource, provider, telemetry, requestedStrategy, effectiveStrategy); + } + + private async _attach( + request: ResourceDebugRequest, + appHost: ResourceDebugAppHostTarget, + resource: ResourceJson, + provider: ResourceAttachProvider, + telemetry: ResourceDebugOperationTelemetry, + requestedStrategy: ResourceDebugStrategy, + effectiveStrategy: 'attach', + ): Promise { + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + if (this._dependencies.sessionRegistry.hasActiveSession(appHost, resource.name)) { + return { outcome: 'alreadyDebugging' }; + } + + const processId = getResourceProcessId(resource); + if (processId !== undefined && this._dependencies.isProcessAlreadyDebugged?.(processId)) { + return { outcome: 'alreadyDebugging' }; + } + + let missingDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; + try { + if (!provider.canAttachToResource(resource)) { + return { outcome: 'unsupportedResource' }; + } + + missingDebuggerExtensions = this._dependencies.attachProviders.getMissingDebuggerExtensions(provider); + } + catch (error) { + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + this._logFailure('resolving the installed resource attach provider', error); + return { outcome: 'error', errorKind: 'providerResolutionFailed' }; + } + + if (missingDebuggerExtensions.length > 0) { + telemetry.recordDebuggerRequirement('missing'); + return { + outcome: 'debuggerExtensionMissing', + debuggerExtensions: missingDebuggerExtensions.map(requirement => requirement.installMessage + ? { id: requirement.id, label: requirement.label, installMessage: requirement.installMessage } + : { id: requirement.id, label: requirement.label }), + }; + } + + telemetry.recordDebuggerRequirement('installed'); + let configuration: vscode.DebugConfiguration; + try { + configuration = await provider.createDebugConfiguration(resource, request.cancellationToken); + const launchConfigurationType = resource.properties?.['resource.launchConfigurationType']; + if (typeof launchConfigurationType === 'string') { + applySafeAttachDebuggerOverrides( + configuration, + this._dependencies.getDebugSessionConfiguration?.(appHost), + launchConfigurationType, + provider.id); + configuration.noDebug = false; + } + } + catch (error) { + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + this._logFailure( + error instanceof ResourceAttachConfigurationError + ? 'creating an attach configuration for an ineligible resource' + : 'creating the resource attach configuration', + error); + return { outcome: 'error', errorKind: 'configurationFailed' }; + } + + const attachProcessId = configuration.processId; + if (typeof attachProcessId === 'number' && + Number.isInteger(attachProcessId) && + attachProcessId > 0 && + this._dependencies.isProcessAlreadyDebugged?.(attachProcessId)) { + return { outcome: 'alreadyDebugging' }; + } + + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + const attempt = this._dependencies.sessionRegistry.createAttempt( + appHost, + resource.name, + configuration, + telemetry.createSessionMetadata(provider.id, requestedStrategy, effectiveStrategy)); + try { + telemetry.recordDebugStart(); + const started = await this._dependencies.startDebugging(undefined, attempt.configuration); + if (!started) { + attempt.abandon(); + return { outcome: 'error', errorKind: 'debuggerStartDeclined' }; + } + + attempt.markStarted(); + return { outcome: 'started', providerId: provider.id }; + } + catch (error) { + attempt.abandon(); + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + this._logFailure('starting the resource debugger', error); + return { outcome: 'error', errorKind: 'debuggerStartFailed' }; + } + } + + private _logFailure(operation: string, error: unknown): void { + extensionLogOutputChannel.error(`Resource debugger failed while ${operation}: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + } +} + +function applySafeAttachDebuggerOverrides( + configuration: vscode.DebugConfiguration, + debugSessionConfiguration: AspireExtendedDebugConfiguration | undefined, + launchConfigurationType: string, + providerId: ResourceAttachProvider['id'], +): void { + const overrides = debugSessionConfiguration?.debuggers?.[launchConfigurationType]; + if (!overrides) { + return; + } + + // A denylist would let a newly supported transport or remote-target property silently retarget + // an operation the user confirmed as a local Aspire resource. Copy only options that affect + // presentation, source mapping, symbol loading, logging, or debugger runtime behavior. + for (const property of safeAttachDebuggerOverrideProperties[providerId]) { + if (Object.prototype.hasOwnProperty.call(overrides, property)) { + configuration[property] = overrides[property]; + } + } +} + +function getResourceProcessId(resource: ResourceJson): number | undefined { + const value: unknown = resource.properties?.['executable.pid']; + if (typeof value === 'number') { + return Number.isInteger(value) && value > 0 ? value : undefined; + } + + if (typeof value === 'string') { + const processId = Number(value); + return Number.isInteger(processId) && processId > 0 ? processId : undefined; + } + + return undefined; +} + +class ResourceDebugOperationTelemetry { + private readonly _startedAt: number | undefined; + private _resourceType: ResourceDebugResourceType | undefined; + private _provider: ResourceAttachProvider['id'] | 'none' = 'none'; + private _state: ResourceDebugResourceState = 'unknown'; + private _debuggerRequirement: ResourceDebugDebuggerRequirement = 'none'; + private _debugStartAt: number | undefined; + private _debugStartAttempted = false; + + constructor( + private readonly _telemetry: ResourceDebugTelemetry, + private readonly _clock: ResourceDebugClock, + private readonly _source: ResourceDebugRequest['source'], + private readonly _requestedStrategy: ResourceDebugRequestedStrategyTelemetryBucket, + ) { + this._startedAt = this._getTimestamp(); + } + + recordStart(): void { + this._record(() => this._telemetry.recordStart({ + source: this._source, + requested_strategy: this._requestedStrategy, + controller: 'editor', + })); + } + + recordResource(resource: ResourceJson): void { + this._record(() => { + this._resourceType = getResourceTypeBucket(resource.resourceType); + this._state = resource.state === 'Running' + ? 'running' + : resource.state === null + ? 'unknown' + : 'notRunning'; + }); + } + + recordProvider(provider: ResourceAttachProvider): void { + this._record(() => { + this._provider = provider.id; + }); + } + + recordDebuggerRequirement(requirement: ResourceDebugDebuggerRequirement): void { + this._record(() => { + this._debuggerRequirement = requirement; + }); + } + + recordDebugStart(): void { + this._record(() => { + this._debugStartAttempted = true; + this._debugStartAt = this._getTimestamp(); + }); + } + + createSessionMetadata( + provider: ResourceAttachProvider['id'], + requestedStrategy: ResourceDebugStrategy, + effectiveStrategy: 'attach', + ): ResourceDebugAttachSessionMetadata { + return { + source: this._source, + provider, + resource_type: this._resourceType ?? 'other', + requested_strategy: requestedStrategy, + effective_strategy: effectiveStrategy, + }; + } + + recordResult(result: ResourceDebugResult): void { + this._record(() => this._telemetry.recordResult({ + source: this._source, + provider: this._provider, + ...(this._resourceType === undefined ? {} : { resource_type: this._resourceType }), + requested_strategy: this._requestedStrategy, + effective_strategy: result.outcome === 'started' || result.outcome === 'alreadyDebugging' + ? 'attach' + : 'none', + outcome: result.outcome, + controller: 'editor', + state: this._state, + debugger_requirement: this._debuggerRequirement, + error_kind: result.outcome === 'error' ? result.errorKind : 'none', + }, this._getMeasurements())); + } + + private _getMeasurements(): ResourceDebugResultTelemetryMeasurements { + const endedAt = this._getTimestamp(); + const resolutionDuration = this._getDuration( + this._startedAt, + this._debugStartAttempted ? this._debugStartAt : endedAt); + const debugStartDuration = this._debugStartAttempted + ? this._getDuration(this._debugStartAt, endedAt) + : undefined; + const totalDuration = this._getDuration(this._startedAt, endedAt); + + return { + ...(resolutionDuration === undefined ? {} : { resolution_duration_ms: resolutionDuration }), + ...(debugStartDuration === undefined ? {} : { debug_start_duration_ms: debugStartDuration }), + ...(totalDuration === undefined ? {} : { total_duration_ms: totalDuration }), + }; + } + + private _getTimestamp(): number | undefined { + try { + const timestamp = this._clock.now(); + return Number.isFinite(timestamp) ? timestamp : undefined; + } + catch { + return undefined; + } + } + + private _getDuration(start: number | undefined, end: number | undefined): number | undefined { + if (start === undefined || end === undefined) { + return undefined; + } + + const duration = end - start; + return Number.isFinite(duration) && duration >= 0 ? duration : undefined; + } + + private _record(record: () => void): void { + try { + record(); + } + catch { + // Telemetry is observational. A telemetry sink must not change debug behavior. + } + } +} + +function getResourceTypeBucket(resourceType: unknown): ResourceDebugResourceType { + switch (typeof resourceType === 'string' ? resourceType.toLowerCase() : '') { + case 'project': + return 'project'; + case 'executable': + return 'executable'; + case 'container': + return 'container'; + default: + return 'other'; + } +} + +function getRequestedStrategy(strategy: unknown): ResourceDebugStrategy | undefined { + return strategy === 'auto' || strategy === 'attach' ? strategy : undefined; +} + +function selectEffectiveStrategy(strategy: ResourceDebugStrategy | undefined): 'attach' | undefined { + switch (strategy) { + case 'auto': + case 'attach': + return 'attach'; + default: + return undefined; + } +} diff --git a/extension/src/debugger/resourceDebugSessionRegistry.ts b/extension/src/debugger/resourceDebugSessionRegistry.ts new file mode 100644 index 00000000000..6ffa5e982ec --- /dev/null +++ b/extension/src/debugger/resourceDebugSessionRegistry.ts @@ -0,0 +1,318 @@ +import * as vscode from 'vscode'; +import { getAppHostIdentityKey } from '../utils/appHostIdentity'; +import { extensionLogOutputChannel } from '../utils/logging'; +import type { ResourceDebugAppHostTarget } from './resourceDebugContracts'; +import { + ExtensionResourceDebugTelemetry, + type ResourceDebugAttachSessionMetadata, + type ResourceDebugClock, + type ResourceDebugTelemetry, + monotonicResourceDebugClock, +} from './resourceDebugTelemetry'; + +const resourceDebugSessionMarkerConfigKey = '__aspireResourceDebugSessionMarker'; + +export interface ResourceDebugSessionEvents { + readonly onDidStartDebugSession: vscode.Event; + readonly onDidTerminateDebugSession: vscode.Event; +} + +export interface ResourceDebugSessionAttempt { + readonly configuration: vscode.DebugConfiguration; + markStarted(): void; + abandon(): void; +} + +export interface ResourceDebugSessionRegistryOptions { + readonly pendingStartTimeoutMs?: number; + readonly telemetry?: ResourceDebugTelemetry; + readonly clock?: ResourceDebugClock; +} + +interface TrackedAttachAttempt { + readonly marker: number; + readonly resourceKey: string; + readonly sessionIds: Set; + pendingStartTimeout: ReturnType | undefined; + startAccepted: boolean; + terminated: boolean; + sessionStarted: boolean; + sessionStartedAt: number | undefined; + readonly telemetry: ResourceDebugAttachSessionMetadata; +} + +/** + * Tracks only attach sessions created by ResourceDebugService. The marker is intentionally + * private to generated configurations so unrelated VS Code debug sessions cannot affect + * resource attach serialization or lifecycle state. + */ +export class ResourceDebugSessionRegistry implements vscode.Disposable { + private static readonly _defaultPendingStartTimeoutMs = 10_000; + + private readonly _attempts = new Map(); + private readonly _attemptsByResource = new Map>(); + private readonly _resourceLocks = new Map>(); + private readonly _onDidChangeSessions = new vscode.EventEmitter(); + readonly onDidChangeSessions = this._onDidChangeSessions.event; + private readonly _subscriptions: vscode.Disposable; + private readonly _pendingStartTimeoutMs: number; + private readonly _telemetry: ResourceDebugTelemetry; + private readonly _clock: ResourceDebugClock; + private _nextMarker = 0; + + constructor(events: ResourceDebugSessionEvents = vscode.debug, options: ResourceDebugSessionRegistryOptions = {}) { + this._pendingStartTimeoutMs = options.pendingStartTimeoutMs ?? ResourceDebugSessionRegistry._defaultPendingStartTimeoutMs; + this._telemetry = options.telemetry ?? new ExtensionResourceDebugTelemetry(); + this._clock = options.clock ?? monotonicResourceDebugClock; + this._subscriptions = vscode.Disposable.from( + events.onDidStartDebugSession(session => this._onDidStartDebugSession(session)), + events.onDidTerminateDebugSession(session => this._onDidTerminateDebugSession(session))); + } + + dispose(): void { + this._subscriptions.dispose(); + for (const attempt of this._attempts.values()) { + this._clearPendingStartExpiry(attempt); + } + this._attempts.clear(); + this._attemptsByResource.clear(); + this._resourceLocks.clear(); + this._onDidChangeSessions.dispose(); + } + + hasActiveSession(appHost: ResourceDebugAppHostTarget, resourceName: string): boolean { + const attemptMarkers = this._attemptsByResource.get(this._getResourceKey(appHost, resourceName)); + if (!attemptMarkers) { + return false; + } + + return Array.from(attemptMarkers).some(marker => { + const attempt = this._attempts.get(marker); + return attempt !== undefined && !attempt.terminated && (attempt.startAccepted || attempt.sessionIds.size > 0); + }); + } + + async runSerialized( + appHost: ResourceDebugAppHostTarget, + resourceName: string, + cancellationToken: vscode.CancellationToken | undefined, + operation: () => Promise, + getCancelledResult: () => T, + ): Promise { + const resourceKey = this._getResourceKey(appHost, resourceName); + const precedingOperation = this._resourceLocks.get(resourceKey); + let releaseCurrentOperation: (() => void) | undefined; + const currentOperationGate = new Promise(resolve => { + releaseCurrentOperation = resolve; + }); + // The map stores a canonical tail, not merely this caller's completion signal. A canceled + // waiter releases its gate promptly, but its tail still waits for the predecessor so later + // callers cannot overtake an active operation. + const currentOperation = (precedingOperation?.catch(() => undefined) ?? Promise.resolve()) + .then(() => currentOperationGate); + this._resourceLocks.set(resourceKey, currentOperation); + + try { + if (!await this._waitForLock(precedingOperation, cancellationToken)) { + return getCancelledResult(); + } + + return await operation(); + } + finally { + releaseCurrentOperation!(); + // A canceled waiter returns before its tail settles behind the active operation. + // Defer deletion until that canonical tail settles so a later request cannot overtake it. + void currentOperation.then(() => { + if (this._resourceLocks.get(resourceKey) === currentOperation) { + this._resourceLocks.delete(resourceKey); + } + }); + } + } + + createAttempt( + appHost: ResourceDebugAppHostTarget, + resourceName: string, + configuration: vscode.DebugConfiguration, + telemetry: ResourceDebugAttachSessionMetadata, + ): ResourceDebugSessionAttempt { + const resourceKey = this._getResourceKey(appHost, resourceName); + const marker = ++this._nextMarker; + const attempt: TrackedAttachAttempt = { + marker, + resourceKey, + sessionIds: new Set(), + pendingStartTimeout: undefined, + startAccepted: false, + terminated: false, + sessionStarted: false, + sessionStartedAt: undefined, + telemetry, + }; + this._attempts.set(marker, attempt); + const attemptMarkers = this._attemptsByResource.get(resourceKey) ?? new Set(); + attemptMarkers.add(marker); + this._attemptsByResource.set(resourceKey, attemptMarkers); + + return { + configuration: { + ...configuration, + [resourceDebugSessionMarkerConfigKey]: marker, + }, + markStarted: () => { + if (this._attempts.get(attempt.marker) !== attempt || attempt.terminated) { + return; + } + + attempt.startAccepted = true; + if (attempt.sessionIds.size === 0) { + this._schedulePendingStartExpiry(attempt); + } + this._onDidChangeSessions.fire(); + }, + abandon: () => this._removeAttempt(attempt), + }; + } + + private _onDidStartDebugSession(session: vscode.DebugSession): void { + const attempt = this._getAttempt(session); + if (!attempt || attempt.terminated) { + return; + } + + attempt.sessionIds.add(session.id); + attempt.sessionStarted = true; + attempt.sessionStartedAt ??= this._getTimestamp(); + this._clearPendingStartExpiry(attempt); + } + + private _onDidTerminateDebugSession(session: vscode.DebugSession): void { + const attempt = this._getAttempt(session); + if (!attempt) { + return; + } + + attempt.sessionIds.delete(session.id); + if (attempt.sessionIds.size > 0) { + return; + } + + attempt.terminated = true; + if (attempt.sessionStarted) { + this._recordTelemetry(() => this._telemetry.recordSessionEnd({ + ...attempt.telemetry, + controller: 'editor', + session_end_reason: 'terminated', + }, this._getMeasurements(attempt.sessionStartedAt))); + } + this._removeAttempt(attempt); + } + + private _getAttempt(session: vscode.DebugSession): TrackedAttachAttempt | undefined { + const marker = session.configuration?.[resourceDebugSessionMarkerConfigKey]; + return typeof marker === 'number' ? this._attempts.get(marker) : undefined; + } + + private _removeAttempt(attempt: TrackedAttachAttempt): void { + this._clearPendingStartExpiry(attempt); + this._attempts.delete(attempt.marker); + const attemptMarkers = this._attemptsByResource.get(attempt.resourceKey); + attemptMarkers?.delete(attempt.marker); + if (attemptMarkers?.size === 0) { + this._attemptsByResource.delete(attempt.resourceKey); + } + this._onDidChangeSessions.fire(); + } + + private _schedulePendingStartExpiry(attempt: TrackedAttachAttempt): void { + this._clearPendingStartExpiry(attempt); + attempt.pendingStartTimeout = setTimeout(() => { + attempt.pendingStartTimeout = undefined; + if (this._attempts.get(attempt.marker) === attempt && attempt.sessionIds.size === 0) { + // Debug adapters can strip private configuration properties. Do not fall back to + // matching sessions by process or configuration: that could claim an unrelated + // debugger session. Expire this bounded entry and make the residual recovery risk + // diagnosable instead. + extensionLogOutputChannel.warn('Resource debugger session tracking expired before its debug session reported the private marker. A later attach may start another session.'); + this._removeAttempt(attempt); + } + }, this._pendingStartTimeoutMs); + } + + private _clearPendingStartExpiry(attempt: TrackedAttachAttempt): void { + if (attempt.pendingStartTimeout) { + clearTimeout(attempt.pendingStartTimeout); + attempt.pendingStartTimeout = undefined; + } + } + + private _getMeasurements(startedAt: number | undefined): { readonly session_duration_ms?: number } { + const duration = this._getDuration(startedAt, this._getTimestamp()); + return duration === undefined ? {} : { session_duration_ms: duration }; + } + + private _getTimestamp(): number | undefined { + try { + const timestamp = this._clock.now(); + return Number.isFinite(timestamp) ? timestamp : undefined; + } + catch { + return undefined; + } + } + + private _getDuration(start: number | undefined, end: number | undefined): number | undefined { + if (start === undefined || end === undefined) { + return undefined; + } + + const duration = end - start; + return Number.isFinite(duration) && duration >= 0 ? duration : undefined; + } + + private _recordTelemetry(record: () => void): void { + try { + record(); + } + catch { + // Telemetry is observational. Debug session lifecycle tracking must continue if it fails. + } + } + + private async _waitForLock( + precedingOperation: Promise | undefined, + cancellationToken: vscode.CancellationToken | undefined, + ): Promise { + if (!precedingOperation) { + return !cancellationToken?.isCancellationRequested; + } + + return await new Promise(resolve => { + let settled = false; + let cancellationRegistration: vscode.Disposable | undefined; + const settle = (acquired: boolean) => { + if (settled) { + return; + } + + settled = true; + cancellationRegistration?.dispose(); + resolve(acquired); + }; + + cancellationRegistration = cancellationToken?.onCancellationRequested(() => settle(false)); + if (cancellationToken?.isCancellationRequested) { + settle(false); + return; + } + + void precedingOperation.catch(() => undefined).then(() => settle(true)); + }); + } + + private _getResourceKey(appHost: ResourceDebugAppHostTarget, resourceName: string): string { + const appHostProcessIdentity = appHost.appHostPid?.toString() ?? ''; + return `${getAppHostIdentityKey(appHost.absolutePath)}\u0000${appHostProcessIdentity}\u0000${resourceName}`; + } +} diff --git a/extension/src/debugger/resourceDebugTelemetry.ts b/extension/src/debugger/resourceDebugTelemetry.ts new file mode 100644 index 00000000000..d9bee2c3429 --- /dev/null +++ b/extension/src/debugger/resourceDebugTelemetry.ts @@ -0,0 +1,108 @@ +import { + type ResourceAttachProviderId, + type ResourceDebugErrorKind, + type ResourceDebugResult, + type ResourceDebugSource, + type ResourceDebugStrategy, +} from './resourceDebugContracts'; +import { sendTelemetryEvent } from '../utils/telemetry'; + +export type ResourceDebugResourceType = 'project' | 'executable' | 'container' | 'other'; +export type ResourceDebugResourceState = 'running' | 'notRunning' | 'unknown'; +export type ResourceDebugDebuggerRequirement = 'installed' | 'missing' | 'none'; +export type ResourceDebugRequestedStrategyTelemetryBucket = ResourceDebugStrategy | 'invalid'; + +export interface ResourceDebugClock { + now(): number; +} + +export interface ResourceDebugStartTelemetryProperties { + readonly source: ResourceDebugSource; + readonly requested_strategy: ResourceDebugRequestedStrategyTelemetryBucket; + readonly controller: 'editor'; +} + +export interface ResourceDebugResultTelemetryProperties { + readonly source: ResourceDebugSource; + readonly provider: ResourceAttachProviderId | 'none'; + readonly resource_type?: ResourceDebugResourceType; + readonly requested_strategy: ResourceDebugRequestedStrategyTelemetryBucket; + readonly effective_strategy: 'attach' | 'none'; + readonly outcome: ResourceDebugResult['outcome']; + readonly controller: 'editor'; + readonly state: ResourceDebugResourceState; + readonly debugger_requirement: ResourceDebugDebuggerRequirement; + readonly error_kind: ResourceDebugErrorKind | 'none'; +} + +export interface ResourceDebugResultTelemetryMeasurements { + readonly resolution_duration_ms?: number; + readonly debug_start_duration_ms?: number; + readonly total_duration_ms?: number; +} + +export interface ResourceDebugAttachSessionMetadata { + readonly source: ResourceDebugSource; + readonly provider: ResourceAttachProviderId; + readonly resource_type: ResourceDebugResourceType; + readonly requested_strategy: ResourceDebugStrategy; + readonly effective_strategy: 'attach'; +} + +export interface ResourceDebugSessionEndTelemetryProperties extends ResourceDebugAttachSessionMetadata { + readonly controller: 'editor'; + readonly session_end_reason: 'terminated'; +} + +export interface ResourceDebugSessionEndTelemetryMeasurements { + readonly session_duration_ms?: number; +} + +export interface ResourceDebugTelemetry { + recordStart(properties: ResourceDebugStartTelemetryProperties): void; + recordResult( + properties: ResourceDebugResultTelemetryProperties, + measurements: ResourceDebugResultTelemetryMeasurements, + ): void; + recordSessionEnd( + properties: ResourceDebugSessionEndTelemetryProperties, + measurements: ResourceDebugSessionEndTelemetryMeasurements, + ): void; +} + +export const monotonicResourceDebugClock: ResourceDebugClock = { + now: () => performance.now(), +}; + +/** + * Sends only the resource-debug telemetry schema. Keeping the event shapes here means the + * service and session registry cannot accidentally forward debug configurations or errors. + */ +export class ExtensionResourceDebugTelemetry implements ResourceDebugTelemetry { + recordStart(properties: ResourceDebugStartTelemetryProperties): void { + this._send(() => sendTelemetryEvent('aspire/vscode/resourcedebug/start', properties)); + } + + recordResult( + properties: ResourceDebugResultTelemetryProperties, + measurements: ResourceDebugResultTelemetryMeasurements, + ): void { + this._send(() => sendTelemetryEvent('aspire/vscode/resourcedebug/result', properties, measurements)); + } + + recordSessionEnd( + properties: ResourceDebugSessionEndTelemetryProperties, + measurements: ResourceDebugSessionEndTelemetryMeasurements, + ): void { + this._send(() => sendTelemetryEvent('aspire/vscode/resourcedebug/session/end', properties, measurements)); + } + + private _send(send: () => void): void { + try { + send(); + } + catch { + // Telemetry is observational. A transport failure must not change resource debugging. + } + } +} diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 2f32bcac9bf..c9f54927219 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -33,11 +33,18 @@ import { CliPathEnvironmentSynchronizer } from './utils/cliPathEnvironment'; import { CliPathRejectionNotifier } from './utils/cliPathRejectionNotifier'; import { cliPathResolver } from './utils/cliPath'; import { AppHostLifecycleToolService, registerAppHostLifecycleTools } from './lm/appHostLifecycleTools'; +import { AppHostTargetResolverService } from './lm/appHostTargetResolverService'; +import { AspireResourceDebugToolService, registerAspireResourceDebugTool } from './lm/resourceDebugTools'; import { registerInstrumentedCommand } from './activation/instrumentedCommand'; import { registerCliCommands } from './activation/registerCliCommands'; import { registerTreeViewCommands } from './activation/registerTreeViewCommands'; import { registerCodeLensCommands } from './activation/registerCodeLensCommands'; +import { extensionResourceAttachProviders, ResourceAttachProviderRegistry } from './debugger/resourceAttachProviders'; +import { ResourceDebugService } from './debugger/resourceDebugService'; +import { ResourceDebugSessionRegistry } from './debugger/resourceDebugSessionRegistry'; +import { ExtensionResourceDebugTelemetry, monotonicResourceDebugClock } from './debugger/resourceDebugTelemetry'; import { initializeHotReloadAdvisory } from './debugger/hotReload'; +import { compareAppHostIdentity } from './utils/appHostIdentity'; import { OutdatedCliNotifier } from './utils/outdatedCliNotifier'; import { onDidResolveCliForOperation } from './utils/cliOperationResolution'; import { FileSystemOutdatedCliSuppressionStore } from './utils/outdatedCliSuppressionStore'; @@ -134,6 +141,37 @@ export async function activate(context: vscode.ExtensionContext) { // Aspire panel - running app hosts tree view const dataRepository = new AppHostDataRepository(terminalProvider, appHostDiscoveryService, configInfoProvider); + const resourceDebugTelemetry = new ExtensionResourceDebugTelemetry(); + const resourceDebugClock = monotonicResourceDebugClock; + const resourceDebugSessionRegistry = new ResourceDebugSessionRegistry(vscode.debug, { + telemetry: resourceDebugTelemetry, + clock: resourceDebugClock, + }); + const resourceDebugService = new ResourceDebugService({ + appHostRepository: dataRepository, + attachProviders: new ResourceAttachProviderRegistry(extensionResourceAttachProviders), + sessionRegistry: resourceDebugSessionRegistry, + startDebugging: (workspaceFolder, configuration) => + vscode.debug.startDebugging(workspaceFolder, configuration), + isProcessAlreadyDebugged: processId => + aspireExtensionContext.aspireDebugSessions.some(session => session.hasResourceDebugSessionProcess(processId)), + getDebugSessionConfiguration: appHost => { + const matchingSessions = aspireExtensionContext.aspireDebugSessions.filter(session => { + if (compareAppHostIdentity(session.resolvedAppHostPath ?? session.appHostPath, appHost.absolutePath) !== 'same') { + return false; + } + + return appHost.cliPid !== undefined + ? session.cliProcessId === appHost.cliPid + : session.operationKind === 'run'; + }); + + return matchingSessions.length === 1 ? matchingSessions[0].configuration : undefined; + }, + telemetry: resourceDebugTelemetry, + clock: resourceDebugClock, + }); + context.subscriptions.push(resourceDebugService); appHostLaunchService.setEditorSessionProvider(() => aspireExtensionContext.aspireDebugSessions); appHostLaunchService.setRunningAppHostProvider(async token => { const appHosts = await dataRepository.fetchRunningAppHostsOnce(token); @@ -141,7 +179,7 @@ export async function activate(context: vscode.ExtensionContext) { }); appHostLaunchService.setExternalAppHostStopper((appHostPath, token) => stopExternalAppHost(terminalProvider, appHostPath, token)); - const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider, appHostLaunchService, context.globalState, vscode.env.clipboard, configInfoProvider); + const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider, appHostLaunchService, resourceDebugService, context.globalState, vscode.env.clipboard, configInfoProvider); const appHostTreeView = vscode.window.createTreeView('aspire-vscode.appHosts', { treeDataProvider: appHostTreeProvider, showCollapseAll: true, @@ -227,13 +265,23 @@ export async function activate(context: vscode.ExtensionContext) { // Language model tools that let an agent use the same AppHost lifecycle service as the // editor and Aspire tree instead of maintaining a separate start/stop policy. + const appHostTargetResolver = new AppHostTargetResolverService({ + discoveryService: appHostDiscoveryService, + }); const appHostLifecycleToolService = new AppHostLifecycleToolService({ launchService: appHostLaunchService, - discoveryService: appHostDiscoveryService, + targetResolver: appHostTargetResolver, }); context.subscriptions.push(appHostLifecycleToolService); const appHostLifecycleToolRegistration = registerAppHostLifecycleTools(appHostLifecycleToolService); context.subscriptions.push(appHostLifecycleToolRegistration); + const resourceDebugToolService = new AspireResourceDebugToolService({ + targetResolver: appHostTargetResolver, + resourceDebugger: resourceDebugService, + }); + context.subscriptions.push(resourceDebugToolService); + const resourceDebugToolRegistration = registerAspireResourceDebugTool(resourceDebugToolService); + context.subscriptions.push(resourceDebugToolRegistration); const getEnableSettingsFileCreationPromptOnStartup = () => vscode.workspace.getConfiguration('aspire').get('enableSettingsFileCreationPromptOnStartup', true); const setEnableSettingsFileCreationPromptOnStartup = async (value: boolean) => await vscode.workspace.getConfiguration('aspire').update('enableSettingsFileCreationPromptOnStartup', value, vscode.ConfigurationTarget.Workspace); @@ -275,7 +323,11 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(appHostLaunchService.onDidChangeLaunchingState(fireStateChanged)); context.subscriptions.push(appHostTreeProvider.onDidChangeStoppingState(fireStateChanged)); context.subscriptions.push(aspireExtensionContext.onDidChangeDebugSessions(fireStateChanged)); - const e2eStateFileBridge = createE2eStateFileBridge(context, aspireExtensionContext, dataRepository, appHostLaunchService, appHostTreeProvider, terminalProvider, onDidChangeStateEmitter.event, appHostLifecycleToolRegistration.tools); + const preparableLanguageModelTools = new Map([ + ...appHostLifecycleToolRegistration.tools, + ...resourceDebugToolRegistration.tools, + ]); + const e2eStateFileBridge = createE2eStateFileBridge(context, aspireExtensionContext, dataRepository, appHostLaunchService, appHostTreeProvider, terminalProvider, onDidChangeStateEmitter.event, preparableLanguageModelTools); context.subscriptions.push(e2eStateFileBridge); await cliPathEnvironmentInitialization; diff --git a/extension/src/lm/appHostLifecycleToolAdapters.ts b/extension/src/lm/appHostLifecycleToolAdapters.ts index 422cc81c104..f7f762f7f69 100644 --- a/extension/src/lm/appHostLifecycleToolAdapters.ts +++ b/extension/src/lm/appHostLifecycleToolAdapters.ts @@ -25,6 +25,7 @@ import { type PreparableAppHostLifecycleTool, } from './appHostLifecycleToolContracts'; import { AppHostLifecycleToolService } from './appHostLifecycleToolService'; +import { escapeMarkdownForConfirmation } from './markdown'; import { isValidLaunchProfile } from '../utils/launchProfile'; export class AppHostStartLanguageModelTool implements vscode.LanguageModelTool { @@ -36,7 +37,7 @@ export class AppHostStartLanguageModelTool implements vscode.LanguageModelTool, token: vscode.CancellationToken): Promise { const description = await this._service.describeStartTarget(options.input, token); - const displayPath = escapeMarkdown(description.displayPath); + const displayPath = escapeMarkdownForConfirmation(description.displayPath); const displayMode = describeRequestedMode(options.input?.mode); const displayLaunchProfile = describeLaunchProfile(options.input?.launchProfile); return { @@ -64,7 +65,7 @@ export class AppHostStopLanguageModelTool implements vscode.LanguageModelTool, token: vscode.CancellationToken): Promise { - const displayPath = escapeMarkdown(await this._service.describeTarget(options.input?.appHostPath, token)); + const displayPath = escapeMarkdownForConfirmation(await this._service.describeTarget(options.input?.appHostPath, token)); return { invocationMessage: appHostLifecycleStopInvocationMessage(displayPath), confirmationMessages: { @@ -143,21 +144,5 @@ function describeLaunchProfile(value: unknown): string | undefined { return undefined; } - return isValidLaunchProfile(value) ? escapeMarkdown(value) : appHostLifecycleInvalidLaunchProfile; -} - -/** - * Escapes the Markdown constructs that change how a path renders inline. - * - * The confirmation body renders as Markdown, so an unescaped `*`, `_`, `` ` ``, `[`, or - * `<` in a real file name would show the user something other than the file the tool is - * about to launch. Escaping keeps the rendered text one-to-one with the path instead of - * deleting characters, which would break that relationship in the other direction. - * Characters that are only meaningful at the start of a line (`.`, `-`, `{`, `}`) are - * left alone: the path is always interpolated mid-sentence and they are extremely common - * in real project paths. - * See https://spec.commonmark.org/0.31.2/#backslash-escapes - */ -function escapeMarkdown(value: string): string { - return value.replace(/[\\`*_[\]()<>#+~|!&]/g, character => `\\${character}`); + return isValidLaunchProfile(value) ? escapeMarkdownForConfirmation(value) : appHostLifecycleInvalidLaunchProfile; } diff --git a/extension/src/lm/appHostLifecycleToolContracts.ts b/extension/src/lm/appHostLifecycleToolContracts.ts index 83af4878ab2..629f24476b6 100644 --- a/extension/src/lm/appHostLifecycleToolContracts.ts +++ b/extension/src/lm/appHostLifecycleToolContracts.ts @@ -1,8 +1,17 @@ import * as vscode from 'vscode'; -import { type CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import { type AppHostIdentityRelation } from '../utils/appHostIdentity'; import { type AppHostLaunchIsolation, type AppHostStopResult } from '../services/AppHostLaunchService'; +import type { + AppHostTarget, + AppHostTargetDiscoveryService, + AppHostTargetResolution, + AppHostTargetResolver, +} from './appHostTargetResolverContracts'; +import type { + PreparableLanguageModelTool, + PreparableLanguageModelToolRegistration, +} from './languageModelToolContracts'; import { isValidLaunchProfile } from '../utils/launchProfile'; /** @@ -107,15 +116,6 @@ export interface AppHostLifecycleLaunchService { stopAppHostFromLifecycleOwner(appHostPath: string, token: vscode.CancellationToken): Promise; } -/** - * Narrow view of `AppHostDiscoveryService`. This is the registry the AppHost view, the - * status bar, and the Run/Debug commands already resolve against, and it is populated by - * the CLI's own `aspire ls --format json` output. - */ -export interface AppHostLifecycleDiscoveryService { - discover(workspaceFolder: vscode.WorkspaceFolder, forceRefresh?: boolean, cancellationToken?: vscode.CancellationToken): Promise; -} - /** * Editor-created sessions for a requested AppHost, plus whether any session's relationship * to it could not be proven. See {@link AppHostIdentityRelation}. @@ -147,22 +147,17 @@ export interface AppHostLifecycleEditorSession { export interface AppHostLifecycleToolDependencies { readonly launchService: AppHostLifecycleLaunchService; - readonly discoveryService: AppHostLifecycleDiscoveryService; + readonly targetResolver: AppHostTargetResolver; } -export interface AppHostLifecycleToolRegistration extends vscode.Disposable { - readonly registered: boolean; - /** - * The registered tool instances by tool name. VS Code does not surface - * `prepareInvocation` through `vscode.lm`, so E2E automation needs a way to ask the - * extension's own instance for the confirmation it would present. - */ - readonly tools: ReadonlyMap; -} - -export interface PreparableAppHostLifecycleTool { - prepareInvocation(options: { readonly input: Record }, token: vscode.CancellationToken): Promise; -} +export type AppHostLifecycleToolRegistration = PreparableLanguageModelToolRegistration; +export type PreparableAppHostLifecycleTool = PreparableLanguageModelTool; +export type { + AppHostTarget as SafeAppHostTarget, + AppHostTargetDiscoveryService as AppHostLifecycleDiscoveryService, + AppHostTargetResolution as SafeAppHostTargetResolution, + AppHostTargetResolver as SafeAppHostTargetResolver, +}; export function createResult( tool: string, diff --git a/extension/src/lm/appHostLifecycleToolService.ts b/extension/src/lm/appHostLifecycleToolService.ts index 39948142bf9..a4f1fabebc8 100644 --- a/extension/src/lm/appHostLifecycleToolService.ts +++ b/extension/src/lm/appHostLifecycleToolService.ts @@ -1,4 +1,3 @@ -import * as path from 'path'; import * as vscode from 'vscode'; import { appHostLifecycleUnresolvedPath } from '../loc/strings'; @@ -7,6 +6,11 @@ import { isLinkedGitWorktree } from '../utils/gitWorktree'; import { extensionLogOutputChannel } from '../utils/logging'; import { isCommandCancellation } from '../utils/telemetry'; import { AppHostLifecycleLockTimeoutError, AppHostStopCancellationError, AppHostStopError, type AppHostStopResult } from '../services/AppHostLaunchService'; +import type { + AppHostTarget, + AppHostTargetResolution, + AppHostTargetResolver, +} from './appHostTargetResolverContracts'; import { aspireAppHostStartToolName, aspireAppHostStopToolName, @@ -24,63 +28,9 @@ import { type AppHostStopToolInput, } from './appHostLifecycleToolContracts'; -/** - * Upper bound on the workspace-relative path a confirmation may show. - * - * A path longer than this is refused outright rather than elided, because an elided path - * no longer identifies one file: two AppHosts sharing a long prefix would produce the same - * prompt. The bound is far above any realistic repository path (Windows' own MAX_PATH is - * 260 for a full path), so refusing beyond it costs nothing in practice. - */ -const maxConfirmationPathLength = 512; - -/** Reject model-supplied selectors large enough to make normalization itself expensive. */ -const maxAppHostSelectorLength = 4096; - -/** Cap on how many AppHost paths an `unknownAppHost` result lists back to the model. */ -const maxReportedKnownAppHosts = 32; - -/** - * Characters that change what a path *is* without changing, or while changing, how it - * looks: C0/C1 controls and DEL, plus every Unicode format character (`\p{Cf}`). - * - * Bidi controls (U+202A-U+202E, U+2066-U+2069) reorder the run that follows them, so a - * path can render as a completely different one. Zero-width characters (U+200B-U+200D) - * are invisible, so two distinct files can produce identical-looking prompts. A registry - * entry carrying one of these is dropped rather than shown with the characters deleted, - * because deleting them would break the one-to-one relationship between the identity the - * user confirms and the file that runs. - * See https://unicode.org/reports/tr9/ and https://unicode.org/reports/tr36/#Bidirectional_Text_Spoofing - */ -const identityChangingCharacters = /[\u0000-\u001F\u007F-\u009F]|\p{Cf}/u; - -/** - * One entry of the AppHost registry, projected into the form the tool speaks. - * - * Every field comes from a candidate the discovery service enumerated, so the string the - * confirmation renders and the path the launcher receives originate from the same object. - * The model's input only ever selects one of these; it never contributes to one. - */ -interface ResolvedAppHostTarget { - /** Absolute path exactly as the registry enumerated it, used for launching. */ - absolutePath: string; - /** Path relative to the containing workspace folder, always with `/` separators. */ - relativePath: string; - /** - * The identity shown in the confirmation dialog. Identical to `relativePath` in a - * single-root workspace, and prefixed with the workspace folder name otherwise, so a - * selector that resolves under one root still names that root in the prompt. - */ - displayPath: string; -} - -type AppHostTargetResolution = - | { resolved: true; target: ResolvedAppHostTarget } - | { resolved: false; outcome: AppHostLifecycleOutcome; knownAppHosts?: readonly string[] }; - type PreflightResult = | { rejected: true; result: AppHostLifecycleToolResult } - | { rejected: false; target: ResolvedAppHostTarget }; + | { rejected: false; target: AppHostTarget }; /** * Backs the `aspire_apphost_start` / `aspire_apphost_stop` language model tools. @@ -320,151 +270,8 @@ export class AppHostLifecycleToolService implements vscode.Disposable { effectiveMode); } - /** - * Resolves a model-supplied selector against the AppHost registry. - * - * The selector is only ever *compared* against entries the discovery service - * enumerated; it is never joined onto a directory, never normalized into a path, and - * never reaches the filesystem. That is what makes confirmation spoofing - * unrepresentable rather than merely rejected: whatever the model sends, the target - * carried forward is one of Aspire's own candidates, so the identity shown in the - * prompt and the identity handed to the launcher come from the same object. - * - * Resolution never guesses. A selector that names nothing is `unknownAppHost`, a - * selector matching several candidates is `ambiguousAppHost`, and a registry that - * could not be read is `discoveryFailed` rather than an empty list. - */ - async resolveTarget(rawAppHost: unknown, token: vscode.CancellationToken): Promise { - if (typeof rawAppHost !== 'string') { - return { resolved: false, outcome: 'invalidInput' }; - } - - const selector = rawAppHost.trim(); - if (selector.length === 0 || selector.length > maxAppHostSelectorLength) { - return { resolved: false, outcome: 'invalidInput' }; - } - - // The manifest, the README, and the tool description all say the selector is a - // workspace-relative path. An absolute path would still have to match a registry - // entry to do anything, but accepting one would make the implementation contradict - // its own documented contract, so it is refused up front. - if (path.isAbsolute(selector)) { - return { resolved: false, outcome: 'invalidInput' }; - } - - let knownAppHosts: readonly ResolvedAppHostTarget[]; - try { - knownAppHosts = await this.enumerateKnownAppHosts(token); - } - catch (error) { - if (isCommandCancellation(error)) { - return { resolved: false, outcome: 'cancelled' }; - } - - // "The registry could not be read" is not "there are no AppHosts". Reporting - // the latter would tell the agent its target does not exist when the truth is - // that the extension could not find out. - extensionLogOutputChannel.warn(`Aspire language model tools could not enumerate AppHosts: ${String(error)}`); - return { resolved: false, outcome: 'discoveryFailed' }; - } - - const requestedKey = toSelectorKey(selector); - const displayMatches = knownAppHosts.filter(candidate => toSelectorKey(candidate.displayPath) === requestedKey); - if ((vscode.workspace.workspaceFolders?.length ?? 0) > 1) { - // A bare relative selector is not stable in a multi-root workspace: a confirmation - // could name the only current match under root A, then a later invocation could - // re-resolve the same text under root B. Require the same folder-qualified identity - // the confirmation displays so each invocation is independently bound to one root. - if (displayMatches.length === 1) { - return { resolved: true, target: displayMatches[0] }; - } - - if (displayMatches.length > 1) { - return { resolved: false, outcome: 'ambiguousAppHost', knownAppHosts: describeKnownAppHosts(displayMatches) }; - } - - const relativeMatches = knownAppHosts.filter(candidate => toSelectorKey(candidate.relativePath) === requestedKey); - if (relativeMatches.length > 0) { - return { resolved: false, outcome: 'ambiguousAppHost', knownAppHosts: describeKnownAppHosts(relativeMatches) }; - } - - return { resolved: false, outcome: 'unknownAppHost', knownAppHosts: describeKnownAppHosts(knownAppHosts) }; - } - - const matches = knownAppHosts.filter(candidate => - toSelectorKey(candidate.relativePath) === requestedKey || - toSelectorKey(candidate.displayPath) === requestedKey); - if (matches.length === 0) { - return { resolved: false, outcome: 'unknownAppHost', knownAppHosts: describeKnownAppHosts(knownAppHosts) }; - } - - // A bare relative path can name candidates under several roots of a multi-root - // workspace. Picking one would launch an AppHost the caller did not identify, so - // the folder-qualified form has to be used instead. - if (matches.length > 1) { - return { resolved: false, outcome: 'ambiguousAppHost', knownAppHosts: describeKnownAppHosts(matches) }; - } - - return { resolved: true, target: matches[0] }; - } - - /** - * Projects the discovery service's candidates into tool targets. - * - * Candidates outside every workspace folder are dropped: the tool's contract is - * expressed in workspace-relative paths, and a candidate with no containing folder - * has no such path to offer or to display. - */ - private async enumerateKnownAppHosts(token: vscode.CancellationToken): Promise { - const workspaceFolders = vscode.workspace.workspaceFolders ?? []; - const candidatesByFolder = await Promise.all(workspaceFolders.map(async folder => ({ - folder, - candidates: await this._dependencies.discoveryService.discover(folder, false, token), - }))); - - const targets = new Map(); - for (const { folder, candidates } of candidatesByFolder) { - // Containment is decided on the real paths, because a link inside the workspace - // can point at a file outside it. The confirmation would show the in-workspace - // link while `startDebugging` executed the external target, so a lexical check - // alone would let the workspace boundary be crossed under an in-workspace name. - const canonicalFolderPath = canonicalizeAppHostPath(folder.uri.fsPath); - for (const candidate of candidates) { - const relativePath = toContainedPosixRelativePath(folder.uri.fsPath, candidate.path); - if (relativePath === undefined) { - continue; - } - - // The lexical relative path is still what gets displayed: it is the name the - // caller sees in the explorer, and it is the one they can pass back. - if (toContainedPosixRelativePath(canonicalFolderPath, canonicalizeAppHostPath(candidate.path)) === undefined) { - continue; - } - - const displayPath = workspaceFolders.length > 1 - ? `${folder.name}/${relativePath}` - : relativePath; - // Nested workspace folders enumerate the same file twice. Keying by the - // absolute path collapses those into one target so a selector matching both - // is not reported as ambiguous against itself. The deepest folder wins, so - // the displayed path matches the folder the user sees in the explorer. - const key = toSelectorKey(candidate.path); - const existing = targets.get(key); - if (existing && existing.relativePath.length <= relativePath.length) { - continue; - } - - targets.set(key, { absolutePath: candidate.path, relativePath, displayPath }); - } - } - - // A real file or folder name can itself carry invisible or bidi characters, and the - // confirmation must never show an identity it cannot render faithfully. Such an - // entry is dropped from the registry rather than displayed altered, which would - // break the one-to-one relationship between the prompt and the launch target. - return [...targets.values()].filter(target => - !identityChangingCharacters.test(target.displayPath) && - target.displayPath.length <= maxConfirmationPathLength); + private async resolveTarget(rawAppHost: unknown, token: vscode.CancellationToken): Promise { + return await this._dependencies.targetResolver.resolveTarget(rawAppHost, token); } private async preflight( @@ -541,49 +348,8 @@ export class AppHostLifecycleToolService implements vscode.Disposable { extensionLogOutputChannel.error(`Aspire language model tool ${tool} failed: ${String(error)}`); return createResult(tool, 'failed', relativePath, controller, requestedMode, effectiveMode); } - } function getSessionMode(session: AppHostLifecycleEditorSession): AppHostLifecycleMode { return session.configuration?.noDebug === true ? 'run' : 'debug'; } - -/** - * Normalizes a selector or registry path into the key both sides are compared on. - * - * The comparison is deliberately narrow: a leading `./` is dropped because it is noise, - * and Windows separators and casing are normalized to match that filesystem. On POSIX a - * backslash is a valid filename character, so treating it as a separator would alias two - * different registry entries. Nothing else is normalized. `..` segments, for instance, - * are left alone precisely so they can never match anything the registry enumerated. - */ -function toSelectorKey(value: string): string { - if (process.platform === 'win32') { - return value.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase(); - } - - return value.replace(/^\.\//, ''); -} - -/** - * Renders the selectors a failed resolution can offer back to the model. - * - * The list is capped because a large monorepo can enumerate hundreds of AppHosts and the - * result is spent from the model's context window. - */ -function describeKnownAppHosts(targets: readonly ResolvedAppHostTarget[]): readonly string[] { - return targets.slice(0, maxReportedKnownAppHosts).map(target => target.displayPath); -} - -/** - * Path relative to `folderPath` with `/` separators, or `undefined` when `candidate` - * is not inside the folder. - */ -function toContainedPosixRelativePath(folderPath: string, candidate: string): string | undefined { - const relative = path.relative(folderPath, candidate); - if (relative.length === 0 || relative.startsWith('..') || path.isAbsolute(relative)) { - return undefined; - } - - return relative.split(path.sep).join('/'); -} diff --git a/extension/src/lm/appHostLifecycleTools.ts b/extension/src/lm/appHostLifecycleTools.ts index 9d9a53a507e..f48fda9225c 100644 --- a/extension/src/lm/appHostLifecycleTools.ts +++ b/extension/src/lm/appHostLifecycleTools.ts @@ -17,6 +17,9 @@ export type { AppHostStartToolInput, AppHostStopToolInput, PreparableAppHostLifecycleTool, + SafeAppHostTarget, + SafeAppHostTargetResolver, + SafeAppHostTargetResolution, } from './appHostLifecycleToolContracts'; export { AppHostLifecycleToolService } from './appHostLifecycleToolService'; export { diff --git a/extension/src/lm/appHostTargetResolverContracts.ts b/extension/src/lm/appHostTargetResolverContracts.ts new file mode 100644 index 00000000000..302bfeab4c8 --- /dev/null +++ b/extension/src/lm/appHostTargetResolverContracts.ts @@ -0,0 +1,45 @@ +import type * as vscode from 'vscode'; + +import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; + +/** + * The discovered AppHost identity that an editor-owned operation may use. The absolute + * path remains internal; callers render only `displayPath` and return only `relativePath` + * or `displayPath` in tool results. + */ +export interface AppHostTarget { + readonly absolutePath: string; + readonly relativePath: string; + readonly displayPath: string; +} + +export type AppHostTargetResolutionOutcome = + | 'invalidInput' + | 'unknownAppHost' + | 'ambiguousAppHost' + | 'discoveryFailed' + | 'cancelled'; + +export type AppHostTargetResolution = + | { readonly resolved: true; readonly target: AppHostTarget } + | { + readonly resolved: false; + readonly outcome: AppHostTargetResolutionOutcome; + readonly knownAppHosts?: readonly string[]; + }; + +/** + * Narrow view of the registry the editor uses to discover AppHosts. Resolution never + * turns a model selector into a path; it only compares it with entries from this registry. + */ +export interface AppHostTargetDiscoveryService { + discover( + workspaceFolder: vscode.WorkspaceFolder, + forceRefresh?: boolean, + cancellationToken?: vscode.CancellationToken, + ): Promise; +} + +export interface AppHostTargetResolver { + resolveTarget(rawAppHost: unknown, token: vscode.CancellationToken): Promise; +} diff --git a/extension/src/lm/appHostTargetResolverService.ts b/extension/src/lm/appHostTargetResolverService.ts new file mode 100644 index 00000000000..b15b467ede2 --- /dev/null +++ b/extension/src/lm/appHostTargetResolverService.ts @@ -0,0 +1,248 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { canonicalizeAppHostPath } from '../utils/appHostIdentity'; +import { extensionLogOutputChannel } from '../utils/logging'; +import { isCommandCancellation } from '../utils/telemetry'; +import { + type AppHostTarget, + type AppHostTargetDiscoveryService, + type AppHostTargetResolution, + type AppHostTargetResolver, +} from './appHostTargetResolverContracts'; + +/** + * Upper bound on the workspace-relative path a confirmation may show. + * + * A path longer than this is refused outright rather than elided, because an elided path + * no longer identifies one file: two AppHosts sharing a long prefix would produce the same + * prompt. The bound is far above any realistic repository path (Windows' own MAX_PATH is + * 260 for a full path), so refusing beyond it costs nothing in practice. + */ +const maxConfirmationPathLength = 512; + +/** Reject model-supplied selectors large enough to make normalization itself expensive. */ +const maxAppHostSelectorLength = 4096; + +/** Cap on how many AppHost paths an `unknownAppHost` result lists back to the model. */ +const maxReportedKnownAppHosts = 32; + +/** + * Characters that change what a path *is* without changing, or while changing, how it + * looks: C0/C1 controls and DEL, line and paragraph separators, plus every Unicode format + * character (`\p{Cf}`). + * + * Bidi controls (U+202A-U+202E, U+2066-U+2069) reorder the run that follows them, so a + * path can render as a completely different one. Zero-width characters (U+200B-U+200D) + * are invisible, so two distinct files can produce identical-looking prompts. U+2028 and + * U+2029 can create a new rendered line or paragraph in Markdown confirmations. A registry + * entry carrying any of these is dropped rather than shown with the characters deleted, + * because deleting them would break the one-to-one relationship between the identity the + * user confirms and the file that runs. + * See https://unicode.org/reports/tr9/ and https://unicode.org/reports/tr36/#Bidirectional_Text_Spoofing + */ +const identityChangingCharacters = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]|\p{Cf}/u; +const confirmationBreakingCharacters = /[\u2028\u2029]/u; + +export interface AppHostTargetResolverServiceDependencies { + readonly discoveryService: AppHostTargetDiscoveryService; +} + +/** + * Resolves a model selector only against the editor's discovered AppHost registry. + * + * Consumers must not recreate discovery, containment, or multi-root selection logic. + */ +export class AppHostTargetResolverService implements AppHostTargetResolver { + constructor(private readonly _dependencies: AppHostTargetResolverServiceDependencies) { + } + + /** + * The selector is only ever compared against entries the discovery service enumerated; + * it is never joined onto a directory, normalized into a path, or passed to the + * filesystem. A resolved target therefore always comes from Aspire's own registry. + */ + async resolveTarget(rawAppHost: unknown, token: vscode.CancellationToken): Promise { + if (typeof rawAppHost !== 'string') { + return { resolved: false, outcome: 'invalidInput' }; + } + + const selector = rawAppHost.trim(); + if (selector.length === 0 || + selector.length > maxAppHostSelectorLength || + confirmationBreakingCharacters.test(selector) || + path.isAbsolute(selector)) { + return { resolved: false, outcome: 'invalidInput' }; + } + + let knownAppHosts: readonly AppHostTarget[]; + try { + knownAppHosts = await this._enumerateKnownAppHosts(token); + } + catch (error) { + if (isCommandCancellation(error) || token.isCancellationRequested) { + return { resolved: false, outcome: 'cancelled' }; + } + + // Discovery errors can contain CLI and filesystem detail. The tool result carries + // only the bounded outcome while the extension log retains diagnostics. + extensionLogOutputChannel.warn(`Aspire language model tools could not enumerate AppHosts: ${String(error)}`); + return { resolved: false, outcome: 'discoveryFailed' }; + } + + if (token.isCancellationRequested) { + return { resolved: false, outcome: 'cancelled' }; + } + + const requestedKey = toSelectorKey(selector); + const displayMatches = knownAppHosts.filter(candidate => toSelectorKey(candidate.displayPath) === requestedKey); + if ((vscode.workspace.workspaceFolders?.length ?? 0) > 1) { + // A bare relative selector is not stable in a multi-root workspace: a confirmation + // could name the only current match under root A, then a later invocation could + // re-resolve the same text under root B. Require the folder-qualified identity + // the confirmation displays so each invocation is independently bound to one root. + if (displayMatches.length === 1) { + return { resolved: true, target: displayMatches[0] }; + } + + if (displayMatches.length > 1) { + return { + resolved: false, + outcome: 'ambiguousAppHost', + knownAppHosts: describeKnownAppHosts(displayMatches), + }; + } + + const relativeMatches = knownAppHosts.filter(candidate => toSelectorKey(candidate.relativePath) === requestedKey); + if (relativeMatches.length > 0) { + return { + resolved: false, + outcome: 'ambiguousAppHost', + knownAppHosts: describeKnownAppHosts(relativeMatches), + }; + } + + return { + resolved: false, + outcome: 'unknownAppHost', + knownAppHosts: describeKnownAppHosts(knownAppHosts), + }; + } + + const matches = knownAppHosts.filter(candidate => + toSelectorKey(candidate.relativePath) === requestedKey || + toSelectorKey(candidate.displayPath) === requestedKey); + if (matches.length === 0) { + return { + resolved: false, + outcome: 'unknownAppHost', + knownAppHosts: describeKnownAppHosts(knownAppHosts), + }; + } + + if (matches.length > 1) { + return { + resolved: false, + outcome: 'ambiguousAppHost', + knownAppHosts: describeKnownAppHosts(matches), + }; + } + + return { resolved: true, target: matches[0] }; + } + + private async _enumerateKnownAppHosts(token: vscode.CancellationToken): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders ?? []; + const candidatesByFolder = await Promise.all(workspaceFolders.map(async folder => ({ + folder, + candidates: await this._dependencies.discoveryService.discover(folder, false, token), + }))); + + const targets = new Map(); + for (const { folder, candidates } of candidatesByFolder) { + // Containment is decided on the real paths, because a link inside the workspace + // can point at a file outside it. The confirmation would show the in-workspace + // link while `startDebugging` executed the external target, so a lexical check + // alone would let the workspace boundary be crossed under an in-workspace name. + const canonicalFolderPath = canonicalizeAppHostPath(folder.uri.fsPath); + for (const candidate of candidates) { + const relativePath = toContainedPosixRelativePath(folder.uri.fsPath, candidate.path); + if (relativePath === undefined) { + continue; + } + + // The lexical relative path is still what gets displayed: it is the name the + // caller sees in the explorer, and it is the one they can pass back. + if (toContainedPosixRelativePath(canonicalFolderPath, canonicalizeAppHostPath(candidate.path)) === undefined) { + continue; + } + + const displayPath = workspaceFolders.length > 1 + ? `${folder.name}/${relativePath}` + : relativePath; + // Nested workspace folders enumerate the same file twice. Keying by the + // absolute path collapses those into one target so a selector matching both + // is not reported as ambiguous against itself. The deepest folder wins, so + // the displayed path matches the folder the user sees in the explorer. + const key = toSelectorKey(candidate.path); + const existing = targets.get(key); + if (existing && existing.relativePath.length <= relativePath.length) { + continue; + } + + targets.set(key, { + absolutePath: candidate.path, + relativePath, + displayPath, + }); + } + } + + return [...targets.values()].filter(target => + !identityChangingCharacters.test(target.displayPath) && + target.displayPath.length <= maxConfirmationPathLength); + } +} + +/** + * Normalizes a selector or registry path into the key both sides are compared on. + * + * The comparison is deliberately narrow: a leading `./` is dropped because it is noise, + * and Windows separators and casing are normalized to match that filesystem. On POSIX a + * backslash is a valid filename character, so treating it as a separator would alias two + * different registry entries. Nothing else is normalized. `..` segments, for instance, + * are left alone precisely so they can never match anything the registry enumerated. + */ +function toSelectorKey(value: string): string { + if (process.platform === 'win32') { + return value.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase(); + } + + return value.replace(/^\.\//, ''); +} + +/** + * Renders the selectors a failed resolution can offer back to the model. + * + * The list is capped because a large monorepo can enumerate hundreds of AppHosts and the + * result is spent from the model's context window. + */ +function describeKnownAppHosts(targets: readonly AppHostTarget[]): readonly string[] { + return targets.slice(0, maxReportedKnownAppHosts).map(target => target.displayPath); +} + +/** + * Path relative to `folderPath` with `/` separators, or `undefined` when `candidate` + * is not inside the folder. + */ +function toContainedPosixRelativePath(folderPath: string, candidate: string): string | undefined { + const relative = path.relative(folderPath, candidate); + if (relative.length === 0 || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative)) { + return undefined; + } + + return relative.split(path.sep).join('/'); +} diff --git a/extension/src/lm/languageModelToolContracts.ts b/extension/src/lm/languageModelToolContracts.ts new file mode 100644 index 00000000000..c4620e71ece --- /dev/null +++ b/extension/src/lm/languageModelToolContracts.ts @@ -0,0 +1,17 @@ +import type * as vscode from 'vscode'; + +/** + * The limited preparation surface exposed to the E2E bridge. It intentionally accepts + * raw JSON-shaped input because each registered tool independently validates every field. + */ +export interface PreparableLanguageModelTool { + prepareInvocation( + options: { readonly input: Record }, + token: vscode.CancellationToken, + ): Promise; +} + +export interface PreparableLanguageModelToolRegistration extends vscode.Disposable { + readonly registered: boolean; + readonly tools: ReadonlyMap; +} diff --git a/extension/src/lm/markdown.ts b/extension/src/lm/markdown.ts new file mode 100644 index 00000000000..528adab88bd --- /dev/null +++ b/extension/src/lm/markdown.ts @@ -0,0 +1,15 @@ +/** + * Escapes the Markdown constructs that change how an identity renders inline. + * + * Confirmation bodies render as Markdown, so an unescaped `*`, `_`, `` ` ``, `[`, or + * `<` in a resolved resource or AppHost identity would show the user something other than + * the identity the tool will act on. Escaping keeps rendered text one-to-one with that + * identity instead of deleting characters, which would break that relationship in the + * other direction. Characters meaningful only at the start of a line (`.`, `-`, `{`, `}`) + * are left alone because callers interpolate values mid-sentence and those characters are + * common in real resource and project names. + * See https://spec.commonmark.org/0.31.2/#backslash-escapes + */ +export function escapeMarkdownForConfirmation(value: string): string { + return value.replace(/[\\`*_[\]()<>#+~|!&]/g, character => `\\${character}`); +} diff --git a/extension/src/lm/resourceDebugToolAdapters.ts b/extension/src/lm/resourceDebugToolAdapters.ts new file mode 100644 index 00000000000..ea8cc7f293b --- /dev/null +++ b/extension/src/lm/resourceDebugToolAdapters.ts @@ -0,0 +1,100 @@ +import * as vscode from 'vscode'; + +import { + resourceDebugToolConfirmationMessage, + resourceDebugToolConfirmationTitle, + resourceDebugToolInvocationMessage, + resourceDebugToolUnresolvedConfirmationMessage, + resourceDebugToolUnavailableInvocationMessage, +} from '../loc/strings'; +import { extensionLogOutputChannel } from '../utils/logging'; +import { + aspireResourceDebugToolName, + type AspireResourceDebugToolInput, + type AspireResourceDebugToolRegistration, + type AspireResourceDebugToolResult, +} from './resourceDebugToolContracts'; +import { AspireResourceDebugToolService } from './resourceDebugToolService'; +import { escapeMarkdownForConfirmation } from './markdown'; + +export class AspireResourceDebugLanguageModelTool implements vscode.LanguageModelTool { + constructor(private readonly _service: AspireResourceDebugToolService) { + } + + async prepareInvocation( + options: vscode.LanguageModelToolInvocationPrepareOptions, + token: vscode.CancellationToken, + ): Promise { + const preparation = await this._service.prepare(options.input, token); + if (!preparation.canDebug) { + // Do not let a transient discovery failure bypass VS Code's confirmation step. + // The generic message contains no model input or unresolved target; invocation + // resolves again and still applies trust and validation checks. + return { + invocationMessage: resourceDebugToolUnavailableInvocationMessage, + confirmationMessages: { + title: resourceDebugToolConfirmationTitle, + message: resourceDebugToolUnresolvedConfirmationMessage, + }, + }; + } + + const resourceName = escapeMarkdownForConfirmation(preparation.resourceName); + const appHost = escapeMarkdownForConfirmation(preparation.target.displayPath); + return { + invocationMessage: resourceDebugToolInvocationMessage(resourceName), + confirmationMessages: { + title: resourceDebugToolConfirmationTitle, + message: resourceDebugToolConfirmationMessage(resourceName, appHost), + }, + }; + } + + async invoke( + options: vscode.LanguageModelToolInvocationOptions, + token: vscode.CancellationToken, + ): Promise { + return createToolResult(await this._service.debug(options.input, token)); + } +} + +export function registerAspireResourceDebugTool(service: AspireResourceDebugToolService): AspireResourceDebugToolRegistration { + const registrations: vscode.Disposable[] = []; + const tool = new AspireResourceDebugLanguageModelTool(service); + const tools = new Map([ + [aspireResourceDebugToolName, { + prepareInvocation: (options: { readonly input: Record }, token: vscode.CancellationToken) => + tool.prepareInvocation({ input: options.input as unknown as AspireResourceDebugToolInput }, token), + invoke: ( + options: { readonly input: Record; readonly toolInvocationToken: undefined }, + token: vscode.CancellationToken, + ) => tool.invoke({ + input: options.input as unknown as AspireResourceDebugToolInput, + toolInvocationToken: options.toolInvocationToken, + }, token), + }], + ]); + + if (typeof vscode.lm?.registerTool !== 'function') { + extensionLogOutputChannel.info('Skipping Aspire resource debug language model tool: the language model tool API is unavailable.'); + } + else { + registrations.push(vscode.lm.registerTool(aspireResourceDebugToolName, tool)); + extensionLogOutputChannel.info('Registered Aspire resource debug language model tool.'); + } + + return { + get registered() { + return registrations.length > 0; + }, + tools, + dispose() { + registrations.forEach(registration => registration.dispose()); + registrations.length = 0; + }, + }; +} + +function createToolResult(result: AspireResourceDebugToolResult): vscode.LanguageModelToolResult { + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(JSON.stringify(result))]); +} diff --git a/extension/src/lm/resourceDebugToolContracts.ts b/extension/src/lm/resourceDebugToolContracts.ts new file mode 100644 index 00000000000..9e0affac7f5 --- /dev/null +++ b/extension/src/lm/resourceDebugToolContracts.ts @@ -0,0 +1,78 @@ +import type { + ResourceDebugErrorKind, + ResourceDebugExtensionRequirement, + ResourceDebugger, + ResourceDebugStrategy, +} from '../debugger/resourceDebugContracts'; +import type { AppHostTarget, AppHostTargetResolver } from './appHostTargetResolverContracts'; +import type { PreparableLanguageModelToolRegistration } from './languageModelToolContracts'; + +export const aspireResourceDebugToolName = 'aspire_resource_debug'; + +export type AspireResourceDebugStrategy = ResourceDebugStrategy; + +export interface AspireResourceDebugToolInput { + readonly appHostPath: string; + readonly resourceName: string; + readonly strategy?: AspireResourceDebugStrategy; +} + +export type AspireResourceDebugToolOutcome = + | 'started' + | 'alreadyDebugging' + | 'appHostNotFound' + | 'resourceNotFound' + | 'unsupportedResource' + | 'resourceNotRunning' + | 'debuggerExtensionMissing' + | 'error' + | 'invalidInput' + | 'unknownAppHost' + | 'ambiguousAppHost' + | 'discoveryFailed' + | 'workspaceNotTrusted' + | 'cancelled' + | 'failed'; + +/** + * The entire language-model result boundary. It contains only caller-approved resource + * identity, resolver-produced display identity, and bounded debugger state. + */ +export interface AspireResourceDebugToolResult { + readonly tool: typeof aspireResourceDebugToolName; + readonly success: boolean; + readonly outcome: AspireResourceDebugToolOutcome; + readonly appHost: string; + readonly resourceName: string; + readonly requestedStrategy: AspireResourceDebugStrategy; + readonly effectiveStrategy: 'attach' | 'none'; + readonly controller: 'editor' | 'none'; + readonly provider?: 'dotnet' | 'go'; + readonly debuggerExtensions?: readonly ResourceDebugExtensionRequirement[]; + readonly errorKind?: ResourceDebugErrorKind; +} + +export interface AspireResourceDebugToolDependencies { + readonly targetResolver: AppHostTargetResolver; + readonly resourceDebugger: ResourceDebugger; +} + +export type AspireResourceDebugToolPreparation = + | { + readonly canDebug: true; + readonly target: AppHostTarget; + readonly resourceName: string; + readonly requestedStrategy: AspireResourceDebugStrategy; + } + | { + readonly canDebug: false; + readonly result: AspireResourceDebugToolResult; + }; + +export type AspireResourceDebugToolRegistration = PreparableLanguageModelToolRegistration; + +export type { + AppHostTarget as SafeAppHostTarget, + AppHostTargetResolution as SafeAppHostTargetResolution, + AppHostTargetResolver as SafeAppHostTargetResolver, +} from './appHostTargetResolverContracts'; diff --git a/extension/src/lm/resourceDebugToolService.ts b/extension/src/lm/resourceDebugToolService.ts new file mode 100644 index 00000000000..1b740a6d6ed --- /dev/null +++ b/extension/src/lm/resourceDebugToolService.ts @@ -0,0 +1,308 @@ +import * as vscode from 'vscode'; + +import { + type ResourceDebugExtensionRequirement, + type ResourceDebugResult, +} from '../debugger/resourceDebugContracts'; +import { isCommandCancellation } from '../utils/telemetry'; +import { + aspireResourceDebugToolName, + type AspireResourceDebugStrategy, + type AspireResourceDebugToolDependencies, + type AspireResourceDebugToolOutcome, + type AspireResourceDebugToolPreparation, + type AspireResourceDebugToolResult, +} from './resourceDebugToolContracts'; + +const maxAppHostPathLength = 4096; +const maxResourceNameLength = 256; + +// Invisible and bidi controls can make a confirmation differ from what the model sent. +// Match the AppHost lifecycle resolver's identity boundary before resource names reach +// either confirmation text or the resource-debug service. +const identityChangingCharacters = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]|\p{Cf}/u; + +interface ParsedInput { + readonly appHostPath: string; + readonly resourceName: string; + readonly requestedStrategy: AspireResourceDebugStrategy; +} + +/** + * Owns only the language-model boundary for resource attach. AppHost discovery and debug + * lifecycle policy remain with the shared resolver and ResourceDebugger respectively. + */ +export class AspireResourceDebugToolService implements vscode.Disposable { + private readonly _operationCancellationSources = new Set(); + private _disposed = false; + + constructor(private readonly _dependencies: AspireResourceDebugToolDependencies) { + } + + dispose(): void { + if (this._disposed) { + return; + } + + this._disposed = true; + for (const cancellationSource of this._operationCancellationSources) { + cancellationSource.cancel(); + } + } + + /** + * Validates and resolves a confirmation target without starting a debugger. Invocation + * calls this again rather than retaining the absolute path from confirmation. + */ + async prepare(input: unknown, token: vscode.CancellationToken): Promise { + return await this._runOperation(token, operationToken => this._prepare(input, operationToken)); + } + + async debug(input: unknown, token: vscode.CancellationToken): Promise { + return await this._runOperation(token, async operationToken => { + const preparation = await this._prepare(input, operationToken); + if (!preparation.canDebug) { + return preparation.result; + } + + if (operationToken.isCancellationRequested) { + return this.createResult( + 'cancelled', + preparation.target.displayPath, + preparation.resourceName, + preparation.requestedStrategy); + } + + try { + const result = await this._dependencies.resourceDebugger.debug({ + source: 'languageModelTool', + strategy: preparation.requestedStrategy, + appHost: preparation.target, + resourceName: preparation.resourceName, + cancellationToken: operationToken, + }); + return mapResourceDebugResult(result, preparation.target.displayPath, preparation.resourceName, preparation.requestedStrategy); + } + catch (error) { + return this.createResult( + isCommandCancellation(error) || operationToken.isCancellationRequested ? 'cancelled' : 'failed', + preparation.target.displayPath, + preparation.resourceName, + preparation.requestedStrategy); + } + }); + } + + private async _prepare(input: unknown, token: vscode.CancellationToken): Promise { + const parsed = parseInput(input); + if (!parsed) { + return this.reject('invalidInput'); + } + + if (token.isCancellationRequested) { + return this.reject('cancelled', '', parsed); + } + + // The manifest gate is advisory: a tool can remain registered while a workspace + // transitions into Restricted Mode, so never resolve or attach there at runtime. + if (!vscode.workspace.isTrusted) { + return this.reject('workspaceNotTrusted', '', parsed); + } + + try { + const resolution = await this._dependencies.targetResolver.resolveTarget(parsed.appHostPath, token); + if (token.isCancellationRequested) { + return this.reject('cancelled', '', parsed); + } + + if (!resolution.resolved) { + return this.reject(resolution.outcome, '', parsed); + } + + return { + canDebug: true, + target: resolution.target, + resourceName: parsed.resourceName, + requestedStrategy: parsed.requestedStrategy, + }; + } + catch (error) { + return this.reject(isCommandCancellation(error) || token.isCancellationRequested ? 'cancelled' : 'failed', '', parsed); + } + } + + private async _runOperation( + callerToken: vscode.CancellationToken, + operation: (token: vscode.CancellationToken) => Promise, + ): Promise { + const cancellationSource = new vscode.CancellationTokenSource(); + this._operationCancellationSources.add(cancellationSource); + const cancellationRegistration = callerToken.onCancellationRequested(() => cancellationSource.cancel()); + try { + if (this._disposed || callerToken.isCancellationRequested) { + cancellationSource.cancel(); + } + + return await operation(cancellationSource.token); + } + finally { + cancellationRegistration.dispose(); + this._operationCancellationSources.delete(cancellationSource); + cancellationSource.dispose(); + } + } + + private reject( + outcome: Extract< + AspireResourceDebugToolOutcome, + 'invalidInput' | 'unknownAppHost' | 'ambiguousAppHost' | 'discoveryFailed' | 'workspaceNotTrusted' | 'cancelled' | 'failed' + >, + appHost = '', + parsed?: ParsedInput, + ): AspireResourceDebugToolPreparation { + return { + canDebug: false, + result: this.createResult( + outcome, + appHost, + parsed?.resourceName ?? '', + parsed?.requestedStrategy ?? 'auto'), + }; + } + + private createResult( + outcome: AspireResourceDebugToolOutcome, + appHost: string, + resourceName: string, + requestedStrategy: AspireResourceDebugStrategy, + ): AspireResourceDebugToolResult { + return { + tool: aspireResourceDebugToolName, + success: false, + outcome, + appHost, + resourceName, + requestedStrategy, + effectiveStrategy: 'none', + controller: 'none', + }; + } +} + +function parseInput(value: unknown): ParsedInput | undefined { + try { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined; + } + + const input = value as Record; + const properties = Reflect.ownKeys(input); + if (properties.some(property => + property !== 'appHostPath' && + property !== 'resourceName' && + property !== 'strategy') || + !Object.prototype.hasOwnProperty.call(input, 'appHostPath') || + !Object.prototype.hasOwnProperty.call(input, 'resourceName')) { + return undefined; + } + + const appHostPath = input.appHostPath; + const resourceName = input.resourceName; + const strategy = input.strategy; + if (!isSafeNonBlankString(appHostPath, maxAppHostPathLength) || + !isSafeNonBlankString(resourceName, maxResourceNameLength) || + (strategy !== undefined && strategy !== 'auto' && strategy !== 'attach')) { + return undefined; + } + + return { + appHostPath, + resourceName, + requestedStrategy: strategy ?? 'auto', + }; + } + catch { + // JSON-shaped tool input normally has data properties, but malformed extension-host + // objects can use getters or proxies. Treating a throwing getter as invalid keeps + // its message out of both the model transcript and the extension's control flow. + return undefined; + } +} + +function isSafeNonBlankString(value: unknown, maxLength: number): value is string { + return typeof value === 'string' && + value.trim().length > 0 && + value.length <= maxLength && + !identityChangingCharacters.test(value); +} + +function mapResourceDebugResult( + result: ResourceDebugResult, + appHost: string, + resourceName: string, + requestedStrategy: AspireResourceDebugStrategy, +): AspireResourceDebugToolResult { + const base = { + tool: aspireResourceDebugToolName, + appHost, + resourceName, + requestedStrategy, + } as const; + + switch (result.outcome) { + case 'started': + return { + ...base, + success: true, + outcome: 'started', + effectiveStrategy: 'attach', + controller: 'editor', + provider: result.providerId, + }; + case 'alreadyDebugging': + return { + ...base, + success: true, + outcome: 'alreadyDebugging', + effectiveStrategy: 'attach', + controller: 'editor', + }; + case 'debuggerExtensionMissing': + return { + ...base, + success: false, + outcome: 'debuggerExtensionMissing', + effectiveStrategy: 'none', + controller: 'none', + debuggerExtensions: result.debuggerExtensions.map(toSafeDebuggerRequirement), + }; + case 'error': + return { + ...base, + success: false, + outcome: 'error', + effectiveStrategy: 'none', + controller: 'none', + errorKind: result.errorKind, + }; + case 'appHostNotFound': + case 'resourceNotFound': + case 'unsupportedResource': + case 'resourceNotRunning': + case 'cancelled': + return { + ...base, + success: false, + outcome: result.outcome, + effectiveStrategy: 'none', + controller: 'none', + }; + } +} + +function toSafeDebuggerRequirement(requirement: ResourceDebugExtensionRequirement): ResourceDebugExtensionRequirement { + return { + id: requirement.id, + label: requirement.label, + }; +} diff --git a/extension/src/lm/resourceDebugTools.ts b/extension/src/lm/resourceDebugTools.ts new file mode 100644 index 00000000000..c9969ed78c1 --- /dev/null +++ b/extension/src/lm/resourceDebugTools.ts @@ -0,0 +1,17 @@ +export { aspireResourceDebugToolName } from './resourceDebugToolContracts'; +export type { + AspireResourceDebugStrategy, + AspireResourceDebugToolDependencies, + AspireResourceDebugToolInput, + AspireResourceDebugToolOutcome, + AspireResourceDebugToolPreparation, + AspireResourceDebugToolRegistration, + AspireResourceDebugToolResult, + SafeAppHostTargetResolver, + SafeAppHostTargetResolution, +} from './resourceDebugToolContracts'; +export { AspireResourceDebugToolService } from './resourceDebugToolService'; +export { + AspireResourceDebugLanguageModelTool, + registerAspireResourceDebugTool, +} from './resourceDebugToolAdapters'; diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 47c4461f15d..e9f92399957 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -165,6 +165,15 @@ export const appHostPublishingDescription = vscode.l10n.t('Publishing...'); export const appHostRunningPipelineStepDescription = vscode.l10n.t('Running pipeline step...'); export const appHostDebuggingPipelineStepDescription = vscode.l10n.t('Debugging pipeline step...'); export const appHostDiscoveryProgress = vscode.l10n.t('Discovering AppHosts...'); +export const attachDebuggerConfigurationName = (resource: string) => vscode.l10n.t('Attach debugger: {0}', resource); +export const attachDebuggerUnavailable = vscode.l10n.t('This resource cannot be attached to a debugger.'); +export const attachDebuggerResourceNotFound = vscode.l10n.t('The selected resource is no longer available. Refresh the Aspire pane and try again.'); +export const attachDebuggerWorkspaceNotTrusted = vscode.l10n.t('Trust this workspace before attaching a debugger to an Aspire resource.'); +export const attachDebuggerCsharpExtensionRequired = vscode.l10n.t('Install the C# extension to attach the debugger to .NET project resources.'); +export const attachDebuggerExtensionsRequired = (labels: string) => vscode.l10n.t('Install {0} to attach the debugger to this resource.', labels); +export const attachDebuggerDeclined = (resource: string) => vscode.l10n.t('VS Code did not start the debugger attach session for {0}.', resource); +export const attachingDebugger = (resource: string) => vscode.l10n.t('Attaching debugger to {0}...', resource); +export const attachDebuggerAlreadyDebugging = (resource: string) => vscode.l10n.t('A debugger is already attached to {0}.', resource); export const resourceCountDescription = (count: number) => vscode.l10n.t('({0} resources)', count); export const appHostCandidateDescription = (language: string, status: string) => vscode.l10n.t('{0} · {1}', language, status); export const workspaceViewSelectedSingleAppHost = (language?: string) => language @@ -333,4 +342,9 @@ export const appHostLifecycleLaunchProfileCapabilityCouldNotBeVerified = vscode. export const appHostLifecycleInvalidLaunchProfile = vscode.l10n.t('an invalid launch profile'); export const appHostLifecycleLaunchProfileRequiresRun = vscode.l10n.t('Launch profiles are only supported for the run command.'); export const appHostLifecycleLaunchAlreadyClaimed = vscode.l10n.t('This Aspire AppHost is already starting or running. The new debug session was cancelled so only one AppHost runs.'); +export const resourceDebugToolConfirmationTitle = vscode.l10n.t('Attach debugger to Aspire resource'); +export const resourceDebugToolConfirmationMessage = (resourceName: string, appHostPath: string) => vscode.l10n.t('Attach the debugger to resource {0} from Aspire AppHost {1}?', resourceName, appHostPath); +export const resourceDebugToolUnresolvedConfirmationMessage = vscode.l10n.t('Attach the debugger to the requested Aspire resource?'); +export const resourceDebugToolInvocationMessage = (resourceName: string) => vscode.l10n.t('Attaching debugger to Aspire resource {0}...', resourceName); +export const resourceDebugToolUnavailableInvocationMessage = vscode.l10n.t('Attaching debugger to the requested Aspire resource...'); export const appHostOperationAlreadyInProgress = vscode.l10n.t('Another operation is already in progress for this Aspire AppHost. The new operation was cancelled.'); diff --git a/extension/src/test-e2e/appHostLifecycleTools.e2e.test.ts b/extension/src/test-e2e/appHostLifecycleTools.e2e.test.ts index b4b0479cfc7..cebc6ccbb1e 100644 --- a/extension/src/test-e2e/appHostLifecycleTools.e2e.test.ts +++ b/extension/src/test-e2e/appHostLifecycleTools.e2e.test.ts @@ -10,6 +10,7 @@ import { ensureDiagnosticsDir, getCliPath, getPrimaryAppHostProjectPath, getRepo import { acceptModalDialog, openAspireView, type AcceptedModalDialog } from './helpers/vscode'; import { assertLinkedAppHostCliLaunch, commandLineArgumentEquals } from '../test/helpers/processArguments'; import { getCmdShimSpawnCommand, shouldWrapWithCmd } from '../utils/cmdShimCommand'; +import { invokeLanguageModelTool, prepareLanguageModelToolInvocation } from './helpers/languageModelTools'; interface LifecycleToolResult { tool: string; @@ -21,12 +22,6 @@ interface LifecycleToolResult { controller: string; } -interface PreparedInvocation { - invocationMessage?: string; - confirmationTitle?: string; - confirmationMessage?: string; -} - interface RegisteredTool { name: string; tags: string[]; @@ -80,22 +75,22 @@ suite('Aspire AppHost lifecycle E2E', function () { const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); const relativeAppHostPath = path.relative(getWorkspaceRoot(), appHostPath).split(path.sep).join('/'); - const registeredTools = await invokeControlCommand({ name: 'getRegisteredLanguageModelTools' }); - assert.deepStrictEqual(registeredTools.map(tool => tool.name), [startToolName, stopToolName]); + const registeredTools = (await executeE2eControlCommand({ name: 'getRegisteredLanguageModelTools' })).result as RegisteredTool[]; + assert.deepStrictEqual( + registeredTools + .filter(tool => tool.name === startToolName || tool.name === stopToolName) + .map(tool => tool.name), + [startToolName, stopToolName]); // The prepared invocation is also captured directly from the registered tool // instance so the exact confirmation strings are asserted, not just what the // modal renders. - const preparedStart = await invokeControlCommand({ - name: 'prepareLanguageModelToolInvocation', - toolName: startToolName, - input: { appHostPath: relativeAppHostPath, mode: 'debug' }, - }); - const preparedStop = await invokeControlCommand({ - name: 'prepareLanguageModelToolInvocation', - toolName: stopToolName, - input: { appHostPath: relativeAppHostPath }, - }); + const preparedStart = await prepareLanguageModelToolInvocation( + startToolName, + { appHostPath: relativeAppHostPath, mode: 'debug' }); + const preparedStop = await prepareLanguageModelToolInvocation( + stopToolName, + { appHostPath: relativeAppHostPath }); assert.strictEqual(preparedStart.confirmationTitle, 'Start Aspire AppHost'); assert.strictEqual(preparedStart.confirmationMessage, `Start the Aspire AppHost ${relativeAppHostPath} in debug mode?`); @@ -105,12 +100,10 @@ suite('Aspire AppHost lifecycle E2E', function () { const debugLaunchesBeforeStart = getDebugLaunchCount(); // Both calls are fired concurrently inside the extension host: the tool must // serialize them per AppHost path so only one of them launches a process. - const concurrentStartInvocation = await invokeLifecycleTool({ - name: 'invokeLanguageModelTool', - toolName: startToolName, - input: { appHostPath: relativeAppHostPath, mode: 'debug' }, - times: 2, - }, 600000, 2, 'apphost-lifecycle-start-confirmation'); + const concurrentStartInvocation = await invokeLanguageModelTool( + startToolName, + { appHostPath: relativeAppHostPath, mode: 'debug' }, + { timeoutMs: 600000, times: 2, expectedConfirmations: 2, screenshotName: 'apphost-lifecycle-start-confirmation' }); const concurrentStarts = concurrentStartInvocation.results; assert.strictEqual(concurrentStartInvocation.dialogs.length, 2, 'Expected each concurrent start call to require its own confirmation.'); @@ -134,11 +127,10 @@ suite('Aspire AppHost lifecycle E2E', function () { const startedSessions = readStateFile().state.debugSessions.filter(session => session.appHostPath !== undefined && isSamePath(session.appHostPath, appHostPath)); assert.strictEqual(startedSessions.length, 1, 'Expected exactly one editor-owned debug session after the concurrent start calls.'); - const repeatedStartInvocation = await invokeLifecycleTool({ - name: 'invokeLanguageModelTool', - toolName: startToolName, - input: { appHostPath: relativeAppHostPath, mode: 'run' }, - }, 180000, 1); + const repeatedStartInvocation = await invokeLanguageModelTool( + startToolName, + { appHostPath: relativeAppHostPath, mode: 'run' }, + { timeoutMs: 180000 }); const repeatedStart = repeatedStartInvocation.results; assert.strictEqual(repeatedStartInvocation.dialogs[0].details, `Start the Aspire AppHost ${relativeAppHostPath} in run mode?`); assert.strictEqual(repeatedStart.length, 1); @@ -154,11 +146,10 @@ suite('Aspire AppHost lifecycle E2E', function () { assert.deepStrictEqual(await findAppHostProcessIds(appHostPath), [appHostPid], 'Expected the repeated start call to leave the original AppHost process running.'); assert.strictEqual(getDebugLaunchCount() - debugLaunchesBeforeStart, 1, 'Expected exactly one AppHost launch across all start calls.'); - const stopInvocation = await invokeLifecycleTool({ - name: 'invokeLanguageModelTool', - toolName: stopToolName, - input: { appHostPath: relativeAppHostPath }, - }, 300000, 1, 'apphost-lifecycle-stop-confirmation'); + const stopInvocation = await invokeLanguageModelTool( + stopToolName, + { appHostPath: relativeAppHostPath }, + { timeoutMs: 300000, screenshotName: 'apphost-lifecycle-stop-confirmation' }); const stopResults = stopInvocation.results; assert.strictEqual(stopInvocation.dialogs[0].message, 'Stop Aspire AppHost'); assert.strictEqual(stopInvocation.dialogs[0].details, `Stop the Aspire AppHost ${relativeAppHostPath}?`); @@ -172,11 +163,10 @@ suite('Aspire AppHost lifecycle E2E', function () { assert.strictEqual(readStateFile().state.debugSessions.length, 0, 'Expected no debug sessions after the stop tool call.'); assert.deepStrictEqual(await waitForAppHostProcessCount(appHostPath, 0, 180000), [], 'Expected no AppHost processes after the stop tool call.'); - const stopAgainResults = (await invokeLifecycleTool({ - name: 'invokeLanguageModelTool', - toolName: stopToolName, - input: { appHostPath: relativeAppHostPath }, - }, 120000, 1)).results; + const stopAgainResults = (await invokeLanguageModelTool( + stopToolName, + { appHostPath: relativeAppHostPath }, + { timeoutMs: 120000 })).results; assert.strictEqual(stopAgainResults[0].outcome, 'notRunning'); assert.strictEqual(stopAgainResults[0].controller, 'none'); @@ -211,11 +201,10 @@ suite('Aspire AppHost lifecycle E2E', function () { externalAppHostPid = await waitForExternalAppHost(externalRun, appHostPath, 600000); assert.strictEqual(readStateFile().state.debugSessions.length, 0, 'Expected a CLI-started AppHost to have no editor debug session.'); - const stopInvocation = await invokeLifecycleTool({ - name: 'invokeLanguageModelTool', - toolName: stopToolName, - input: { appHostPath: relativeAppHostPath }, - }, 300000, 1, 'apphost-lifecycle-external-stop-confirmation'); + const stopInvocation = await invokeLanguageModelTool( + stopToolName, + { appHostPath: relativeAppHostPath }, + { timeoutMs: 300000, screenshotName: 'apphost-lifecycle-external-stop-confirmation' }); assert.strictEqual(stopInvocation.dialogs[0].message, 'Stop Aspire AppHost'); assert.strictEqual(stopInvocation.dialogs[0].details, `Stop the Aspire AppHost ${relativeAppHostPath}?`); @@ -404,11 +393,10 @@ suite('Aspire AppHost lifecycle E2E', function () { const discovered = await waitForSelectedWorkspaceAppHost(fixture.appHostPath); assert.ok(discovered.state.workspaceAppHostPath && isSamePath(discovered.state.workspaceAppHostPath, fixture.appHostPath)); - const preparedStart = await invokeControlCommand({ - name: 'prepareLanguageModelToolInvocation', - toolName: startToolName, - input: { appHostPath: relativeAppHostPath, mode: 'debug' }, - }); + const preparedStart = await prepareLanguageModelToolInvocation( + startToolName, + { appHostPath: relativeAppHostPath, mode: 'debug' }, + ); // The AppHost lives in a linked worktree and `isolated` was omitted, so the // lifecycle tool infers isolation. The confirmation has to disclose that, because // this dialog is what "Always allow" is granted against. diff --git a/extension/src/test-e2e/edgeCases.e2e.test.ts b/extension/src/test-e2e/edgeCases.e2e.test.ts index 790b547c91c..bd76ea562dc 100644 --- a/extension/src/test-e2e/edgeCases.e2e.test.ts +++ b/extension/src/test-e2e/edgeCases.e2e.test.ts @@ -2,6 +2,7 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; import type { AspireExtensionE2EControlCommand } from '../types/extensionApi'; +import type { ExecutableLaunchConfiguration } from '../dcp/types'; import { getCommandInvocationCount, getDebugLaunchCount, isSamePath, waitForCommandOutcome, waitForDebugLaunch, waitForDebugSessionStartup, waitForExtensionState, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForRunningAppHost, waitForSelectedWorkspaceAppHost, waitForWorkspaceAppHost } from './helpers/assertions'; import { createEmptyAppHostProject, createExternalSingleFileAppHost, executeE2eControlCommand, getGeneratedAppHostPath, getGeneratedProjectRoot, isProcessAlive, removeExternalSingleFileAppHost, removeGeneratedProject, restoreWorkspaceAppHostConfig, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setDebugLaunchSuppressedForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning, waitForKnownProcessExit, writeFileWithRetry, writeWorkspaceAppHostConfigForPath } from './helpers/fixtures'; import { getPrimaryAppHostProjectPath, getWorkspaceRoot } from './helpers/paths'; @@ -75,6 +76,17 @@ suite('Aspire extension edge case E2E', function () { executeE2eControlCommand({ name: 'publishAppHost' }), /publishAppHost requires appHostPath/); assert.strictEqual(getDebugLaunchCount(), beforePublishLaunch); + + await assert.rejects( + executeE2eControlCommand({ + name: 'createResourceDebugConfiguration', + launchConfig: { + type: 'browser', + browser: 'safari', + url: 'https://top-secret.invalid', + } as ExecutableLaunchConfiguration, + }), + /E2E control command failed\./); }); test('keeps CLI-independent settings commands available when the CLI is unavailable', async () => { diff --git a/extension/src/test-e2e/helpers/languageModelTools.ts b/extension/src/test-e2e/helpers/languageModelTools.ts new file mode 100644 index 00000000000..bf96c982498 --- /dev/null +++ b/extension/src/test-e2e/helpers/languageModelTools.ts @@ -0,0 +1,118 @@ +import type { AspireExtensionE2EControlCommand } from '../../types/extensionApi'; +import { executeE2eControlCommand } from './fixtures'; +import { acceptModalDialog, type AcceptedModalDialog } from './vscode'; + +export interface PreparedLanguageModelToolInvocation { + invocationMessage?: string; + confirmationTitle?: string; + confirmationMessage?: string; +} + +export interface LanguageModelToolInvocationOptions { + expectedConfirmations?: number; + confirmationButtonTitle?: string; + screenshotName?: string; + timeoutMs?: number; + times?: number; + cancelAfterMs?: number; +} + +export interface LanguageModelToolInvocation { + results: T[]; + dialogs: AcceptedModalDialog[]; + cancelled: boolean; +} + +export async function prepareLanguageModelToolInvocation( + toolName: string, + input: Record, + timeoutMs = 120000, +): Promise { + return await invokeControlCommand({ + name: 'prepareLanguageModelToolInvocation', + toolName, + input, + }, timeoutMs); +} + +/** + * Drives any registered language-model tool through VS Code's public invocation API. + * Invocation begins before confirmation is accepted because `vscode.lm.invokeTool` waits + * for the modal. The state bridge stores only the tool's bounded text result. + */ +export async function invokeLanguageModelTool( + toolName: string, + input: Record, + options: LanguageModelToolInvocationOptions = {}, +): Promise> { + const expectedConfirmations = options.expectedConfirmations ?? 1; + const invocation = invokeControlCommand<{ results: string[]; cancelled?: boolean }>({ + name: 'invokeLanguageModelTool', + toolName, + input, + times: options.times, + cancelAfterMs: options.cancelAfterMs, + }, options.timeoutMs ?? 120000); + invocation.catch(() => undefined); + + const dialogs: AcceptedModalDialog[] = []; + let invocationSettled = false; + void invocation.finally(() => invocationSettled = true).catch(() => undefined); + for (let index = 0; index < expectedConfirmations; index++) { + const buttonTitle = options.confirmationButtonTitle ?? 'Yes'; + const screenshotName = index === 0 ? options.screenshotName : undefined; + if (options.cancelAfterMs === undefined) { + dialogs.push(await acceptModalDialog(buttonTitle, 180000, screenshotName)); + continue; + } + + // Cancellation can win before VS Code creates the confirmation dialog, or it can leave + // an already-open dialog waiting for acknowledgement. Probe while the invocation is + // pending so either ordering completes without leaving a modal for the next test. + const deadline = Date.now() + 180000; + let confirmationAccepted = false; + while (!invocationSettled) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw new Error(`Timed out waiting for the cancelled language-model invocation or a '${buttonTitle}' confirmation.`); + } + + try { + dialogs.push(await acceptModalDialog(buttonTitle, Math.min(1000, remainingMs), screenshotName)); + confirmationAccepted = true; + break; + } + catch { + // A confirmation is optional once the invocation has observed cancellation. + } + } + + if (!confirmationAccepted && invocationSettled) { + try { + dialogs.push(await acceptModalDialog(buttonTitle, 1000, screenshotName)); + } + catch { + // The invocation completed before VS Code created a confirmation. + } + } + } + + const result = await invocation; + return { + results: result.results.map(item => JSON.parse(item) as T), + dialogs, + cancelled: result.cancelled === true, + }; +} + +async function invokeControlCommand( + command: AspireExtensionE2EControlCommand, + timeoutMs: number, +): Promise { + const status = await executeE2eControlCommand(command, { timeoutMs }); + if (status.errorMessage) { + throw new Error(`E2E control command '${command.name}' failed: ${status.errorMessage}`); + } + + return status.result as T; +} diff --git a/extension/src/test-e2e/packageSurface.e2e.test.ts b/extension/src/test-e2e/packageSurface.e2e.test.ts index b45715f1319..08e5ce61091 100644 --- a/extension/src/test-e2e/packageSurface.e2e.test.ts +++ b/extension/src/test-e2e/packageSurface.e2e.test.ts @@ -144,6 +144,7 @@ suite('Aspire package contribution surface E2E', function () { 'aspire-vscode.openInIntegratedBrowser', 'aspire-vscode.copyEndpointUrl', 'aspire-vscode.openResourceTerminal', + 'aspire-vscode.attachDebuggerToResource', ]) { assert.ok(hiddenPaletteCommands.includes(commandId), `${commandId} should stay hidden from the command palette.`); } @@ -196,6 +197,27 @@ suite('Aspire package contribution surface E2E', function () { assert.deepStrictEqual(Object.entries(assetStatus).filter(([, exists]) => !exists), []); }); + test('prepares the resource debug tool from the merged preparable tool map when its AppHost is unresolved', async () => { + const prepared = (await executeE2eControlCommand({ + name: 'prepareLanguageModelToolInvocation', + toolName: 'aspire_resource_debug', + input: { + appHostPath: 'unresolved/AppHost.csproj', + resourceName: 'api', + }, + })).result as { + invocationMessage?: string; + confirmationTitle?: string; + confirmationMessage?: string; + }; + + assert.deepStrictEqual(prepared, { + invocationMessage: 'Attaching debugger to the requested Aspire resource...', + confirmationTitle: 'Attach debugger to Aspire resource', + confirmationMessage: 'Attach the debugger to the requested Aspire resource?', + }); + }); + test('applies the shared CLI availability path to visible CLI-dependent package commands', async () => { await openAspireView(); await waitForRepositoryIdle(); @@ -511,6 +533,7 @@ const expectedActivationEvents = [ 'onCommand:aspire-vscode.verifyCliInstalled', 'onLanguageModelTool:aspire_apphost_start', 'onLanguageModelTool:aspire_apphost_stop', + 'onLanguageModelTool:aspire_resource_debug', ]; const expectedSourceLanguageModelTools = createExpectedLanguageModelTools({ @@ -524,6 +547,12 @@ const expectedSourceLanguageModelTools = createExpectedLanguageModelTools({ stopModelDescription: '%languageModelTool.aspireAppHostStop.modelDescription%', stopUserDescription: '%languageModelTool.aspireAppHostStop.userDescription%', appHostPathDescription: '%languageModelTool.aspireAppHost.appHostPath.description%', + resourceDebugDisplayName: '%languageModelTool.aspireResourceDebug.displayName%', + resourceDebugModelDescription: '%languageModelTool.aspireResourceDebug.modelDescription%', + resourceDebugUserDescription: '%languageModelTool.aspireResourceDebug.userDescription%', + resourceDebugAppHostPathDescription: '%languageModelTool.aspireResourceDebug.appHostPath.description%', + resourceDebugResourceNameDescription: '%languageModelTool.aspireResourceDebug.resourceName.description%', + resourceDebugStrategyDescription: '%languageModelTool.aspireResourceDebug.strategy.description%', }); const expectedInstalledLanguageModelTools = createExpectedLanguageModelTools({ @@ -537,6 +566,12 @@ const expectedInstalledLanguageModelTools = createExpectedLanguageModelTools({ stopModelDescription: 'Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Stop a running Aspire AppHost that Aspire has already discovered in the current workspace. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. AppHosts started by this editor stop through the coordinated debug lifecycle. AppHosts started outside the editor stop through \'aspire stop --apphost\' for the same discovered path. The extension never kills arbitrary processes. If it cannot determine whether the AppHost is running, the call fails rather than reporting that nothing is running.', stopUserDescription: 'Stop a running Aspire AppHost from this workspace.', appHostPathDescription: 'Workspace-relative path of an AppHost that Aspire has already discovered in this workspace, for example \'AppHost/AppHost.csproj\' or \'apphost.cs\'. The value must match one of the discovered AppHosts exactly; arbitrary paths, absolute paths, and files Aspire did not discover are rejected. In a multi-root workspace, always prefix the path with the workspace folder name (for example \'backend/AppHost/AppHost.csproj\').', + resourceDebugDisplayName: 'Debug Aspire resource', + resourceDebugModelDescription: 'Attach the VS Code debugger to a running Aspire resource that the extension has already discovered. Requires a workspace-relative AppHost path and the resource name. The default auto strategy currently attaches to the resource; start and restart under debug are not supported.', + resourceDebugUserDescription: 'Attach the debugger to a running Aspire resource.', + resourceDebugAppHostPathDescription: 'Workspace-relative path of an AppHost that Aspire has already discovered. Absolute paths and paths Aspire did not discover are rejected. In a multi-root workspace, prefix the path with the workspace folder name.', + resourceDebugResourceNameDescription: 'Name of a running resource from the selected AppHost. Resource names are limited to 256 characters.', + resourceDebugStrategyDescription: 'Debug strategy. auto selects the available safe action, currently attach. attach only attaches a debugger; starting and restarting resources are not supported.', }); function createExpectedLanguageModelTools(strings: { @@ -550,6 +585,12 @@ function createExpectedLanguageModelTools(strings: { stopModelDescription: string; stopUserDescription: string; appHostPathDescription: string; + resourceDebugDisplayName: string; + resourceDebugModelDescription: string; + resourceDebugUserDescription: string; + resourceDebugAppHostPathDescription: string; + resourceDebugResourceNameDescription: string; + resourceDebugStrategyDescription: string; }) { return [ { @@ -605,11 +646,44 @@ function createExpectedLanguageModelTools(strings: { additionalProperties: false, }, }, + { + name: 'aspire_resource_debug', + toolReferenceName: 'aspireDebugResource', + displayName: strings.resourceDebugDisplayName, + modelDescription: strings.resourceDebugModelDescription, + userDescription: strings.resourceDebugUserDescription, + icon: '$(debug-alt)', + canBeReferencedInPrompt: true, + when: 'isWorkspaceTrusted', + tags: ['aspire', 'debug', 'resource'], + inputSchema: { + type: 'object', + properties: { + appHostPath: { + type: 'string', + description: strings.resourceDebugAppHostPathDescription, + }, + resourceName: { + type: 'string', + description: strings.resourceDebugResourceNameDescription, + }, + strategy: { + type: 'string', + enum: ['auto', 'attach'], + default: 'auto', + description: strings.resourceDebugStrategyDescription, + }, + }, + required: ['appHostPath', 'resourceName'], + additionalProperties: false, + }, + }, ]; } const expectedCommandIds = [ 'aspire-vscode.add', + 'aspire-vscode.attachDebuggerToResource', 'aspire-vscode.codeLensDebugPipelineStep', 'aspire-vscode.codeLensOpenDashboard', 'aspire-vscode.codeLensResourceAction', @@ -715,6 +789,7 @@ const expectedViewItemContextCommands = [ 'aspire-vscode.stopResource', 'aspire-vscode.startResource', 'aspire-vscode.restartResource', + 'aspire-vscode.attachDebuggerToResource', 'aspire-vscode.executeResourceCommand', 'aspire-vscode.executeResourceCommandItem', 'aspire-vscode.viewResourceLogs', diff --git a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts new file mode 100644 index 00000000000..813d1b4fdc4 --- /dev/null +++ b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts @@ -0,0 +1,357 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { findResource, waitForCommandOutcome, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResourceState, waitForWorkspaceAppHost } from './helpers/assertions'; +import { executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; +import { invokeLanguageModelTool, prepareLanguageModelToolInvocation } from './helpers/languageModelTools'; +import { getPrimaryAppHostProjectPath, getWorkspaceRoot } from './helpers/paths'; +import { openAspireView } from './helpers/vscode'; + +interface ResourceDebugToolResult { + tool: 'aspire_resource_debug'; + success: boolean; + outcome: string; + appHost: string; + resourceName: string; + requestedStrategy: 'auto' | 'attach'; + effectiveStrategy: 'attach' | 'none'; + controller: 'editor' | 'none'; + provider?: 'dotnet' | 'go'; + debuggerExtensions?: Array<{ id: string; label: string }>; +} + +interface AttachedResourceDebugProof { + proof: 'aspire-resource-attach-breakpoint-detach'; + toolPayload: ResourceDebugToolResult; + resourceName: string; + debugType: 'coreclr' | 'go'; + breakpoint: { + sourcePath: string; + line: number; + text: string; + matchingStackFrame: { + source?: { path?: string }; + line?: number; + }; + }; + attachRequests: unknown[]; + breakpointResponses: Array<{ success?: boolean }>; + debugAdapterResponses: unknown[]; + resourceResponseAfterDetach: string; + sessionTerminated: boolean; +} + +const resourceDebugToolName = 'aspire_resource_debug'; +const resourceDebugPrerequisitesInstalled = process.env.ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG === 'true'; +const negativePathTest = resourceDebugPrerequisitesInstalled ? test.skip : test; + +// VS Code does not expose its telemetry transport to an Extension Host test, and the E2E bridge +// intentionally persists only bounded tool results. resourceDebugService.test.ts asserts the exact +// languageModelTool telemetry payload; this suite proves that source invokes the real registered tool. +suite('Aspire resource debug language model tool E2E', function () { + this.timeout(360000); + + teardown(async () => { + await runE2eTeardown([ + () => stopPrimaryAppHostIfRunning(), + () => waitForNoRunningAppHost(), + () => restoreWorkspaceCliPath(), + ], 'Resource debug language model tool E2E teardown failed.'); + }); + + negativePathTest('returns bounded results for invalid, additional, and unknown selectors after generic confirmation', async () => { + await openAspireView(); + await waitForRepositoryIdle(); + const discovered = await waitForWorkspaceAppHost(); + const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); + const relativeAppHostPath = toWorkspaceRelativePath(appHostPath); + + const unresolved = await prepareLanguageModelToolInvocation(resourceDebugToolName, { + appHostPath: 'missing/AppHost.csproj', + resourceName: 'e2e-worker', + }); + assert.deepStrictEqual(unresolved, { + invocationMessage: 'Attaching debugger to the requested Aspire resource...', + confirmationTitle: 'Attach debugger to Aspire resource', + confirmationMessage: 'Attach the debugger to the requested Aspire resource?', + }); + + const cases: Array<{ input: Record; outcome: string }> = [ + { + input: { + appHostPath: relativeAppHostPath, + resourceName: ' ', + }, + outcome: 'invalidInput', + }, + { + input: { + appHostPath: relativeAppHostPath, + resourceName: 'e2e-worker', + unexpected: 'value', + }, + outcome: 'invalidInput', + }, + { + input: { + appHostPath: 'missing/AppHost.csproj', + resourceName: 'e2e-worker', + }, + outcome: 'unknownAppHost', + }, + ]; + + for (const testCase of cases) { + const invocation = await invokeLanguageModelTool( + resourceDebugToolName, + testCase.input, + { expectedConfirmations: 1 }); + + assert.deepStrictEqual(invocation.dialogs[0], { + message: 'Attach debugger to Aspire resource', + details: 'Attach the debugger to the requested Aspire resource?', + }); + assert.strictEqual(invocation.results.length, 1); + assert.strictEqual(invocation.results[0].outcome, testCase.outcome); + assertSafeResourceDebugResult(invocation.results[0]); + } + }); + + negativePathTest('requires explicit confirmation and returns safe running-resource outcomes', async () => { + await openAspireView(); + await waitForRepositoryIdle(); + const discovered = await waitForWorkspaceAppHost(); + const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); + const relativeAppHostPath = toWorkspaceRelativePath(appHostPath); + + const runBefore = await executeE2eControlCommand({ name: 'runAppHost', appHostPath }, { waitFor: 'started' }); + assert.ok(runBefore.startedObserved); + await waitForCommandOutcome('aspire-vscode.runAppHost', 'success', 120000); + const running = await waitForResourceState('e2e-worker', ['Running'], 180000); + const worker = findResource(running.state, 'e2e-worker'); + assert.ok(worker); + + const prepared = await prepareLanguageModelToolInvocation(resourceDebugToolName, { + appHostPath: relativeAppHostPath, + resourceName: worker.name, + }); + assert.deepStrictEqual(prepared, { + invocationMessage: `Attaching debugger to Aspire resource ${worker.name}...`, + confirmationTitle: 'Attach debugger to Aspire resource', + confirmationMessage: `Attach the debugger to resource ${worker.name} from Aspire AppHost ${relativeAppHostPath}?`, + }); + + if (!resourceDebugPrerequisitesInstalled) { + const invocation = await invokeLanguageModelTool( + resourceDebugToolName, + { + appHostPath: relativeAppHostPath, + resourceName: worker.name, + }, + { expectedConfirmations: 1, screenshotName: 'resource-debug-confirmation' }); + + assert.deepStrictEqual(invocation.dialogs[0], { + message: 'Attach debugger to Aspire resource', + details: `Attach the debugger to resource ${worker.name} from Aspire AppHost ${relativeAppHostPath}?`, + }); + assert.deepStrictEqual(invocation.results, [{ + tool: resourceDebugToolName, + success: false, + outcome: 'debuggerExtensionMissing', + appHost: relativeAppHostPath, + resourceName: worker.name, + requestedStrategy: 'auto', + effectiveStrategy: 'none', + controller: 'none', + debuggerExtensions: [{ id: 'ms-dotnettools.csharp', label: 'C#' }], + }]); + assertSafeResourceDebugResult(invocation.results[0]); + } + + const missingResource = await invokeLanguageModelTool( + resourceDebugToolName, + { + appHostPath: relativeAppHostPath, + resourceName: 'missing-resource', + }, + { expectedConfirmations: 1 }); + assert.deepStrictEqual(missingResource.results, [{ + tool: resourceDebugToolName, + success: false, + outcome: 'resourceNotFound', + appHost: relativeAppHostPath, + resourceName: 'missing-resource', + requestedStrategy: 'auto', + effectiveStrategy: 'none', + controller: 'none', + }]); + assertSafeResourceDebugResult(missingResource.results[0]); + + const unsupportedResource = await invokeLanguageModelTool( + resourceDebugToolName, + { + appHostPath: relativeAppHostPath, + resourceName: 'e2e-no-commands', + }, + { expectedConfirmations: 1 }); + assert.deepStrictEqual(unsupportedResource.results, [{ + tool: resourceDebugToolName, + success: false, + outcome: 'unsupportedResource', + appHost: relativeAppHostPath, + resourceName: 'e2e-no-commands', + requestedStrategy: 'auto', + effectiveStrategy: 'none', + controller: 'none', + }]); + assertSafeResourceDebugResult(unsupportedResource.results[0]); + }); + + negativePathTest('cancels through the VS Code invocation token and reports a stopped resource without invoking a debugger', async () => { + await openAspireView(); + await waitForRepositoryIdle(); + const discovered = await waitForWorkspaceAppHost(); + const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); + const relativeAppHostPath = toWorkspaceRelativePath(appHostPath); + + await executeE2eControlCommand({ name: 'runAppHost', appHostPath }, { waitFor: 'started' }); + await waitForCommandOutcome('aspire-vscode.runAppHost', 'success', 120000); + const running = await waitForResourceState('e2e-worker', ['Running'], 180000); + const worker = findResource(running.state, 'e2e-worker'); + assert.ok(worker); + + const cancelled = await invokeLanguageModelTool( + resourceDebugToolName, + { + appHostPath: relativeAppHostPath, + resourceName: worker.name, + }, + { cancelAfterMs: 0, expectedConfirmations: 1 }); + assert.strictEqual(cancelled.cancelled, true); + assert.deepStrictEqual(cancelled.results, []); + assert.ok(cancelled.dialogs.length <= 1); + if (cancelled.dialogs.length === 1) { + assert.deepStrictEqual(cancelled.dialogs[0], { + message: 'Attach debugger to Aspire resource', + details: `Attach the debugger to resource ${worker.name} from Aspire AppHost ${relativeAppHostPath}?`, + }); + } + + await executeE2eControlCommand({ name: 'stopResource', appHostPath, resourceName: worker.name }); + await waitForResourceState(worker.name, ['Exited', 'Finished', 'Stopped'], 90000); + + const stopped = await invokeLanguageModelTool( + resourceDebugToolName, + { + appHostPath: relativeAppHostPath, + resourceName: worker.name, + strategy: 'attach', + }, + { expectedConfirmations: 1 }); + assert.strictEqual(stopped.results.length, 1); + assert.strictEqual(stopped.results[0].outcome, 'resourceNotRunning'); + assertSafeResourceDebugResult(stopped.results[0]); + }); + + test('attaches packaged .NET and Go debuggers, hits breakpoints, detaches, and tears down', async function () { + this.timeout(900000); + if (!resourceDebugPrerequisitesInstalled) { + this.skip(); + } + + await openAspireView(); + await waitForRepositoryIdle(); + const discovered = await waitForWorkspaceAppHost(); + const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); + + const start = (await executeE2eControlCommand({ + name: 'runAspireCli', + args: ['start', '--apphost', appHostPath, '--format', 'json', '--non-interactive', '--nologo'], + workingDirectory: '.', + timeoutMs: 180000, + noExtensionVariables: true, + }, { timeoutMs: 210000 })).result as { exitCode: number | null; stdout: string; stderr: string }; + assert.strictEqual(start.exitCode, 0, `aspire start failed.\nstdout:\n${start.stdout}\nstderr:\n${start.stderr}`); + const workerRunning = await waitForResourceState('e2e-worker', ['Running'], 180000); + const worker = findResource(workerRunning.state, 'e2e-worker'); + assert.ok(worker); + const goRunning = await waitForResourceState('e2e-go', ['Running'], 180000); + const go = findResource(goRunning.state, 'e2e-go'); + assert.ok(go); + + const scenarios = [ + { + resourceName: worker.name, + debugType: 'coreclr' as const, + sourcePath: path.join(getWorkspaceRoot(), 'AspireE2E.Worker', 'Program.cs'), + marker: 'app.MapGet("/", () => "ok");', + expectedResponse: 'ok', + }, + { + resourceName: go.name, + debugType: 'go' as const, + sourcePath: path.join(getWorkspaceRoot(), 'AspireE2E.Go', 'main.go'), + marker: 'message := "go-ok"', + expectedResponse: 'go-ok', + }, + ]; + + for (const scenario of scenarios) { + const proof = (await executeE2eControlCommand({ + name: 'proveAttachedResourceDebugging', + appHostPath, + resourceName: scenario.resourceName, + sourcePath: scenario.sourcePath, + breakpointLine: findBreakpointLine(scenario.sourcePath, scenario.marker), + expectedDebugType: scenario.debugType, + expectedResponse: scenario.expectedResponse, + timeoutMs: 300000, + }, { timeoutMs: 360000 })).result as AttachedResourceDebugProof; + + assert.strictEqual(proof.proof, 'aspire-resource-attach-breakpoint-detach'); + assert.strictEqual(proof.toolPayload.outcome, 'started'); + assert.strictEqual(proof.toolPayload.provider, scenario.debugType === 'coreclr' ? 'dotnet' : 'go'); + assertSafeResourceDebugResult(proof.toolPayload); + assert.strictEqual(proof.resourceName, scenario.resourceName); + assert.strictEqual(proof.debugType, scenario.debugType); + assert.strictEqual(proof.breakpoint.matchingStackFrame.line, proof.breakpoint.line); + assert.ok(isSamePath(proof.breakpoint.matchingStackFrame.source?.path, scenario.sourcePath)); + assert.ok(proof.attachRequests.length > 0); + assert.ok(proof.breakpointResponses.some(response => response.success === true)); + assert.deepStrictEqual(proof.debugAdapterResponses, []); + assert.strictEqual(proof.resourceResponseAfterDetach, scenario.expectedResponse); + assert.strictEqual(proof.sessionTerminated, true); + } + + await stopPrimaryAppHostIfRunning(); + await waitForNoDebugSessions(120000); + await waitForNoRunningAppHost(120000, appHostPath); + }); +}); + +function toWorkspaceRelativePath(filePath: string): string { + const relativePath = path.relative(getWorkspaceRoot(), filePath); + assert.ok(relativePath.length > 0 && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)); + return relativePath.split(path.sep).join('/'); +} + +function assertSafeResourceDebugResult(result: ResourceDebugToolResult): void { + const serialized = JSON.stringify(result); + assert.deepStrictEqual(JSON.parse(serialized), result); + assert.ok(!path.isAbsolute(result.appHost)); + assert.doesNotMatch(serialized, /(?:pid|process|configuration|arguments?|args|environment|env|secret|token|executable)|https?:\/\/|\/(?:Users|private|var|tmp)\b/i); +} + +function findBreakpointLine(sourcePath: string, marker: string): number { + const lines = fs.readFileSync(sourcePath, 'utf8').split(/\r?\n/); + const index = lines.findIndex(line => line.includes(marker)); + if (index < 0) { + throw new Error(`Could not find '${marker}' in ${sourcePath} to place a breakpoint on.`); + } + + return index; +} + +function isSamePath(left: string | undefined, right: string): boolean { + return left !== undefined && path.resolve(left) === path.resolve(right); +} diff --git a/extension/src/test/adapterTracker.test.ts b/extension/src/test/adapterTracker.test.ts index 4e3c8ed24e0..53139510c06 100644 --- a/extension/src/test/adapterTracker.test.ts +++ b/extension/src/test/adapterTracker.test.ts @@ -291,6 +291,46 @@ suite('Debug Adapter Tracker Tests', () => { disposable.dispose(); }); + test('reports the debuggee process for non-AppHost sessions', () => { + const processHandler = sinon.spy(); + const disposable = createDebugAdapterTracker(dcpServer as any, 'coreclr', undefined, processHandler); + const factory = registerFactoryStub.lastCall.args[1]; + const tracker = factory.createDebugAdapterTracker(debugSession); + + tracker.onDidSendMessage({ + type: 'event', + event: 'process', + body: { systemProcessId: 4242 } + }); + + assert.strictEqual(processHandler.calledOnceWithExactly(debugSession, 4242), true); + disposable.dispose(); + }); + + test('clears the tracked debuggee process on missing restart PIDs and exit', () => { + const processHandler = sinon.spy(); + const disposable = createDebugAdapterTracker(dcpServer as any, 'coreclr', undefined, processHandler); + const factory = registerFactoryStub.lastCall.args[1]; + const tracker = factory.createDebugAdapterTracker(debugSession); + + tracker.onDidSendMessage({ + type: 'event', + event: 'process', + body: {} + }); + tracker.onDidSendMessage({ + type: 'event', + event: 'exited', + body: { exitCode: 0 } + }); + + assert.deepStrictEqual(processHandler.getCalls().map(call => call.args), [ + [debugSession, undefined], + [debugSession, undefined], + ]); + disposable.dispose(); + }); + test('process event without a system process ID still resets a captured exit code', async () => { const disposable = createDebugAdapterTracker(dcpServer as any, 'coreclr'); const factory = registerFactoryStub.lastCall.args[1]; diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index d1eb7c30b9e..3cf80deeeaa 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -12,7 +12,7 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { AppHostDiscoveryService, type CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import * as cliModule from '../utils/process/cliProcess'; import * as configInfoProvider from '../utils/configInfoProvider'; -import { describeIncludeDisabledCommandsCapability, lsJsonStreamCapability } from '../types/configInfo'; +import { describeAppHostPidCapability, describeIncludeDisabledCommandsCapability, lsJsonStreamCapability } from '../types/configInfo'; import { errorFetchingAppHosts } from '../loc/strings'; import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; @@ -97,7 +97,7 @@ suite('AppHostDataRepository', () => { // Default to a current CLI so common-path tests use streamed discovery and include disabled // commands in describe output. Compatibility tests override this response explicitly. getConfigInfoStub = sinon.stub(configInfoProvider.ConfigInfoProvider.prototype, 'getConfigInfo').resolves({ - capabilities: [describeIncludeDisabledCommandsCapability, lsJsonStreamCapability], + capabilities: [describeAppHostPidCapability, describeIncludeDisabledCommandsCapability, lsJsonStreamCapability], } as any); defaultWorkspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined); findFilesStub = sinon.stub(vscode.workspace, 'findFiles').resolves([]); @@ -1427,6 +1427,43 @@ suite('AppHostDataRepository', () => { } }); + test('fetchAppHostResourcesOnce describes one AppHost process with the caller cancellation token', async () => { + const describeProcess = new TestChildProcess(); + spawnStub.onFirstCall().returns(describeProcess); + const repository = new AppHostDataRepository(terminalProvider); + const cancellation = new vscode.CancellationTokenSource(); + + try { + const fetchPromise = repository.fetchAppHostResourcesOnce('/workspace/AppHost.csproj', cancellation.token, 1234); + await waitForMicrotasks(); + + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['describe', '--format', 'json', '--nologo', '--apphost', '/workspace/AppHost.csproj', '--apphost-pid', '1234']); + cancellation.cancel(); + + await assert.rejects(fetchPromise, vscode.CancellationError); + assert.strictEqual(describeProcess.killed, true); + } finally { + cancellation.dispose(); + repository.dispose(); + } + }); + + test('fetchAppHostResourcesOnce fails before describe when the CLI cannot bind an AppHost process', async () => { + getConfigInfoStub.resolves({ + capabilities: [describeIncludeDisabledCommandsCapability, lsJsonStreamCapability], + }); + const repository = new AppHostDataRepository(terminalProvider); + + try { + await assert.rejects( + repository.fetchAppHostResourcesOnce('/workspace/AppHost.csproj', undefined, 1234), + /cannot bind resource snapshots to an AppHost process/); + assert.strictEqual(spawnStub.called, false); + } finally { + repository.dispose(); + } + }); + test('fetchAppHostsOnce retries without nologo when an older CLI rejects it', async () => { const rejectedPsProcess = new TestChildProcess(); const psProcess = new TestChildProcess(); diff --git a/extension/src/test/appHostLifecycleTools.test.ts b/extension/src/test/appHostLifecycleTools.test.ts index 9f24cfb26e8..5dd36608577 100644 --- a/extension/src/test/appHostLifecycleTools.test.ts +++ b/extension/src/test/appHostLifecycleTools.test.ts @@ -21,6 +21,7 @@ import { type AppHostLifecycleRunningAppHost, type AppHostLifecycleToolResult, } from '../lm/appHostLifecycleTools'; +import { AppHostTargetResolverService } from '../lm/appHostTargetResolverService'; import { AppHostLifecycleLockTimeoutError, AppHostStopCancellationError, AppHostStopError, type AppHostStopResult } from '../services/AppHostLaunchService'; import { type CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import { compareAppHostIdentity, type AppHostIdentityRelation } from '../utils/appHostIdentity'; @@ -288,7 +289,10 @@ class FakeDiscoveryService implements AppHostLifecycleDiscoveryService { return this.registeredPaths .filter(candidatePath => { const relative = path.relative(folderPath, candidatePath); - return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative); + return relative.length > 0 && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative); }) .map(candidatePath => ({ path: candidatePath, language: 'csharp', status: 'buildable' })); } @@ -385,9 +389,10 @@ suite('AppHost lifecycle language model tools', () => { discoveryService = new FakeDiscoveryService(); discoveryService.registeredPaths.push(appHostProjectPath); editorSessions = []; + const targetResolver = new AppHostTargetResolverService({ discoveryService }); service = new AppHostLifecycleToolService({ launchService, - discoveryService, + targetResolver, }); launchService.editorSessions = editorSessions; }); @@ -410,9 +415,12 @@ suite('AppHost lifecycle language model tools', () => { const packageNls = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.nls.json'), 'utf8')) as Record; const tools = manifest.contributes.languageModelTools ?? []; - assert.deepStrictEqual(tools.map(tool => tool.name), [aspireAppHostStartToolName, aspireAppHostStopToolName]); + const lifecycleTools = tools.filter(tool => + tool.name === aspireAppHostStartToolName || + tool.name === aspireAppHostStopToolName); + assert.deepStrictEqual(lifecycleTools.map(tool => tool.name), [aspireAppHostStartToolName, aspireAppHostStopToolName]); - for (const tool of tools) { + for (const tool of lifecycleTools) { for (const localizedField of ['displayName', 'modelDescription', 'userDescription']) { const reference = tool[localizedField] as string; assert.match(reference, /^%[\w.-]+%$/, `${tool.name}.${localizedField} must be a package.nls reference.`); @@ -570,6 +578,18 @@ suite('AppHost lifecycle language model tools', () => { assert.strictEqual(discoveryService.discoverCalls, 0); }); + test('rejects line and paragraph separators before consulting the AppHost registry', async () => { + for (const separator of ['\u2028', '\u2029']) { + const result = await service.start( + { appHostPath: `AppHost${separator}/AppHost.csproj`, mode: 'run' }, + new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'invalidInput'); + assert.strictEqual(discoveryService.discoverCalls, 0); + assert.strictEqual(launchService.launchCalls.length, 0); + } + }); + test('rejects a non-boolean isolated property before consulting the AppHost registry', async () => { const result = await service.start({ appHostPath: 'AppHost/AppHost.csproj', @@ -837,6 +857,19 @@ suite('AppHost lifecycle language model tools', () => { assert.strictEqual(result.appHostPath, 'SingleFile/apphost.cs'); }); + test('accepts an in-workspace directory whose name begins with two dots', async () => { + const directory = path.join(workspaceRoot, '..services'); + fs.mkdirSync(directory, { recursive: true }); + const project = path.join(directory, 'AppHost.csproj'); + fs.writeFileSync(project, appHostProjectContents); + discoveryService.registeredPaths.push(project); + + const result = await service.start({ appHostPath: '..services/AppHost.csproj', mode: 'run' }, new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'started'); + assert.strictEqual(result.appHostPath, '..services/AppHost.csproj'); + }); + test('treats a symlinked AppHost as the AppHost it points at', async function () { const directory = path.join(workspaceRoot, 'Symlinked'); fs.mkdirSync(directory, { recursive: true }); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 2a2c10883da..eea82dedc40 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -5,6 +5,10 @@ import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; +import * as capabilities from '../capabilities'; +import { projectResourceAttachProvider } from '../debugger/languages/dotnet'; +import { goResourceAttachProvider } from '../debugger/languages/go'; +import type { ResourceDebugger, ResourceDebugRequest, ResourceDebugResult } from '../debugger/resourceDebugContracts'; import * as cliModule from '../utils/process/cliProcess'; import * as cliPathModule from '../utils/cliPath'; import * as configInfoProvider from '../utils/configInfoProvider'; @@ -15,6 +19,7 @@ import { AppHostDataRepository, shortenPath, shortenPaths } from '../data/AppHos import { AspireCliFailedError } from '../data/appHostCliContracts'; import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; import { getResourceContextValue, getResourceIcon, getResourceCommandIcon, resolveAppHostSourcePath, buildResourceDescription } from '../views/treePresentation'; +import { ResourceItem } from '../views/treeItems/resourceItems'; import { AppHostItem, WorkspaceAppHostItem, WorkspaceResourcesItem } from '../views/treeItems'; import type { Clipboard } from '../views/AspireAppHostTreeProvider'; import type { AppHostDisplayInfo, ResourceJson, ViewMode } from '../data/AppHostDataRepository'; @@ -54,6 +59,15 @@ function makeResource(overrides: Partial = {}): ResourceJson { return { ...base, ...overrides } as ResourceJson; } +function makeAttachableProjectProperties(overrides: Record = {}): ResourceJson['properties'] { + return { + 'executable.pid': '4242', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/api.csproj', + ...overrides, + }; +} + function buildPath(...segments: string[]): string { return path.join(...segments); } @@ -75,6 +89,15 @@ function makeLaunchService(): AppHostLaunchService { }); } +function makeResourceDebugger(result: ResourceDebugResult = { outcome: 'started', providerId: 'dotnet' }): ResourceDebugger { + return { + debug: async () => result, + canAttachToResource: resource => + projectResourceAttachProvider.canAttachToResource(resource) + && capabilities.isExtensionInstalled('ms-dotnettools.csharp'), + }; +} + function makeTerminalProvider(): AspireTerminalProvider { return { resolveAspireCliPath: async () => ({ cliPath: 'aspire', available: true, source: 'path' }), @@ -99,7 +122,12 @@ function makeClipboard(): FakeClipboard { }; } -function makeTreeProvider(appHosts: readonly AppHostDisplayInfo[], viewMode: ViewMode = 'global', workspaceAppHostDescription?: string): AspireAppHostTreeProvider { +function makeTreeProvider( + appHosts: readonly AppHostDisplayInfo[], + viewMode: ViewMode = 'global', + workspaceAppHostDescription?: string, + resourceDebugger: ResourceDebugger = makeResourceDebugger(), +): AspireAppHostTreeProvider { const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); const repository = { viewMode, @@ -110,9 +138,17 @@ function makeTreeProvider(appHosts: readonly AppHostDisplayInfo[], viewMode: Vie workspaceAppHostName: undefined, workspaceAppHostDescription, onDidChangeData, + fetchAppHostsOnce: async () => appHosts, } as unknown as AppHostDataRepository; - return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), resourceDebugger); +} + +function getFirstResourceItem(provider: AspireAppHostTreeProvider): any { + const [appHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + return provider.getChildren(resourcesGroup)[0]; } function getResourceCommandItems(provider: AspireAppHostTreeProvider): readonly vscode.TreeItem[] { @@ -139,7 +175,7 @@ function makeTreeProviderWithLaunchService(appHosts: readonly AppHostDisplayInfo onDidChangeData, } as unknown as AppHostDataRepository; - return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); } function makeWorkspaceTreeProvider(workspaceAppHostDescription: string): AspireAppHostTreeProvider { @@ -155,7 +191,7 @@ function makeWorkspaceTreeProvider(workspaceAppHostDescription: string): AspireA onDidChangeData, } as unknown as AppHostDataRepository; - return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); } function registerTreeCommandCallbacks( @@ -477,7 +513,7 @@ suite('AspireAppHostTreeProvider', () => { onDidChangeVisibility: visibilityEmitter.event, reveal, } as unknown as Parameters[0]; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); provider.setTreeView(treeView); dataEmitter.fire(); @@ -554,7 +590,7 @@ suite('AspireAppHostTreeProvider', () => { } as unknown as AppHostDataRepository; const launchService = makeLaunchService(); const stopStub = sandbox.stub(launchService, 'stopAppHost').resolves({ outcome: 'stopped', controller: 'external' }); - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); const [item] = provider.getChildren(); provider.stopAppHost(item as any); @@ -583,7 +619,7 @@ suite('AspireAppHostTreeProvider', () => { requestAppHostStopRefresh, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); provider.notifyAppHostStopping(appHostPath); @@ -610,7 +646,7 @@ suite('AspireAppHostTreeProvider', () => { requestAppHostStopRefresh, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); provider.notifyAppHostStopping(appHostPath, false); @@ -646,7 +682,7 @@ suite('AspireAppHostTreeProvider', () => { requestAppHostStopRefresh: sandbox.stub(), onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); try { provider.notifyAppHostStopping(upperCasePath); @@ -681,7 +717,7 @@ suite('AspireAppHostTreeProvider', () => { sandbox.stub(launchService, 'stopAppHost').returns(new Promise(resolve => { resolveStop = () => resolve({ outcome: 'stopped', controller: 'external' }); })); - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); const [item] = provider.getChildren(); const stopTask = provider.stopAppHost(item as any); @@ -719,7 +755,7 @@ suite('AspireAppHostTreeProvider', () => { } as unknown as AppHostDataRepository; const launchService = makeLaunchService(); sandbox.stub(launchService, 'stopAppHost').resolves(result); - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); const [item] = provider.getChildren(); await provider.stopAppHost(item as any); @@ -748,7 +784,7 @@ suite('AspireAppHostTreeProvider', () => { requestAppHostStopRefresh, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); provider.notifyAppHostStopping(appHostPath); @@ -777,7 +813,7 @@ suite('AspireAppHostTreeProvider', () => { requestAppHostStopRefresh, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); provider.notifyAppHostStopping(workspaceRoot); @@ -807,7 +843,7 @@ suite('AspireAppHostTreeProvider', () => { requestAppHostStopRefresh, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); provider.notifyAppHostStopping(appHostPath); provider.notifyAppHostStopping(unknownAppHostPath); @@ -838,7 +874,7 @@ suite('AspireAppHostTreeProvider', () => { } as unknown as AppHostDataRepository; const launchService = makeLaunchService(); const stopStub = sandbox.stub(launchService, 'stopAppHost').resolves({ outcome: 'stopped', controller: 'external' }); - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); const [item] = provider.getChildren(); provider.stopAppHost(item as any); @@ -868,7 +904,7 @@ suite('AspireAppHostTreeProvider', () => { } as unknown as AppHostDataRepository; const launchService = makeLaunchService(); const stopStub = sandbox.stub(launchService, 'stopAppHost').resolves({ outcome: 'stopped', controller: 'external' }); - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); const [item] = provider.getChildren(); provider.stopAppHost(item as any); @@ -904,7 +940,7 @@ suite('AspireAppHostTreeProvider', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, proofTerminalProvider.terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, proofTerminalProvider.terminalProvider, makeLaunchService(), makeResourceDebugger()); try { const [workspaceItem] = provider.getChildren(); @@ -948,7 +984,7 @@ suite('AspireAppHostTreeProvider', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, proofTerminalProvider.terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, proofTerminalProvider.terminalProvider, makeLaunchService(), makeResourceDebugger()); try { const [workspaceItem] = provider.getChildren(); @@ -987,7 +1023,7 @@ suite('AspireAppHostTreeProvider', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, proofTerminalProvider.terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, proofTerminalProvider.terminalProvider, makeLaunchService(), makeResourceDebugger()); try { const [workspaceItem] = provider.getChildren(); @@ -1026,7 +1062,7 @@ suite('AspireAppHostTreeProvider', () => { workspaceAppHostDescription: undefined, onDidChangeData: changeEmitter.event, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [item] = provider.getChildren(); provider.stopAppHost(item as any); @@ -1412,7 +1448,7 @@ suite('AspireAppHostTreeProvider', () => { return { stdout: '', stderr: '' }; }, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService(), makeResourceDebugger()); const infoStub = sandbox.stub(vscode.window, 'showInformationMessage'); const [commandItem] = getResourceCommandItems(provider); @@ -1454,7 +1490,7 @@ suite('AspireAppHostTreeProvider', () => { throw new Error('resource command failed'); }, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService(), makeResourceDebugger()); const errorStub = sandbox.stub(vscode.window, 'showErrorMessage'); const [commandItem] = getResourceCommandItems(provider); @@ -1496,7 +1532,7 @@ suite('AspireAppHostTreeProvider', () => { throw new Error(`${commandName} failed`); }, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); try { const [appHostItem] = provider.getChildren(); @@ -1846,6 +1882,88 @@ suite('getResourceContextValue', () => { assert.strictEqual(result, 'resource:canRestart:canOpenTerminal'); }); + test('running .NET project with a process ID includes attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), true); + assert.strictEqual(result, 'resource:canAttachDebugger'); + }); + + test('running resource with a numeric process ID includes provider-approved attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Running, + properties: { + ...makeAttachableProjectProperties(), + 'executable.pid': 4242, + } as unknown as ResourceJson['properties'], + }), true); + assert.strictEqual(result, 'resource:canAttachDebugger'); + }); + + test('running non-Project resource includes provider-approved attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'GoExecutable', + state: ResourceState.Running, + }), true); + assert.strictEqual(result, 'resource:canAttachDebugger'); + }); + + test('running provider-approved Go resource includes attach debugger tree context', () => { + const resource = makeResource({ + resourceType: 'Executable', + state: ResourceState.Running, + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go', + 'executable.pid': '4242', + }, + }); + const resourceDebugger: ResourceDebugger = { + debug: async () => ({ outcome: 'started', providerId: 'go' }), + canAttachToResource: candidate => goResourceAttachProvider.canAttachToResource(candidate), + }; + const provider = makeTreeProvider([ + makeAppHost({ resources: [resource] }), + ], 'global', undefined, resourceDebugger); + + try { + assert.strictEqual(getFirstResourceItem(provider).contextValue, 'resource:canAttachDebugger'); + } + finally { + provider.dispose(); + } + }); + + test('project without provider approval does not include attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ 'executable.pid': null }), + }), false); + assert.strictEqual(result, 'resource'); + }); + + test('uses provider attachment approval without checking resource state', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Finished, + properties: makeAttachableProjectProperties(), + }), true); + assert.strictEqual(result, 'resource:canAttachDebugger'); + }); + + test('running .NET project excludes attach debugger context without C# support', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), false); + assert.strictEqual(result, 'resource'); + }); + test('resource with disabled lifecycle command has base context only', () => { const result = getResourceContextValue(makeResource({ commands: { 'start': { displayName: null, description: null, state: 'Disabled' } }, @@ -2265,7 +2383,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const result = provider.findAppHostElement(hostPath); @@ -2285,7 +2403,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const result = provider.findAppHostElement('/repo/AppHost/AppHost.cs'); @@ -2304,7 +2422,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); // A single AppHost is surfaced directly at the root with no "Workspace AppHosts" // grouping node (https://github.com/microsoft/aspire/issues/18420). @@ -2359,7 +2477,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const topLevel = provider.getChildren(); assert.strictEqual(topLevel.length, 1); @@ -2392,7 +2510,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); try { const [group] = provider.getChildren(); @@ -2427,7 +2545,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); try { const topLevelItems = provider.getChildren(); @@ -2457,7 +2575,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); assert.deepStrictEqual(provider.getChildren(), []); provider.dispose(); @@ -2488,7 +2606,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); // A single launching AppHost is surfaced directly at the root with no grouping node. const [item] = provider.getChildren(); @@ -2517,7 +2635,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const topLevelItems = provider.getChildren(); @@ -2552,7 +2670,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const topLevelItems = provider.getChildren(); @@ -2583,7 +2701,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const topLevelItems = provider.getChildren(); @@ -2626,7 +2744,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); // A single candidate is surfaced directly at the root (no grouping node); pass it to runAppHost. const [item] = provider.getChildren(); @@ -2681,7 +2799,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { globalSettingsSchema: { properties: [] }, capabilities: [pipelineInteractionCapability], }); - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, makeResourceDebugger()); const callbacks = registerTreeCommandCallbacks(sandbox, provider, repository); const [workspaceAppHostsGroup] = provider.getChildren(); await waitForCondition( @@ -2761,7 +2879,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const globalProvider = new AspireAppHostTreeProvider(globalRepository, terminalProvider, launchService); + const globalProvider = new AspireAppHostTreeProvider(globalRepository, terminalProvider, launchService, makeResourceDebugger()); const [appHostItem] = globalProvider.getChildren(); assert.ok(appHostItem instanceof AppHostItem); const workspaceResourcesRepository = { @@ -2775,7 +2893,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const workspaceResourcesProvider = new AspireAppHostTreeProvider(workspaceResourcesRepository, terminalProvider, launchService); + const workspaceResourcesProvider = new AspireAppHostTreeProvider(workspaceResourcesRepository, terminalProvider, launchService, makeResourceDebugger()); const [workspaceResourcesItem] = workspaceResourcesProvider.getChildren(); assert.ok(workspaceResourcesItem instanceof WorkspaceResourcesItem); const workspaceAppHostRepository = { @@ -2788,7 +2906,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const workspaceAppHostProvider = new AspireAppHostTreeProvider(workspaceAppHostRepository, terminalProvider, launchService); + const workspaceAppHostProvider = new AspireAppHostTreeProvider(workspaceAppHostRepository, terminalProvider, launchService, makeResourceDebugger()); const [workspaceAppHostItem] = workspaceAppHostProvider.getChildren(); assert.ok(workspaceAppHostItem instanceof WorkspaceAppHostItem); @@ -2851,7 +2969,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }); sandbox.stub(vscode.window, 'showInputBox').resolves(undefined); const showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, makeResourceDebugger()); const callbacks = registerTreeCommandCallbacks(sandbox, provider, repository); const [appHostItem] = provider.getChildren(); @@ -2899,7 +3017,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { const launchStub = sandbox.stub(launchService, 'launch').rejects(launchError); sandbox.stub(configInfoProvider.ConfigInfoProvider.prototype, 'getCapabilityStatus').resolves('supported'); const showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, makeResourceDebugger()); const callbacks = registerTreeCommandCallbacks(sandbox, provider, repository); const [appHostItem] = provider.getChildren(); @@ -2939,7 +3057,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, makeResourceDebugger()); const callbacks = registerTreeCommandCallbacks(sandbox, provider, repository); const [appHostItem] = provider.getChildren(); @@ -2974,7 +3092,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); const [item] = provider.getChildren(); await assert.rejects(provider.runAppHost(item as any, false), /startDebugging blew up/); @@ -2986,6 +3104,495 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); + test('attachDebuggerToResource delegates to the injected debug service', async () => { + let request: unknown; + const resourceDebugger: ResourceDebugger = { + debug: async value => { + request = value; + return { outcome: 'started', providerId: 'dotnet' }; + }, + canAttachToResource: () => true, + }; + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ], 'global', undefined, resourceDebugger); + + await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); + + const debugRequest = request as ResourceDebugRequest; + assert.strictEqual(debugRequest.source, 'tree'); + assert.strictEqual(debugRequest.appHost.absolutePath, '/test/AppHost.csproj'); + assert.strictEqual(debugRequest.resourceName, 'api'); + provider.dispose(); + }); + + test('attachDebuggerToResource rejects Restricted Mode before shared debugger work', async () => { + sandbox.stub(vscode.workspace, 'isTrusted').value(false); + const debug = sinon.stub().rejects(new Error('restricted workspaces must not invoke the shared debugger')); + const withProgress = sandbox.stub(vscode.window, 'withProgress'); + const warning = sandbox.stub(vscode.window, 'showWarningMessage'); + const resourceDebugger: ResourceDebugger = { + debug, + canAttachToResource: () => true, + }; + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ], 'global', undefined, resourceDebugger); + + const result = await provider.attachDebuggerToResource(getFirstResourceItem(provider)); + + assert.deepStrictEqual(result, { success: false, errorKind: 'ResourceNotAttachable' }); + assert.ok(debug.notCalled); + assert.ok(withProgress.notCalled); + assert.ok(warning.calledOnce); + assert.strictEqual(warning.firstCall.args[0], 'Trust this workspace before attaching a debugger to an Aspire resource.'); + provider.dispose(); + }); + + test('attachDebuggerToResource passes the workspace AppHost path to the debug service', async () => { + let request: ResourceDebugRequest | undefined; + const appHostPath = '/workspace/apps/Store/AppHost.csproj'; + const resourceDebugger: ResourceDebugger = { + debug: async value => { + request = value; + return { outcome: 'started', providerId: 'dotnet' }; + }, + canAttachToResource: () => true, + }; + const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); + const repository = { + viewMode: 'workspace' as ViewMode, + appHosts: [], + workspaceResources: [ + makeResource({ + name: 'api', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + workspaceAppHost: makeAppHost({ appHostPath }), + workspaceAppHostPath: appHostPath, + workspaceAppHostCandidatePaths: [appHostPath], + workspaceAppHostName: 'AppHost.csproj', + onDidChangeData, + } as unknown as AppHostDataRepository; + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), resourceDebugger); + + const [workspaceAppHost] = provider.getChildren(); + const [resourceItem] = provider.getChildren(workspaceAppHost); + await (provider as any).attachDebuggerToResource(resourceItem); + + assert.ok(request); + assert.strictEqual(request.appHost.absolutePath, appHostPath); + assert.strictEqual(request.appHost.displayPath, vscode.workspace.asRelativePath(appHostPath)); + assert.strictEqual(request.appHost.appHostPid, 1234); + provider.dispose(); + }); + + test('attachDebuggerToResource shows cancellable progress and reports an active debugger', async () => { + const progressToken = new vscode.CancellationTokenSource(); + const withProgressStub = sandbox.stub(vscode.window, 'withProgress').callsFake(async (options, task) => { + assert.strictEqual(options.cancellable, true); + await task({ report: () => { } }, progressToken.token); + }); + const informationStub = sandbox.stub(vscode.window, 'showInformationMessage'); + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ], 'global', undefined, makeResourceDebugger({ outcome: 'alreadyDebugging' })); + + try { + await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); + + assert.ok(withProgressStub.calledOnce); + assert.ok(informationStub.calledOnce); + } + finally { + progressToken.dispose(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource preserves the owning AppHost for duplicate resource names', async () => { + let request: unknown; + const resourceDebugger: ResourceDebugger = { + debug: async value => { + request = value; + return { outcome: 'started', providerId: 'dotnet' }; + }, + canAttachToResource: () => true, + }; + const appHosts = [ + makeAppHost({ + appHostPath: '/repo/first/AppHost.csproj', + appHostPid: 1111, + resources: [ + makeResource({ + name: 'api', + displayName: 'First API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ 'executable.pid': '111' }), + }), + ], + }), + makeAppHost({ + appHostPath: '/repo/second/AppHost.csproj', + // The resource tree already knows which AppHost rendered the resource. The attach + // adapter must preserve that path instead of looking the owner up from a PID. + appHostPid: 1111, + resources: [ + makeResource({ + name: 'api', + displayName: 'Second API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ 'executable.pid': '222' }), + }), + ], + }), + ]; + const provider = makeTreeProvider(appHosts, 'global', undefined, resourceDebugger); + const secondAppHostItem = provider.getChildren()[1]; + const resourcesGroup = provider.getChildren(secondAppHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group for the second AppHost'); + const secondResourceItem = provider.getChildren(resourcesGroup)[0]; + + await (provider as any).attachDebuggerToResource(secondResourceItem); + + const debugRequest = request as ResourceDebugRequest; + assert.strictEqual(debugRequest.appHost.absolutePath, '/repo/second/AppHost.csproj'); + assert.strictEqual(debugRequest.resourceName, 'api'); + provider.dispose(); + }); + + test('global AppHost snapshots with the same path have stable process-specific tree IDs', async () => { + let request: unknown; + const resourceDebugger: ResourceDebugger = { + debug: async value => { + request = value; + return { outcome: 'started', providerId: 'dotnet' }; + }, + canAttachToResource: () => true, + }; + const appHostPath = '/repo/AppHost.csproj'; + const provider = makeTreeProvider([ + makeAppHost({ + appHostPath, + appHostPid: 1111, + resources: [ + makeResource({ + name: 'api', + displayName: 'Previous API', + properties: makeAttachableProjectProperties({ 'executable.pid': '111' }), + }), + ], + }), + makeAppHost({ + appHostPath, + appHostPid: 2222, + resources: [ + makeResource({ + name: 'api', + displayName: 'Current API', + properties: makeAttachableProjectProperties({ 'executable.pid': '222' }), + }), + ], + }), + ], 'global', undefined, resourceDebugger); + + const [firstAppHostItem, secondAppHostItem] = provider.getChildren(); + const firstResourcesGroup = provider.getChildren(firstAppHostItem).find(item => item.contextValue === 'resourcesGroup'); + const secondResourcesGroup = provider.getChildren(secondAppHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(firstResourcesGroup); + assert.ok(secondResourcesGroup); + const [firstResourceItem] = provider.getChildren(firstResourcesGroup); + const [secondResourceItem] = provider.getChildren(secondResourcesGroup); + + assert.notStrictEqual(firstResourcesGroup.id, secondResourcesGroup.id); + assert.notStrictEqual(firstResourceItem.id, secondResourceItem.id); + + const refreshedGroups = provider.getChildren() + .map(appHostItem => provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup')); + const refreshedResourceIds = refreshedGroups.map(resourcesGroup => { + assert.ok(resourcesGroup); + return provider.getChildren(resourcesGroup)[0].id; + }); + assert.deepStrictEqual(refreshedGroups.map(resourcesGroup => resourcesGroup?.id), [firstResourcesGroup.id, secondResourcesGroup.id]); + assert.deepStrictEqual(refreshedResourceIds, [firstResourceItem.id, secondResourceItem.id]); + + await (provider as any).attachDebuggerToResource(secondResourceItem); + + const debugRequest = request as ResourceDebugRequest; + assert.strictEqual(debugRequest.appHost.absolutePath, appHostPath); + assert.strictEqual(debugRequest.appHost.appHostPid, 2222); + assert.strictEqual(debugRequest.resourceName, 'api'); + + const workspaceResourceItem = new ResourceItem(makeResource({ name: 'workspace-api' }), null, false, undefined, appHostPath); + assert.ok(workspaceResourceItem.id?.includes(':workspace:')); + provider.dispose(); + }); + + test('attachDebuggerToResource passes progress cancellation to the debug service without a warning', async () => { + const cancellation = new vscode.CancellationTokenSource(); + let receivedToken: vscode.CancellationToken | undefined; + const resourceDebugger: ResourceDebugger = { + debug: async request => { + receivedToken = request.cancellationToken; + return { outcome: 'cancelled' }; + }, + canAttachToResource: () => true, + }; + const withProgressStub = sandbox.stub(vscode.window, 'withProgress').callsFake(async (_options, task) => + await task({ report: () => { } }, cancellation.token)); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ], 'global', undefined, resourceDebugger); + + try { + await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); + + assert.ok(withProgressStub.calledOnce); + assert.strictEqual(receivedToken, cancellation.token); + assert.strictEqual(warningStub.called, false); + } + finally { + cancellation.dispose(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource refreshes the tree when resource debug session state changes', () => { + const debugSessionChanges = new vscode.EventEmitter(); + const resourceDebugger = { + debug: async () => ({ outcome: 'started', providerId: 'dotnet' as const }), + canAttachToResource: () => true, + onDidChangeDebugSessions: debugSessionChanges.event, + } as ResourceDebugger & { onDidChangeDebugSessions: vscode.Event }; + const provider = makeTreeProvider([], 'global', undefined, resourceDebugger); + let refreshCount = 0; + const subscription = provider.onDidChangeTreeData(() => refreshCount++); + + try { + debugSessionChanges.fire(); + + assert.strictEqual(refreshCount, 1); + } + finally { + subscription.dispose(); + debugSessionChanges.dispose(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource rejects a resource removed before invocation', async () => { + const appHosts = [ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ]; + const provider = makeTreeProvider( + appHosts, + 'global', + undefined, + makeResourceDebugger({ outcome: 'resourceNotFound' })); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); + const resourceItem = getFirstResourceItem(provider); + appHosts.length = 0; + + const outcome = await (provider as any).attachDebuggerToResource(resourceItem); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotFound' }); + assert.ok(warningStub.calledOnce); + provider.dispose(); + }); + + test('attachDebuggerToResource rejects a resource that is no longer attachable', async () => { + const appHost = makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }); + const provider = makeTreeProvider( + [appHost], + 'global', + undefined, + makeResourceDebugger({ outcome: 'resourceNotRunning' })); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); + const resourceItem = getFirstResourceItem(provider); + appHost.resources = [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Finished, + properties: makeAttachableProjectProperties(), + }), + ]; + + const outcome = await (provider as any).attachDebuggerToResource(resourceItem); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotAttachable' }); + assert.ok(warningStub.calledOnce); + provider.dispose(); + }); + + test('attachDebuggerToResource reports missing C# debugger support', async () => { + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ], 'global', undefined, makeResourceDebugger({ + outcome: 'debuggerExtensionMissing', + debuggerExtensions: [{ id: 'ms-dotnettools.csharp', label: 'C#' }], + })); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); + + const outcome = await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotAttachable' }); + assert.ok(warningStub.calledOnce); + provider.dispose(); + }); + + test('attachDebuggerToResource reports missing Go debugger support without .NET-specific text', async () => { + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Executable', + state: ResourceState.Running, + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go', + 'executable.pid': '4242', + }, + }), + ], + }), + ], 'global', undefined, makeResourceDebugger({ + outcome: 'debuggerExtensionMissing', + debuggerExtensions: [{ id: 'golang.go', label: 'Go' }], + })); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); + + const outcome = await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotAttachable' }); + assert.ok(warningStub.calledOnceWith('Install Go to attach the debugger to this resource.')); + provider.dispose(); + }); + + test('attachDebuggerToResource uses future provider requirement labels without language-specific branching', async () => { + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ], 'global', undefined, makeResourceDebugger({ + outcome: 'debuggerExtensionMissing', + debuggerExtensions: [{ id: 'future.debugger', label: 'Future debugger' }], + })); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); + + const outcome = await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotAttachable' }); + assert.ok(warningStub.calledOnceWith('Install Future debugger to attach the debugger to this resource.')); + provider.dispose(); + }); + + test('attachDebuggerToResource reports when VS Code declines the attach session', async () => { + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ], 'global', undefined, makeResourceDebugger({ + outcome: 'error', + errorKind: 'debuggerStartDeclined', + })); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); + + const outcome = await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotAttachable' }); + assert.ok(warningStub.calledOnceWith('VS Code did not start the debugger attach session for API.')); + provider.dispose(); + }); + test('workspace mode renders a running AppHost with no resources', () => { const hostPath = '/repo/AppHost/AppHost.csproj'; const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); @@ -3006,7 +3613,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostCandidatePaths: [hostPath], onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [appHostItem] = provider.getChildren(); const appHostChildren = provider.getChildren(appHostItem); @@ -3036,7 +3643,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: 'Workspace view selected because aspire ls found 2 buildable AppHosts.', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const appHostItems = provider.getChildren(); @@ -3083,7 +3690,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { createEnvironment: () => ({}), sendAspireCommandToAspireTerminal: (command: AspireSubcommand, _showTerminal?: boolean, _additionalArgs?: string[], options?: unknown) => commands.push({ command, options }), } as unknown as AspireTerminalProvider; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService(), makeResourceDebugger()); const otherAppHostItem = provider.getChildren()[1]; const resourcesGroup = provider.getChildren(otherAppHostItem).find(child => child.label === 'Resources'); @@ -3139,7 +3746,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { createEnvironment: () => ({}), sendAspireCommandToAspireTerminal: (command: AspireSubcommand) => commands.push(command), } as unknown as AspireTerminalProvider; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService(), makeResourceDebugger()); const [runningAppHostItem] = provider.getChildren(); const resourceItem = provider.getChildren(runningAppHostItem)[0]; @@ -3185,7 +3792,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { createEnvironment: () => ({}), sendAspireCommandToAspireTerminal: (command: AspireSubcommand) => commands.push(command), } as unknown as AspireTerminalProvider; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService(), makeResourceDebugger()); const [workspaceItem] = provider.getChildren(); const [resourceItem] = provider.getChildren(workspaceItem); @@ -3214,7 +3821,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: 'Workspace view selected because aspire ls found 2 buildable AppHosts.', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [selectedAppHostItem] = provider.getChildren(); const selectedChildren = provider.getChildren(selectedAppHostItem); @@ -3242,7 +3849,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: 'Workspace view selected because aspire ls found 2 buildable AppHosts.', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [selectedAppHostItem] = provider.getChildren(); const selectedChildren = provider.getChildren(selectedAppHostItem); @@ -3275,7 +3882,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: 'AppHost.csproj', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [appHostItem] = provider.getChildren(); const appHostChildren = provider.getChildren(appHostItem); @@ -3435,7 +4042,7 @@ suite('LogFileItem in tree', () => { workspaceAppHostName: 'AppHost.csproj', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [appHostItem] = provider.getChildren(); const children = provider.getChildren(appHostItem); @@ -3462,7 +4069,7 @@ suite('LogFileItem in tree', () => { workspaceAppHostName: 'AppHost.csproj', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [appHostItem] = provider.getChildren(); const children = provider.getChildren(appHostItem); @@ -3593,7 +4200,7 @@ suite('copyAppHostPath', () => { onDidChangeData, } as unknown as AppHostDataRepository; const clipboard = makeClipboard(); - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), undefined, clipboard); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger(), undefined, clipboard); try { const infoStub = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); @@ -3621,7 +4228,7 @@ suite('copyAppHostPath', () => { workspaceAppHostDescription: undefined, onDidChangeData: (() => ({ dispose: () => { } })) as vscode.Event, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), undefined, clipboard); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger(), undefined, clipboard); try { const infoStub = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); const warningStub = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined as any); @@ -3660,7 +4267,7 @@ suite('viewAppHostSource', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const fakeDoc = { uri: vscode.Uri.parse('aspire-source:AppHost-999.json') } as vscode.TextDocument; sandbox.stub(vscode.workspace, 'openTextDocument').resolves(fakeDoc); @@ -3692,7 +4299,7 @@ suite('viewAppHostSource', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const registerStub = sandbox.stub(vscode.workspace, 'registerTextDocumentContentProvider').returns({ dispose: () => { } }); const fakeDoc = { uri: vscode.Uri.parse('aspire-source:AppHost-999.json') } as vscode.TextDocument; sandbox.stub(vscode.workspace, 'openTextDocument').resolves(fakeDoc); @@ -3921,6 +4528,7 @@ suite('AppHost tree actions', () => { repository, makeTerminalProvider(), launchService, + makeResourceDebugger(), undefined, makeClipboard(), configInfoProviderInstance, diff --git a/extension/src/test/aspireCodeLensProvider.test.ts b/extension/src/test/aspireCodeLensProvider.test.ts index 2a610abb964..cf1ae9ab42e 100644 --- a/extension/src/test/aspireCodeLensProvider.test.ts +++ b/extension/src/test/aspireCodeLensProvider.test.ts @@ -15,6 +15,7 @@ import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; import { AppHostDataRepository, AppHostDisplayInfo, ResourceJson } from '../data/AppHostDataRepository'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { AppHostLaunchService } from '../services/AppHostLaunchService'; +import type { ResourceDebugger } from '../debugger/resourceDebugContracts'; import { launchConfigurationTypePropertyName } from '../debugger/debuggerInstallHints'; // Import parsers so they self-register before the provider consults them. import '../editor/parsers/csharpAppHostParser'; @@ -72,9 +73,13 @@ function createHarness(opts: { const subs: vscode.Disposable[] = []; const terminalProvider = new AspireTerminalProvider(subs); const repository = new AppHostDataRepository(terminalProvider); + const resourceDebugger: ResourceDebugger = { + debug: async () => ({ outcome: 'unsupportedResource' }), + canAttachToResource: () => false, + }; const treeProvider = new AspireAppHostTreeProvider(repository, terminalProvider, new AppHostLaunchService({ getCapabilityStatus: async () => 'supported', - })); + }), resourceDebugger); const appHostsStub = sinon.stub(repository, 'appHosts').get(() => opts.appHosts ?? []); const workspaceResourcesStub = sinon.stub(repository, 'workspaceResources').get(() => opts.workspaceResources ?? []); diff --git a/extension/src/test/aspireDebugSession.test.ts b/extension/src/test/aspireDebugSession.test.ts index 7df6e2242ab..05f1663a77e 100644 --- a/extension/src/test/aspireDebugSession.test.ts +++ b/extension/src/test/aspireDebugSession.test.ts @@ -5297,6 +5297,7 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); exit_code: 17, }, ]); + assert.strictEqual(aspireDebugSession.hasResourceDebugSessionProcess(4242), false); aspireDebugSession.dispose(); }); @@ -5343,6 +5344,48 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); assert.strictEqual(stopSession.calledOnce, true); }); + test('reports whether an Aspire-owned resource debug session has a process ID', () => { + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + workspaceFolder: undefined, + configuration: { + type: 'aspire', + request: 'launch', + name: 'Aspire', + program: '/workspace/AppHost/AppHost.csproj', + }, + customRequest: sinon.stub(), + getDebugProtocolBreakpoint: sinon.stub(), + }; + const terminalProvider = { + isDebugConfigEnvironmentLoggingEnabled: () => false, + }; + const aspireDebugSession = new AspireDebugSession(parentDebugSession as unknown as vscode.DebugSession, {} as any, {} as any, terminalProvider as any, () => { }); + (aspireDebugSession as any)._resourceDebugSessions = [{ + id: 'run-1', + processId: 4242, + session: { id: 'run-1' } as vscode.DebugSession, + stopSession: sinon.stub(), + }]; + + assert.strictEqual( + (aspireDebugSession as unknown as { hasResourceDebugSessionProcess(processId: number): boolean }) + .hasResourceDebugSessionProcess(4242), + true); + assert.strictEqual( + (aspireDebugSession as unknown as { hasResourceDebugSessionProcess(processId: number): boolean }) + .hasResourceDebugSessionProcess(5252), + false); + (aspireDebugSession as any)._resourceDebugSessionProcessIds.set('run-2', 5252); + assert.strictEqual( + (aspireDebugSession as unknown as { hasResourceDebugSessionProcess(processId: number): boolean }) + .hasResourceDebugSessionProcess(5252), + true); + aspireDebugSession.dispose(); + }); + test('retries MAUI resource debug sessions when the first start attempt is canceled', async () => { let startSessionCallback: ((session: vscode.DebugSession) => void) | undefined; const parentDebugSession = { diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index a24550dfacc..d47d3cd4bc5 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -4,21 +4,31 @@ import { EventEmitter } from 'events'; import * as nodePath from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; -import { createProjectDebuggerExtension, DotNetService, projectDebuggerExtension, quoteCommandLineArgument } from '../debugger/languages/dotnet'; +import { createProjectDebuggerExtension, createProjectResourceAttachProvider, DotNetService, projectDebuggerExtension, quoteCommandLineArgument } from '../debugger/languages/dotnet'; import { AspireExtendedDebugConfiguration, AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, ProjectLaunchConfiguration } from '../dcp/types'; import * as io from '../utils/io'; import { createDebugSessionConfiguration, ResourceDebuggerExtension } from '../debugger/debuggerExtensions'; +import type { ResourceAttachProvider } from '../debugger/resourceDebugContracts'; import { AppHostParentOutputFilter, AspireDebugSession } from '../debugger/AspireDebugSession'; import * as hotReload from '../debugger/hotReload'; +import * as cliProcess from '../utils/process/cliProcess'; +import { extensionLogOutputChannel } from '../utils/logging'; +import { + LaunchedChildProcessResolver, + type LaunchedChildProcess, + type LaunchedChildProcessClock, + type LaunchedChildProcessQuery, +} from '../debugger/launchedChildProcessDiscovery'; import * as cliPathModule from '../utils/cliPath'; import * as cliPathEnvironmentModule from '../utils/cliPathEnvironment'; import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { removeDirectorySafely } from './testHelpers'; class TestDotNetService { - private _getDotNetTargetPathStub: sinon.SinonStub; private _hasDevKit: boolean; + public getDotNetAttachTargetInfoStub: sinon.SinonStub; + public getDotNetTargetPathStub: sinon.SinonStub; public buildDotNetProjectStub: sinon.SinonStub; // `dotnet run-api` output returned for file-based (.cs) apps. Tests override this with a serialized @@ -27,8 +37,11 @@ class TestDotNetService { public runApiEnvironment: NodeJS.ProcessEnv | undefined; constructor(outputPath: string, rejectBuild: Error | null, hasDevKit: boolean) { - this._getDotNetTargetPathStub = sinon.stub(); - this._getDotNetTargetPathStub.resolves(outputPath); + this.getDotNetAttachTargetInfoStub = sinon.stub(); + this.getDotNetAttachTargetInfoStub.resolves({ targetPath: outputPath, useAppHost: true }); + + this.getDotNetTargetPathStub = sinon.stub(); + this.getDotNetTargetPathStub.resolves(outputPath); this.buildDotNetProjectStub = sinon.stub(); if (rejectBuild) { @@ -40,8 +53,16 @@ class TestDotNetService { this._hasDevKit = hasDevKit; } + getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken, framework?: string): Promise<{ targetPath: string, targetName?: string, useAppHost: boolean }> { + return framework + ? this.getDotNetAttachTargetInfoStub(projectFile, configuration, cancellationToken, framework) + : cancellationToken + ? this.getDotNetAttachTargetInfoStub(projectFile, configuration, cancellationToken) + : this.getDotNetAttachTargetInfoStub(projectFile, configuration); + } + getDotNetTargetPath(projectFile: string): Promise { - return this._getDotNetTargetPathStub(projectFile); + return this.getDotNetTargetPathStub(projectFile); } buildDotNetProject(projectFile: string): Promise { @@ -58,6 +79,107 @@ class TestDotNetService { } } +function createMsbuildProcess(): { + process: childProcess.ChildProcessWithoutNullStreams; + stdout: EventEmitter & { setEncoding(encoding: string): void }; + stderr: EventEmitter & { setEncoding(encoding: string): void }; + kill: sinon.SinonStub; +} { + const process = new EventEmitter() as unknown as childProcess.ChildProcessWithoutNullStreams; + const stdout = Object.assign(new EventEmitter(), { + setEncoding: (_encoding: string) => { }, + }); + const stderr = Object.assign(new EventEmitter(), { + setEncoding: (_encoding: string) => { }, + }); + const kill = sinon.stub().callsFake((signal?: NodeJS.Signals | number) => { + (process as unknown as { killed: boolean }).killed = true; + process.emit('close', null, signal); + return true; + }); + Object.assign(process, { + exitCode: null, + signalCode: null, + killed: false, + pid: 1234, + kill, + stdout, + stderr, + }); + + return { process, stdout, stderr, kill }; +} + +interface TestLaunchedChildProcess { + readonly pid: number; + readonly parentPid: number; + readonly executable: string; + readonly command: string; + readonly commandLineArguments?: readonly string[]; +} + +interface TestLaunchedChildProcessIdentity { + readonly requiresDirectChild?: boolean; + isLauncher(process: TestLaunchedChildProcess): boolean; + isCandidate(process: TestLaunchedChildProcess): boolean; +} + +interface TestLaunchedChildProcessResolver { + resolveProcessId( + launcherPid: number, + identity: TestLaunchedChildProcessIdentity, + cancellationToken?: vscode.CancellationToken, + ): Promise; +} + +class StaticLaunchedChildProcessQuery implements LaunchedChildProcessQuery { + constructor(private readonly _processes: readonly LaunchedChildProcess[]) { + } + + async listProcesses(): Promise { + return this._processes; + } +} + +const immediateProcessClock: LaunchedChildProcessClock = { + now: () => 0, + sleep: async () => { }, +}; + +function createLaunchedProcess(pid: number, parentPid: number, executable: string, command = executable): LaunchedChildProcess { + return { pid, parentPid, executable, command }; +} + +type TestResource = Parameters[0]; + +function createProjectResource( + properties: TestResource['properties'], + name = 'api', + displayName = 'API', +): TestResource { + return { + name, + displayName, + resourceType: 'Project', + state: 'Running', + properties, + }; +} + +function createAttachProvider( + dotNetService: TestDotNetService, + childProcessResolver: TestLaunchedChildProcessResolver, + fileSystem?: { realpath(path: string): Promise }, +): ResourceAttachProvider { + const factory = createProjectResourceAttachProvider as unknown as ( + dotNetServiceProducer: () => TestDotNetService, + resolver: TestLaunchedChildProcessResolver, + fileSystem?: { realpath(path: string): Promise }, + ) => ResourceAttachProvider; + + return factory(() => dotNetService, childProcessResolver, fileSystem); +} + suite('Dotnet Debugger Extension Tests', () => { let getHotReloadDiagnostics: sinon.SinonStub; let logHotReloadDiagnostics: sinon.SinonStub; @@ -77,11 +199,1168 @@ suite('Dotnet Debugger Extension Tests', () => { teardown(() => sinon.restore()); - function createDebuggerExtension(outputPath: string, rejectBuild: Error | null, hasDevKit: boolean, doesOutputFileExist: boolean): { dotNetService: TestDotNetService, extension: ResourceDebuggerExtension, doesFileExistStub: sinon.SinonStub } { + function createDebuggerExtension(outputPath: string, rejectBuild: Error | null, hasDevKit: boolean, doesOutputFileExist: boolean): { dotNetService: TestDotNetService, extension: ResourceDebuggerExtension, attachProvider: ResourceAttachProvider, doesFileExistStub: sinon.SinonStub } { const fakeDotNetService = new TestDotNetService(outputPath, rejectBuild, hasDevKit); - return { dotNetService: fakeDotNetService, extension: createProjectDebuggerExtension(() => fakeDotNetService), doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist) }; + const childProcessResolver: TestLaunchedChildProcessResolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + return { + dotNetService: fakeDotNetService, + extension: createProjectDebuggerExtension(() => fakeDotNetService), + attachProvider: createProjectResourceAttachProvider(() => fakeDotNetService, childProcessResolver), + doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist), + }; } + test('attach configuration resolves the evaluated framework-dependent TargetPath child PID', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/EvaluatedAssemblyName.dll', null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath: '/repo/bin/Debug/net10.0/EvaluatedAssemblyName.dll', + useAppHost: false, + }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + + const configuration = await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + 'project.launchCommand': 'run', + })); + + assert.deepStrictEqual(configuration, { + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + processId: 4321, + }); + assert.strictEqual(resolver.resolveProcessId.firstCall.args[0], 1234); + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isLauncher({ + pid: 1234, + parentPid: 1, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet run --project /repo/api/Api.csproj', + }), true); + assert.strictEqual(processIdentity.isLauncher({ + pid: 1234, + parentPid: 1, + executable: '/usr/local/share', + command: '/usr/local/share/dotnet/dotnet run --project /repo/api/Api.csproj', + }), false); + assert.strictEqual(processIdentity.requiresDirectChild, true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec /repo/bin/Debug/net10.0/EvaluatedAssemblyName.dll', + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec /repo/bin/Debug/net10.0/Api.dll', + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec /repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll "" --urls http://localhost:5000', + }), false); + }); + + test('watch attach permits a transitive TargetPath descendant', async () => { + const targetPath = '/repo/bin/Debug/net10.0/Api.dll'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: false }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + 'project.launchCommand': 'watch', + })); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.requiresDirectChild, false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 5678, + executable: '/usr/local/share/dotnet/dotnet', + command: `dotnet exec ${targetPath}`, + }), true); + }); + + test('matches a spaced evaluated TargetPath from the raw framework-dependent command without matching a prefix sibling', async () => { + const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: false }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: `dotnet exec ${targetPath} "" --urls http://localhost:5000`, + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: `dotnet exec ${targetPath}.bak`, + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: `dotnet run ${targetPath}`, + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4324, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: `dotnet exec /repo/bin/Debug/net10.0/Other.dll ${targetPath}`, + }), false); + }); + + test('matches a structured framework-dependent TargetPath without accepting other dotnet children', async () => { + const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: false }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec malformed-posix-command', + commandLineArguments: ['dotnet', 'exec', targetPath, '', '--urls', 'http://localhost:5000'], + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec malformed-posix-command', + commandLineArguments: ['dotnet', 'exec', `${targetPath}.bak`], + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec malformed-posix-command', + commandLineArguments: [ + 'dotnet', + 'exec', + '/repo/bin/Debug/net10.0/Other.dll', + targetPath, + ], + }), false); + }); + + test('older redacted framework-dependent snapshots match TargetName instead of the default TargetPath', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath: '/repo/bin/Debug/net10.0/Api.dll', + targetName: 'Api', + useAppHost: false, + }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + })); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.requiresDirectChild, true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec /repo/bin/Release/net10.0/Api.dll --urls http://localhost:5000', + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec "/repo/bin/Release/net10.0/Api.dll" --urls http://localhost:5000', + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec /repo/bin/Release/net10.0/Api.Worker.dll', + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4324, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec /repo/bin/Release/net10.0/Other.dll /repo/app-arguments/Api.dll', + }), false); + }); + + test('older structured framework-dependent snapshots use the first DLL target after dotnet exec', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath: '/repo/bin/Debug/net10.0/Api.dll', + targetName: 'Api', + useAppHost: false, + }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + })); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec malformed-posix-command', + commandLineArguments: [ + 'dotnet', + 'exec', + '/repo/bin/Release/net10.0/Other.dll', + '/repo/app-arguments/Api.dll', + ], + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec malformed-posix-command', + commandLineArguments: [ + 'dotnet', + 'exec', + '/repo/bin/Release/net10.0/Api.dll', + '/repo/app-arguments/Other.dll', + ], + }), true); + }); + + test('older redacted apphost snapshots match the TargetName executable basename', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath: '/repo/bin/Debug/net10.0/Api.dll', + targetName: 'Api', + useAppHost: true, + }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + })); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/repo/bin/Release/net10.0/Api', + command: '/repo/bin/Release/net10.0/Api', + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/repo/bin/Release/net10.0/Api.Worker', + command: '/repo/bin/Release/net10.0/Api.Worker', + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: '/repo/bin/Release/net10.0/api', + command: '/repo/bin/Release/net10.0/api', + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4324, + parentPid: 1234, + executable: 'C:\\repo\\bin\\Release\\net10.0\\API.EXE', + command: 'C:\\repo\\bin\\Release\\net10.0\\API.EXE', + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4325, + parentPid: 1234, + executable: 'API.EXE', + command: 'API.EXE', + }), true); + }); + + test('older TargetName fallback remains scoped to the selected launcher tree', async () => { + const targetPath = '/repo/bin/Debug/net10.0/Api.dll'; + const dotNetService = new TestDotNetService(targetPath, null, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath, + targetName: 'Api', + useAppHost: false, + }); + const resolver = new LaunchedChildProcessResolver( + new StaticLaunchedChildProcessQuery([ + createLaunchedProcess(1234, 1, '/usr/local/share/dotnet/dotnet', 'dotnet run --project /repo/api/Api.csproj'), + createLaunchedProcess(4321, 1234, '/usr/local/share/dotnet/dotnet', 'dotnet exec /repo/bin/Release/net10.0/Api.dll'), + createLaunchedProcess(5678, 1, '/usr/local/share/dotnet/dotnet', 'dotnet run --project /repo/api/Api.csproj'), + createLaunchedProcess(8765, 5678, '/usr/local/share/dotnet/dotnet', 'dotnet exec /repo/bin/Release/net10.0/Api.dll'), + ]), + immediateProcessClock, + { timeoutMs: 20, retryDelayMs: 10 }); + const attachProvider = createAttachProvider(dotNetService, resolver); + + const configuration = await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + })); + + assert.strictEqual(configuration.processId, 4321); + }); + + test('older TargetName fallback fails closed when MSBuild omits TargetName', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath: '/repo/bin/Debug/net10.0/Api.dll', + useAppHost: false, + }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + + await assert.rejects( + attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + })), + (error: unknown) => error instanceof Error + && error.message === 'This resource cannot be attached to a debugger.'); + + assert.strictEqual(resolver.resolveProcessId.called, false); + }); + + test('older TargetName fallback rejects present invalid safe metadata', async () => { + const dotNetService = new TestDotNetService('/repo/bin/Debug/net10.0/Api.dll', null, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath: '/repo/bin/Debug/net10.0/Api.dll', + targetName: 'Api', + useAppHost: false, + }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + const invalidProperties = [ + { label: 'empty configuration', propertyName: 'project.configuration', propertyValue: '' }, + { label: 'null target framework', propertyName: 'project.targetFramework', propertyValue: null }, + { label: 'non-string configuration', propertyName: 'project.configuration', propertyValue: 42 }, + ] as const; + + for (const [index, { label, propertyName, propertyValue }] of invalidProperties.entries()) { + const resourceName = `api-${index}`; + await assert.rejects( + attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + [propertyName]: propertyValue, + }, resourceName)), + (error: unknown) => error instanceof Error + && error.message === `Invalid launch configuration for ${resourceName}.`, + label); + } + + assert.strictEqual(dotNetService.getDotNetAttachTargetInfoStub.called, false); + assert.strictEqual(resolver.resolveProcessId.called, false); + }); + + test('attach configuration resolves an apphost child by its evaluated executable identity', async () => { + sinon.stub(process, 'platform').value('linux'); + const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: true }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + + const configuration = await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': 1234, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); + + assert.strictEqual(configuration.processId, 4321); + assert.strictEqual(configuration.processName, undefined); + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: targetPath, + command: `"${targetPath}" "" --urls http://localhost:5000`, + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/repo/OneDrive', + command: `"${targetPath} Worker"`, + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: '/repo/OneDrive', + command: `${targetPath} Worker`, + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4324, + parentPid: 1234, + executable: '/repo/OneDrive', + command: `"${targetPath}"`, + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4325, + parentPid: 1234, + executable: `${targetPath} Worker`, + command: `${targetPath} Worker`, + }), false); + }); + + test('matches a long macOS apphost path only through the exact executable identity', async () => { + sinon.stub(process, 'platform').value('darwin'); + const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service With A Long Name'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: true }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': 1234, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: targetPath, + command: `${targetPath} --urls http://localhost:5000`, + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/repo/OneDrive -', + command: targetPath, + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: `${targetPath} Worker`, + command: targetPath, + }), false); + }); + + test('preserves raw and full-realpath apphost candidates', async () => { + const targetPath = '/workspace/link/bin/Debug/net10.0/Api.dll'; + const appHostPath = '/workspace/link/bin/Debug/net10.0/Api'; + const appHostExePath = `${appHostPath}.exe`; + const canonicalAppHostPath = '/workspace/physical/bin/Debug/net10.0/Api'; + const canonicalAppHostExePath = `${canonicalAppHostPath}.exe`; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const realpath = sinon.stub().callsFake(async (candidate: string) => { + if (candidate === appHostPath) { + return canonicalAppHostPath; + } + if (candidate === appHostExePath) { + return canonicalAppHostExePath; + } + + throw new Error('ENOENT'); + }); + const attachProvider = createAttachProvider(dotNetService, resolver, { realpath }); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/workspace/link/api/Api.csproj', + })); + + const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: canonicalAppHostPath, + command: canonicalAppHostPath, + }), true); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: appHostPath, + command: appHostPath, + }), true); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: canonicalAppHostExePath, + command: canonicalAppHostExePath, + }), true); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4324, + parentPid: 1234, + executable: appHostExePath, + command: appHostExePath, + }), true); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4325, + parentPid: 1234, + executable: '/workspace/other/bin/Debug/net10.0/Api', + command: '/workspace/other/bin/Debug/net10.0/Api', + }), false); + assert.ok(realpath.calledWithExactly(appHostPath)); + assert.ok(realpath.calledWithExactly(appHostExePath)); + }); + + test('matches a deleted apphost from a symlinked TargetPath directory', async () => { + const targetPath = '/workspace/link/bin/Debug/net10.0/Api.dll'; + const appHostPath = '/workspace/link/bin/Debug/net10.0/Api'; + const appHostExePath = `${appHostPath}.exe`; + const targetDirectory = nodePath.dirname(targetPath); + const canonicalTargetDirectory = '/workspace/physical/bin/Debug/net10.0'; + const canonicalAppHostPath = nodePath.join(canonicalTargetDirectory, nodePath.basename(appHostPath)); + const canonicalAppHostExePath = nodePath.join(canonicalTargetDirectory, nodePath.basename(appHostExePath)); + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const realpath = sinon.stub().callsFake(async (candidate: string) => { + if (candidate === targetDirectory) { + return canonicalTargetDirectory; + } + + throw new Error('ENOENT'); + }); + const attachProvider = createAttachProvider(dotNetService, resolver, { realpath }); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/workspace/link/api/Api.csproj', + })); + + const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: canonicalAppHostPath, + command: canonicalAppHostPath, + }), true); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: canonicalAppHostExePath, + command: canonicalAppHostExePath, + }), true); + assert.ok(realpath.calledWithExactly(appHostPath)); + assert.ok(realpath.calledWithExactly(targetDirectory)); + }); + + test('does not match a same-named apphost outside the canonical TargetPath directory', async () => { + const targetPath = '/workspace/link/bin/Debug/net10.0/Api.dll'; + const targetDirectory = nodePath.dirname(targetPath); + const canonicalTargetDirectory = '/workspace/physical/bin/Debug/net10.0'; + const unrelatedAppHostPath = '/workspace/other/bin/Debug/net10.0/Api'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const realpath = sinon.stub().callsFake(async (candidate: string) => { + if (candidate === targetDirectory) { + return canonicalTargetDirectory; + } + + throw new Error('ENOENT'); + }); + const attachProvider = createAttachProvider(dotNetService, resolver, { realpath }); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/workspace/link/api/Api.csproj', + })); + + const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: unrelatedAppHostPath, + command: unrelatedAppHostPath, + }), false); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/workspace/physical/bin/Debug/net10.0/Api Replica', + command: '/workspace/physical/bin/Debug/net10.0/Api Replica', + }), false); + }); + + test('falls back to raw apphost candidates when canonical TargetPath directory lookup fails', async () => { + const targetPath = '/workspace/link/bin/Debug/net10.0/Api.dll'; + const appHostPath = '/workspace/link/bin/Debug/net10.0/Api'; + const appHostExePath = `${appHostPath}.exe`; + const targetDirectory = nodePath.dirname(targetPath); + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const realpath = sinon.stub().rejects(new Error('ENOENT')); + const attachProvider = createAttachProvider(dotNetService, resolver, { realpath }); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/workspace/link/api/Api.csproj', + })); + + const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: appHostPath, + command: appHostPath, + }), true); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: appHostExePath, + command: appHostExePath, + }), true); + assert.ok(realpath.calledWithExactly(appHostPath)); + assert.ok(realpath.calledWithExactly(appHostExePath)); + assert.ok(realpath.calledWithExactly(targetDirectory)); + }); + + test('normalizes Windows executable and command identities before matching', async () => { + const targetPath = 'C:\\Repo\\My Attach Service.dll'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: true }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); + + const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: 'c:/repo/MY ATTACH SERVICE.EXE', + command: 'not-used-for-apphost', + }), true); + + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath: 'C:\\Repo\\Api.dll', useAppHost: false }); + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); + + const frameworkDependentIdentity = resolver.resolveProcessId.secondCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(frameworkDependentIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: 'c:/Program Files/dotnet/DOTNET.EXE', + command: 'dotnet exec c:/REPO\\api.DLL', + }), true); + }); + + test('attach configuration derives the default apphost identity from TargetPath', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': 1234, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/repo/bin/Debug/net10.0/Api', + command: '/repo/bin/Debug/net10.0/Api --urls http://localhost:5000', + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/repo/bin/Debug/net10.0/Api.dll', + command: '/repo/bin/Debug/net10.0/Api.dll', + }), false); + }); + + test('attach configuration scopes replicas with the same TargetPath to their launcher PIDs', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Replica.dll', null, true, true); + const resolver = { + resolveProcessId: sinon.stub() + .onFirstCall().resolves(4321) + .onSecondCall().resolves(4322), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + const resource = (pid: number) => createProjectResource({ + 'executable.pid': pid, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, `api-${pid}`); + + const firstConfiguration = await attachProvider.createDebugConfiguration(resource(1234)); + const secondConfiguration = await attachProvider.createDebugConfiguration(resource(5678)); + + assert.strictEqual(firstConfiguration.processId, 4321); + assert.strictEqual(secondConfiguration.processId, 4322); + assert.deepStrictEqual(resolver.resolveProcessId.firstCall.args.slice(0, 1), [1234]); + assert.deepStrictEqual(resolver.resolveProcessId.secondCall.args.slice(0, 1), [5678]); + }); + + test('attach configuration fails closed for missing and ambiguous framework-dependent children', async () => { + const targetPath = '/repo/bin/Debug/net10.0/Api.dll'; + const createProvider = (processes: readonly LaunchedChildProcess[]) => { + const dotNetService = new TestDotNetService(targetPath, null, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: false }); + const resolver = new LaunchedChildProcessResolver( + new StaticLaunchedChildProcessQuery(processes), + immediateProcessClock, + { timeoutMs: 20, retryDelayMs: 10 }); + return createProjectResourceAttachProvider(() => dotNetService, resolver); + }; + const resource = createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }); + const noChild = createProvider([ + createLaunchedProcess(1234, 1, '/usr/local/share/dotnet/dotnet', 'dotnet run --project /repo/api/Api.csproj'), + createLaunchedProcess(4321, 1234, '/usr/local/share/dotnet/dotnet', 'dotnet exec /repo/bin/Debug/net10.0/Other.dll'), + ]); + const ambiguousChildren = createProvider([ + createLaunchedProcess(1234, 1, '/usr/local/share/dotnet/dotnet', 'dotnet run --project /repo/api/Api.csproj'), + createLaunchedProcess(4321, 1234, '/usr/local/share/dotnet/dotnet', `dotnet exec ${targetPath}`), + createLaunchedProcess(4322, 1234, '/usr/local/share/dotnet/dotnet', `dotnet exec ${targetPath}`), + ]); + + await assert.rejects(noChild.createDebugConfiguration(resource)); + await assert.rejects(ambiguousChildren.createDebugConfiguration(resource)); + }); + + test('attach configuration resolves same-name framework-dependent replicas within each launcher tree', async () => { + const targetPath = '/repo/bin/Debug/net10.0/Api.dll'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: false }); + const resolver = new LaunchedChildProcessResolver( + new StaticLaunchedChildProcessQuery([ + createLaunchedProcess(1234, 1, '/usr/local/share/dotnet/dotnet', 'dotnet run --project /repo/api/Api.csproj'), + createLaunchedProcess(4321, 1234, '/usr/local/share/dotnet/dotnet', `dotnet exec ${targetPath}`), + createLaunchedProcess(5678, 1, '/usr/local/share/dotnet/dotnet', 'dotnet run --project /repo/api/Api.csproj'), + createLaunchedProcess(8765, 5678, '/usr/local/share/dotnet/dotnet', `dotnet exec ${targetPath}`), + ]), + immediateProcessClock, + { timeoutMs: 20, retryDelayMs: 10 }); + const attachProvider = createAttachProvider(dotNetService, resolver); + const resource = (pid: number) => createProjectResource({ + 'executable.pid': pid, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, `api-${pid}`); + + assert.strictEqual((await attachProvider.createDebugConfiguration(resource(1234))).processId, 4321); + assert.strictEqual((await attachProvider.createDebugConfiguration(resource(5678))).processId, 8765); + }); + + test('attach configuration uses the resolved project TargetPath child process ID', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); + + const configuration = await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); + + assert.strictEqual(configuration.type, 'coreclr'); + assert.strictEqual(configuration.request, 'attach'); + assert.strictEqual(configuration.name, 'Attach debugger: API'); + assert.strictEqual(configuration.processId, 4321); + assert.strictEqual(configuration.processName, undefined); + assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly('/repo/api/Api.csproj', undefined)); + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + }); + + test('attach configuration uses safe properties when executable arguments are redacted', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/ReleaseApi.dll', null, true, true); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + 'project.configuration': 'Release', + 'project.targetFramework': 'net10.0', + 'project.launchCommand': 'run', + })); + + assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly( + '/repo/api/Api.csproj', 'Release', undefined, 'net10.0')); + }); + + test('attach configuration prefers safe properties and does not parse executable arguments', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/ReleaseApi.dll', null, true, true); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': ['run', '--configuration', 'Debug', '--framework', 'net9.0'], + 'project.path': '/repo/api/Api.csproj', + 'project.configuration': 'Release', + 'project.targetFramework': 'net10.0', + 'project.launchCommand': 'run', + })); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '5678', + 'executable.path': 'dotnet', + 'executable.args': ['run', '--configuration', 'Debug', '--framework', 'net9.0'], + 'project.path': '/repo/api/Api.csproj', + 'project.launchCommand': 'run', + }, 'args-only', 'Args only')); + + assert.deepStrictEqual(dotNetService.getDotNetAttachTargetInfoStub.firstCall.args, [ + '/repo/api/Api.csproj', 'Release', undefined, 'net10.0', + ]); + assert.deepStrictEqual(dotNetService.getDotNetAttachTargetInfoStub.secondCall.args, [ + '/repo/api/Api.csproj', undefined, + ]); + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + }); + + test('attach configuration rejects present invalid launch command metadata', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + const cases = [ + { label: 'unsupported publish marker', launchCommand: 'publish' }, + { label: 'present null marker fails before older fallback', launchCommand: null }, + ] as const; + + for (const { label, launchCommand } of cases) { + dotNetService.getDotNetAttachTargetInfoStub.resetHistory(); + dotNetService.getDotNetTargetPathStub.resetHistory(); + + await assert.rejects( + attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + 'project.launchCommand': launchCommand, + })), + (error: unknown) => error instanceof Error + && error.message === 'Invalid launch configuration for api.', + label); + + assert.strictEqual(dotNetService.getDotNetAttachTargetInfoStub.called, false, label); + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false, label); + } + }); + + test('attach configuration passes cancellation to target discovery', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + const cancellation = new vscode.CancellationTokenSource(); + + try { + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }), cancellation.token); + + assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly( + '/repo/api/Api.csproj', + undefined, + cancellation.token)); + } + finally { + cancellation.dispose(); + } + }); + + test('target discovery cancels and terminates its specific msbuild process', async () => { + const dotNetService = new DotNetService(undefined); + const msbuildProcess = createMsbuildProcess(); + sinon.stub(childProcess, 'spawn').returns(msbuildProcess.process); + const terminate = sinon.stub(cliProcess, 'terminateCliProcess').resolves(); + const cancellation = new vscode.CancellationTokenSource(); + + try { + const targetDiscovery = dotNetService.getDotNetAttachTargetInfo('/repo/api/Api.csproj', undefined, cancellation.token); + cancellation.cancel(); + const outcome = await Promise.race([ + targetDiscovery.then( + () => 'completed', + error => error instanceof vscode.CancellationError ? 'cancelled' : 'failed'), + new Promise<'timedOut'>(resolve => setTimeout(() => resolve('timedOut'), 100)), + ]); + + assert.strictEqual(outcome, 'cancelled'); + assert.ok(terminate.calledOnceWithExactly( + msbuildProcess.process, + 'dotnet msbuild target discovery', + { force: true, suppressTimeoutWarning: true })); + } + finally { + cancellation.dispose(); + } + }); + + test('target discovery drains bounded stderr and includes probe output on failure', async () => { + const dotNetService = new DotNetService(undefined); + const msbuildProcess = createMsbuildProcess(); + sinon.stub(childProcess, 'spawn').callsFake(() => { + queueMicrotask(() => { + msbuildProcess.stdout.emit('data', 'stdout-marker'); + msbuildProcess.stderr.emit('data', 'x'.repeat(128 * 1024)); + msbuildProcess.stderr.emit('data', 'stderr-marker'); + msbuildProcess.process.emit('close', 1); + }); + return msbuildProcess.process; + }); + + await assert.rejects( + dotNetService.getDotNetAttachTargetInfo('/repo/api/Api.csproj'), + (error: unknown) => error instanceof Error + && error.message.includes('stdout-marker') + && error.message.includes('stderr-marker') + && error.message.length < 132 * 1024); + }); + + test('target discovery terminates its specific msbuild probe when it times out', async () => { + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + const dotNetService = new DotNetService(undefined); + const msbuildProcess = createMsbuildProcess(); + sinon.stub(childProcess, 'spawn').returns(msbuildProcess.process); + const terminate = sinon.stub(cliProcess, 'terminateCliProcess').resolves(); + + try { + const targetDiscovery = dotNetService.getDotNetAttachTargetInfo('/repo/api/Api.csproj'); + const completion = targetDiscovery.then( + () => 'completed', + error => error instanceof Error && /timed out/.test(error.message) ? 'timedOut' : 'failed'); + + await clock.tickAsync(10_000); + + assert.strictEqual(await Promise.race([completion, Promise.resolve('pending')]), 'timedOut'); + assert.ok(terminate.calledOnceWithExactly( + msbuildProcess.process, + 'dotnet msbuild target discovery', + { force: true, suppressTimeoutWarning: true })); + } + finally { + clock.restore(); + } + }); + + test('target discovery does not arm a timeout after it has been cancelled', async () => { + const clock = sinon.useFakeTimers(); + const dotNetService = new DotNetService(undefined); + const msbuildProcess = createMsbuildProcess(); + sinon.stub(childProcess, 'spawn').returns(msbuildProcess.process); + const terminate = sinon.stub(cliProcess, 'terminateCliProcess').resolves(); + const cancellationToken: vscode.CancellationToken = { + isCancellationRequested: true, + onCancellationRequested: listener => { + listener(undefined); + return new vscode.Disposable(() => { }); + }, + }; + + try { + const targetDiscovery = dotNetService.getDotNetAttachTargetInfo('/repo/api/Api.csproj', undefined, cancellationToken); + + await assert.rejects(targetDiscovery, error => error instanceof vscode.CancellationError); + await clock.tickAsync(10_000); + + assert.ok(terminate.calledOnceWithExactly( + msbuildProcess.process, + 'dotnet msbuild target discovery', + { force: true, suppressTimeoutWarning: true })); + } + finally { + clock.restore(); + } + }); + + test('target discovery returns TargetName for framework-dependent projects', async () => { + const dotNetService = new DotNetService(undefined); + const msbuildProcess = createMsbuildProcess(); + const spawn = sinon.stub(childProcess, 'spawn').callsFake(() => { + queueMicrotask(() => { + msbuildProcess.stdout.emit('data', JSON.stringify({ + Properties: { + TargetPath: '/repo/bin/Release/net10.0/ReleaseApi.dll', + TargetName: ' ReleaseApi ', + UseAppHost: 'false', + }, + })); + msbuildProcess.process.emit('close', 0); + }); + return msbuildProcess.process; + }); + + const targetInfo = await dotNetService.getDotNetAttachTargetInfo('/repo/api/Api.csproj', 'Release'); + + assert.deepStrictEqual(targetInfo, { + targetPath: '/repo/bin/Release/net10.0/ReleaseApi.dll', + targetName: 'ReleaseApi', + useAppHost: false, + }); + assert.deepStrictEqual(spawn.firstCall.args[1], [ + 'msbuild', + '/repo/api/Api.csproj', + '-nologo', + '-getProperty:TargetPath', + '-getProperty:TargetName', + '-getProperty:UseAppHost', + '-v:q', + '-property:GenerateFullPaths=true', + '-property:Configuration=Release', + ]); + }); + + test('attach configuration rejects file-based project resources', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + + await assert.rejects( + attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.cs', + 'secret.snapshot.property': 'top-secret', + })), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.strictEqual(error.message, 'Invalid launch configuration for api.'); + assert.strictEqual(error.name, 'ResourceAttachConfigurationError'); + assert.strictEqual( + (error as Error & { errorKind?: string }).errorKind, + 'resourceNotAttachable'); + return true; + }); + + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + }); + + test('attach configuration keeps parented project resources attachable', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); + + const configuration = await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + 'resource.launchConfigurationType': 'project', + 'resource.parentName': 'group', + }, 'api-grouped')); + + assert.strictEqual(configuration.processId, 4321); + assert.strictEqual(configuration.processName, undefined); + assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly('/repo/api/Api.csproj', undefined)); + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + }); + + test('attach configuration rejects parented MAUI platform resources', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); + + await assert.rejects( + attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/maui/MauiApp.csproj', + 'resource.launchConfigurationType': 'maui', + 'resource.parentName': 'mauiapp', + }, 'mauiapp-android-emulator', 'MAUI')), + (error: unknown) => error instanceof Error + && error.name === 'ResourceAttachConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'resourceNotAttachable'); + + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + }); + + test('attach configuration rejects parented resources without explicit launch metadata', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); + + await assert.rejects( + attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + 'resource.parentName': 'group', + }, 'legacy-parented', 'Legacy parented project')), + (error: unknown) => error instanceof Error + && error.name === 'ResourceAttachConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'resourceNotAttachable'); + + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + }); function restoreEnvironmentVariable(name: string, value: string | undefined): void { if (value === undefined) { delete process.env[name]; @@ -1248,6 +2527,52 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(projectDebuggerExtension.getProjectFile(fileBasedConfig), '/tmp/app.cs'); }); + test('invalid project launch configurations do not expose arbitrary properties', async () => { + const secret = 'top-secret'; + const invalidLaunchConfig = { + type: 'node', + name: 'api', + secret, + } as unknown as ExecutableLaunchConfiguration; + const info = sinon.stub(extensionLogOutputChannel, 'info'); + + assert.throws( + () => projectDebuggerExtension.getProjectFile(invalidLaunchConfig), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.strictEqual(error.message, 'Invalid launch configuration for node.'); + return true; + }); + + await assert.rejects( + projectDebuggerExtension.createDebugSessionConfigurationCallback!( + invalidLaunchConfig, + [], + [], + { + debug: true, + runId: '1', + debugSessionId: '1', + isApphost: false, + debugSession: sinon.createStubInstance(AspireDebugSession), + }, + { + runId: '1', + debugSessionId: '1', + type: 'coreclr', + name: 'Test Debug Config', + request: 'launch', + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.strictEqual(error.message, 'Invalid launch configuration for node.'); + return true; + }); + + assert.strictEqual(info.calledOnceWithExactly('The resource type was not project for node'), true); + assert.strictEqual(info.firstCall.args.join(' ').includes(secret), false); + }); + test('file-based AppHost follows CLI build ownership', async () => { const executablePath = '/tmp/obj/Debug/net10.0/apphost'; const { extension, dotNetService } = createDebuggerExtension('unused-build-output', null, true, true); @@ -1759,7 +3084,7 @@ suite('Dotnet Debugger Extension Tests', () => { }); test('file-based dotnet.cs apphost named dotnet is not mistaken for the launcher', async () => { - // A file-based app whose entry file is `dotnet.cs` builds an apphost whose AssemblyName — and therefore + // A file-based app whose entry file is `dotnet.cs` builds an apphost whose TargetName — and therefore // executable file name — is `dotnet`/`dotnet.exe`, the same name as the launcher but at a full build-output // path. run-api returns that full path as ExecutablePath and echoes the SDK default profile's arguments // in CommandLineArguments. Because the program is an apphost (a rooted path), not the launcher (a bare diff --git a/extension/src/test/e2eAddWorkspaceFolderGuard.test.ts b/extension/src/test/e2eAddWorkspaceFolderGuard.test.ts index 33feb7d6a09..34af13e65ba 100644 --- a/extension/src/test/e2eAddWorkspaceFolderGuard.test.ts +++ b/extension/src/test/e2eAddWorkspaceFolderGuard.test.ts @@ -44,6 +44,13 @@ suite('E2E addWorkspaceFolder guard', () => { assert.strictEqual(getE2eAddableWorkspaceFolderPath(folderPath), folderPath); }); + test('accepts a contained folder whose name begins with two dots', () => { + const folderPath = path.join(workspaceRoot, '..services'); + fs.mkdirSync(folderPath, { recursive: true }); + + assert.strictEqual(getE2eAddableWorkspaceFolderPath(folderPath), folderPath); + }); + test('rejects a folder outside every configured root', () => { const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-addfolder-outside-')); diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index 0227281b19e..c939d1ed08a 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -633,10 +633,10 @@ suite('E2E launch profile', () => { assert.ok(csharpInstallIndex > dotnetRuntimeInstallIndex); assert.ok(resourceGroupsInstallIndex > csharpInstallIndex); assert.ok(functionsInstallIndex > resourceGroupsInstallIndex); - assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX')")); - assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_CSHARP_VSIX')")); - assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_RESOURCE_GROUPS_VSIX')")); - assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_FUNCTIONS_VSIX')")); + assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS')")); + assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_CSHARP_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS')")); + assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_RESOURCE_GROUPS_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS')")); + assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_FUNCTIONS_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS')")); assert.ok(runner.includes("const executable = isWindows ? (process.env.ComSpec || 'cmd.exe') : displayName;")); assert.ok(runner.includes("const args = isWindows ? ['/d', '/s', '/c', 'func.cmd --version'] : ['--version'];")); assert.ok(runner.includes("const certificatePassword = String.raw`Aspire E2E p@ss'\\word`;")); @@ -645,6 +645,62 @@ suite('E2E launch profile', () => { assert.strictEqual(runStep.includes('continue-on-error:'), false); }); + test('attributes missing VSIX dependencies to the selecting E2E shard', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const runner = fs.readFileSync(path.join(extensionRoot, 'scripts', 'run-e2e.js'), 'utf8'); + const azureFunctionsResolver = runner.slice( + runner.indexOf('function resolveAzureFunctionsVsixPaths()'), + runner.indexOf('function resolveResourceDebugVsixPaths()')); + const resourceDebugResolver = runner.slice( + runner.indexOf('function resolveResourceDebugVsixPaths()'), + runner.indexOf('function validateResourceDebugTools()')); + + assert.ok(azureFunctionsResolver.includes( + "resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS')")); + assert.ok(resourceDebugResolver.includes( + "resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG')")); + assert.ok(runner.includes('`${environmentVariable} is required when ${selectingFeatureFlag}=true.`')); + assert.ok(runner.includes('`${environmentVariable} points to a missing file: ${resolvedPath}. It is required when ${selectingFeatureFlag}=true.`')); + }); + + test('configures Linux ptrace access for packaged Go resource attach', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const runner = fs.readFileSync(path.join(extensionRoot, 'scripts', 'run-e2e.js'), 'utf8'); + const workflow = fs.readFileSync(path.join(extensionRoot, '..', '.github', 'workflows', 'extension-e2e-tests.yml'), 'utf8'); + const resourceDebugPrerequisites = workflow.slice( + workflow.indexOf('- name: Install resource debug E2E prerequisites'), + workflow.indexOf('- name: Set up the JDK for the Java E2E specs')); + + assert.ok(resourceDebugPrerequisites.includes('sudo sysctl --write kernel.yama.ptrace_scope=0')); + assert.ok(resourceDebugPrerequisites.includes('test "$(cat /proc/sys/kernel/yama/ptrace_scope)" = "0"')); + assert.ok(runner.includes("const ptraceScopePath = '/proc/sys/kernel/yama/ptrace_scope';")); + assert.ok(runner.includes('The resource debug E2E shard requires kernel.yama.ptrace_scope=0')); + }); + + test('disables Go optimizations for the packaged attach breakpoint proof', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const runner = fs.readFileSync(path.join(extensionRoot, 'scripts', 'run-e2e.js'), 'utf8'); + const resourceDebugSpec = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'resourceDebugTools.e2e.test.ts'), 'utf8'); + + assert.ok(runner.includes('builder.AddGoApp("e2e-go", "../AspireE2E.Go", gcFlags: "all=-N -l")')); + assert.ok(runner.includes('.WithCommand("./test-tools/go")')); + assert.ok(runner.includes("['build', '-gcflags=all=-N -l', '-o', debugExecutable, '.']")); + assert.ok(runner.includes("'go-build-debug', 'exe'")); + assert.ok(resourceDebugSpec.includes("marker: 'message := \"go-ok\"'")); + assert.ok(resourceDebugSpec.includes("proof.proof, 'aspire-resource-attach-breakpoint-detach'")); + }); + + test('keeps packaged attach failures diagnosable before the outer timeout', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const bridge = fs.readFileSync(path.join(extensionRoot, 'src', 'testing', 'e2eStateFileBridge.ts'), 'utf8'); + const resourceDebugSpec = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'resourceDebugTools.e2e.test.ts'), 'utf8'); + + assert.ok(bridge.includes('Resource debug E2E ${session.type} setBreakpoints response:')); + assert.ok(bridge.includes('Resource debug E2E first traffic response for resource')); + assert.ok(bridge.includes('Resource debug E2E attach proof failed:')); + assert.ok(resourceDebugSpec.includes('{ timeoutMs: 360000 }')); + }); + test('wires structured E2E harness failures into advisory handling', () => { const extensionRoot = path.resolve(__dirname, '..', '..'); const runner = fs.readFileSync(path.join(extensionRoot, 'scripts', 'run-e2e.js'), 'utf8'); diff --git a/extension/src/test/e2eStateFileBridge.test.ts b/extension/src/test/e2eStateFileBridge.test.ts index 807389667d3..6e2b7cd01b4 100644 --- a/extension/src/test/e2eStateFileBridge.test.ts +++ b/extension/src/test/e2eStateFileBridge.test.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { AspireExtensionContext } from '../AspireExtensionContext'; import { registerTreeViewCommands } from '../activation/registerTreeViewCommands'; import { AppHostDataRepository, ViewMode } from '../data/AppHostDataRepository'; +import type { ResourceDebugger } from '../debugger/resourceDebugContracts'; import { AppHostLaunchService } from '../services/AppHostLaunchService'; import { executeE2eControlCommand } from '../testing/e2eStateFileBridge'; import { pipelineInteractionCapability } from '../types/configInfo'; @@ -24,6 +25,13 @@ function createLaunchService(): AppHostLaunchService { }); } +function createResourceDebugger(): ResourceDebugger { + return { + canAttachToResource: () => false, + debug: async () => ({ outcome: 'unsupportedResource' }), + }; +} + suite('E2E state file bridge', () => { let sandbox: sinon.SinonSandbox; @@ -72,7 +80,7 @@ suite('E2E state file bridge', () => { pipelineInteractionCapability, ], }); - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, createResourceDebugger()); const registeredCommands = captureRegisteredTreeCommands(sandbox, provider, repository); sandbox.stub(vscode.commands, 'executeCommand').callsFake(async (commandId: string, ...args: unknown[]) => { const command = registeredCommands.get(commandId); @@ -109,7 +117,7 @@ suite('E2E state file bridge', () => { const repository = createRepository(['/repo/primary/AppHost/AppHost.csproj']); const terminalProvider = {} as AspireTerminalProvider; const launchService = createLaunchService(); - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, createResourceDebugger()); const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves(undefined); await assert.rejects( @@ -133,7 +141,7 @@ suite('E2E state file bridge', () => { const terminalProvider = {} as AspireTerminalProvider; const launchService = createLaunchService(); const launchStub = sandbox.stub(launchService, 'launch').resolves(); - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, createResourceDebugger()); const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves('refreshed'); const markStarted = sandbox.spy(); diff --git a/extension/src/test/goDebugger.test.ts b/extension/src/test/goDebugger.test.ts index 770db293e16..2f527ee4ecc 100644 --- a/extension/src/test/goDebugger.test.ts +++ b/extension/src/test/goDebugger.test.ts @@ -4,14 +4,148 @@ import * as vscode from 'vscode'; import { getSupportedCapabilities } from '../capabilities'; import { AspireDebugSession } from '../debugger/AspireDebugSession'; import { getResourceDebuggerExtensions } from '../debugger/debuggerExtensions'; -import { goDebuggerExtension } from '../debugger/languages/go'; +import { createGoResourceAttachProvider, goDebuggerExtension, goResourceAttachProvider } from '../debugger/languages/go'; +import { extensionResourceAttachProviders } from '../debugger/resourceAttachProviders'; +import type { ResourceAttachProvider, ResourceDebugResourceSnapshot } from '../debugger/resourceDebugContracts'; import { AspireResourceExtendedDebugConfiguration, GoLaunchConfiguration } from '../dcp/types'; +interface GoProcessResolver { + resolveApplicationPid(parentPid: number, cancellationToken?: vscode.CancellationToken): Promise; +} + +function createGoResource(overrides: Partial = {}): ResourceDebugResourceSnapshot { + return { + name: 'api', + displayName: 'API', + resourceType: 'Executable', + state: 'Running', + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go', + 'executable.pid': 123, + }, + ...overrides, + }; +} + +function createGoAttachProvider(resolvedProcessId = 456): { + provider: ResourceAttachProvider; + resolver: GoProcessResolver & { parentPids: number[] }; +} { + const resolver: GoProcessResolver & { parentPids: number[] } = { + parentPids: [], + async resolveApplicationPid(parentPid: number): Promise { + this.parentPids.push(parentPid); + return resolvedProcessId; + }, + }; + + return { + provider: createGoResourceAttachProvider(resolver), + resolver, + }; +} + suite('Go Debugger Extension Tests', () => { const fakeAspireDebugSession = {} as AspireDebugSession; teardown(() => sinon.restore()); + test('exposes an attach provider independently from the Go launch provider', () => { + assert.notStrictEqual(goResourceAttachProvider, goDebuggerExtension); + }); + + test('keeps Go after .NET in the extension resource attach provider registry', () => { + assert.deepStrictEqual(extensionResourceAttachProviders.map(provider => provider.id), ['dotnet', 'go']); + }); + + test('recognizes only Go launch-configuration resources with Go executable metadata', () => { + const { provider } = createGoAttachProvider(); + + assert.strictEqual(provider.canRecognizeResource(createGoResource()), true); + assert.strictEqual(provider.canRecognizeResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'node', + 'executable.path': 'node', + 'executable.pid': 123, + }, + })), false); + assert.strictEqual(provider.canRecognizeResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'python', + 'executable.pid': 123, + }, + })), false); + assert.strictEqual(provider.canRecognizeResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'bun', + 'executable.pid': 123, + }, + })), false); + assert.strictEqual(provider.canRecognizeResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'GO', + 'executable.path': 'go', + 'executable.pid': 123, + }, + })), false); + }); + + test('accepts numeric and numeric-string Go parent process IDs', () => { + const { provider } = createGoAttachProvider(); + + assert.strictEqual(provider.canAttachToResource(createGoResource()), true); + assert.strictEqual(provider.canAttachToResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go.exe', + 'executable.pid': '123', + }, + })), true); + }); + + test('requires a running Go resource with a valid parent process ID', () => { + const { provider } = createGoAttachProvider(); + + assert.strictEqual(provider.canRecognizeResource(createGoResource({ state: 'Finished' })), true); + assert.strictEqual(provider.canAttachToResource(createGoResource({ state: 'Finished' })), false); + assert.strictEqual(provider.canAttachToResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go', + 'executable.pid': '12.5', + }, + })), false); + assert.strictEqual(provider.canAttachToResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go', + 'executable.pid': 0, + }, + })), false); + }); + + test('creates the exact Go attach configuration for the resolved application process', async () => { + const { provider, resolver } = createGoAttachProvider(456); + + const configuration = await provider.createDebugConfiguration(createGoResource({ + displayName: null, + name: 'api', + })); + + assert.deepStrictEqual(configuration, { + type: 'go', + request: 'attach', + mode: 'local', + debugAdapter: 'dlv-dap', + name: 'Attach debugger: api', + processId: 456, + }); + assert.deepStrictEqual(resolver.parentPids, [123]); + }); + test('advertises Go support when the Go extension is installed', () => { sinon.stub(vscode.extensions, 'getExtension').callsFake((extensionId: string) => { return extensionId === 'golang.go' ? { id: extensionId } as vscode.Extension : undefined; diff --git a/extension/src/test/goProcessDiscovery.test.ts b/extension/src/test/goProcessDiscovery.test.ts new file mode 100644 index 00000000000..c3fb77b61de --- /dev/null +++ b/extension/src/test/goProcessDiscovery.test.ts @@ -0,0 +1,289 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { createGoRunProcessIdentity } from '../debugger/languages/go'; +import { + LaunchedChildProcessResolver, + parsePosixProcessList, + parseWindowsProcessList, + SystemLaunchedChildProcessQuery, + type LaunchedChildProcess as GoProcessInfo, + type LaunchedChildProcessClock as GoProcessDiscoveryClock, + type LaunchedChildProcessCommandRunner as GoProcessCommandRunner, + type LaunchedChildProcessQuery as GoProcessQuery, +} from '../debugger/launchedChildProcessDiscovery'; + +class TestClock implements GoProcessDiscoveryClock { + private _now = 0; + + now(): number { + return this._now; + } + + async sleep(milliseconds: number, cancellationToken?: vscode.CancellationToken): Promise { + if (cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + this._now += milliseconds; + } +} + +class SequenceProcessQuery implements GoProcessQuery { + private _index = 0; + + constructor(private readonly _snapshots: readonly (readonly GoProcessInfo[] | Error)[]) { + } + + async listProcesses(): Promise { + const snapshot = this._snapshots[Math.min(this._index, this._snapshots.length - 1)]; + this._index++; + if (snapshot instanceof Error) { + throw snapshot; + } + + return snapshot; + } +} + +function process(pid: number, parentPid: number, executable: string, command = executable): GoProcessInfo { + return { pid, parentPid, executable, command }; +} + +function goRunProcessTree(applicationPid = 42): readonly GoProcessInfo[] { + return [ + process(10, 1, '/usr/local/go/bin/go', 'go run ./cmd/api'), + process(22, 10, '/usr/local/go/pkg/tool/darwin_arm64/compile'), + process(33, 22, '/usr/local/go/pkg/tool/darwin_arm64/link'), + process(applicationPid, 33, `/private/var/folders/x/go-build123/b001/exe/api`, `/private/var/folders/x/go-build123/b001/exe/api --port 8080`), + ]; +} + +function createGoRunApplicationProcessResolver( + processQuery: GoProcessQuery, + clock?: GoProcessDiscoveryClock, + options?: { readonly timeoutMs?: number; readonly retryDelayMs?: number }, +): { resolveApplicationPid(goProcessId: number, cancellationToken?: vscode.CancellationToken): Promise } { + const resolver = new LaunchedChildProcessResolver(processQuery, clock, options); + const identity = createGoRunProcessIdentity(); + return { + resolveApplicationPid: async (goProcessId, cancellationToken) => + await resolver.resolveProcessId(goProcessId, identity, cancellationToken), + }; +} + +suite('Go process discovery', () => { + teardown(() => sinon.restore()); + + test('parses POSIX process topology without retaining incomplete rows', () => { + assert.deepStrictEqual(parsePosixProcessList([ + ' 10 1', + ' 42 10', + 'not a process row', + ].join('\n')), [ + process(10, 1, '', ''), + process(42, 10, '', ''), + ]); + }); + + test('parses Windows CIM process listings', () => { + assert.deepStrictEqual(parseWindowsProcessList(JSON.stringify([ + { + ProcessId: 10, + ParentProcessId: 1, + Name: 'go.exe', + ExecutablePath: 'C:\\Go\\bin\\go.exe', + CommandLine: 'go run .\\cmd\\api', + }, + { + ProcessId: 42, + ParentProcessId: 10, + Name: 'api.exe', + ExecutablePath: 'C:\\Users\\me\\AppData\\Local\\Temp\\go-build123\\b001\\exe\\api.exe', + CommandLine: 'C:\\Users\\me\\AppData\\Local\\Temp\\go-build123\\b001\\exe\\api.exe', + }, + ])), [ + process(10, 1, 'C:\\Go\\bin\\go.exe', 'go run .\\cmd\\api'), + process(42, 10, 'C:\\Users\\me\\AppData\\Local\\Temp\\go-build123\\b001\\exe\\api.exe', 'C:\\Users\\me\\AppData\\Local\\Temp\\go-build123\\b001\\exe\\api.exe'), + ]); + }); + + test('uses fixed platform-specific process discovery commands', async () => { + const calls: Array<{ command: string; args: readonly string[] }> = []; + const commandRunner: GoProcessCommandRunner = { + async run(command, args): Promise { + calls.push({ command, args }); + return command === 'ps' + ? '10 1 /usr/local/go/bin/go go run ./cmd/api' + : JSON.stringify({ + ProcessId: 10, + ParentProcessId: 1, + Name: 'go.exe', + ExecutablePath: 'C:\\Go\\bin\\go.exe', + CommandLine: 'go run .\\cmd\\api', + }); + }, + }; + + await new SystemLaunchedChildProcessQuery('linux', commandRunner).listProcesses(); + await new SystemLaunchedChildProcessQuery('win32', commandRunner).listProcesses(); + + assert.deepStrictEqual(calls, [ + { + command: 'ps', + args: ['-axo', 'pid=,ppid='], + }, + { + command: 'powershell.exe', + args: [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + '$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress', + ], + }, + ]); + }); + + test('traverses nested children and ignores Go toolchain processes', async () => { + const resolver = createGoRunApplicationProcessResolver( + new SequenceProcessQuery([goRunProcessTree(), goRunProcessTree()]), + new TestClock(), + { timeoutMs: 100, retryDelayMs: 10 }); + + assert.strictEqual(await resolver.resolveApplicationPid(10), 42); + }); + + test('resolves a cached Go run application and ignores linker output paths', async () => { + const cachedApplication = process( + 42, + 10, + '/Users/me/Library/Caches/go-build/8a/8a26e5d38d9d4f6e7c8b0a1d2e3f4c5b6a7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f-d/api', + '/Users/me/Library/Caches/go-build/8a/8a26e5d38d9d4f6e7c8b0a1d2e3f4c5b6a7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f-d/api --port 8080'); + const linker = process( + 33, + 10, + '/usr/local/go/pkg/tool/darwin_arm64/link', + '/usr/local/go/pkg/tool/darwin_arm64/link -o /private/var/folders/x/go-build123/b001/exe/api'); + const processTree = [ + process(10, 1, '/usr/local/go/bin/go', 'go run ./cmd/api'), + linker, + cachedApplication, + ]; + const resolver = createGoRunApplicationProcessResolver( + new SequenceProcessQuery([processTree, processTree]), + new TestClock(), + { timeoutMs: 100, retryDelayMs: 10 }); + + assert.strictEqual(await resolver.resolveApplicationPid(10), 42); + }); + + test('does not trust a Go launcher command when executable identity differs', () => { + const identity = createGoRunProcessIdentity(); + + assert.strictEqual(identity.isLauncher(process( + 10, + 1, + '/Users/me/Very', + '/Users/me/Very Long Go Installation/bin/go run ./cmd/api')), false); + assert.strictEqual(identity.isLauncher(process( + 11, + 1, + '/bin/bash', + 'bash -c "go run ./cmd/api"')), false); + assert.strictEqual(identity.isCandidate(process( + 42, + 10, + '/usr/bin/other', + '/private/var/folders/x/go-build123/b001/exe/api')), false); + }); + + test('waits for the same Go build application candidate twice', async () => { + const resolver = createGoRunApplicationProcessResolver( + new SequenceProcessQuery([ + goRunProcessTree(42), + goRunProcessTree(43), + goRunProcessTree(43), + ]), + new TestClock(), + { timeoutMs: 100, retryDelayMs: 10 }); + + assert.strictEqual(await resolver.resolveApplicationPid(10), 43); + }); + + test('fails closed when no Go build application process exists', async () => { + const resolver = createGoRunApplicationProcessResolver( + new SequenceProcessQuery([ + [process(10, 1, '/usr/local/go/bin/go', 'go run ./cmd/api')], + ]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveApplicationPid(10)); + }); + + test('fails closed when Go build application candidates are ambiguous', async () => { + const resolver = createGoRunApplicationProcessResolver( + new SequenceProcessQuery([ + [ + ...goRunProcessTree(42), + process(43, 10, '/private/var/folders/x/go-build456/b001/exe/worker'), + ], + ]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveApplicationPid(10)); + }); + + test('fails closed when the reported Go parent process was reused', async () => { + const resolver = createGoRunApplicationProcessResolver( + new SequenceProcessQuery([ + [ + process(10, 1, '/bin/bash', 'bash build.sh'), + process(42, 10, '/private/var/folders/x/go-build123/b001/exe/api'), + ], + ]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveApplicationPid(10)); + }); + + test('fails within its bounded timeout without a stable candidate', async () => { + const resolver = createGoRunApplicationProcessResolver( + new SequenceProcessQuery([ + goRunProcessTree(42), + goRunProcessTree(43), + goRunProcessTree(42), + ]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveApplicationPid(10)); + }); + + test('propagates cancellation and process-query failures without process details', async () => { + const cancellation = new vscode.CancellationTokenSource(); + const failedResolver = createGoRunApplicationProcessResolver( + new SequenceProcessQuery([new Error('/private/go-build123/b001/exe/api 4242')]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + const cancelledResolver = createGoRunApplicationProcessResolver( + new SequenceProcessQuery([goRunProcessTree()]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + try { + await assert.rejects( + failedResolver.resolveApplicationPid(10), + error => error instanceof Error && !/go-build|4242/.test(error.message)); + cancellation.cancel(); + await assert.rejects(cancelledResolver.resolveApplicationPid(10, cancellation.token), vscode.CancellationError); + } + finally { + cancellation.dispose(); + } + }); +}); diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts new file mode 100644 index 00000000000..57d7ee9b2c8 --- /dev/null +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -0,0 +1,904 @@ +import * as assert from 'assert'; +import type * as childProcess from 'child_process'; +import { EventEmitter } from 'events'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { + LaunchedChildProcessResolver, + parseMacOsTextExecutablePath, + parsePosixProcessList, + parseWindowsProcessList, + SystemLaunchedChildProcessQuery, + SystemLaunchedChildProcessCommandRunner, + type LaunchedChildProcess, + type LaunchedChildProcessClock, + type LaunchedChildProcessCommandRunner, + type LaunchedChildProcessFileSystem, + type LaunchedChildProcessIdentity, + type LaunchedChildProcessQuery, +} from '../debugger/launchedChildProcessDiscovery'; + +class TestClock implements LaunchedChildProcessClock { + private _now = 0; + + now(): number { + return this._now; + } + + async sleep(milliseconds: number, cancellationToken?: vscode.CancellationToken): Promise { + if (cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + this._now += milliseconds; + } +} + +class SequenceProcessQuery implements LaunchedChildProcessQuery { + private _index = 0; + + constructor(private readonly _snapshots: readonly (readonly LaunchedChildProcess[] | Error)[]) { + } + + async listProcesses(): Promise { + const snapshot = this._snapshots[Math.min(this._index, this._snapshots.length - 1)]; + this._index++; + if (snapshot instanceof Error) { + throw snapshot; + } + + return snapshot; + } +} + +function process( + pid: number, + parentPid: number, + executable: string, + command = executable, + commandLineArguments?: readonly string[], +): LaunchedChildProcess { + return { + pid, + parentPid, + executable, + command, + ...(commandLineArguments ? { commandLineArguments } : {}), + }; +} + +function createCommandProcess(): childProcess.ChildProcessWithoutNullStreams { + const child = new EventEmitter() as childProcess.ChildProcessWithoutNullStreams; + const stdout = Object.assign(new EventEmitter(), { setEncoding: () => { } }); + const stderr = Object.assign(new EventEmitter(), { resume: sinon.stub() }); + Object.assign(child, { + killed: false, + stdout, + stderr, + kill: sinon.stub().callsFake(() => { + (child as unknown as { killed: boolean }).killed = true; + return true; + }), + }); + return child; +} + +const identity: LaunchedChildProcessIdentity = { + isLauncher: candidate => candidate.executable === '/tool/launcher', + isCandidate: candidate => candidate.executable.includes('/target/'), +}; + +function createLinuxProcessQuery( + commandRunner: LaunchedChildProcessCommandRunner, + fileSystem: LaunchedChildProcessFileSystem, +): SystemLaunchedChildProcessQuery { + return new SystemLaunchedChildProcessQuery('linux', commandRunner, fileSystem); +} + +function createResolver( + query: LaunchedChildProcessQuery, + timeoutMs: number, + clock: LaunchedChildProcessClock = new TestClock(), +): LaunchedChildProcessResolver { + return new LaunchedChildProcessResolver(query, clock, { timeoutMs, retryDelayMs: 10 }); +} + +suite('Launched child process discovery', () => { + teardown(() => sinon.restore()); + + test('parses POSIX topology listings without process identity fields', () => { + assert.deepStrictEqual(parsePosixProcessList([ + ' 10 1', + ' 42 10', + 'not a process row', + ].join('\n')), [ + process(10, 1, '', ''), + process(42, 10, '', ''), + ]); + }); + + test('parses complete and incomplete Windows CIM process listings', () => { + const cases = [ + { + label: 'complete identity', + commandLine: 'C:\\target\\api.exe', + expectedCommand: 'C:\\target\\api.exe', + }, + { + label: 'missing command preserves empty command', + commandLine: null, + expectedCommand: '', + }, + ] as const; + + for (const { label, commandLine, expectedCommand } of cases) { + const processes = parseWindowsProcessList(JSON.stringify({ + ProcessId: 42, + ParentProcessId: 10, + Name: 'api.exe', + ExecutablePath: 'C:\\target\\api.exe', + CommandLine: commandLine, + })); + + assert.deepStrictEqual(processes, [ + process(42, 10, 'C:\\target\\api.exe', expectedCommand), + ], label); + assert.deepStrictEqual(Object.keys(processes[0]), [ + 'pid', + 'parentPid', + 'executable', + 'command', + ], label); + } + }); + + test('trusts listed process identity only on Windows', () => { + const cases = [ + { platform: 'win32', expected: true }, + { platform: 'darwin', expected: false }, + { platform: 'linux', expected: false }, + ] as const; + + for (const { platform, expected } of cases) { + assert.strictEqual( + new SystemLaunchedChildProcessQuery(platform).canTrustListedProcessIdentity, + expected, + platform); + } + }); + + test('trusts the Windows Name fallback when ExecutablePath is unavailable', async () => { + const targetedProcessReads: number[] = []; + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(_command, args): Promise { + const command = args.at(-1) ?? ''; + const processIdMatch = /ProcessId = (\d+)/.exec(command); + if (processIdMatch) { + const processId = Number(processIdMatch[1]); + targetedProcessReads.push(processId); + return JSON.stringify(processId === 10 + ? { + ProcessId: 10, + ParentProcessId: 1, + Name: 'launcher.exe', + ExecutablePath: 'C:\\tool\\launcher.exe', + CommandLine: '"C:\\tool\\launcher.exe" --run', + } + : { + ProcessId: 42, + ParentProcessId: 10, + Name: 'api.exe', + ExecutablePath: 'C:\\target\\api.exe', + CommandLine: '"C:\\target\\api.exe" --listen', + }); + } + + return JSON.stringify([ + { + ProcessId: 10, + ParentProcessId: 1, + Name: 'launcher.exe', + ExecutablePath: null, + CommandLine: '"C:\\tool\\launcher.exe" --run', + }, + { + ProcessId: 42, + ParentProcessId: 10, + Name: 'api.exe', + ExecutablePath: 'C:\\target\\api.exe', + CommandLine: '', + }, + ]); + }, + }; + const resolver = createResolver( + new SystemLaunchedChildProcessQuery('win32', commandRunner), + 20); + const windowsIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + isLauncher: candidate => + candidate.executable === 'launcher.exe' && + candidate.command === '"C:\\tool\\launcher.exe" --run', + isCandidate: candidate => + candidate.executable === 'C:\\target\\api.exe' && + candidate.command === '"C:\\target\\api.exe" --listen', + }; + + assert.strictEqual(await resolver.resolveProcessId(10, windowsIdentity), 42); + assert.deepStrictEqual(targetedProcessReads, [42, 42, 42]); + }); + + test('parses UTF-8 BOM-prefixed Windows CIM output with non-ASCII command text', () => { + assert.deepStrictEqual(parseWindowsProcessList(`\uFEFF${JSON.stringify({ + ProcessId: 42, + ParentProcessId: 10, + Name: 'api.exe', + ExecutablePath: 'C:\\target\\über api.exe', + CommandLine: '"C:\\target\\über api.exe" --name "日本語"', + })}`), [ + process(42, 10, 'C:\\target\\über api.exe', '"C:\\target\\über api.exe" --name "日本語"'), + ]); + }); + + test('reads exact Linux process details from procfs without using truncated ps command output', async () => { + const calls: Array<{ command: string; args: readonly string[] }> = []; + const fileSystemCalls: string[] = []; + const executablePath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service'; + const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll'; + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(command, args): Promise { + calls.push({ command, args }); + if (command === 'ps') { + if (args.join(' ') === '-axo pid=,ppid=') { + return '10 1\n42 10'; + } + + throw new Error(`Unexpected ps query: ${args.join(' ')}`); + } + + return JSON.stringify({ + ProcessId: 10, + ParentProcessId: 1, + Name: 'launcher.exe', + ExecutablePath: 'C:\\tool\\launcher.exe', + CommandLine: 'launcher --run', + }); + }, + }; + const fileSystem: LaunchedChildProcessFileSystem = { + async readlink(path): Promise { + fileSystemCalls.push(path); + assert.strictEqual(path, '/proc/42/exe'); + return executablePath; + }, + async readFile(path): Promise { + fileSystemCalls.push(path); + switch (path) { + case '/proc/42/cmdline': + return Buffer.from(['/usr/local/share/dotnet/dotnet', 'exec', targetPath, '', '--urls', 'http://localhost:5000'].join('\0') + '\0'); + case '/proc/42/status': + return Buffer.from('Name:\tMy Attach Service\nPid:\t42\nPPid:\t10\nNSpid:\t42\n'); + default: + throw new Error(`Unexpected procfs path: ${path}`); + } + }, + }; + + const query = createLinuxProcessQuery(commandRunner, fileSystem); + assert.deepStrictEqual(await query.listProcesses(), [ + process(10, 1, '', ''), + process(42, 10, '', ''), + ]); + assert.deepStrictEqual(await query.getProcess(42), process( + 42, + 10, + executablePath, + `/usr/local/share/dotnet/dotnet exec ${targetPath} --urls http://localhost:5000`, + ['/usr/local/share/dotnet/dotnet', 'exec', targetPath, '', '--urls', 'http://localhost:5000'])); + await query.getProcess(42); + await new SystemLaunchedChildProcessQuery('win32', commandRunner).listProcesses(); + + assert.deepStrictEqual(calls, [ + { + command: 'ps', + args: ['-axo', 'pid=,ppid='], + }, + { + command: 'powershell.exe', + args: [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + '$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress', + ], + }, + ]); + assert.deepStrictEqual(fileSystemCalls.sort(), [ + '/proc/42/cmdline', + '/proc/42/cmdline', + '/proc/42/exe', + '/proc/42/exe', + '/proc/42/status', + '/proc/42/status', + ]); + }); + + test('strips only the exact trailing Linux procfs deleted executable marker', async () => { + const deletedExecutable = '/repo/bin/Debug/net10.0/Api (deleted)'; + const nonMarkerSuffix = `${deletedExecutable} after-restart`; + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(): Promise { + throw new Error('The process topology should not be queried.'); + }, + }; + const fileSystem: LaunchedChildProcessFileSystem = { + async readlink(path): Promise { + switch (path) { + case '/proc/42/exe': + return deletedExecutable; + case '/proc/43/exe': + return nonMarkerSuffix; + default: + throw new Error(`Unexpected procfs path: ${path}`); + } + }, + async readFile(path): Promise { + switch (path) { + case '/proc/42/cmdline': + case '/proc/43/cmdline': + return Buffer.from('/repo/bin/Debug/net10.0/Api\0'); + case '/proc/42/status': + case '/proc/43/status': + return Buffer.from('Name:\tApi\nPPid:\t10\n'); + default: + throw new Error(`Unexpected procfs path: ${path}`); + } + }, + }; + const query = createLinuxProcessQuery(commandRunner, fileSystem); + + assert.strictEqual((await query.getProcess(42))?.executable, '/repo/bin/Debug/net10.0/Api'); + assert.strictEqual((await query.getProcess(43))?.executable, nonMarkerSuffix); + }); + + test('observes rejecting procfs reads before returning an already requested cancellation', async () => { + const cancellation = new vscode.CancellationTokenSource(); + cancellation.cancel(); + let unhandledRejection: unknown; + const captureUnhandledRejection = (reason: unknown) => { + unhandledRejection = reason; + }; + globalThis.process.once('unhandledRejection', captureUnhandledRejection); + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(): Promise { + throw new Error('The process topology should not be queried.'); + }, + }; + const fileSystem: LaunchedChildProcessFileSystem = { + readlink: () => Promise.reject(new Error('readlink failed')), + readFile: () => Promise.reject(new Error('readFile failed')), + }; + + try { + await assert.rejects( + createLinuxProcessQuery(commandRunner, fileSystem).getProcess(42, cancellation.token), + error => error instanceof vscode.CancellationError); + await new Promise(resolve => setImmediate(resolve)); + + assert.strictEqual(unhandledRejection, undefined); + } + finally { + globalThis.process.removeListener('unhandledRejection', captureUnhandledRejection); + cancellation.dispose(); + } + }); + + test('resolves a macOS child with a spaced non-ASCII executable path from lsof identity', async () => { + const calls: Array<{ command: string; args: readonly string[] }> = []; + const processDetails = new Map([ + [10, { parentPid: 1, executable: '/tool/launcher', command: '/tool/launcher --run' }], + [42, { + parentPid: 10, + executable: '/repo/OneDrive - Microsoft/über-long-path/My Attach Service', + command: '"/repo/OneDrive - Microsoft/über-long-path/My Attach Service" --urls http://localhost:5000', + }], + ]); + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(command, args): Promise { + calls.push({ command, args }); + if (command === 'lsof') { + const processId = Number(args[2]); + const details = processDetails.get(processId); + if (!details) { + throw new Error(`Unexpected process ID: ${processId}`); + } + + return `p${processId}\nftxt\nn${details.executable}\n`; + } + + assert.strictEqual(command, 'ps'); + if (args.join(' ') === '-axo pid=,ppid=') { + return '10 1\n42 10'; + } + + const processId = Number(args[1]); + const details = processDetails.get(processId); + if (!details) { + throw new Error(`Unexpected process ID: ${processId}`); + } + + switch (args[args.length - 1]) { + case 'ppid=': + return String(details.parentPid); + case 'args=': + return details.command; + default: + throw new Error(`Unexpected ps query: ${args.join(' ')}`); + } + }, + }; + const resolver = createResolver( + new SystemLaunchedChildProcessQuery('darwin', commandRunner), + 100); + const spacedTargetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service'; + const exactPathIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + isLauncher: candidate => candidate.executable === '/tool/launcher', + isCandidate: candidate => candidate.executable === spacedTargetPath, + }; + + assert.strictEqual(await resolver.resolveProcessId(10, exactPathIdentity), 42); + assert.ok(calls.every(call => call.args.join(' ') !== '-axo pid=,ppid=,comm=,args=')); + }); + + test('parses only the requested macOS lsof text executable record', () => { + const output = 'p42\nftxt\nn/Applications/My Long App.app/Contents/MacOS/My Long App\n'; + + assert.strictEqual( + parseMacOsTextExecutablePath(output, 42), + '/Applications/My Long App.app/Contents/MacOS/My Long App'); + assert.strictEqual(parseMacOsTextExecutablePath(output, 43), undefined); + assert.strictEqual(parseMacOsTextExecutablePath('p42\nfcwd\nn/repo\n', 42), undefined); + }); + + test('retries when a Linux candidate exits between topology and procfs reads', async () => { + const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll'; + let candidateReadAttempts = 0; + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(command, args): Promise { + assert.strictEqual(command, 'ps'); + assert.deepStrictEqual(args, ['-axo', 'pid=,ppid=']); + return '10 1\n42 10'; + }, + }; + const fileSystem: LaunchedChildProcessFileSystem = { + async readlink(path): Promise { + if (path === '/proc/42/exe') { + candidateReadAttempts++; + if (candidateReadAttempts === 1) { + throw new Error('Process exited.'); + } + + return '/usr/local/share/dotnet/dotnet'; + } + + assert.strictEqual(path, '/proc/10/exe'); + return '/tool/launcher'; + }, + async readFile(path): Promise { + switch (path) { + case '/proc/10/cmdline': + return Buffer.from('/tool/launcher\0'); + case '/proc/10/status': + return Buffer.from('Name:\tlauncher\nPPid:\t1\n'); + case '/proc/42/cmdline': + return Buffer.from(`/usr/local/share/dotnet/dotnet\0exec\0${targetPath}\0`); + case '/proc/42/status': + return Buffer.from('Name:\tdotnet\nPPid:\t10\n'); + default: + throw new Error(`Unexpected procfs path: ${path}`); + } + }, + }; + const resolver = createResolver(createLinuxProcessQuery(commandRunner, fileSystem), 100); + const frameworkDependentIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + isLauncher: candidate => candidate.executable === '/tool/launcher', + isCandidate: candidate => candidate.executable === '/usr/local/share/dotnet/dotnet' && + candidate.commandLineArguments?.includes(targetPath) === true, + }; + + assert.strictEqual(await resolver.resolveProcessId(10, frameworkDependentIdentity), 42); + assert.ok(candidateReadAttempts >= 3); + }); + + test('fails closed after repeated Linux procfs permission errors', async () => { + let candidateReadAttempts = 0; + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(command, args): Promise { + assert.strictEqual(command, 'ps'); + assert.deepStrictEqual(args, ['-axo', 'pid=,ppid=']); + return '10 1\n42 10'; + }, + }; + const fileSystem: LaunchedChildProcessFileSystem = { + async readlink(path): Promise { + if (path === '/proc/42/exe') { + candidateReadAttempts++; + throw new Error('EACCES'); + } + + assert.strictEqual(path, '/proc/10/exe'); + return '/tool/launcher'; + }, + async readFile(path): Promise { + switch (path) { + case '/proc/10/cmdline': + return Buffer.from('/tool/launcher\0'); + case '/proc/10/status': + return Buffer.from('Name:\tlauncher\nPPid:\t1\n'); + case '/proc/42/cmdline': + return Buffer.from('/target/api\0'); + case '/proc/42/status': + return Buffer.from('Name:\tapi\nPPid:\t10\n'); + default: + throw new Error(`Unexpected procfs path: ${path}`); + } + }, + }; + const resolver = createResolver(createLinuxProcessQuery(commandRunner, fileSystem), 20); + + await assert.rejects(resolver.resolveProcessId(10, identity)); + assert.ok(candidateReadAttempts >= 2); + }); + + test('command runner returns UTF-8/BOM output after draining stderr', async () => { + const child = createCommandProcess(); + const result = new SystemLaunchedChildProcessCommandRunner(() => child).run('ps', [], undefined, 100); + + child.stdout.emit('data', '\uFEFF日本語'); + child.stderr.emit('data', 'diagnostic'); + child.emit('close', 0); + + assert.strictEqual(await result, '\uFEFF日本語'); + assert.strictEqual((child.stderr.resume as sinon.SinonStub).calledOnce, true); + }); + + test('command runner rejects and cleans up on nonzero exit, cancellation, timeout, and output cap', async () => { + const children = [createCommandProcess(), createCommandProcess(), createCommandProcess(), createCommandProcess()]; + const spawn = sinon.stub(); + spawn.onCall(0).returns(children[0]); + spawn.onCall(1).returns(children[1]); + spawn.onCall(2).returns(children[2]); + spawn.onCall(3).returns(children[3]); + const runner = new SystemLaunchedChildProcessCommandRunner(spawn); + const cancellation = new vscode.CancellationTokenSource(); + + const nonzero = runner.run('ps', [], undefined, 100); + children[0].emit('close', 1); + await assert.rejects(nonzero); + + const cancelled = runner.run('ps', [], cancellation.token, 100); + cancellation.cancel(); + await assert.rejects(cancelled); + + const capped = runner.run('ps', [], undefined, 100); + children[2].stdout.emit('data', 'x'.repeat(16 * 1024 * 1024 + 1)); + await assert.rejects(capped); + + const timedOut = runner.run('ps', [], undefined, 1); + await assert.rejects(timedOut); + assert.strictEqual((children[0].kill as sinon.SinonStub).called, false); + assert.strictEqual((children[1].kill as sinon.SinonStub).calledOnce, true); + assert.strictEqual((children[2].kill as sinon.SinonStub).calledOnce, true); + assert.strictEqual((children[3].kill as sinon.SinonStub).calledOnce, true); + cancellation.dispose(); + }); + + test('resolves a stable nested child only beneath its launcher', async () => { + const resolver = createResolver( + new SequenceProcessQuery([ + [ + process(10, 1, '/tool/launcher'), + process(22, 10, '/tool/intermediate'), + process(42, 22, '/target/api'), + process(43, 1, '/target/unrelated'), + ], + [ + process(10, 1, '/tool/launcher'), + process(22, 10, '/tool/intermediate'), + process(42, 22, '/target/api'), + process(43, 1, '/target/unrelated'), + ], + ]), + 100); + + assert.strictEqual(await resolver.resolveProcessId(10, identity), 42); + }); + + test('waits for the same matching child twice', async () => { + const resolver = createResolver( + new SequenceProcessQuery([ + [process(10, 1, '/tool/launcher'), process(42, 10, '/target/old')], + [process(10, 1, '/tool/launcher'), process(43, 10, '/target/new')], + [process(10, 1, '/tool/launcher'), process(43, 10, '/target/new')], + ]), + 100); + + assert.strictEqual(await resolver.resolveProcessId(10, identity), 43); + }); + + test('fails closed for a missing or ambiguous matching child', async () => { + const noCandidate = createResolver( + new SequenceProcessQuery([[process(10, 1, '/tool/launcher')]]), + 20); + const ambiguous = createResolver( + new SequenceProcessQuery([[ + process(10, 1, '/tool/launcher'), + process(42, 10, '/target/api'), + process(43, 10, '/target/worker'), + ]]), + 20); + + await assert.rejects(noCandidate.resolveProcessId(10, identity)); + await assert.rejects(ambiguous.resolveProcessId(10, identity)); + }); + + test('fails closed when scoped direct dotnet children are ambiguous', async () => { + const directDotnetIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + isLauncher: candidate => candidate.executable === '/tool/launcher', + isCandidate: candidate => candidate.executable === '/usr/local/share/dotnet/dotnet', + }; + const resolver = createResolver( + new SequenceProcessQuery([[ + process(10, 1, '/tool/launcher'), + process(42, 10, '/usr/local/share/dotnet/dotnet', 'dotnet exec malformed-posix-command'), + process(43, 10, '/usr/local/share/dotnet/dotnet', 'dotnet exec another-malformed-posix-command'), + ]]), + 20); + + await assert.rejects(resolver.resolveProcessId(10, directDotnetIdentity)); + }); + + test('fails closed for a cyclic process listing', async () => { + const cyclic = createResolver( + new SequenceProcessQuery([[ + process(10, 42, '/tool/launcher'), + process(42, 10, '/target/api'), + ]]), + 20); + + await assert.rejects(cyclic.resolveProcessId(10, identity)); + }); + + test('uses trusted complete list identity until the final direct candidate read', async () => { + const getProcess = sinon.stub().callsFake(async (processId: number) => + processId === 42 ? process(42, 10, '/target/api') : undefined); + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => [ + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(42, 10, '/target/api', '/target/api'), + ], + getProcess, + }; + const directIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + ...identity, + }; + const resolver = createResolver(query, 20); + + assert.strictEqual(await resolver.resolveProcessId(10, directIdentity), 42); + assert.deepStrictEqual(getProcess.getCalls().map(call => call.args[0]), [42]); + }); + + test('falls back to targeted identity queries for incomplete trusted list records', async () => { + const directIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + ...identity, + }; + const cases = [ + { + label: 'launcher command is missing', + processes: [ + process(10, 1, '/tool/launcher', ''), + process(42, 10, '/target/api', '/target/api'), + ], + expectedProcessReads: [10, 10, 42], + }, + { + label: 'candidate executable is missing', + processes: [ + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(42, 10, '', '/target/api'), + ], + expectedProcessReads: [42, 42, 42], + }, + ]; + + for (const { label, processes, expectedProcessReads } of cases) { + const getProcess = sinon.stub().callsFake(async (processId: number) => + processId === 10 + ? process(10, 1, '/tool/launcher') + : process(42, 10, '/target/api')); + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => processes, + getProcess, + }; + const resolver = createResolver(query, 20); + + assert.strictEqual(await resolver.resolveProcessId(10, directIdentity), 42, label); + assert.deepStrictEqual( + getProcess.getCalls().map(call => call.args[0]), + expectedProcessReads, + label); + } + }); + + test('rejects direct candidates that exit, are reused, or are reparented before return', async () => { + const directIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + ...identity, + }; + const cases = [ + { label: 'candidate exited', finalCandidate: undefined }, + { label: 'PID reused by another executable', finalCandidate: process(42, 10, '/other/api') }, + { label: 'candidate reparented', finalCandidate: process(42, 99, '/target/api') }, + ]; + + for (const { label, finalCandidate } of cases) { + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => [ + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(42, 10, '/target/api', '/target/api'), + ], + getProcess: async () => finalCandidate, + }; + const resolver = createResolver(query, 20); + + await assert.rejects(resolver.resolveProcessId(10, directIdentity), label); + } + }); + + test('rejects a direct candidate when its final identity read completes after the deadline', async () => { + let now = 0; + const clock: LaunchedChildProcessClock = { + now: () => now, + sleep: async milliseconds => { + now += milliseconds; + }, + }; + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => [ + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(42, 10, '/target/api', '/target/api'), + ], + getProcess: async () => { + now = 21; + return process(42, 10, '/target/api'); + }, + }; + const directIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + ...identity, + }; + const resolver = createResolver(query, 20, clock); + + await assert.rejects(resolver.resolveProcessId(10, directIdentity)); + }); + + test('freshly re-reads the full transitive candidate ancestry', async () => { + const getProcess = sinon.stub().callsFake(async (processId: number) => new Map([ + [10, process(10, 1, '/tool/launcher')], + [22, process(22, 10, '/tool/intermediate')], + [42, process(42, 22, '/target/api')], + ]).get(processId)); + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => [ + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(22, 10, '/tool/intermediate', '/tool/intermediate'), + process(42, 22, '/target/api', '/target/api'), + ], + getProcess, + }; + const resolver = createResolver(query, 20); + + assert.strictEqual(await resolver.resolveProcessId(10, identity), 42); + assert.deepStrictEqual(getProcess.getCalls().map(call => call.args[0]), [42, 22, 10]); + }); + + test('rejects transitive candidates when freshly queried ancestry changes', async () => { + const cases = [ + { + label: 'launcher identity changes', + freshLauncher: process(10, 1, '/tool/other'), + freshIntermediate: process(22, 10, '/tool/intermediate'), + }, + { + label: 'intermediate is reparented', + freshLauncher: process(10, 1, '/tool/launcher'), + freshIntermediate: process(22, 99, '/tool/intermediate'), + }, + ]; + + for (const { label, freshLauncher, freshIntermediate } of cases) { + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => [ + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(22, 10, '/tool/intermediate', '/tool/intermediate'), + process(42, 22, '/target/api', '/target/api'), + ], + getProcess: async processId => new Map([ + [10, freshLauncher], + [22, freshIntermediate], + [42, process(42, 22, '/target/api')], + ]).get(processId), + }; + + await assert.rejects(createResolver(query, 20).resolveProcessId(10, identity), label); + } + }); + + test('re-verifies selected PID ancestry before accepting a process-list candidate', async () => { + const injectedCandidate = process(42, 10, '/target/api', '/target/api'); + const query: LaunchedChildProcessQuery = { + listProcesses: async () => [ + process(10, 1, '/tool/launcher'), + injectedCandidate, + ], + // A newline in another process's command can forge the row above. The direct PID + // query exposes the real parent and must prevent attaching to that unrelated process. + getProcess: async processId => processId === 42 + ? process(42, 99, '/target/api', '/target/api') + : process(10, 1, '/tool/launcher'), + }; + const resolver = createResolver(query, 20); + + await assert.rejects(resolver.resolveProcessId(10, identity)); + }); + + test('normalizes query failures and supports cancellation', async () => { + const failedResolver = createResolver( + new SequenceProcessQuery([new Error('/private/target/api 4242')]), + 20); + const cancellation = new vscode.CancellationTokenSource(); + const cancelledResolver = createResolver( + new SequenceProcessQuery([[process(10, 1, '/tool/launcher')]]), + 20); + + try { + await assert.rejects( + failedResolver.resolveProcessId(10, identity), + error => error instanceof Error && !/target|4242/.test(error.message)); + cancellation.cancel(); + await assert.rejects( + cancelledResolver.resolveProcessId(10, identity, cancellation.token), + vscode.CancellationError); + } + finally { + cancellation.dispose(); + } + }); + + test('propagates cancellation from a per-process identity query', async () => { + const query: LaunchedChildProcessQuery = { + listProcesses: async () => [ + process(10, 1, '/tool/launcher'), + process(42, 10, '/target/api'), + ], + getProcess: async () => { + throw new vscode.CancellationError(); + }, + }; + const resolver = createResolver(query, 20); + + await assert.rejects(resolver.resolveProcessId(10, identity), vscode.CancellationError); + }); +}); diff --git a/extension/src/test/packageManifest.test.ts b/extension/src/test/packageManifest.test.ts index b7d62abb9d3..3939c404a62 100644 --- a/extension/src/test/packageManifest.test.ts +++ b/extension/src/test/packageManifest.test.ts @@ -119,6 +119,20 @@ suite('extension/package.json', () => { assertContains(openResourceTerminal?.when, 'viewItem =~ /^resource.*:canOpenTerminal/'); }); + test('attach debugger context action targets debuggable resources', () => { + const manifest = readManifest(); + const commands = manifest.contributes.commands ?? []; + const contextMenus = manifest.contributes.menus?.['view/item/context'] ?? []; + + const command = commands.find(item => item.command === 'aspire-vscode.attachDebuggerToResource'); + const menuItem = contextMenus.find(item => item.command === 'aspire-vscode.attachDebuggerToResource'); + + assert.ok(command, 'Expected attach debugger command to be contributed'); + assert.strictEqual(command.icon, '$(debug-alt)'); + assertContains(menuItem?.when, 'view == aspire-vscode.appHosts'); + assertContains(menuItem?.when, 'viewItem =~ /^resource.*:canAttachDebugger/'); + }); + test('running apphost context actions only target running apphost contexts', () => { const manifest = readManifest(); const contextMenus = manifest.contributes.menus?.['view/item/context'] ?? []; diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts new file mode 100644 index 00000000000..546ff5d79c4 --- /dev/null +++ b/extension/src/test/resourceDebugService.test.ts @@ -0,0 +1,2008 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import type { AppHostDisplayInfo, ResourceJson } from '../data/AppHostDataRepository'; +import { createProjectResourceAttachProvider, projectDebuggerExtension, projectResourceAttachProvider } from '../debugger/languages/dotnet'; +import { createGoResourceAttachProvider } from '../debugger/languages/go'; +import { ResourceAttachProviderRegistry } from '../debugger/resourceAttachProviders'; +import { ResourceDebugAppHostIdentityComparer, ResourceDebugAppHostRepository, ResourceDebugService, ResourceDebugServiceDependencies } from '../debugger/resourceDebugService'; +import { ResourceDebugSessionEvents, ResourceDebugSessionRegistry, ResourceDebugSessionRegistryOptions } from '../debugger/resourceDebugSessionRegistry'; +import { ResourceAttachConfigurationError, type ResourceAttachProvider, type ResourceDebugAppHostTarget, type ResourceDebugRequest, type ResourceDebugResourceSnapshot, type ResourceDebugResult } from '../debugger/resourceDebugContracts'; +import type { AspireExtendedDebugConfiguration } from '../dcp/types'; +import { extensionLogOutputChannel } from '../utils/logging'; + +const target: ResourceDebugAppHostTarget = { + absolutePath: '/repo/AppHost.csproj', + displayPath: 'AppHost.csproj', +}; +const resolvedTarget: ResourceDebugAppHostTarget = { + ...target, + appHostPid: 42, +}; + +function createResource(overrides: Partial = {}): ResourceJson { + return { + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + stateStyle: null, + healthStatus: null, + healthReports: null, + exitCode: null, + dashboardUrl: null, + urls: null, + commands: null, + properties: { + 'project.path': '/repo/api/Api.csproj', + 'executable.path': 'dotnet', + }, + ...overrides, + }; +} + +function createGoResource(overrides: Partial = {}): ResourceJson { + return createResource({ + resourceType: 'Executable', + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go', + 'executable.pid': '1234', + }, + ...overrides, + }); +} + +function createAppHost(overrides: Partial = {}): AppHostDisplayInfo { + return { + appHostPath: target.absolutePath, + appHostPid: 42, + cliPid: null, + dashboardUrl: null, + resources: [createResource()], + ...overrides, + }; +} + +function createRequest(overrides: Partial = {}): ResourceDebugRequest { + return { + source: 'tree', + strategy: 'attach', + appHost: target, + resourceName: 'api', + ...overrides, + }; +} + +interface RecordedResourceDebugTelemetryEvent { + readonly name: string; + readonly properties: Record; + readonly measurements: Record | undefined; +} + +class TestResourceDebugTelemetry { + public readonly events: RecordedResourceDebugTelemetryEvent[] = []; + public currentTime = 0; + + now(): number { + return this.currentTime; + } + + recordStart(properties: Record): void { + this._record('aspire/vscode/resourcedebug/start', properties); + } + + recordResult(properties: Record, measurements: Record): void { + this._record('aspire/vscode/resourcedebug/result', properties, measurements); + } + + recordSessionEnd(properties: Record, measurements: Record): void { + this._record('aspire/vscode/resourcedebug/session/end', properties, measurements); + } + + private _record(name: string, properties: Record, measurements?: Record): void { + this.events.push({ name, properties, measurements }); + } +} + +class TestResourceDebugClock { + private readonly _timestamps: (number | Error)[]; + + constructor(...timestamps: (number | Error)[]) { + this._timestamps = timestamps; + } + + now(): number { + const timestamp = this._timestamps.shift(); + if (timestamp instanceof Error) { + throw timestamp; + } + + return timestamp ?? 0; + } +} + +function createProvider(overrides: Partial = {}): ResourceAttachProvider { + return { + id: 'dotnet', + requiredDebuggerExtensions: [{ + id: 'ms-dotnettools.csharp', + label: 'C#', + }], + canRecognizeResource: () => true, + canAttachToResource: () => true, + createDebugConfiguration: async () => ({ + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + }), + ...overrides, + }; +} + +class TestDebugSessionEvents implements ResourceDebugSessionEvents { + private _startListener: ((session: vscode.DebugSession) => void) | undefined; + private _terminateListener: ((session: vscode.DebugSession) => void) | undefined; + public startedConfiguration: vscode.DebugConfiguration | undefined; + + onDidStartDebugSession(listener: (session: vscode.DebugSession) => void): vscode.Disposable { + this._startListener = listener; + return new vscode.Disposable(() => { + this._startListener = undefined; + }); + } + + onDidTerminateDebugSession(listener: (session: vscode.DebugSession) => void): vscode.Disposable { + this._terminateListener = listener; + return new vscode.Disposable(() => { + this._terminateListener = undefined; + }); + } + + start(configuration: vscode.DebugConfiguration): void { + this.startedConfiguration = configuration; + this._startListener?.({ + id: 'resource-attach-session', + configuration, + } as vscode.DebugSession); + } + + terminate(configuration: vscode.DebugConfiguration): void { + this._terminateListener?.({ + id: 'resource-attach-session', + configuration, + } as vscode.DebugSession); + } +} + +function createService(options: { + appHosts?: readonly AppHostDisplayInfo[]; + provider?: ResourceAttachProvider; + providers?: readonly ResourceAttachProvider[]; + isExtensionInstalled?: (extensionId: string) => boolean; + startDebugging?: (folder: vscode.WorkspaceFolder | undefined, configuration: vscode.DebugConfiguration) => Thenable; + compareAppHostIdentity?: ResourceDebugAppHostIdentityComparer; + telemetry?: TestResourceDebugTelemetry; + clock?: { now(): number }; + pendingStartTimeoutMs?: number; + isProcessAlreadyDebugged?: (processId: number) => boolean; + getDebugSessionConfiguration?: (appHost: ResourceDebugAppHostTarget) => AspireExtendedDebugConfiguration | undefined; +} = {}): { + service: ResourceDebugService; + repository: ResourceDebugAppHostRepository; + sessions: ResourceDebugSessionRegistry; + events: TestDebugSessionEvents; + telemetry: TestResourceDebugTelemetry; +} { + const repository: ResourceDebugAppHostRepository = { + fetchRunningAppHostsOnce: async () => options.appHosts ?? [createAppHost()], + fetchAppHostResourcesOnce: async (appHostPath, _cancellationToken, appHostPid) => + (options.appHosts ?? [createAppHost()]).find(appHost => + appHost.appHostPath === appHostPath && + (appHostPid === undefined || appHost.appHostPid === appHostPid))?.resources ?? [], + }; + const events = new TestDebugSessionEvents(); + const telemetry = options.telemetry ?? new TestResourceDebugTelemetry(); + const clock = options.clock ?? telemetry; + const sessions = new ResourceDebugSessionRegistry(events, { + pendingStartTimeoutMs: options.pendingStartTimeoutMs, + telemetry, + clock, + } as unknown as ResourceDebugSessionRegistryOptions); + const providers = new ResourceAttachProviderRegistry( + options.providers ?? [options.provider ?? createProvider()], + options.isExtensionInstalled ?? (() => true)); + const service = new ResourceDebugService({ + appHostRepository: repository, + attachProviders: providers, + sessionRegistry: sessions, + startDebugging: options.startDebugging ?? (async () => true), + compareAppHostIdentity: options.compareAppHostIdentity, + telemetry, + clock, + isProcessAlreadyDebugged: options.isProcessAlreadyDebugged, + getDebugSessionConfiguration: options.getDebugSessionConfiguration, + } as unknown as ResourceDebugServiceDependencies); + + return { service, repository, sessions, events, telemetry }; +} + +suite('Resource debug service', () => { + teardown(() => sinon.restore()); + + test('keeps ResourceDebuggerExtension launch-only', () => { + assert.deepStrictEqual( + Object.keys(projectDebuggerExtension).sort(), + [ + 'createDebugSessionConfigurationCallback', + 'debugAdapter', + 'extensionId', + 'getDisplayName', + 'getProjectFile', + 'getSupportedFileTypes', + 'resourceType', + ]); + }); + + test('registers .NET attach behavior independently from the launch provider', () => { + const providers = new ResourceAttachProviderRegistry([projectResourceAttachProvider], () => true); + + assert.strictEqual(providers.getRecognizedProviderForResource(createResource({ + properties: { + 'project.path': '/repo/api/Api.csproj', + 'executable.path': 'dotnet', + 'executable.pid': '42', + }, + }))?.id, 'dotnet'); + }); + + test('uses the first recognized provider for readiness and configuration', async () => { + const firstProvider = createProvider({ + canAttachToResource: sinon.stub().returns(false), + createDebugConfiguration: sinon.stub().rejects(new Error('first provider should not configure')), + }); + + const secondProvider = createProvider({ + canAttachToResource: sinon.stub().returns(true), + createDebugConfiguration: sinon.stub().resolves({ + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: second provider', + }), + }); + const startDebugging = sinon.stub().resolves(true); + const { service, sessions } = createService({ + providers: [firstProvider, secondProvider], + startDebugging, + }); + + try { + assert.strictEqual(service.canAttachToResource(createResource()), false); + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'unsupportedResource' }); + assert.strictEqual((firstProvider.canAttachToResource as sinon.SinonStub).callCount, 2); + assert.strictEqual((firstProvider.createDebugConfiguration as sinon.SinonStub).callCount, 0); + assert.strictEqual((secondProvider.canAttachToResource as sinon.SinonStub).callCount, 0); + assert.strictEqual((secondProvider.createDebugConfiguration as sinon.SinonStub).callCount, 0); + assert.strictEqual(startDebugging.callCount, 0); + } + finally { + sessions.dispose(); + } + }); + + test('merges Go debugger overrides while preserving the resolved attach identity', async () => { + let startedConfiguration: vscode.DebugConfiguration | undefined; + const appHosts = [createAppHost({ cliPid: 84, resources: [createGoResource()] })]; + const provider = createGoResourceAttachProvider({ + resolveApplicationPid: async () => 4567, + }); + const { service, sessions } = createService({ + appHosts, + provider, + getDebugSessionConfiguration: appHost => { + assert.deepStrictEqual(appHost, { ...resolvedTarget, cliPid: 84 }); + return { + type: 'aspire', + name: 'AppHost', + request: 'launch', + program: target.absolutePath, + debuggers: { + go: { + name: 'Custom Go attach', + substitutePath: [{ from: '/workspace', to: '/repo' }], + trace: 'verbose', + justMyCode: false, + type: 'node', + request: 'launch', + mode: 'remote', + debugAdapter: 'legacy', + processId: 9999, + noDebug: true, + pipeTransport: { pipeProgram: 'ssh', pipeArgs: ['remote-host'] }, + remotePath: '/remote/source', + host: 'remote-host', + port: 2345, + dlvToolPath: '/remote/dlv', + dlvFlags: ['--backend=rr'], + }, + }, + }; + }, + startDebugging: async (_folder, configuration) => { + startedConfiguration = configuration; + return true; + }, + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'go' }); + assert.ok(startedConfiguration); + assert.strictEqual(startedConfiguration.type, 'go'); + assert.strictEqual(startedConfiguration.request, 'attach'); + assert.strictEqual(startedConfiguration.mode, 'local'); + assert.strictEqual(startedConfiguration.debugAdapter, 'dlv-dap'); + assert.strictEqual(startedConfiguration.name, 'Custom Go attach'); + assert.strictEqual(startedConfiguration.processId, 4567); + assert.strictEqual(startedConfiguration.noDebug, false); + assert.deepStrictEqual(startedConfiguration.substitutePath, [{ from: '/workspace', to: '/repo' }]); + assert.strictEqual(startedConfiguration.trace, 'verbose'); + assert.strictEqual(startedConfiguration.justMyCode, undefined); + assert.strictEqual(startedConfiguration.pipeTransport, undefined); + assert.strictEqual(startedConfiguration.remotePath, undefined); + assert.strictEqual(startedConfiguration.host, undefined); + assert.strictEqual(startedConfiguration.port, undefined); + assert.strictEqual(startedConfiguration.dlvToolPath, undefined); + assert.strictEqual(startedConfiguration.dlvFlags, undefined); + } + finally { + sessions.dispose(); + } + }); + + test('merges safe CoreCLR overrides without allowing a remote attach target', async () => { + let startedConfiguration: vscode.DebugConfiguration | undefined; + const provider = createProvider({ + createDebugConfiguration: async () => ({ + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + processId: 4321, + }), + }); + const appHosts = [createAppHost({ + resources: [createResource({ + properties: { + 'resource.launchConfigurationType': 'project', + 'project.path': '/repo/api/Api.csproj', + 'executable.path': 'dotnet', + 'executable.pid': '1234', + }, + })], + })]; + const { service, sessions } = createService({ + appHosts, + provider, + getDebugSessionConfiguration: () => ({ + type: 'aspire', + name: 'AppHost', + request: 'launch', + program: target.absolutePath, + debuggers: { + project: { + name: 'Custom .NET attach', + justMyCode: false, + sourceFileMap: { '/build': '/repo' }, + substitutePath: [{ from: '/workspace', to: '/repo' }], + trace: 'verbose', + type: 'cppdbg', + request: 'launch', + processId: 9999, + noDebug: true, + pipeTransport: { pipeProgram: 'ssh', pipeArgs: ['remote-host'] }, + remoteMachineName: 'remote-host', + debugServer: 4711, + }, + }, + }), + startDebugging: async (_folder, configuration) => { + startedConfiguration = configuration; + return true; + }, + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.ok(startedConfiguration); + assert.strictEqual(startedConfiguration.type, 'coreclr'); + assert.strictEqual(startedConfiguration.request, 'attach'); + assert.strictEqual(startedConfiguration.name, 'Custom .NET attach'); + assert.strictEqual(startedConfiguration.processId, 4321); + assert.strictEqual(startedConfiguration.noDebug, false); + assert.strictEqual(startedConfiguration.justMyCode, false); + assert.deepStrictEqual(startedConfiguration.sourceFileMap, { '/build': '/repo' }); + assert.strictEqual(startedConfiguration.substitutePath, undefined); + assert.strictEqual(startedConfiguration.trace, undefined); + assert.strictEqual(startedConfiguration.pipeTransport, undefined); + assert.strictEqual(startedConfiguration.remoteMachineName, undefined); + assert.strictEqual(startedConfiguration.debugServer, undefined); + } + finally { + sessions.dispose(); + } + }); + + test('uses a fresh AppHost snapshot instead of a tree resource', async () => { + let fetchCount = 0; + let configuredResource: ResourceDebugResourceSnapshot | undefined; + const repository: ResourceDebugAppHostRepository = { + fetchRunningAppHostsOnce: async () => { + return [createAppHost({ resources: null })]; + }, + fetchAppHostResourcesOnce: async () => { + fetchCount++; + return [createResource({ + properties: { + 'project.path': '/repo/api/Api.csproj', + 'executable.path': `dotnet-${fetchCount}`, + }, + })]; + }, + }; + const provider = createProvider({ + createDebugConfiguration: async resource => { + configuredResource = resource; + return { type: 'coreclr', request: 'attach', name: 'Attach debugger: API' }; + }, + }); + const events = new TestDebugSessionEvents(); + const sessions = new ResourceDebugSessionRegistry(events); + const service = new ResourceDebugService({ + appHostRepository: repository, + attachProviders: new ResourceAttachProviderRegistry([provider], () => true), + sessionRegistry: sessions, + startDebugging: async () => true, + }); + + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'started', providerId: 'dotnet' }); + assert.strictEqual(fetchCount, 1); + assert.strictEqual(configuredResource?.properties?.['executable.path'], 'dotnet-1'); + sessions.dispose(); + }); + + test('resolves the running AppHost before fetching only its resource snapshot', async () => { + const cancellation = new vscode.CancellationTokenSource(); + const fetchedPaths: string[] = []; + const receivedTokens: Array = []; + const repository: ResourceDebugAppHostRepository = { + fetchRunningAppHostsOnce: async token => { + assert.strictEqual(token, cancellation.token); + return [ + createAppHost({ appHostPath: '/repo/other/AppHost.csproj', resources: null }), + createAppHost({ appHostPath: '/repo/resolved/AppHost.csproj', resources: null }), + ]; + }, + fetchAppHostResourcesOnce: async (appHostPath, token) => { + fetchedPaths.push(appHostPath); + receivedTokens.push(token); + return [createResource()]; + }, + }; + const events = new TestDebugSessionEvents(); + const sessions = new ResourceDebugSessionRegistry(events); + const service = new ResourceDebugService({ + appHostRepository: repository, + attachProviders: new ResourceAttachProviderRegistry([createProvider()], () => true), + sessionRegistry: sessions, + startDebugging: async () => true, + compareAppHostIdentity: (requestedPath, appHostPath) => + requestedPath === '/repo/alias/AppHost.csproj' && appHostPath === '/repo/resolved/AppHost.csproj' + ? 'same' + : 'different', + }); + + try { + const result = await service.debug(createRequest({ + appHost: { absolutePath: '/repo/alias/AppHost.csproj', displayPath: 'alias/AppHost.csproj' }, + cancellationToken: cancellation.token, + })); + + assert.deepStrictEqual(result, { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(fetchedPaths, ['/repo/resolved/AppHost.csproj']); + assert.deepStrictEqual(receivedTokens, [cancellation.token]); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + + test('returns a snapshot failure when the selected AppHost cannot be described', async () => { + const logError = sinon.stub(extensionLogOutputChannel, 'error'); + const { service, sessions, repository } = createService(); + repository.fetchAppHostResourcesOnce = async () => { + throw new Error('process 1234 at /repo/private/AppHost.csproj'); + }; + + try { + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'error', errorKind: 'resourceSnapshotFailed' }); + assert.doesNotMatch(JSON.stringify(result), /1234|\/repo|AppHost\.csproj/); + assert.ok(logError.calledOnce); + } + finally { + sessions.dispose(); + } + }); + + test('resolves duplicate resource names only within the requested AppHost', async () => { + let configuredResource: ResourceDebugResourceSnapshot | undefined; + const { service, sessions } = createService({ + appHosts: [ + createAppHost({ + appHostPath: '/repo/first/AppHost.csproj', + resources: [createResource({ displayName: 'First API' })], + }), + createAppHost({ + appHostPath: target.absolutePath, + resources: [createResource({ displayName: 'Second API' })], + }), + ], + provider: createProvider({ + createDebugConfiguration: async resource => { + configuredResource = resource; + return { type: 'coreclr', request: 'attach', name: 'Attach debugger: Second API' }; + }, + }), + }); + + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'started', providerId: 'dotnet' }); + assert.strictEqual(configuredResource?.displayName, 'Second API'); + sessions.dispose(); + }); + + test('keeps a typed configuration failure when the provider rejects an unattached resource', async () => { + const logError = sinon.stub(extensionLogOutputChannel, 'error'); + const { service, sessions } = createService({ + provider: createProvider({ + createDebugConfiguration: async () => { + throw new ResourceAttachConfigurationError('resourceNotAttachable', 'process 1234 at /repo/private/Api.dll'); + }, + }), + }); + + try { + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'error', errorKind: 'configurationFailed' }); + assert.doesNotMatch(JSON.stringify(result), /1234|\/repo|Api\.dll/); + assert.ok(logError.calledOnce); + } + finally { + sessions.dispose(); + } + }); + + test('fails closed when the AppHost identity is ambiguous', async () => { + const { service, sessions } = createService({ + compareAppHostIdentity: () => 'ambiguous', + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'appHostNotFound' }); + sessions.dispose(); + }); + + test('fails closed when one matching AppHost identity is ambiguous', async () => { + const { service, sessions } = createService({ + appHosts: [ + createAppHost(), + createAppHost({ appHostPath: '/repo/ambiguous/AppHost.csproj' }), + ], + compareAppHostIdentity: (_requestedPath, appHostPath) => + appHostPath === target.absolutePath ? 'same' : 'ambiguous', + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'appHostNotFound' }); + sessions.dispose(); + }); + + test('resolves the selected AppHost process when one path has overlapping snapshots', async () => { + const appHosts = [ + createAppHost({ appHostPid: 1111 }), + createAppHost({ appHostPid: 2222 }), + ]; + const { service, repository, sessions } = createService({ appHosts }); + const fetchResources = sinon.spy(repository, 'fetchAppHostResourcesOnce'); + + assert.deepStrictEqual(await service.debug(createRequest({ + appHost: { + ...target, + appHostPid: 2222, + }, + })), { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(fetchResources.firstCall.args, [target.absolutePath, undefined, 2222]); + sessions.dispose(); + }); + + test('rejects an AppHost process that no longer matches the selected tree item', async () => { + const { service, sessions } = createService({ + appHosts: [createAppHost({ appHostPid: 2222 })], + }); + + assert.deepStrictEqual(await service.debug(createRequest({ + appHost: { + ...target, + appHostPid: 1111, + }, + })), { outcome: 'appHostNotFound' }); + sessions.dispose(); + }); + + test('fails closed when a resource is stale or duplicated', async () => { + const missing = createService({ + appHosts: [createAppHost({ resources: [] })], + }); + const duplicated = createService({ + appHosts: [createAppHost({ resources: [createResource(), createResource()] })], + }); + + assert.deepStrictEqual(await missing.service.debug(createRequest()), { outcome: 'resourceNotFound' }); + assert.deepStrictEqual(await duplicated.service.debug(createRequest()), { outcome: 'resourceNotFound' }); + missing.sessions.dispose(); + duplicated.sessions.dispose(); + }); + + test('reports a missing debugger extension without exposing resource details', async () => { + const { service, sessions } = createService({ + isExtensionInstalled: () => false, + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { + outcome: 'debuggerExtensionMissing', + debuggerExtensions: [{ id: 'ms-dotnettools.csharp', label: 'C#' }], + }); + sessions.dispose(); + }); + + test('returns alreadyDebugging when Aspire already owns the reported resource process', async () => { + const startDebugging = sinon.stub().resolves(true); + const { service, sessions } = createService({ + appHosts: [createAppHost({ + resources: [createResource({ + properties: { + 'project.path': '/repo/api/Api.csproj', + 'executable.path': 'dotnet', + 'executable.pid': 4242, + } as unknown as ResourceJson['properties'], + })], + })], + isProcessAlreadyDebugged: processId => processId === 4242, + startDebugging, + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'alreadyDebugging' }); + assert.strictEqual(startDebugging.called, false); + sessions.dispose(); + }); + + test('returns alreadyDebugging when Aspire owns the resolved attach process', async () => { + const startDebugging = sinon.stub().resolves(true); + const provider = createProvider({ + createDebugConfiguration: async () => ({ + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + processId: 5252, + }), + }); + const { service, sessions } = createService({ + appHosts: [createAppHost({ + resources: [createResource({ + properties: { + 'project.path': '/repo/api/Api.csproj', + 'executable.path': 'dotnet', + 'executable.pid': 4242, + } as unknown as ResourceJson['properties'], + })], + })], + provider, + isProcessAlreadyDebugged: processId => processId === 5252, + startDebugging, + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'alreadyDebugging' }); + assert.strictEqual(startDebugging.called, false); + sessions.dispose(); + }); + + test('reports the missing Go debugger extension using only its requirement metadata', async () => { + const resolver = { + resolveApplicationPid: sinon.stub().rejects(new Error('/private/go-build123/b001/exe/api 4567')), + }; + const { service, sessions } = createService({ + appHosts: [createAppHost({ resources: [createGoResource()] })], + provider: createGoResourceAttachProvider(resolver), + isExtensionInstalled: () => false, + }); + + try { + assert.strictEqual(service.canAttachToResource(createGoResource()), true); + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { + outcome: 'debuggerExtensionMissing', + debuggerExtensions: [{ id: 'golang.go', label: 'Go' }], + }); + assert.doesNotMatch(JSON.stringify(result), /1234|4567|go-build|\/private/); + assert.strictEqual(resolver.resolveApplicationPid.called, false); + } + finally { + sessions.dispose(); + } + }); + + test('normalizes Go process discovery failures without exposing process details', async () => { + const resolver = { + resolveApplicationPid: async () => { + throw new Error('/private/go-build123/b001/exe/api --port 8080 4567'); + }, + }; + const { service, sessions } = createService({ + appHosts: [createAppHost({ resources: [createGoResource()] })], + provider: createGoResourceAttachProvider(resolver), + }); + + try { + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'error', errorKind: 'configurationFailed' }); + assert.doesNotMatch(JSON.stringify(result), /1234|4567|go-build|\/private|8080/); + } + finally { + sessions.dispose(); + } + }); + + test('checks attach eligibility before reporting a missing debugger extension', async () => { + const { service, sessions } = createService({ + provider: createProvider({ canAttachToResource: () => false }), + isExtensionInstalled: () => false, + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'unsupportedResource' }); + sessions.dispose(); + }); + + test('returns typed outcomes for unsupported and stopped resources', async () => { + const unsupported = createService({ + provider: createProvider({ canAttachToResource: () => false }), + }); + const stopped = createService({ + appHosts: [createAppHost({ resources: [createResource({ state: 'Finished' })] })], + provider: createProvider({ canAttachToResource: () => false }), + }); + + assert.deepStrictEqual(await unsupported.service.debug(createRequest()), { outcome: 'unsupportedResource' }); + assert.deepStrictEqual(await stopped.service.debug(createRequest()), { outcome: 'resourceNotRunning' }); + unsupported.sessions.dispose(); + stopped.sessions.dispose(); + }); + + test('recognizes stopped .NET resources before checking attach readiness', async () => { + const stopped = createService({ + appHosts: [createAppHost({ resources: [createResource({ state: 'Finished' })] })], + provider: projectResourceAttachProvider, + }); + + assert.deepStrictEqual(await stopped.service.debug(createRequest()), { outcome: 'resourceNotRunning' }); + stopped.sessions.dispose(); + }); + + test('normalizes provider eligibility errors without exposing their details', async () => { + const { service, sessions } = createService({ + provider: createProvider({ + canAttachToResource: () => { + throw new Error('process 1234 at /repo/private/Api.dll'); + }, + }), + }); + + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'error', errorKind: 'providerResolutionFailed' }); + assert.doesNotMatch(JSON.stringify(result), /1234|\/repo|Api\.dll/); + sessions.dispose(); + }); + + test('serializes concurrent requests and returns alreadyDebugging for the duplicate', async () => { + let completeStart: ((value: boolean) => void) | undefined; + let markStartCalled: (() => void) | undefined; + const startRequest = new Promise(resolve => { + completeStart = resolve; + }); + const startCalled = new Promise(resolve => { + markStartCalled = resolve; + }); + const startDebugging = sinon.stub().callsFake(() => { + markStartCalled!(); + return startRequest; + }); + const { service, repository, sessions } = createService({ startDebugging }); + let fetchCount = 0; + repository.fetchRunningAppHostsOnce = async () => { + fetchCount++; + return [createAppHost()]; + }; + + const first = service.debug(createRequest()); + const second = service.debug(createRequest()); + await startCalled; + assert.strictEqual(startDebugging.callCount, 1); + assert.strictEqual(fetchCount, 2); + + completeStart!(true); + + assert.deepStrictEqual(await first, { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(await second, { outcome: 'alreadyDebugging' }); + sessions.dispose(); + }); + + test('cancels a request while it waits for the resource lock', async () => { + let completeStart: ((value: boolean) => void) | undefined; + let signalStart: (() => void) | undefined; + let signalSecondIdentityFetch: (() => void) | undefined; + const startRequest = new Promise(resolve => { + completeStart = resolve; + }); + const startCalled = new Promise(resolve => { + signalStart = resolve; + }); + const secondIdentityFetch = new Promise(resolve => { + signalSecondIdentityFetch = resolve; + }); + const startDebugging = sinon.stub().callsFake(() => { + signalStart!(); + return startRequest; + }); + const { service, repository, sessions } = createService({ startDebugging }); + let identityFetchCount = 0; + let resourceSnapshotCount = 0; + repository.fetchRunningAppHostsOnce = async () => { + identityFetchCount++; + if (identityFetchCount === 2) { + signalSecondIdentityFetch!(); + } + return [createAppHost({ resources: null })]; + }; + repository.fetchAppHostResourcesOnce = async () => { + resourceSnapshotCount++; + return [createResource()]; + }; + const cancellation = new vscode.CancellationTokenSource(); + + try { + const first = service.debug(createRequest()); + await startCalled; + + const second = service.debug(createRequest({ cancellationToken: cancellation.token })); + await secondIdentityFetch; + cancellation.cancel(); + + assert.deepStrictEqual(await second, { outcome: 'cancelled' }); + assert.strictEqual(resourceSnapshotCount, 1); + + completeStart!(true); + assert.deepStrictEqual(await first, { outcome: 'started', providerId: 'dotnet' }); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + + test('keeps a later request blocked when a canceled waiter has already completed', async () => { + const sessions = new ResourceDebugSessionRegistry(); + let releaseFirst: (() => void) | undefined; + let firstEntered: (() => void) | undefined; + let firstCompleted = false; + const firstCanComplete = new Promise(resolve => { + releaseFirst = resolve; + }); + const firstHasEntered = new Promise(resolve => { + firstEntered = resolve; + }); + const cancellation = new vscode.CancellationTokenSource(); + let laterWaiterStarted = false; + + try { + const first = sessions.runSerialized( + target, + 'api', + undefined, + async () => { + firstEntered!(); + await firstCanComplete; + firstCompleted = true; + return 'first'; + }, + () => 'cancelled'); + await firstHasEntered; + + const canceledWaiter = sessions.runSerialized( + target, + 'api', + cancellation.token, + async () => 'second', + () => 'cancelled'); + + cancellation.cancel(); + + assert.strictEqual(await canceledWaiter, 'cancelled'); + assert.strictEqual(firstCompleted, false); + + const laterWaiter = sessions.runSerialized( + target, + 'api', + undefined, + async () => { + laterWaiterStarted = true; + return 'third'; + }, + () => 'cancelled'); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(laterWaiterStarted, false); + + releaseFirst!(); + assert.strictEqual(await first, 'first'); + assert.strictEqual(await laterWaiter, 'third'); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + + test('passes the request cancellation token to providers that support cancellation', async () => { + const cancellation = new vscode.CancellationTokenSource(); + let receivedToken: vscode.CancellationToken | undefined; + const { service, sessions } = createService({ + provider: createProvider({ + createDebugConfiguration: async (_resource, token) => { + receivedToken = token; + return { type: 'coreclr', request: 'attach', name: 'Attach debugger: API' }; + }, + }), + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest({ cancellationToken: cancellation.token })), { + outcome: 'started', + providerId: 'dotnet', + }); + assert.strictEqual(receivedToken, cancellation.token); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + + test('returns cancelled when .NET target discovery observes request cancellation', async () => { + let receivedToken: vscode.CancellationToken | undefined; + let signalTargetDiscoveryStarted: (() => void) | undefined; + const targetDiscoveryStarted = new Promise(resolve => { + signalTargetDiscoveryStarted = resolve; + }); + const provider = createProjectResourceAttachProvider(() => ({ + getAndActivateDevKit: async () => false, + buildDotNetProject: async () => { }, + getDotNetAttachTargetInfo: async ( + _projectFile: string, + _configuration: string | undefined, + cancellationToken: vscode.CancellationToken | undefined) => { + receivedToken = cancellationToken; + signalTargetDiscoveryStarted!(); + return await new Promise((_resolve, reject) => { + cancellationToken?.onCancellationRequested(() => reject(new vscode.CancellationError())); + }); + }, + getDotNetTargetPath: async () => '', + getDotNetRunApiOutput: async () => '', + } as never)); + const cancellation = new vscode.CancellationTokenSource(); + const startDebugging = sinon.stub().resolves(true); + const { service, sessions } = createService({ + appHosts: [createAppHost({ + resources: [createResource({ + properties: { + 'project.path': '/repo/api/Api.csproj', + 'executable.path': 'dotnet', + 'executable.pid': '42', + }, + })], + })], + provider, + startDebugging, + }); + + try { + const operation = service.debug(createRequest({ cancellationToken: cancellation.token })); + await targetDiscoveryStarted; + cancellation.cancel(); + + const result = await Promise.race([ + operation, + new Promise<'timedOut'>(resolve => setTimeout(() => resolve('timedOut'), 100)), + ]); + assert.deepStrictEqual(result, { outcome: 'cancelled' }); + assert.strictEqual(receivedToken, cancellation.token); + assert.strictEqual(startDebugging.callCount, 0); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + + test('returns alreadyDebugging while an independent attach session is active', async () => { + const { service, sessions } = createService(); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'alreadyDebugging' }); + sessions.dispose(); + }); + + test('logs marker-loss expiry before allowing a recovery attach attempt', async () => { + const clock = sinon.useFakeTimers(); + const logWarning = sinon.stub(extensionLogOutputChannel, 'warn'); + const events = new TestDebugSessionEvents(); + const sessions = new ResourceDebugSessionRegistry(events, { pendingStartTimeoutMs: 100 }); + const service = new ResourceDebugService({ + appHostRepository: { + fetchRunningAppHostsOnce: async () => [createAppHost({ resources: null })], + fetchAppHostResourcesOnce: async () => [createResource()], + }, + attachProviders: new ResourceAttachProviderRegistry([createProvider()], () => true), + sessionRegistry: sessions, + startDebugging: async () => true, + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + + // A third-party debug configuration provider can resolve the session without preserving + // private properties from the launch configuration. + events.start({ type: 'coreclr', request: 'attach', name: 'Attach debugger: API' }); + await clock.tickAsync(100); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.ok(logWarning.calledOnceWithExactly( + 'Resource debugger session tracking expired before its debug session reported the private marker. A later attach may start another session.')); + } + finally { + sessions.dispose(); + clock.restore(); + } + }); + + test('keeps an accepted start active when a correlated independent session starts', async () => { + const clock = sinon.useFakeTimers(); + const events = new TestDebugSessionEvents(); + const sessions = new ResourceDebugSessionRegistry(events, { pendingStartTimeoutMs: 100 }); + let startedConfiguration: vscode.DebugConfiguration | undefined; + const service = new ResourceDebugService({ + appHostRepository: { + fetchRunningAppHostsOnce: async () => [createAppHost({ resources: null })], + fetchAppHostResourcesOnce: async () => [createResource()], + }, + attachProviders: new ResourceAttachProviderRegistry([createProvider()], () => true), + sessionRegistry: sessions, + startDebugging: async (_folder, configuration) => { + startedConfiguration = configuration; + events.start(configuration); + return true; + }, + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.ok(startedConfiguration); + await clock.tickAsync(100); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'alreadyDebugging' }); + } + finally { + sessions.dispose(); + clock.restore(); + } + }); + + test('does not reactivate an attempt terminated before start acceptance', () => { + const events = new TestDebugSessionEvents(); + const sessions = new ResourceDebugSessionRegistry(events); + const attempt = sessions.createAttempt(target, 'api', { + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + }, { + source: 'tree', + provider: 'dotnet', + resource_type: 'project', + requested_strategy: 'attach', + effective_strategy: 'attach', + }); + + try { + events.terminate(attempt.configuration); + attempt.markStarted(); + + assert.strictEqual(sessions.hasActiveSession(target, 'api'), false); + } + finally { + sessions.dispose(); + } + }); + + test('tracks attach sessions separately for overlapping AppHost processes', () => { + const events = new TestDebugSessionEvents(); + const sessions = new ResourceDebugSessionRegistry(events); + const firstTarget = { + ...target, + appHostPid: 1111, + }; + const secondTarget = { + ...target, + appHostPid: 2222, + }; + const attempt = sessions.createAttempt(firstTarget, 'api', { + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + }, { + source: 'tree', + provider: 'dotnet', + resource_type: 'project', + requested_strategy: 'attach', + effective_strategy: 'attach', + }); + + try { + attempt.markStarted(); + + assert.strictEqual(sessions.hasActiveSession(firstTarget, 'api'), true); + assert.strictEqual(sessions.hasActiveSession(secondTarget, 'api'), false); + } + finally { + sessions.dispose(); + } + }); + + test('serializes aliases that resolve to the same running AppHost', async () => { + let completeStart: ((value: boolean) => void) | undefined; + let signalStart: (() => void) | undefined; + const startRequest = new Promise(resolve => { + completeStart = resolve; + }); + const startCalled = new Promise(resolve => { + signalStart = resolve; + }); + const startDebugging = sinon.stub().callsFake(() => { + signalStart!(); + return startRequest; + }); + const { service, sessions } = createService({ + startDebugging, + compareAppHostIdentity: () => 'same', + appHosts: [createAppHost({ appHostPath: '/repo/resolved/AppHost.csproj' })], + }); + const first = service.debug(createRequest({ + appHost: { absolutePath: '/repo/alias-one/AppHost.csproj', displayPath: 'alias-one/AppHost.csproj' }, + })); + const second = service.debug(createRequest({ + appHost: { absolutePath: '/repo/alias-two/AppHost.csproj', displayPath: 'alias-two/AppHost.csproj' }, + })); + + await startCalled; + assert.strictEqual(startDebugging.callCount, 1); + + completeStart!(true); + + assert.deepStrictEqual(await first, { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(await second, { outcome: 'alreadyDebugging' }); + sessions.dispose(); + }); + + test('returns a bounded failure when VS Code declines to start debugging', async () => { + const { service, sessions } = createService({ + startDebugging: async () => false, + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { + outcome: 'error', + errorKind: 'debuggerStartDeclined', + }); + sessions.dispose(); + }); + + test('normalizes configuration errors without exposing their details', async () => { + const { service, sessions } = createService({ + provider: createProvider({ + createDebugConfiguration: async () => { + throw new Error('process 1234 at /repo/private/Api.dll'); + }, + }), + }); + + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'error', errorKind: 'configurationFailed' }); + assert.doesNotMatch(JSON.stringify(result), /1234|\/repo|Api\.dll/); + sessions.dispose(); + }); + + test('normalizes unexpected service errors while logging their raw details internally', async () => { + const rawError = 'process 1234 at /repo/private/AppHost.csproj'; + const logError = sinon.stub(extensionLogOutputChannel, 'error'); + const { service, sessions } = createService({ + compareAppHostIdentity: () => { + throw new Error(rawError); + }, + }); + + try { + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'error', errorKind: 'unexpected' }); + assert.doesNotMatch(JSON.stringify(result), /1234|\/repo|AppHost\.csproj/); + assert.ok(logError.calledWithMatch(rawError)); + } + finally { + sessions.dispose(); + } + }); + + test('returns cancelled when the request cancellation token is already cancelled', async () => { + const cancellation = new vscode.CancellationTokenSource(); + cancellation.cancel(); + const startDebugging = sinon.stub().resolves(true); + const { service, sessions } = createService({ startDebugging }); + + assert.deepStrictEqual(await service.debug(createRequest({ cancellationToken: cancellation.token })), { + outcome: 'cancelled', + }); + assert.strictEqual(startDebugging.callCount, 0); + cancellation.dispose(); + sessions.dispose(); + }); + + test('does not start debugging when cancellation occurs during configuration', async () => { + let finishConfiguration: (() => void) | undefined; + let markConfigurationStarted: (() => void) | undefined; + const configuration = new Promise(resolve => { + finishConfiguration = resolve; + }); + const configurationStarted = new Promise(resolve => { + markConfigurationStarted = resolve; + }); + const cancellation = new vscode.CancellationTokenSource(); + const startDebugging = sinon.stub().resolves(true); + const { service, sessions } = createService({ + provider: createProvider({ + createDebugConfiguration: async () => { + markConfigurationStarted!(); + await configuration; + return { type: 'coreclr', request: 'attach', name: 'Attach debugger: API' }; + }, + }), + startDebugging, + }); + + const operation = service.debug(createRequest({ cancellationToken: cancellation.token })); + await configurationStarted; + cancellation.cancel(); + finishConfiguration!(); + + assert.deepStrictEqual(await operation, { outcome: 'cancelled' }); + assert.strictEqual(startDebugging.callCount, 0); + cancellation.dispose(); + sessions.dispose(); + }); + + test('does not start debugging when cancellation occurs during the fresh resource snapshot', async () => { + let finishSnapshot: (() => void) | undefined; + let markSnapshotStarted: (() => void) | undefined; + const snapshot = new Promise(resolve => { + finishSnapshot = resolve; + }); + const snapshotStarted = new Promise(resolve => { + markSnapshotStarted = resolve; + }); + const cancellation = new vscode.CancellationTokenSource(); + const startDebugging = sinon.stub().resolves(true); + const { service, repository, sessions } = createService({ startDebugging }); + repository.fetchAppHostResourcesOnce = async () => { + markSnapshotStarted!(); + await snapshot; + return [createResource()]; + }; + + try { + const operation = service.debug(createRequest({ cancellationToken: cancellation.token })); + await snapshotStarted; + cancellation.cancel(); + finishSnapshot!(); + + assert.deepStrictEqual(await operation, { outcome: 'cancelled' }); + assert.strictEqual(startDebugging.callCount, 0); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + + test('keeps an accepted attach session when cancellation arrives after debugging starts', async () => { + const cancellation = new vscode.CancellationTokenSource(); + let startedConfiguration: vscode.DebugConfiguration | undefined; + const { service, sessions, events } = createService({ + startDebugging: async (_folder, configuration) => { + startedConfiguration = configuration; + events.start(configuration); + cancellation.cancel(); + return true; + }, + }); + + try { + assert.deepStrictEqual( + await service.debug(createRequest({ cancellationToken: cancellation.token })), + { outcome: 'started', providerId: 'dotnet' }); + assert.ok(startedConfiguration); + assert.strictEqual(sessions.hasActiveSession(resolvedTarget, 'api'), true); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + + test('removes a terminated independent attach session without stopping its resource', async () => { + let startedConfiguration: vscode.DebugConfiguration | undefined; + const { service, sessions, events } = createService({ + startDebugging: async (_folder, configuration) => { + startedConfiguration = configuration; + events.start(configuration); + return true; + }, + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.ok(startedConfiguration); + assert.strictEqual(sessions.hasActiveSession(resolvedTarget, 'api'), true); + + events.terminate(startedConfiguration!); + + assert.strictEqual(sessions.hasActiveSession(resolvedTarget, 'api'), false); + sessions.dispose(); + }); + + test('publishes attach session start and termination changes to tree consumers', async () => { + const { service, sessions, events } = createService({ + startDebugging: async (_folder, configuration) => { + events.start(configuration); + return true; + }, + }); + const lifecycle = service.onDidChangeDebugSessions; + let changeCount = 0; + const subscription = lifecycle(() => changeCount++); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.strictEqual(changeCount, 1); + assert.ok(events.startedConfiguration); + + events.terminate(events.startedConfiguration); + + assert.strictEqual(changeCount, 2); + } + finally { + subscription.dispose(); + sessions.dispose(); + } + }); + + test('emits a bounded start and success result with deterministic durations', async () => { + const telemetry = new TestResourceDebugTelemetry(); + telemetry.currentTime = 100; + const { service, sessions } = createService({ + telemetry, + provider: createProvider({ + createDebugConfiguration: async () => { + telemetry.currentTime = 105; + return { type: 'coreclr', request: 'attach', name: 'Attach debugger: API' }; + }, + }), + startDebugging: async () => { + telemetry.currentTime = 108; + return true; + }, + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(telemetry.events, [ + { + name: 'aspire/vscode/resourcedebug/start', + properties: { + source: 'tree', + requested_strategy: 'attach', + controller: 'editor', + }, + measurements: undefined, + }, + { + name: 'aspire/vscode/resourcedebug/result', + properties: { + source: 'tree', + provider: 'dotnet', + resource_type: 'project', + requested_strategy: 'attach', + effective_strategy: 'attach', + outcome: 'started', + controller: 'editor', + state: 'running', + debugger_requirement: 'installed', + error_kind: 'none', + }, + measurements: { + resolution_duration_ms: 5, + debug_start_duration_ms: 3, + total_duration_ms: 8, + }, + }, + ]); + } + finally { + sessions.dispose(); + } + }); + + test('selects attach centrally for the auto strategy and records the requested strategy', async () => { + const telemetry = new TestResourceDebugTelemetry(); + const { service, sessions } = createService({ telemetry }); + + try { + assert.deepStrictEqual( + await service.debug(createRequest({ source: 'languageModelTool', strategy: 'auto' })), + { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual( + telemetry.events.map(event => ({ + name: event.name, + requestedStrategy: event.properties.requested_strategy, + effectiveStrategy: event.properties.effective_strategy, + })), + [ + { + name: 'aspire/vscode/resourcedebug/start', + requestedStrategy: 'auto', + effectiveStrategy: undefined, + }, + { + name: 'aspire/vscode/resourcedebug/result', + requestedStrategy: 'auto', + effectiveStrategy: 'attach', + }, + ]); + } + finally { + sessions.dispose(); + } + }); + + test('fails closed when a caller bypasses the bounded debug strategy contract', async () => { + const startDebugging = sinon.stub().resolves(true); + const telemetry = new TestResourceDebugTelemetry(); + const { service, sessions } = createService({ startDebugging, telemetry }); + + try { + assert.deepStrictEqual( + await service.debug(createRequest({ strategy: 'restart' as never })), + { outcome: 'error', errorKind: 'unexpected' }); + assert.strictEqual(startDebugging.callCount, 0); + assert.deepStrictEqual( + telemetry.events.map(event => ({ + name: event.name, + requestedStrategy: event.properties.requested_strategy, + effectiveStrategy: event.properties.effective_strategy, + })), + [ + { + name: 'aspire/vscode/resourcedebug/start', + requestedStrategy: 'invalid', + effectiveStrategy: undefined, + }, + { + name: 'aspire/vscode/resourcedebug/result', + requestedStrategy: 'invalid', + effectiveStrategy: 'none', + }, + ]); + } + finally { + sessions.dispose(); + } + }); + + test('emits exactly one bounded result for every resource debug outcome', async () => { + const run = async ( + create: () => { + service: ResourceDebugService; + sessions: ResourceDebugSessionRegistry; + telemetry: TestResourceDebugTelemetry; + }, + expectedOutcome: ResourceDebugResult['outcome'], + expectedErrorKind = 'none', + ) => { + const { service, sessions, telemetry } = create(); + try { + const result = await service.debug(createRequest()); + assert.strictEqual(result.outcome, expectedOutcome); + + const startEvents = telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/start'); + const resultEvents = telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/result'); + assert.strictEqual(startEvents.length, 1); + assert.strictEqual(resultEvents.length, 1); + assert.strictEqual(resultEvents[0].properties.outcome, expectedOutcome); + assert.strictEqual(resultEvents[0].properties.error_kind, expectedErrorKind); + } + finally { + sessions.dispose(); + } + }; + + const cancelled = new vscode.CancellationTokenSource(); + cancelled.cancel(); + await run( + () => createService(), + 'started'); + await run( + () => createService({ + appHosts: [], + }), + 'appHostNotFound'); + await run( + () => createService({ + appHosts: [createAppHost({ resources: [] })], + }), + 'resourceNotFound'); + await run( + () => createService({ + provider: createProvider({ canAttachToResource: () => false }), + }), + 'unsupportedResource'); + await run( + () => createService({ + appHosts: [createAppHost({ resources: [createResource({ state: 'Finished' })] })], + }), + 'resourceNotRunning'); + await run( + () => createService({ + isExtensionInstalled: () => false, + }), + 'debuggerExtensionMissing'); + await run( + () => createService({ + provider: createProvider({ + createDebugConfiguration: async () => { + throw new Error('raw configuration error'); + }, + }), + }), + 'error', + 'configurationFailed'); + await run( + () => createService({ + startDebugging: async () => false, + }), + 'error', + 'debuggerStartDeclined'); + await run( + () => createService({ + startDebugging: async () => { + throw new Error('raw debugger failure'); + }, + }), + 'error', + 'debuggerStartFailed'); + await run( + () => { + const fixture = createService(); + fixture.repository.fetchRunningAppHostsOnce = async () => { + throw new Error('raw AppHost snapshot failure'); + }; + return fixture; + }, + 'error', + 'resourceSnapshotFailed'); + await run( + () => createService({ + provider: createProvider({ + canRecognizeResource: () => { + throw new Error('raw provider resolution failure'); + }, + }), + }), + 'error', + 'providerResolutionFailed'); + await run( + () => createService({ + compareAppHostIdentity: () => { + throw new Error('raw unexpected comparison failure'); + }, + }), + 'error', + 'unexpected'); + + const cancelledFixture = createService(); + try { + const result = await cancelledFixture.service.debug(createRequest({ cancellationToken: cancelled.token })); + assert.deepStrictEqual(result, { outcome: 'cancelled' }); + assert.strictEqual(cancelledFixture.telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/start').length, 1); + assert.strictEqual(cancelledFixture.telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/result').length, 1); + assert.strictEqual( + cancelledFixture.telemetry.events.find(event => event.name === 'aspire/vscode/resourcedebug/result')?.properties.error_kind, + 'none'); + } + finally { + cancelled.dispose(); + cancelledFixture.sessions.dispose(); + } + + const duplicate = createService(); + try { + assert.deepStrictEqual(await duplicate.service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(await duplicate.service.debug(createRequest()), { outcome: 'alreadyDebugging' }); + const resultEvents = duplicate.telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/result'); + assert.strictEqual(resultEvents.length, 2); + assert.deepStrictEqual(resultEvents.map(event => event.properties.outcome), ['started', 'alreadyDebugging']); + } + finally { + duplicate.sessions.dispose(); + } + }); + + test('emits an exact private-data-free payload without correlation identifiers', async () => { + const telemetry = new TestResourceDebugTelemetry(); + telemetry.currentTime = 50; + const secrets = [ + '/Users/example/Private Workspace/Secret AppHost.csproj', + 'Secret AppHost.csproj', + 'private-resource-name', + 'Private Resource Display Name', + '54321', + 'session-secret-marker', + '/opt/private/bin/secret-process --api-key very-secret', + 'https://private.example.test/dashboard?token=very-secret', + 'PRIVATE_ENVIRONMENT_VARIABLE', + '--private-argument', + 'private-property-value', + 'private.debugger.extension', + 'raw configuration error with stack trace', + ]; + const privateTarget: ResourceDebugAppHostTarget = { + absolutePath: secrets[0], + displayPath: secrets[1], + }; + const { service, sessions } = createService({ + telemetry, + appHosts: [createAppHost({ + appHostPath: privateTarget.absolutePath, + appHostPid: Number(secrets[4]), + dashboardUrl: secrets[7], + resources: [createResource({ + name: secrets[2], + displayName: secrets[3], + resourceType: 'Container', + properties: { + pid: secrets[4], + marker: secrets[5], + executable: secrets[6], + url: secrets[7], + environment: secrets[8], + args: secrets[9], + property: secrets[10], + }, + })], + })], + provider: createProvider({ + requiredDebuggerExtensions: [{ id: secrets[11], label: secrets[11] }], + createDebugConfiguration: async () => { + telemetry.currentTime = 70; + throw new Error(secrets[12]); + }, + }), + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest({ + source: 'languageModelTool', + appHost: privateTarget, + resourceName: secrets[2], + })), { + outcome: 'error', + errorKind: 'configurationFailed', + }); + + const serializedEvents = JSON.stringify(telemetry.events); + assert.deepStrictEqual(telemetry.events, [ + { + name: 'aspire/vscode/resourcedebug/start', + properties: { + source: 'languageModelTool', + requested_strategy: 'attach', + controller: 'editor', + }, + measurements: undefined, + }, + { + name: 'aspire/vscode/resourcedebug/result', + properties: { + source: 'languageModelTool', + provider: 'dotnet', + resource_type: 'container', + requested_strategy: 'attach', + effective_strategy: 'none', + outcome: 'error', + controller: 'editor', + state: 'running', + debugger_requirement: 'installed', + error_kind: 'configurationFailed', + }, + measurements: { + resolution_duration_ms: 20, + total_duration_ms: 20, + }, + }, + ]); + assert.strictEqual(secrets.every(secret => !serializedEvents.includes(secret)), true); + } + finally { + sessions.dispose(); + } + }); + + test('emits one session-end only after a correlated attach session starts and terminates', async () => { + const telemetry = new TestResourceDebugTelemetry(); + telemetry.currentTime = 100; + let events: TestDebugSessionEvents | undefined; + const fixture = createService({ + telemetry, + provider: createProvider({ + createDebugConfiguration: async () => { + telemetry.currentTime = 110; + return { type: 'coreclr', request: 'attach', name: 'Attach debugger: API' }; + }, + }), + startDebugging: async (_folder, configuration) => { + events!.start(configuration); + telemetry.currentTime = 115; + return true; + }, + }); + events = fixture.events; + + try { + assert.deepStrictEqual( + await fixture.service.debug(createRequest({ source: 'languageModelTool', strategy: 'auto' })), + { outcome: 'started', providerId: 'dotnet' }); + telemetry.currentTime = 140; + assert.ok(events.startedConfiguration); + events.terminate(events.startedConfiguration); + + assert.deepStrictEqual(telemetry.events.at(-1), { + name: 'aspire/vscode/resourcedebug/session/end', + properties: { + source: 'languageModelTool', + provider: 'dotnet', + resource_type: 'project', + requested_strategy: 'auto', + effective_strategy: 'attach', + controller: 'editor', + session_end_reason: 'terminated', + }, + measurements: { + session_duration_ms: 30, + }, + }); + assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/session/end').length, 1); + } + finally { + fixture.sessions.dispose(); + } + }); + + test('does not emit a session-end for a pending attach that expires before a session starts', async () => { + const clock = sinon.useFakeTimers(); + const telemetry = new TestResourceDebugTelemetry(); + const fixture = createService({ + telemetry, + pendingStartTimeoutMs: 10, + }); + + try { + assert.deepStrictEqual(await fixture.service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + await clock.tickAsync(10); + + assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/start').length, 1); + assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/result').length, 1); + assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/session/end').length, 0); + } + finally { + fixture.sessions.dispose(); + clock.restore(); + } + }); + + test('ignores telemetry sink failures when debugging a resource', async () => { + const telemetry = new TestResourceDebugTelemetry(); + sinon.stub(telemetry, 'recordStart').throws(new Error('raw telemetry start failure')); + const { service, sessions } = createService({ telemetry }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.strictEqual((telemetry.recordStart as sinon.SinonStub).callCount, 1); + } + finally { + sessions.dispose(); + } + }); + + test('ignores a throwing result telemetry sink when debugging a resource', async () => { + const telemetry = new TestResourceDebugTelemetry(); + sinon.stub(telemetry, 'recordResult').throws(new Error('raw telemetry result failure')); + const { service, sessions } = createService({ telemetry }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.strictEqual((telemetry.recordResult as sinon.SinonStub).callCount, 1); + } + finally { + sessions.dispose(); + } + }); + + test('treats non-string and missing resource types as other without changing the debug result', async () => { + for (const resourceType of [undefined, 42] as const) { + const telemetry = new TestResourceDebugTelemetry(); + const { service, sessions } = createService({ + telemetry, + appHosts: [createAppHost({ + resources: [createResource({ resourceType: resourceType as unknown as string })], + })], + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.strictEqual( + telemetry.events.find(event => event.name === 'aspire/vscode/resourcedebug/result')?.properties.resource_type, + 'other'); + } + finally { + sessions.dispose(); + } + } + }); + + test('omits invalid result durations from a separate monotonic clock', async () => { + const testCases: readonly { + readonly name: string; + readonly clock: TestResourceDebugClock; + readonly measurements: Record; + }[] = [ + { + name: 'throws', + clock: new TestResourceDebugClock( + new Error('clock failure'), + new Error('clock failure'), + new Error('clock failure')), + measurements: {}, + }, + { + name: 'moves backwards', + clock: new TestResourceDebugClock(100, 150, 50), + measurements: { resolution_duration_ms: 50 }, + }, + { + name: 'returns NaN', + clock: new TestResourceDebugClock(Number.NaN, Number.NaN, Number.NaN), + measurements: {}, + }, + { + name: 'returns infinity', + clock: new TestResourceDebugClock( + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY), + measurements: {}, + }, + ]; + + for (const testCase of testCases) { + const telemetry = new TestResourceDebugTelemetry(); + const { service, sessions } = createService({ telemetry, clock: testCase.clock }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }, testCase.name); + assert.deepStrictEqual( + telemetry.events.find(event => event.name === 'aspire/vscode/resourcedebug/result')?.measurements, + testCase.measurements, + testCase.name); + } + finally { + sessions.dispose(); + } + } + }); + + test('swallows a throwing session-end sink and cleans up the session exactly once', async () => { + const telemetry = new TestResourceDebugTelemetry(); + const recordSessionEnd = sinon.stub(telemetry, 'recordSessionEnd').throws(new Error('raw session-end telemetry failure')); + let events: TestDebugSessionEvents | undefined; + const fixture = createService({ + telemetry, + startDebugging: async (_folder, configuration) => { + events!.start(configuration); + return true; + }, + }); + events = fixture.events; + + try { + assert.deepStrictEqual(await fixture.service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.ok(events.startedConfiguration); + + events.terminate(events.startedConfiguration); + events.terminate(events.startedConfiguration); + + assert.strictEqual(recordSessionEnd.callCount, 1); + assert.strictEqual(fixture.sessions.hasActiveSession(resolvedTarget, 'api'), false); + } + finally { + fixture.sessions.dispose(); + } + }); + + test('omits the session duration when the injected monotonic clock moves backwards', async () => { + const telemetry = new TestResourceDebugTelemetry(); + const clock = new TestResourceDebugClock(100, 110, 120, 130, 90); + let events: TestDebugSessionEvents | undefined; + const fixture = createService({ + telemetry, + clock, + startDebugging: async (_folder, configuration) => { + events!.start(configuration); + return true; + }, + }); + events = fixture.events; + + try { + assert.deepStrictEqual(await fixture.service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.ok(events.startedConfiguration); + + events.terminate(events.startedConfiguration); + + assert.deepStrictEqual( + telemetry.events.find(event => event.name === 'aspire/vscode/resourcedebug/session/end')?.measurements, + {}); + } + finally { + fixture.sessions.dispose(); + } + }); +}); diff --git a/extension/src/test/resourceDebugTools.test.ts b/extension/src/test/resourceDebugTools.test.ts new file mode 100644 index 00000000000..1454eb9ec9f --- /dev/null +++ b/extension/src/test/resourceDebugTools.test.ts @@ -0,0 +1,725 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; + +import type { + ResourceDebugger, + ResourceDebugRequest, + ResourceDebugResult, +} from '../debugger/resourceDebugContracts'; +import { + AppHostLifecycleToolService, + aspireAppHostStartToolName, + aspireAppHostStopToolName, +} from '../lm/appHostLifecycleTools'; +import { + AspireResourceDebugLanguageModelTool, + AspireResourceDebugToolService, + aspireResourceDebugToolName, + registerAspireResourceDebugTool, + type AspireResourceDebugToolInput, + type AspireResourceDebugToolResult, + type SafeAppHostTargetResolver, + type SafeAppHostTargetResolution, +} from '../lm/resourceDebugTools'; +import { AppHostTargetResolverService } from '../lm/appHostTargetResolverService'; + +const absoluteAppHostPath = '/private/workspace/AppHost/AppHost.csproj'; +const safeAppHostPath = 'AppHost/AppHost.csproj'; + +class FakeTargetResolver implements SafeAppHostTargetResolver { + calls = 0; + results: SafeAppHostTargetResolution[] = [{ + resolved: true, + target: { + absolutePath: absoluteAppHostPath, + relativePath: safeAppHostPath, + displayPath: safeAppHostPath, + }, + }]; + error: Error | undefined; + errors: Array = []; + tokens: vscode.CancellationToken[] = []; + onResolve: ((token: vscode.CancellationToken) => void | Promise) | undefined; + + async resolveTarget(_rawAppHost: unknown, token: vscode.CancellationToken): Promise { + this.calls++; + this.tokens.push(token); + await this.onResolve?.(token); + if (token.isCancellationRequested) { + return { resolved: false, outcome: 'cancelled' }; + } + + const error = this.errors[this.calls - 1] ?? this.error; + if (error) { + throw error; + } + + return this.results[Math.min(this.calls - 1, this.results.length - 1)]; + } +} + +class FakeResourceDebugger implements ResourceDebugger { + calls: ResourceDebugRequest[] = []; + result: ResourceDebugResult = { outcome: 'started', providerId: 'dotnet' }; + error: Error | undefined; + onDebug: ((request: ResourceDebugRequest) => void | Promise) | undefined; + + async debug(request: ResourceDebugRequest): Promise { + this.calls.push(request); + await this.onDebug?.(request); + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + if (this.error) { + throw this.error; + } + + return this.result; + } + + canAttachToResource(): boolean { + return true; + } +} + +function readToolResultPayload(result: vscode.LanguageModelToolResult): AspireResourceDebugToolResult { + const parts = result.content as Array<{ value?: unknown }>; + assert.strictEqual(parts.length, 1, 'Tool results must be a single bounded content part.'); + assert.strictEqual(typeof parts[0]?.value, 'string'); + return JSON.parse(parts[0].value as string) as AspireResourceDebugToolResult; +} + +function createService( + targetResolver = new FakeTargetResolver(), + resourceDebugger = new FakeResourceDebugger(), +): { + readonly service: AspireResourceDebugToolService; + readonly targetResolver: FakeTargetResolver; + readonly resourceDebugger: FakeResourceDebugger; +} { + return { + service: new AspireResourceDebugToolService({ targetResolver, resourceDebugger }), + targetResolver, + resourceDebugger, + }; +} + +function createInput(overrides: Record = {}): Record { + return { + appHostPath: safeAppHostPath, + resourceName: 'api', + ...overrides, + }; +} + +suite('Aspire resource debug language model tool', () => { + let isTrustedStub: sinon.SinonStub; + + setup(() => { + isTrustedStub = sinon.stub(vscode.workspace, 'isTrusted').value(true); + }); + + teardown(() => { + isTrustedStub.restore(); + sinon.restore(); + }); + + suite('manifest and localization', () => { + test('contributes the localized resource debug tool contract without changing lifecycle tools', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const manifest = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.json'), 'utf8')) as { + activationEvents?: string[]; + contributes: { languageModelTools?: Array> }; + }; + const packageNls = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.nls.json'), 'utf8')) as Record; + const tools = manifest.contributes.languageModelTools ?? []; + const tool = tools.find(candidate => candidate.name === aspireResourceDebugToolName); + + assert.ok(tool); + assert.strictEqual(tool.toolReferenceName, 'aspireDebugResource'); + assert.strictEqual(tool.icon, '$(debug-alt)'); + assert.strictEqual(tool.canBeReferencedInPrompt, true); + assert.strictEqual(tool.when, 'isWorkspaceTrusted'); + assert.deepStrictEqual(tool.tags, ['aspire', 'debug', 'resource']); + assert.ok(manifest.activationEvents?.includes(`onLanguageModelTool:${aspireResourceDebugToolName}`)); + + for (const field of ['displayName', 'modelDescription', 'userDescription']) { + const reference = tool[field] as string; + assert.match(reference, /^%[\w.-]+%$/); + assert.ok(packageNls[reference.slice(1, -1)]); + } + + assert.deepStrictEqual(tool.inputSchema, { + type: 'object', + properties: { + appHostPath: { + type: 'string', + description: '%languageModelTool.aspireResourceDebug.appHostPath.description%', + }, + resourceName: { + type: 'string', + description: '%languageModelTool.aspireResourceDebug.resourceName.description%', + }, + strategy: { + type: 'string', + enum: ['auto', 'attach'], + default: 'auto', + description: '%languageModelTool.aspireResourceDebug.strategy.description%', + }, + }, + required: ['appHostPath', 'resourceName'], + additionalProperties: false, + }); + assert.deepStrictEqual( + tools + .filter(candidate => candidate.name === aspireAppHostStartToolName || candidate.name === aspireAppHostStopToolName) + .map(candidate => [candidate.name, candidate.toolReferenceName]), + [ + [aspireAppHostStartToolName, 'aspireStartAppHost'], + [aspireAppHostStopToolName, 'aspireStopAppHost'], + ]); + }); + + test('adds localized manifest and runtime strings for the confirmation', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const packageNls = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.nls.json'), 'utf8')) as Record; + + assert.deepStrictEqual( + { + title: packageNls['aspire-vscode.strings.resourceDebugToolConfirmationTitle'], + message: packageNls['aspire-vscode.strings.resourceDebugToolConfirmationMessage'], + invocation: packageNls['aspire-vscode.strings.resourceDebugToolInvocationMessage'], + unresolvedInvocation: packageNls['aspire-vscode.strings.resourceDebugToolUnavailableInvocationMessage'], + display: packageNls['languageModelTool.aspireResourceDebug.displayName'], + model: packageNls['languageModelTool.aspireResourceDebug.modelDescription'], + user: packageNls['languageModelTool.aspireResourceDebug.userDescription'], + appHostPath: packageNls['languageModelTool.aspireResourceDebug.appHostPath.description'], + resourceName: packageNls['languageModelTool.aspireResourceDebug.resourceName.description'], + strategy: packageNls['languageModelTool.aspireResourceDebug.strategy.description'], + }, + { + title: 'Attach debugger to Aspire resource', + message: 'Attach the debugger to resource {0} from Aspire AppHost {1}?', + invocation: 'Attaching debugger to Aspire resource {0}...', + unresolvedInvocation: 'Attaching debugger to the requested Aspire resource...', + display: 'Debug Aspire resource', + model: 'Attach the VS Code debugger to a running Aspire resource that the extension has already discovered. Requires a workspace-relative AppHost path and the resource name. The default auto strategy currently attaches to the resource; start and restart under debug are not supported.', + user: 'Attach the debugger to a running Aspire resource.', + appHostPath: 'Workspace-relative path of an AppHost that Aspire has already discovered. Absolute paths and paths Aspire did not discover are rejected. In a multi-root workspace, prefix the path with the workspace folder name.', + resourceName: 'Name of a running resource from the selected AppHost. Resource names are limited to 256 characters.', + strategy: 'Debug strategy. auto selects the available safe action, currently attach. attach only attaches a debugger; starting and restarting resources are not supported.', + }); + }); + }); + + suite('registration', () => { + test('registers and disposes the resource debug tool once', () => { + const { service } = createService(); + const disposed: string[] = []; + const registerToolStub = sinon.stub(vscode.lm, 'registerTool').callsFake((name: string) => + new vscode.Disposable(() => disposed.push(name))); + + const registration = registerAspireResourceDebugTool(service); + + assert.strictEqual(registration.registered, true); + assert.deepStrictEqual(registerToolStub.getCalls().map(call => call.args[0]), [aspireResourceDebugToolName]); + assert.deepStrictEqual([...registration.tools.keys()], [aspireResourceDebugToolName]); + assert.strictEqual( + typeof (registration.tools.get(aspireResourceDebugToolName) as { invoke?: unknown }).invoke, + 'function'); + registration.dispose(); + assert.deepStrictEqual(disposed, [aspireResourceDebugToolName]); + }); + + test('does not register when the language model tool API is unavailable', () => { + const { service } = createService(); + const registerToolStub = sinon.stub(vscode.lm, 'registerTool').value(undefined); + + const registration = registerAspireResourceDebugTool(service); + + assert.strictEqual(registration.registered, false); + registration.dispose(); + registerToolStub.restore(); + }); + }); + + suite('input and target resolution', () => { + test('rejects invalid input and additional properties before resolving or debugging', async () => { + const throwingInput = { resourceName: 'api' }; + Object.defineProperty(throwingInput, 'appHostPath', { + enumerable: true, + get: () => { + throw new Error('token=super-secret'); + }, + }); + const hiddenAdditionalPropertyInput = createInput(); + Object.defineProperty(hiddenAdditionalPropertyInput, 'hidden', { + value: 'unexpected', + }); + const invalidInputs: unknown[] = [ + undefined, + null, + [], + { appHostPath: safeAppHostPath }, + { resourceName: 'api' }, + createInput({ appHostPath: ' ' }), + createInput({ appHostPath: 'AppHost/\u200bAppHost.csproj' }), + createInput({ appHostPath: 'AppHost\u2028/AppHost.csproj' }), + createInput({ appHostPath: 'AppHost\u2029/AppHost.csproj' }), + createInput({ resourceName: '\t' }), + createInput({ resourceName: 'api\u200b' }), + createInput({ resourceName: 'api\u2028injected' }), + createInput({ resourceName: 'api\u2029injected' }), + createInput({ resourceName: 'a'.repeat(257) }), + createInput({ strategy: 'restart' }), + createInput({ unexpected: 'value' }), + throwingInput, + hiddenAdditionalPropertyInput, + ]; + + for (const input of invalidInputs) { + const { service, targetResolver, resourceDebugger } = createService(); + const result = await service.debug(input, new vscode.CancellationTokenSource().token); + + assert.deepStrictEqual(result, { + tool: aspireResourceDebugToolName, + success: false, + outcome: 'invalidInput', + appHost: '', + resourceName: '', + requestedStrategy: 'auto', + effectiveStrategy: 'none', + controller: 'none', + }); + assert.strictEqual(targetResolver.calls, 0); + assert.strictEqual(resourceDebugger.calls.length, 0); + } + }); + + test('defaults and explicitly maps auto and attach to attach', async () => { + for (const [input, requestedStrategy] of [ + [createInput(), 'auto'], + [createInput({ strategy: 'auto' }), 'auto'], + [createInput({ strategy: 'attach' }), 'attach'], + ] as const) { + const { service, resourceDebugger } = createService(); + const result = await service.debug(input, new vscode.CancellationTokenSource().token); + + assert.deepStrictEqual( + { + success: result.success, + requestedStrategy: result.requestedStrategy, + effectiveStrategy: result.effectiveStrategy, + controller: result.controller, + }, + { + success: true, + requestedStrategy, + effectiveStrategy: 'attach', + controller: 'editor', + }); + assert.strictEqual(resourceDebugger.calls[0].source, 'languageModelTool'); + assert.strictEqual(resourceDebugger.calls[0].strategy, requestedStrategy); + } + }); + + test('rejects untrusted workspaces without resolving or debugging', async () => { + isTrustedStub.value(false); + const { service, targetResolver, resourceDebugger } = createService(); + + const result = await service.debug(createInput(), new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'workspaceNotTrusted'); + assert.strictEqual(targetResolver.calls, 0); + assert.strictEqual(resourceDebugger.calls.length, 0); + }); + + test('maps missing, ambiguous, and failed AppHost resolution without leaking a target', async () => { + for (const outcome of ['unknownAppHost', 'ambiguousAppHost', 'discoveryFailed'] as const) { + const resolver = new FakeTargetResolver(); + resolver.results = [{ resolved: false, outcome }]; + const { service, resourceDebugger } = createService(resolver); + + const result = await service.debug(createInput(), new vscode.CancellationTokenSource().token); + + assert.deepStrictEqual( + { + outcome: result.outcome, + appHost: result.appHost, + controller: result.controller, + effectiveStrategy: result.effectiveStrategy, + }, + { + outcome, + appHost: '', + controller: 'none', + effectiveStrategy: 'none', + }); + assert.strictEqual(resourceDebugger.calls.length, 0); + } + }); + + test('retains the resolver safe multi-root display path and never returns its absolute target', async () => { + const resolver = new FakeTargetResolver(); + resolver.results = [{ + resolved: true, + target: { + absolutePath: '/private/workspace/backend/AppHost/AppHost.csproj', + relativePath: 'AppHost/AppHost.csproj', + displayPath: 'backend/AppHost/AppHost.csproj', + }, + }]; + const { service, resourceDebugger } = createService(resolver); + + const result = await service.debug( + createInput({ appHostPath: 'backend/AppHost/AppHost.csproj' }), + new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.appHost, 'backend/AppHost/AppHost.csproj'); + assert.strictEqual(resourceDebugger.calls[0].appHost.absolutePath, '/private/workspace/backend/AppHost/AppHost.csproj'); + assert.strictEqual(JSON.stringify(result).includes('/private/workspace'), false); + }); + }); + + suite('confirmation and invocation', () => { + test('confirms only the user resource name and safe AppHost display path', async () => { + const resolver = new FakeTargetResolver(); + resolver.results = [{ + resolved: true, + target: { + absolutePath: absoluteAppHostPath, + relativePath: safeAppHostPath, + displayPath: 'backend/AppHost/AppHost.csproj', + }, + }]; + const { service } = createService(resolver); + const tool = new AspireResourceDebugLanguageModelTool(service); + + const prepared = await tool.prepareInvocation( + { input: createInput({ appHostPath: 'backend/AppHost/AppHost.csproj', resourceName: 'api' }) as unknown as AspireResourceDebugToolInput }, + new vscode.CancellationTokenSource().token); + const confirmation = `${prepared.confirmationMessages?.title}\n${prepared.confirmationMessages?.message}\n${prepared.invocationMessage}`; + + assert.strictEqual(prepared.confirmationMessages?.title, 'Attach debugger to Aspire resource'); + assert.strictEqual(prepared.confirmationMessages?.message, 'Attach the debugger to resource api from Aspire AppHost backend/AppHost/AppHost.csproj?'); + assert.strictEqual(prepared.invocationMessage, 'Attaching debugger to Aspire resource api...'); + assert.strictEqual(confirmation.includes(absoluteAppHostPath), false); + assert.strictEqual(confirmation.includes('pid'), false); + assert.strictEqual(confirmation.includes('debug configuration'), false); + }); + + test('always requires a generic confirmation when preparation cannot resolve the AppHost', async () => { + const resolver = new FakeTargetResolver(); + for (const outcome of ['unknownAppHost', 'discoveryFailed', 'cancelled'] as const) { + resolver.calls = 0; + resolver.results = [ + { resolved: false, outcome }, + { + resolved: true, + target: { + absolutePath: absoluteAppHostPath, + relativePath: safeAppHostPath, + displayPath: safeAppHostPath, + }, + }, + ]; + const { service, resourceDebugger } = createService(resolver); + const tool = new AspireResourceDebugLanguageModelTool(service); + const input = createInput({ appHostPath: '../private/token=secret' }); + + const prepared = await tool.prepareInvocation( + { input: input as unknown as AspireResourceDebugToolInput }, + new vscode.CancellationTokenSource().token); + const result = readToolResultPayload(await tool.invoke( + { input: input as unknown as AspireResourceDebugToolInput, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token)); + + assert.deepStrictEqual(prepared.confirmationMessages, { + title: 'Attach debugger to Aspire resource', + message: 'Attach the debugger to the requested Aspire resource?', + }); + assert.strictEqual(prepared.invocationMessage, 'Attaching debugger to the requested Aspire resource...'); + assert.strictEqual(JSON.stringify(prepared).includes('../private/token=secret'), false); + assert.strictEqual(JSON.stringify(prepared).includes(absoluteAppHostPath), false); + assert.strictEqual(result.outcome, 'started'); + assert.strictEqual(resourceDebugger.calls.length, 1); + } + + const { service } = createService(); + const tool = new AspireResourceDebugLanguageModelTool(service); + const prepared = await tool.prepareInvocation( + { input: createInput({ resourceName: 'api\u2028injected' }) as unknown as AspireResourceDebugToolInput }, + new vscode.CancellationTokenSource().token); + + assert.deepStrictEqual(prepared.confirmationMessages, { + title: 'Attach debugger to Aspire resource', + message: 'Attach the debugger to the requested Aspire resource?', + }); + assert.strictEqual(JSON.stringify(prepared).includes('injected'), false); + }); + + test('allows an invocation to resolve after preparation fails', async () => { + const resolver = new FakeTargetResolver(); + resolver.errors = [new Error('initial AppHost discovery failure'), undefined]; + const { service, resourceDebugger } = createService(resolver); + const tool = new AspireResourceDebugLanguageModelTool(service); + const input = createInput(); + + const prepared = await tool.prepareInvocation( + { input: input as unknown as AspireResourceDebugToolInput }, + new vscode.CancellationTokenSource().token); + const result = readToolResultPayload(await tool.invoke( + { input: input as unknown as AspireResourceDebugToolInput, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token)); + + assert.deepStrictEqual(prepared.confirmationMessages, { + title: 'Attach debugger to Aspire resource', + message: 'Attach the debugger to the requested Aspire resource?', + }); + assert.strictEqual(prepared.invocationMessage, 'Attaching debugger to the requested Aspire resource...'); + assert.strictEqual(result.outcome, 'started'); + assert.strictEqual(resourceDebugger.calls.length, 1); + }); + + test('escapes confirmed resource and AppHost identities with the shared Markdown helper', async () => { + const resolver = new FakeTargetResolver(); + resolver.results = [{ + resolved: true, + target: { + absolutePath: absoluteAppHostPath, + relativePath: 'AppHost/[unsafe]*.csproj', + displayPath: 'AppHost/[unsafe]*.csproj', + }, + }]; + const { service } = createService(resolver); + const tool = new AspireResourceDebugLanguageModelTool(service); + + const prepared = await tool.prepareInvocation( + { input: createInput({ resourceName: 'api_[unsafe]*' }) as unknown as AspireResourceDebugToolInput }, + new vscode.CancellationTokenSource().token); + + assert.strictEqual( + prepared.confirmationMessages?.message, + 'Attach the debugger to resource api\\_\\[unsafe\\]\\* from Aspire AppHost AppHost/\\[unsafe\\]\\*.csproj?'); + }); + + test('re-resolves the AppHost immediately after confirmation', async () => { + const resolver = new FakeTargetResolver(); + resolver.results = [ + { + resolved: true, + target: { + absolutePath: '/private/workspace/first/AppHost.csproj', + relativePath: 'first/AppHost.csproj', + displayPath: 'first/AppHost.csproj', + }, + }, + { + resolved: true, + target: { + absolutePath: '/private/workspace/second/AppHost.csproj', + relativePath: 'second/AppHost.csproj', + displayPath: 'second/AppHost.csproj', + }, + }, + ]; + const { service, resourceDebugger } = createService(resolver); + const tool = new AspireResourceDebugLanguageModelTool(service); + const input = createInput({ appHostPath: 'first/AppHost.csproj' }); + + const prepared = await tool.prepareInvocation({ input: input as unknown as AspireResourceDebugToolInput }, new vscode.CancellationTokenSource().token); + const result = readToolResultPayload(await tool.invoke( + { input: input as unknown as AspireResourceDebugToolInput, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token)); + + assert.strictEqual(prepared.confirmationMessages?.message, 'Attach the debugger to resource api from Aspire AppHost first/AppHost.csproj?'); + assert.strictEqual(result.appHost, 'second/AppHost.csproj'); + assert.strictEqual(resourceDebugger.calls[0].appHost.absolutePath, '/private/workspace/second/AppHost.csproj'); + }); + }); + + suite('cancellation and result mapping', () => { + test('maps cancellation before and during resolution or debugging without side effects after cancellation', async () => { + const before = createService(); + const beforeToken = new vscode.CancellationTokenSource(); + beforeToken.cancel(); + assert.strictEqual((await before.service.debug(createInput(), beforeToken.token)).outcome, 'cancelled'); + assert.strictEqual(before.targetResolver.calls, 0); + + const duringResolution = createService(); + const resolveToken = new vscode.CancellationTokenSource(); + duringResolution.targetResolver.onResolve = () => resolveToken.cancel(); + assert.strictEqual((await duringResolution.service.debug(createInput(), resolveToken.token)).outcome, 'cancelled'); + assert.strictEqual(duringResolution.resourceDebugger.calls.length, 0); + + const duringDebug = createService(); + const debugToken = new vscode.CancellationTokenSource(); + duringDebug.resourceDebugger.onDebug = () => debugToken.cancel(); + assert.strictEqual((await duringDebug.service.debug(createInput(), debugToken.token)).outcome, 'cancelled'); + }); + + test('fails closed when disposal races AppHost resolution before an attach starts', async () => { + const resolver = new FakeTargetResolver(); + const { service, resourceDebugger } = createService(resolver); + resolver.onResolve = () => service.dispose(); + + const result = await service.debug(createInput(), new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'cancelled'); + assert.strictEqual(resourceDebugger.calls.length, 0); + }); + + test('cancels a resolver operation owned by the service when it is disposed', async () => { + const resolver = new FakeTargetResolver(); + let markResolutionStarted: (() => void) | undefined; + const resolutionStarted = new Promise(resolve => { + markResolutionStarted = resolve; + }); + resolver.onResolve = token => new Promise(resolve => { + markResolutionStarted!(); + token.onCancellationRequested(resolve); + }); + const { service, resourceDebugger } = createService(resolver); + const callerCancellation = new vscode.CancellationTokenSource(); + + try { + const operation = service.debug(createInput(), callerCancellation.token); + await resolutionStarted; + service.dispose(); + + assert.strictEqual((await operation).outcome, 'cancelled'); + assert.strictEqual(resourceDebugger.calls.length, 0); + assert.notStrictEqual(resolver.tokens[0], callerCancellation.token); + assert.strictEqual(resolver.tokens[0].isCancellationRequested, true); + } + finally { + callerCancellation.dispose(); + } + }); + + test('cancels an in-flight debugger operation when the service is disposed', async () => { + const { service, resourceDebugger } = createService(); + let markDebugStarted: (() => void) | undefined; + const debugStarted = new Promise(resolve => { + markDebugStarted = resolve; + }); + resourceDebugger.onDebug = request => new Promise(resolve => { + markDebugStarted!(); + request.cancellationToken?.onCancellationRequested(resolve); + }); + const callerCancellation = new vscode.CancellationTokenSource(); + + try { + const operation = service.debug(createInput(), callerCancellation.token); + await debugStarted; + service.dispose(); + + assert.strictEqual((await operation).outcome, 'cancelled'); + assert.strictEqual(resourceDebugger.calls.length, 1); + assert.notStrictEqual(resourceDebugger.calls[0].cancellationToken, callerCancellation.token); + assert.strictEqual(resourceDebugger.calls[0].cancellationToken?.isCancellationRequested, true); + } + finally { + callerCancellation.dispose(); + } + }); + + test('maps every bounded resource debug result', async () => { + const cases: Array<{ + readonly result: ResourceDebugResult; + readonly expected: Pick; + }> = [ + { result: { outcome: 'started', providerId: 'dotnet' }, expected: { success: true, outcome: 'started', effectiveStrategy: 'attach', controller: 'editor', provider: 'dotnet', errorKind: undefined } }, + { result: { outcome: 'alreadyDebugging' }, expected: { success: true, outcome: 'alreadyDebugging', effectiveStrategy: 'attach', controller: 'editor', provider: undefined, errorKind: undefined } }, + { result: { outcome: 'appHostNotFound' }, expected: { success: false, outcome: 'appHostNotFound', effectiveStrategy: 'none', controller: 'none', provider: undefined, errorKind: undefined } }, + { result: { outcome: 'resourceNotFound' }, expected: { success: false, outcome: 'resourceNotFound', effectiveStrategy: 'none', controller: 'none', provider: undefined, errorKind: undefined } }, + { result: { outcome: 'unsupportedResource' }, expected: { success: false, outcome: 'unsupportedResource', effectiveStrategy: 'none', controller: 'none', provider: undefined, errorKind: undefined } }, + { result: { outcome: 'resourceNotRunning' }, expected: { success: false, outcome: 'resourceNotRunning', effectiveStrategy: 'none', controller: 'none', provider: undefined, errorKind: undefined } }, + { result: { outcome: 'cancelled' }, expected: { success: false, outcome: 'cancelled', effectiveStrategy: 'none', controller: 'none', provider: undefined, errorKind: undefined } }, + ...(['resourceSnapshotFailed', 'providerResolutionFailed', 'configurationFailed', 'debuggerStartDeclined', 'debuggerStartFailed', 'unexpected'] as const).map(errorKind => ({ + result: { outcome: 'error', errorKind } as ResourceDebugResult, + expected: { success: false, outcome: 'error' as const, effectiveStrategy: 'none' as const, controller: 'none' as const, provider: undefined, errorKind }, + })), + ]; + + for (const testCase of cases) { + const { service, resourceDebugger } = createService(); + resourceDebugger.result = testCase.result; + + const result = await service.debug(createInput(), new vscode.CancellationTokenSource().token); + + assert.deepStrictEqual( + { + success: result.success, + outcome: result.outcome, + effectiveStrategy: result.effectiveStrategy, + controller: result.controller, + provider: result.provider, + errorKind: result.errorKind, + }, + testCase.expected); + } + }); + + test('returns only safe C# and Go debugger requirements', async () => { + for (const [id, label] of [ + ['ms-dotnettools.csharp', 'C#'], + ['golang.go', 'Go'], + ]) { + const { service, resourceDebugger } = createService(); + resourceDebugger.result = { + outcome: 'debuggerExtensionMissing', + debuggerExtensions: [{ id, label, installMessage: 'token=super-secret /private/debug.json --args bad' }], + }; + + const result = await service.debug(createInput(), new vscode.CancellationTokenSource().token); + + assert.deepStrictEqual(result.debuggerExtensions, [{ id, label }]); + assert.strictEqual(result.provider, undefined); + assert.strictEqual(result.success, false); + assert.strictEqual(JSON.stringify(result).includes('super-secret'), false); + } + }); + + test('converts unexpected exceptions to a bounded, valid JSON result without sensitive data', async () => { + const { service, resourceDebugger } = createService(); + resourceDebugger.error = new Error('token=super-secret pid=42 /private/debug.json --configuration {"process":"dotnet"} https://private.example args=unsafe'); + const tool = new AspireResourceDebugLanguageModelTool(service); + + const languageModelResult = await tool.invoke( + { input: createInput() as unknown as AspireResourceDebugToolInput, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token); + const payload = readToolResultPayload(languageModelResult); + const serialized = JSON.stringify(payload); + + assert.deepStrictEqual( + { + outcome: payload.outcome, + success: payload.success, + appHost: payload.appHost, + effectiveStrategy: payload.effectiveStrategy, + controller: payload.controller, + }, + { + outcome: 'failed', + success: false, + appHost: safeAppHostPath, + effectiveStrategy: 'none', + controller: 'none', + }); + for (const forbidden of ['super-secret', '/private/', 'pid=42', 'dotnet', 'private.example', 'args=unsafe', 'debug.json']) { + assert.strictEqual(serialized.includes(forbidden), false, `Tool result leaked ${forbidden}.`); + } + assert.deepStrictEqual(JSON.parse(serialized), payload); + }); + }); + + test('uses the neutral AppHost target resolver contract without importing lifecycle policy', () => { + assert.strictEqual(typeof AppHostTargetResolverService.prototype.resolveTarget, 'function'); + assert.strictEqual(AppHostLifecycleToolService.prototype.isPrototypeOf(AppHostTargetResolverService.prototype), false); + }); +}); diff --git a/extension/src/test/strings.test.ts b/extension/src/test/strings.test.ts index 847c6b052b8..13724144874 100644 --- a/extension/src/test/strings.test.ts +++ b/extension/src/test/strings.test.ts @@ -132,6 +132,22 @@ suite('utils/strings tests', () => { assert.deepStrictEqual(missingFromXlf, [], 'Regenerate loc/xlf/aspire-vscode.xlf with "yarn run localize" after adding package.nls.json entries.'); }); + test('resource debugger strings are present in package.nls.json and the generated XLF catalog', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const packageNls = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.nls.json'), 'utf8')) as Record; + const xlf = fs.readFileSync(path.join(extensionRoot, 'loc', 'xlf', 'aspire-vscode.xlf'), 'utf8'); + const expectedStrings = { + attachingDebugger: 'Attaching debugger to {0}...', + attachDebuggerAlreadyDebugging: 'A debugger is already attached to {0}.', + }; + + for (const [name, value] of Object.entries(expectedStrings)) { + const key = `aspire-vscode.strings.${name}`; + assert.strictEqual(packageNls[key], value); + assert.ok(xlf.includes(``)); + } + }); + test('Java loc strings are present in package.nls.json and the generated XLF catalog', () => { // Same guard as the Rust strings above: package.nls.json is the only input to the XLF // catalog (see gulpfile.js), so a Java string that only exists in strings.ts ships diff --git a/extension/src/test/telemetryInventory.test.ts b/extension/src/test/telemetryInventory.test.ts index 1f829eb7649..f7b8be86500 100644 --- a/extension/src/test/telemetryInventory.test.ts +++ b/extension/src/test/telemetryInventory.test.ts @@ -13,6 +13,14 @@ type TelemetryRegistryEvent = { entries: string[]; }; +type ResourceDebugTelemetryPropertyExpectation = { + eventName: string; + interfaceName: string; + propertyName: string; + values: readonly string[]; + comment: string; +}; + // Telemetry events emit verbatim to the wire — the registry-declared name // (e.g. `aspire/vscode/command/invoked`, `aspire/dashboard/operation`) is // what appears in `extension/telemetry.json`. The transport sender strips VS @@ -40,12 +48,78 @@ const platformCommonTelemetryProperties = [ 'common.vscodesessionid', 'common.vscodeversion', ] as const; +const resourceDebugTelemetryPropertyExpectations: readonly ResourceDebugTelemetryPropertyExpectation[] = [ + { + eventName: 'aspire/vscode/resourcedebug/start', + interfaceName: 'ResourceDebugStartTelemetryProperties', + propertyName: 'requested_strategy', + values: ['attach', 'auto', 'invalid'], + comment: 'The bounded resource debug strategy requested by the caller: auto, attach, or invalid.', + }, + { + eventName: 'aspire/vscode/resourcedebug/result', + interfaceName: 'ResourceDebugResultTelemetryProperties', + propertyName: 'requested_strategy', + values: ['attach', 'auto', 'invalid'], + comment: 'The bounded resource debug strategy requested by the caller: auto, attach, or invalid.', + }, + { + eventName: 'aspire/vscode/resourcedebug/result', + interfaceName: 'ResourceDebugResultTelemetryProperties', + propertyName: 'effective_strategy', + values: ['attach', 'none'], + comment: 'The bounded effective resource debug strategy: attach or none.', + }, + { + eventName: 'aspire/vscode/resourcedebug/session/end', + interfaceName: 'ResourceDebugSessionEndTelemetryProperties', + propertyName: 'requested_strategy', + values: ['attach', 'auto'], + comment: 'The bounded resource debug strategy requested by the caller: auto or attach.', + }, + { + eventName: 'aspire/vscode/resourcedebug/session/end', + interfaceName: 'ResourceDebugSessionEndTelemetryProperties', + propertyName: 'effective_strategy', + values: ['attach'], + comment: 'The bounded effective resource debug strategy: attach.', + }, +]; function readTelemetryInventory(): TelemetryInventory { const inventoryPath = path.resolve(__dirname, '../../telemetry.json'); return JSON.parse(fs.readFileSync(inventoryPath, 'utf8')) as TelemetryInventory; } +function readResourceDebugTelemetryPropertyValues(interfaceName: string, propertyName: string): string[] { + const telemetryPath = path.resolve(__dirname, '../../src/debugger/resourceDebugTelemetry.ts'); + const program = ts.createProgram([telemetryPath], { + moduleResolution: ts.ModuleResolutionKind.Node10, + target: ts.ScriptTarget.Latest, + }); + const sourceFile = program.getSourceFile(telemetryPath); + const telemetryInterface = sourceFile?.statements.find((node): node is ts.InterfaceDeclaration => + ts.isInterfaceDeclaration(node) && node.name.text === interfaceName); + if (!telemetryInterface) { + return []; + } + + const typeChecker = program.getTypeChecker(); + const property = typeChecker + .getTypeAtLocation(telemetryInterface) + .getProperty(propertyName); + if (!property) { + return []; + } + + const declaration = property.valueDeclaration ?? property.declarations?.[0]; + if (!declaration) { + return []; + } + + return getStringLiteralValues(typeChecker.getTypeOfSymbolAtLocation(property, declaration)); +} + function readTelemetryRegistryEvents(): TelemetryRegistryEvent[] { const registryPath = path.resolve(__dirname, '../../src/utils/telemetryRegistry.ts'); const sourceText = fs.readFileSync(registryPath, 'utf8'); @@ -128,10 +202,21 @@ function getStringLiteralUnion(typeNode: ts.TypeNode): string[] { return []; } +function getStringLiteralValues(type: ts.Type): string[] { + if (type.isUnion()) { + return [...new Set(type.types.flatMap(getStringLiteralValues))].sort(); + } + + return type.flags & ts.TypeFlags.StringLiteral + ? [(type as ts.StringLiteralType).value] + : []; +} + suite('extension/telemetry.json', () => { - test('event entity names are lowercase to match VS Code telemetry ingestion', () => { + test('event entity names are lowercase', () => { const inventory = readTelemetryInventory(); - const mixedCaseEntityNames = Object.keys(inventory.events).filter(name => name !== name.toLowerCase()); + const mixedCaseEntityNames = Object.keys(inventory.events) + .filter(name => name !== name.toLowerCase()); assert.deepStrictEqual(mixedCaseEntityNames, []); }); @@ -174,4 +259,27 @@ suite('extension/telemetry.json', () => { assert.deepStrictEqual(suspiciousRegistryEntries, []); }); + + test('documents bounded resource debug strategy telemetry', () => { + const inventory = readTelemetryInventory(); + const inconsistencies = resourceDebugTelemetryPropertyExpectations.flatMap(expectation => { + const inventoryProperty = inventory.events[expectation.eventName]?.[expectation.propertyName] as { comment?: unknown } | undefined; + const actualValues = readResourceDebugTelemetryPropertyValues(expectation.interfaceName, expectation.propertyName); + const actualComment = inventoryProperty?.comment; + + return actualComment === expectation.comment && + JSON.stringify(actualValues) === JSON.stringify(expectation.values) + ? [] + : [{ + eventName: expectation.eventName, + propertyName: expectation.propertyName, + expectedValues: expectation.values, + actualValues, + expectedComment: expectation.comment, + actualComment, + }]; + }); + + assert.deepStrictEqual(inconsistencies, []); + }); }); diff --git a/extension/src/test/testRunSessionManager.test.ts b/extension/src/test/testRunSessionManager.test.ts index e36db4360f7..6306fd61d43 100644 --- a/extension/src/test/testRunSessionManager.test.ts +++ b/extension/src/test/testRunSessionManager.test.ts @@ -148,25 +148,25 @@ function stubDebugSessionEvents(): { start: (session: vscode.DebugSession) => void; terminate: (session: vscode.DebugSession) => void; } { - let startDebugSession: ((session: vscode.DebugSession) => void) | undefined; - let terminateDebugSession: ((session: vscode.DebugSession) => void) | undefined; + const startDebugSessions: Array<(session: vscode.DebugSession) => void> = []; + const terminateDebugSessions: Array<(session: vscode.DebugSession) => void> = []; sinon.stub(vscode.debug, 'onDidStartDebugSession').callsFake(listener => { - startDebugSession = listener; + startDebugSessions.push(listener); return { dispose: () => { } }; }); sinon.stub(vscode.debug, 'onDidTerminateDebugSession').callsFake(listener => { - terminateDebugSession = listener; + terminateDebugSessions.push(listener); return { dispose: () => { } }; }); return { start: session => { - assert.ok(startDebugSession); - startDebugSession(session); + assert.ok(startDebugSessions.length > 0); + startDebugSessions.forEach(listener => listener(session)); }, terminate: session => { - assert.ok(terminateDebugSession); - terminateDebugSession(session); + assert.ok(terminateDebugSessions.length > 0); + terminateDebugSessions.forEach(listener => listener(session)); }, }; } diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 80b66da5153..3364ea2778d 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -11,14 +11,14 @@ import { redactCliArgsForLogging, spawnCliProcess, terminateCliProcess } from '. import { cleanupRun } from '../debugger/runCleanupRegistry'; import type { AspireResourceExtendedDebugConfiguration, EnvVar, ExecutableLaunchConfiguration } from '../dcp/types'; import { createStateSnapshot, getSensitiveDashboardUrl, isSamePath } from '../extensionState'; -import type { PreparableAppHostLifecycleTool } from '../lm/appHostLifecycleTools'; +import type { PreparableLanguageModelTool } from '../lm/languageModelToolContracts'; import { AppHostLaunchRequestedEvent, AppHostLaunchService } from '../services/AppHostLaunchService'; import type { AspireDebugConsoleOutputEvent, AspireExtensionE2EBrowserDebugSession, AspireExtensionE2ECodeLensProbeResult, AspireExtensionE2ECommandInvocation, AspireExtensionE2EControlCommand, AspireExtensionE2EControlPayload, AspireExtensionE2EControlStatus, AspireExtensionE2EDebugConsoleOutput, AspireExtensionE2EDebugLaunch, AspireExtensionE2EStoppingPathEvent, AspireExtensionE2ETaskProcessEvent, AspireExtensionE2ETerminalCommand, AspireExtensionStateSnapshot } from '../types/extensionApi'; import { AspireTerminalCommandEvent, AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { delay } from '../utils/async'; import { dashboardDefaultChangedNotificationKey } from '../utils/dashboardNotificationState'; import { extensionLogOutputChannel } from '../utils/logging'; -import { onDidInvokeCommand } from '../utils/telemetry'; +import { isCommandCancellation, onDidInvokeCommand } from '../utils/telemetry'; import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; import { ResourceItem } from '../views/treeItems/resourceItems'; import { ResourceJson } from '../data/appHostCliContracts'; @@ -36,7 +36,7 @@ export function createE2eStateFileBridge( appHostTreeProvider: AspireAppHostTreeProvider, terminalProvider: AspireTerminalProvider, onDidChangeState: vscode.Event, - appHostLifecycleTools: ReadonlyMap, + preparableLanguageModelTools: ReadonlyMap, ): vscode.Disposable { const stateFile = process.env.ASPIRE_EXTENSION_E2E_STATE_FILE; const controlFile = process.env.ASPIRE_EXTENSION_E2E_CONTROL_FILE; @@ -257,7 +257,7 @@ export function createE2eStateFileBridge( } }; - const result = await executeE2eControlCommand(context, aspireContext, dataRepository, appHostLaunchService, appHostTreeProvider, terminalProvider, clipboardSnapshot, clipboardExpectation, appHostLifecycleTools, payload.command, markCommandStarted); + const result = await executeE2eControlCommand(context, aspireContext, dataRepository, appHostLaunchService, appHostTreeProvider, terminalProvider, clipboardSnapshot, clipboardExpectation, preparableLanguageModelTools, payload.command, markCommandStarted); controlStatus = { revision, status: 'applied', startedObserved: commandStarted, result }; } else { @@ -368,7 +368,13 @@ async function processE2eControlFile( } function getE2eErrorMessage(error: unknown): string { - return error instanceof Error ? (error.stack ?? error.message) : String(error); + if (isCommandCancellation(error)) { + return 'E2E control command cancelled.'; + } + + return error instanceof Error && error.message.startsWith('Aspire extension E2E ') + ? error.message + : 'E2E control command failed.'; } export async function executeE2eControlCommand( @@ -380,7 +386,7 @@ export async function executeE2eControlCommand( terminalProvider: AspireTerminalProvider, clipboardSnapshot: E2eClipboardSnapshot, clipboardExpectation: E2eClipboardExpectation, - appHostLifecycleTools: ReadonlyMap, + preparableLanguageModelTools: ReadonlyMap, command: AspireExtensionE2EControlCommand, markStarted: () => void ): Promise { @@ -648,7 +654,7 @@ export async function executeE2eControlCommand( } case 'prepareLanguageModelToolInvocation': { markStarted(); - const tool = appHostLifecycleTools.get(command.toolName); + const tool = preparableLanguageModelTools.get(command.toolName); if (!tool) { throw new Error(`Language model tool '${command.toolName}' is not registered.`); } @@ -663,17 +669,39 @@ export async function executeE2eControlCommand( case 'invokeLanguageModelTool': { markStarted(); const invocationCount = Math.max(1, command.times ?? 1); - const invocationResults = await Promise.all(Array.from({ length: invocationCount }, () => vscode.lm.invokeTool(command.toolName, { - input: command.input, - toolInvocationToken: undefined, - }))); + const cancellationDelayMs = getE2eCancellationDelay(command.cancelAfterMs); + const cancellationSource = cancellationDelayMs === undefined + ? undefined + : new vscode.CancellationTokenSource(); + const cancellationTimer = cancellationSource + ? setTimeout(() => cancellationSource.cancel(), cancellationDelayMs) + : undefined; + try { + const invocationResults = await Promise.all(Array.from({ length: invocationCount }, () => vscode.lm.invokeTool(command.toolName, { + input: command.input, + toolInvocationToken: undefined, + }, cancellationSource?.token))); + + return { + results: invocationResults.map(invocationResult => invocationResult.content + .filter((part): part is vscode.LanguageModelTextPart => part instanceof vscode.LanguageModelTextPart) + .map(part => part.value) + .join('')), + }; + } + catch (error) { + if (isCommandCancellation(error)) { + return { results: [], cancelled: true }; + } - return { - results: invocationResults.map(invocationResult => invocationResult.content - .filter((part): part is vscode.LanguageModelTextPart => part instanceof vscode.LanguageModelTextPart) - .map(part => part.value) - .join('')), - }; + throw error; + } + finally { + if (cancellationTimer !== undefined) { + clearTimeout(cancellationTimer); + } + cancellationSource?.dispose(); + } } case 'getDebugSessionProcessInfo': { markStarted(); @@ -768,6 +796,10 @@ export async function executeE2eControlCommand( cleanupRun(runId); } } + case 'proveAttachedResourceDebugging': { + markStarted(); + return await proveAttachedResourceDebugging(command, appHostTreeProvider, preparableLanguageModelTools); + } case 'proveAppHostAndResourceDebugging': { markStarted(); return await proveAppHostAndResourceDebugging(command, aspireContext, appHostTreeProvider); @@ -931,9 +963,9 @@ export async function executeE2eControlCommand( [...command.args], workingDirectory, timeoutMs, - terminalProvider.createEnvironment(), + terminalProvider.createEnvironment(undefined, undefined, command.noExtensionVariables), { - noExtensionVariables: false, + noExtensionVariables: command.noExtensionVariables === true, rejectOnNonZero: command.allowNonZeroExit !== true, }); markStarted(); @@ -1028,8 +1060,16 @@ function getE2eEnvVars(value: unknown): EnvVar[] { } type AppHostAndResourceDebugProofCommand = Extract; +type AttachedResourceDebugProofCommand = Extract; type MauiResourceDebugProofCommand = Extract; +interface E2eInvocableLanguageModelTool extends PreparableLanguageModelTool { + invoke( + options: { readonly input: Record; readonly toolInvocationToken: undefined }, + token: vscode.CancellationToken, + ): Promise; +} + interface DebugSessionSnapshot { id: string; type: string; @@ -1069,6 +1109,265 @@ interface DebugAdapterMessageSummary { body?: unknown; } +async function proveAttachedResourceDebugging( + command: AttachedResourceDebugProofCommand, + appHostTreeProvider: AspireAppHostTreeProvider, + languageModelTools: ReadonlyMap, +): Promise { + const appHostPath = getE2eWorkspacePath(command.appHostPath); + const sourcePath = getE2eWorkspacePath(command.sourcePath); + const resourceName = getE2eRequiredString(command.resourceName, 'Aspire extension E2E attach proof requires resourceName.'); + const expectedResponse = getE2eRequiredString(command.expectedResponse, 'Aspire extension E2E attach proof requires expectedResponse.'); + const breakpointLine = getE2eBreakpointLine(command.breakpointLine); + const resourceRequestPath = command.resourceRequestPath ?? '/'; + const timeoutMs = getE2ePositiveInteger(command.timeoutMs, 300000, 'timeoutMs'); + const expectedDebugType = command.expectedDebugType; + if (expectedDebugType !== 'coreclr' && expectedDebugType !== 'go') { + throw new Error(`Aspire extension E2E attach proof expected coreclr or go, got '${String(expectedDebugType)}'.`); + } + + const debugSessions: DebugSessionSnapshot[] = []; + const sessionById = new Map(); + const terminatedSessionIds = new Set(); + const attachRequests: DebugAdapterMessageSummary[] = []; + const debugAdapterResponses: DebugAdapterMessageSummary[] = []; + const breakpointResponses: DebugAdapterMessageSummary[] = []; + const stoppedEvents: DebugAdapterStoppedEvent[] = []; + + const sessionSubscription = vscode.debug.onDidStartDebugSession(session => { + sessionById.set(session.id, session); + debugSessions.push(toDebugSessionSnapshot(session)); + }); + const terminationSubscription = vscode.debug.onDidTerminateDebugSession(session => { + terminatedSessionIds.add(session.id); + }); + const trackerRegistration = vscode.debug.registerDebugAdapterTrackerFactory('*', { + createDebugAdapterTracker(session) { + return { + onWillReceiveMessage(message) { + if (message?.type === 'request' && message.command === 'attach') { + attachRequests.push({ + sessionId: session.id, + sessionType: session.type, + sessionName: session.name, + command: message.command, + body: redactDebugAdapterArguments(message.arguments), + }); + } + }, + onDidSendMessage(message) { + if (message?.type === 'response' && message.success === false) { + debugAdapterResponses.push({ + sessionId: session.id, + sessionType: session.type, + sessionName: session.name, + command: message.command, + success: false, + body: redactDebugAdapterArguments(message), + }); + } + if (message?.type === 'response' && message.command === 'setBreakpoints') { + const breakpointResponse = { + sessionId: session.id, + sessionType: session.type, + sessionName: session.name, + command: message.command, + success: message.success, + body: redactDebugAdapterArguments(message.body), + }; + breakpointResponses.push(breakpointResponse); + extensionLogOutputChannel.info(`Resource debug E2E ${session.type} setBreakpoints response: ${JSON.stringify(breakpointResponse)}`); + } + if (message?.type === 'event' && message.event === 'stopped') { + const stoppedEvent = { + sessionId: session.id, + sessionType: session.type, + sessionName: session.name, + reason: message.body?.reason, + threadId: message.body?.threadId, + }; + stoppedEvents.push(stoppedEvent); + extensionLogOutputChannel.info(`Resource debug E2E ${session.type} stopped event: ${JSON.stringify(stoppedEvent)}`); + } + }, + }; + }, + }); + + const breakpoint = new vscode.SourceBreakpoint( + new vscode.Location(vscode.Uri.file(sourcePath), new vscode.Position(breakpointLine, 0)), + true); + vscode.debug.addBreakpoints([breakpoint]); + let attachedSession: vscode.DebugSession | undefined; + let toolPayload: Record | undefined; + + try { + const resourceDebugTool = languageModelTools.get('aspire_resource_debug') as E2eInvocableLanguageModelTool | undefined; + if (!resourceDebugTool?.invoke) { + throw new Error('Aspire extension E2E attach proof could not find the registered aspire_resource_debug tool.'); + } + + const workspaceRoot = getE2eWorkspacePath(process.env.ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT); + const relativeAppHostPath = path.relative(workspaceRoot, appHostPath).split(path.sep).join('/'); + const toolCancellation = new vscode.CancellationTokenSource(); + let languageModelResult: vscode.LanguageModelToolResult; + try { + languageModelResult = await resourceDebugTool.invoke({ + input: { + appHostPath: relativeAppHostPath, + resourceName, + strategy: 'attach', + }, + toolInvocationToken: undefined, + }, toolCancellation.token); + } + finally { + toolCancellation.dispose(); + } + const resultPart = languageModelResult.content[0]; + if (!(resultPart instanceof vscode.LanguageModelTextPart)) { + throw new Error('aspire_resource_debug returned a non-text result.'); + } + + toolPayload = JSON.parse(resultPart.value) as Record; + const expectedProvider = expectedDebugType === 'coreclr' ? 'dotnet' : 'go'; + if (toolPayload.outcome !== 'started' || toolPayload.provider !== expectedProvider) { + throw new Error(`aspire_resource_debug returned ${JSON.stringify(toolPayload)} instead of starting ${expectedProvider}.`); + } + + attachedSession = await waitForE2eValue( + `${expectedDebugType} attach session for resource '${resourceName}'`, + timeoutMs, + () => [...sessionById.values()].find(session => + session.type === expectedDebugType && + session.configuration.request === 'attach')); + + const breakpointHit = await withResourceTraffic( + appHostTreeProvider, + appHostPath, + resourceName, + resourceRequestPath, + timeoutMs, + async () => await waitForE2eValue( + `breakpoint in ${sourcePath}:${breakpointLine + 1}`, + timeoutMs, + async () => { + for (const stoppedEvent of stoppedEvents) { + if (stoppedEvent.sessionId !== attachedSession?.id || stoppedEvent.threadId === undefined) { + continue; + } + + let stackTrace: { stackFrames?: Array<{ source?: { path?: string }; line?: number }> } | undefined; + try { + stackTrace = await attachedSession.customRequest('stackTrace', { + threadId: stoppedEvent.threadId, + startFrame: 0, + levels: 20, + }); + } + catch { + continue; + } + + const matchingFrame = stackTrace?.stackFrames?.find(frame => + typeof frame.source?.path === 'string' && + isSamePath(frame.source.path, sourcePath) && + frame.line === breakpointLine + 1); + if (matchingFrame) { + return { stoppedEvent, matchingFrame }; + } + } + + return undefined; + })); + + // Remove the breakpoint before continuing so requests queued by the traffic driver cannot + // immediately stop the process again and race debugger detach. + vscode.debug.removeBreakpoints([breakpoint]); + await attachedSession.customRequest('continue', { threadId: breakpointHit.stoppedEvent.threadId }); + await vscode.debug.stopDebugging(attachedSession); + await waitForE2eValue( + `${expectedDebugType} attach session termination`, + timeoutMs, + () => terminatedSessionIds.has(attachedSession!.id) ? true : undefined); + + const responseBody = await waitForE2eValue( + `resource '${resourceName}' response after debugger detach`, + timeoutMs, + async () => { + const resourceAfterDetach = appHostTreeProvider.findResourceElement(resourceName, appHostPath); + if (!(resourceAfterDetach instanceof ResourceItem) || resourceAfterDetach.resource.state !== 'Running') { + return undefined; + } + + const requestUrl = await resolveResourceRequestUrl( + appHostTreeProvider, + appHostPath, + resourceName, + resourceRequestPath, + timeoutMs); + try { + const response = await fetch(requestUrl, { signal: AbortSignal.timeout(5000) }); + const body = await response.text(); + return response.ok && body === expectedResponse ? body : undefined; + } + catch { + return undefined; + } + }); + + if (attachRequests.length === 0 || breakpointResponses.every(response => response.success !== true)) { + throw new Error(`The ${expectedDebugType} adapter did not report both attach and bound-breakpoint protocol traffic.`); + } + + return { + proof: 'aspire-resource-attach-breakpoint-detach', + toolPayload, + resourceName, + debugType: expectedDebugType, + debugSessionId: attachedSession.id, + breakpoint: { + sourcePath, + line: breakpointLine + 1, + text: fs.readFileSync(sourcePath, 'utf8').split(/\r?\n/)[breakpointLine]?.trim(), + stoppedEvent: breakpointHit.stoppedEvent, + matchingStackFrame: breakpointHit.matchingFrame, + }, + attachRequests, + breakpointResponses, + debugAdapterResponses, + resourceResponseAfterDetach: responseBody, + sessionTerminated: true, + }; + } + catch (error) { + const diagnostics = { + debugSessions, + attachRequests, + breakpointResponses, + debugAdapterResponses, + stoppedEvents, + terminatedSessionIds: [...terminatedSessionIds], + toolPayload, + }; + extensionLogOutputChannel.error(`Resource debug E2E attach proof failed: ${error instanceof Error ? error.message : String(error)} +Diagnostics: +${JSON.stringify(diagnostics, undefined, 2)}`); + throw new Error(`${error instanceof Error ? error.message : String(error)} +Diagnostics: +${JSON.stringify(diagnostics, undefined, 2)}`); + } + finally { + vscode.debug.removeBreakpoints([breakpoint]); + sessionSubscription.dispose(); + terminationSubscription.dispose(); + trackerRegistration.dispose(); + if (attachedSession && !terminatedSessionIds.has(attachedSession.id)) { + await vscode.debug.stopDebugging(attachedSession); + } + } +} + async function proveAppHostAndResourceDebugging(command: AppHostAndResourceDebugProofCommand, aspireContext: AspireExtensionContext, appHostTreeProvider: AspireAppHostTreeProvider): Promise { const appHostPath = getE2eWorkspacePath(command.appHostPath); const appHostSourcePath = getE2eWorkspacePath(command.appHostSourcePath); @@ -1547,9 +1846,13 @@ async function runAspireCliForE2E( } completed = true; + // `getE2eErrorMessage` only forwards messages that carry the "Aspire extension E2E " marker; + // everything else is collapsed to a generic string so failures cannot leak user-supplied + // values. These two diagnostics are safe to forward because `diagnosticCommand` has already + // been redacted by `redactCliArgsForLogging` and neither embeds captured stdout/stderr. void terminateCliProcess(child, 'Aspire extension E2E CLI command', { force: true, suppressTimeoutWarning: true }) .then( - () => reject(new Error(`${diagnosticCommand} timed out after ${timeoutMs}ms.`)), + () => reject(new Error(`Aspire extension E2E ${diagnosticCommand} timed out after ${timeoutMs}ms.`)), reject); }, timeoutMs); @@ -1568,7 +1871,7 @@ async function runAspireCliForE2E( if (code === 0 || !options.rejectOnNonZero) { resolve(result); } else { - reject(new Error(`${diagnosticCommand} exited with code ${code}.`)); + reject(new Error(`Aspire extension E2E ${diagnosticCommand} exited with code ${code}.`)); } }, errorCallback: error => { @@ -1607,26 +1910,30 @@ async function withResourceTraffic( endpointTimeoutMs: number, waitForHit: () => Promise ): Promise { - const baseUrl = await waitForE2eValue( - `an HTTP endpoint for resource '${resourceName}'`, - endpointTimeoutMs, - () => { - const element = appHostTreeProvider.findEndpointElement({ appHostPath, resourceName }); - return element && hasEndpointUrl(element) ? element.url : undefined; - }, - () => describeResourcesForE2E(appHostTreeProvider, appHostPath, resourceName)); - - // A relative path resolves against the endpoint only when the base ends in '/'; without it the - // last segment of the endpoint would be replaced instead. - const requestUrl = new URL(requestPath.replace(/^\//, ''), baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(); + const requestUrl = await resolveResourceRequestUrl( + appHostTreeProvider, + appHostPath, + resourceName, + requestPath, + endpointTimeoutMs); + extensionLogOutputChannel.info(`Resource debug E2E traffic target resolved for resource '${resourceName}'.`); let driving = true; + let firstAttempt = true; const driver = (async () => { while (driving) { try { - await fetch(requestUrl, { signal: AbortSignal.timeout(2000) }); + const response = await fetch(requestUrl, { signal: AbortSignal.timeout(2000) }); + if (firstAttempt) { + firstAttempt = false; + extensionLogOutputChannel.info(`Resource debug E2E first traffic response for resource '${resourceName}': HTTP ${response.status}.`); + } } - catch { + catch (error) { + if (firstAttempt) { + firstAttempt = false; + extensionLogOutputChannel.info(`Resource debug E2E first traffic attempt for resource '${resourceName}' failed: ${error instanceof Error ? error.name : typeof error}.`); + } // Connection refused until the server is listening, and aborted once a request parks on the // breakpoint. Neither says anything about whether the breakpoint bound, so both are ignored // and the wait below is left to decide. @@ -1645,6 +1952,27 @@ async function withResourceTraffic( } } +async function resolveResourceRequestUrl( + appHostTreeProvider: AspireAppHostTreeProvider, + appHostPath: string, + resourceName: string, + requestPath: string, + endpointTimeoutMs: number, +): Promise { + const baseUrl = await waitForE2eValue( + `an HTTP endpoint for resource '${resourceName}'`, + endpointTimeoutMs, + () => { + const element = appHostTreeProvider.findEndpointElement({ appHostPath, resourceName }); + return element && hasEndpointUrl(element) ? element.url : undefined; + }, + () => describeResourcesForE2E(appHostTreeProvider, appHostPath, resourceName)); + + // A relative path resolves against the endpoint only when the base ends in '/'; without it the + // last segment of the endpoint would be replaced instead. + return new URL(requestPath.replace(/^\//, ''), baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(); +} + async function waitForE2eValue(description: string, timeoutMs: number, getValue: () => T | undefined | Promise, describeState?: () => string): Promise { const started = Date.now(); let lastError: string | undefined; while (Date.now() - started < timeoutMs) { @@ -1734,6 +2062,18 @@ function getE2ePositiveInteger(value: unknown, defaultValue: number, propertyNam return value; } +function getE2eCancellationDelay(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 10000) { + throw new Error('Aspire extension E2E language-model cancellation delay must be an integer between 0 and 10000.'); + } + + return value; +} + function getE2eAspireCommandId(commandId: unknown): string { if (typeof commandId !== 'string' || !commandId.startsWith('aspire-vscode.')) { throw new Error('Aspire extension E2E executeAspireCommand requires an aspire-vscode command id.'); @@ -1968,7 +2308,10 @@ function isPathWithinDirectory(candidatePath: string, directoryPath: string): bo const resolvedCandidate = path.resolve(candidatePath); const resolvedDirectory = path.resolve(directoryPath); const relativePath = path.relative(resolvedDirectory, resolvedCandidate); - return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); + return relativePath === '' || + (relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath)); } function getE2eBreakpoints(): Array<{ filePath: string; line: number; enabled: boolean }> { diff --git a/extension/src/types/configInfo.ts b/extension/src/types/configInfo.ts index 3ad8746eacd..eb740c18624 100644 --- a/extension/src/types/configInfo.ts +++ b/extension/src/types/configInfo.ts @@ -59,6 +59,13 @@ export const pipelineStepListJsonCapability = 'pipeline-step-list-json.v1'; */ export const describeIncludeDisabledCommandsCapability = 'describe-include-disabled-commands.v1'; +/** + * Capability advertised by the CLI when `aspire describe` accepts `--apphost-pid` to bind an + * explicit AppHost path to one running process. Keep in sync with + * `KnownCapabilities.DescribeAppHostPid` in src/Aspire.Cli/Utils/ExtensionHelper.cs. + */ +export const describeAppHostPidCapability = 'describe-apphost-pid.v1'; + /** * Capability advertised by the CLI when `aspire ls --format json --stream` emits AppHost * candidates as newline-delimited JSON. Tooling uses this to avoid probing localized CLI errors diff --git a/extension/src/types/extensionApi.ts b/extension/src/types/extensionApi.ts index 9dfe54d461f..7a8076d1519 100644 --- a/extension/src/types/extensionApi.ts +++ b/extension/src/types/extensionApi.ts @@ -236,7 +236,7 @@ export type AspireExtensionE2EControlCommand = | { name: 'getRegisteredAspireCommands' } | { name: 'getRegisteredLanguageModelTools' } | { name: 'prepareLanguageModelToolInvocation'; toolName: string; input: Record } - | { name: 'invokeLanguageModelTool'; toolName: string; input: Record; times?: number } + | { name: 'invokeLanguageModelTool'; toolName: string; input: Record; times?: number; cancelAfterMs?: number } | { name: 'getDebugSessionProcessInfo'; appHostPath?: string } | { name: 'getExtensionPackageJson' } | { name: 'getExtensionFileStatus'; relativePaths: readonly string[] } @@ -258,7 +258,7 @@ export type AspireExtensionE2EControlCommand = | { name: 'addWorkspaceFolder'; folderPath: string } | { name: 'getActiveEditor' } | { name: 'getOpenEditors' } - | { name: 'runAspireCli'; args: readonly string[]; workingDirectory: string; timeoutMs?: number; allowNonZeroExit?: boolean } + | { name: 'runAspireCli'; args: readonly string[]; workingDirectory: string; timeoutMs?: number; noExtensionVariables?: boolean; allowNonZeroExit?: boolean } | { name: 'getResourceDebuggerExtensions' } | { name: 'getSupportedCapabilities' } | { name: 'getVisibleExtensionIds' } @@ -273,5 +273,16 @@ export type AspireExtensionE2EControlCommand = debuggers?: Readonly>; environmentKeys?: readonly string[]; } + | { + name: 'proveAttachedResourceDebugging'; + appHostPath: string; + resourceName: string; + sourcePath: string; + breakpointLine: number; + expectedDebugType: 'coreclr' | 'go'; + expectedResponse: string; + resourceRequestPath?: string; + timeoutMs?: number; + } | { name: 'proveAppHostAndResourceDebugging'; appHostPath: string; resourceName: string; appHostSourcePath: string; appHostBreakpointLine: number; resourceSourcePath: string; resourceBreakpointLine: number; resourceRequestPath?: string; timeoutMs?: number } | { name: 'proveMauiResourceDebugging'; appHostPath: string; resourceName: string; sourcePath: string; breakpointLine: number; timeoutMs?: number; pauseOnBreakpointMs?: number }; diff --git a/extension/src/utils/telemetryRegistry.ts b/extension/src/utils/telemetryRegistry.ts index e51dd6683d1..f1d6b7b73f4 100644 --- a/extension/src/utils/telemetryRegistry.ts +++ b/extension/src/utils/telemetryRegistry.ts @@ -106,6 +106,35 @@ export interface TelemetryEventSchema { properties: 'resource_type' | 'mode' | 'exit_code_bucket' | 'end_reason' | 'error_kind'; measurements: 'duration_ms' | 'exit_code'; }; + 'aspire/vscode/resourcedebug/start': { + properties: 'source' | 'requested_strategy' | 'controller'; + measurements: never; + }; + 'aspire/vscode/resourcedebug/result': { + properties: + | 'source' + | 'provider' + | 'resource_type' + | 'requested_strategy' + | 'effective_strategy' + | 'outcome' + | 'controller' + | 'state' + | 'debugger_requirement' + | 'error_kind'; + measurements: 'resolution_duration_ms' | 'debug_start_duration_ms' | 'total_duration_ms'; + }; + 'aspire/vscode/resourcedebug/session/end': { + properties: + | 'source' + | 'provider' + | 'resource_type' + | 'requested_strategy' + | 'effective_strategy' + | 'controller' + | 'session_end_reason'; + measurements: 'session_duration_ms'; + }; 'aspire/vscode/dashboard/launch/resolved': { properties: 'behavior' | 'source'; measurements: never; diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index b20add906a2..4d2b81a0a9f 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -20,6 +20,13 @@ import { appHostSourceOpenFailed, logFileOpenFailed, logFilePathInvalid, + attachingDebugger, + attachDebuggerAlreadyDebugging, + attachDebuggerUnavailable, + attachDebuggerResourceNotFound, + attachDebuggerWorkspaceNotTrusted, + attachDebuggerExtensionsRequired, + attachDebuggerDeclined, dashboardUrlNotFound, dashboardUrlUnsupported, errorMessage, @@ -44,6 +51,7 @@ import { extensionLogOutputChannel } from '../utils/logging'; import { pipelineInteractionCapability, pipelineStepListJsonCapability } from '../types/configInfo'; import { isAppHostSourceFile, isProjectFile } from '../utils/paths/comparison'; import { isCommandCancellation } from '../utils/telemetry'; +import type { ResourceDebugger } from '../debugger/resourceDebugContracts'; import { getParentResourceName, getTerminalReplicaIndex, @@ -105,6 +113,11 @@ interface AppHostRenderState { readonly actions: AppHostActionAvailability | undefined; } +interface AttachDebuggerHandledFailure { + success: false; + errorKind: 'ResourceNotFound' | 'ResourceNotAttachable'; +} + function isSamePath(left: string, right: string): boolean { return isSameFileSystemEntry(left, right); } @@ -143,6 +156,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider(); private _treeView: vscode.TreeView | undefined; + private readonly _resourceDebugService: ResourceDebugger; private _treeViewVisibilitySubscription: vscode.Disposable | undefined; private _documentCloseSubscription: vscode.Disposable | undefined; @@ -169,11 +184,13 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { this._clearLaunchingPathsForRunningAppHosts(); this._clearStoppingPathsForStoppedAppHosts(); @@ -185,6 +202,9 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { this._onDidChangeTreeData.fire(); }); + this._resourceDebugSessionSubscription = this._resourceDebugService.onDidChangeDebugSessions?.(() => { + this._onDidChangeTreeData.fire(); + }); // A durable deploy/publish/do is only visible on the AppHost row that owns it, so the // tree has to redraw when one starts or finishes. @@ -257,6 +277,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider !getParentResourceName(r)); for (const resource of sortResources(topLevel)) { const hasChildren = element.resources.some(r => getParentResourceName(r) === resource.name); - items.push(new ResourceItem(resource, null, hasChildren, element.resources, element.appHostPath)); + items.push(new ResourceItem( + resource, + element.appHost?.appHostPid ?? null, + hasChildren, + element.resources, + element.appHostPath, + this._resourceDebugService.canAttachToResource(resource))); } return items; } @@ -964,7 +991,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider 0) { - items.push(new ResourcesGroupItem(appHost.resources, appHost.appHostPid)); + items.push(new ResourcesGroupItem(appHost.resources, appHost.appHostPid, appHost.appHostPath)); } return items; @@ -974,7 +1001,13 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider !getParentResourceName(r)); return sortResources(topLevel).map(r => { const hasChildren = element.resources.some(c => getParentResourceName(c) === r.name); - return new ResourceItem(r, element.appHostPid, hasChildren, element.resources); + return new ResourceItem( + r, + element.appHostPid, + hasChildren, + element.resources, + element.appHostPath, + this._resourceDebugService.canAttachToResource(r)); }); } @@ -1000,7 +1033,13 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider getParentResourceName(r) === element.resource.name); for (const child of sortResources(children)) { const hasChildren = allResources.some(r => getParentResourceName(r) === child.name); - items.push(new ResourceItem(child, element.appHostPid, hasChildren, allResources, element.appHostPath)); + items.push(new ResourceItem( + child, + element.appHostPid, + hasChildren, + allResources, + element.appHostPath, + this._resourceDebugService.canAttachToResource(child))); } const urls = getVisibleResourceUrls(element.resource); @@ -1476,6 +1515,76 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { + if (!vscode.workspace.isTrusted) { + vscode.window.showWarningMessage(attachDebuggerWorkspaceNotTrusted); + return { success: false, errorKind: 'ResourceNotAttachable' }; + } + + return await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: attachingDebugger(element.resource.displayName ?? element.resource.name), + cancellable: true, + }, async (_progress, cancellationToken) => + await this._attachDebuggerToResource(element, cancellationToken)); + } + + private async _attachDebuggerToResource( + element: ResourceItem, + cancellationToken: vscode.CancellationToken, + ): Promise { + // The tree captures the owning AppHost when it renders a resource so an attach never + // chooses a same-named resource from another AppHost based on a mutable PID lookup. + const ownerAppHostPath = element.appHostPath; + if (!ownerAppHostPath) { + vscode.window.showWarningMessage(attachDebuggerResourceNotFound); + return { success: false, errorKind: 'ResourceNotFound' }; + } + + const result = await this._resourceDebugService.debug({ + source: 'tree', + strategy: 'attach', + appHost: { + absolutePath: ownerAppHostPath, + displayPath: vscode.workspace.asRelativePath(ownerAppHostPath), + appHostPid: element.appHostPid ?? undefined, + }, + resourceName: element.resource.name, + cancellationToken, + }); + switch (result.outcome) { + case 'started': + case 'cancelled': + return; + case 'alreadyDebugging': + vscode.window.showInformationMessage(attachDebuggerAlreadyDebugging(element.resource.displayName ?? element.resource.name)); + return; + case 'appHostNotFound': + case 'resourceNotFound': + vscode.window.showWarningMessage(attachDebuggerResourceNotFound); + return { success: false, errorKind: 'ResourceNotFound' }; + case 'debuggerExtensionMissing': + const installMessage = result.debuggerExtensions.length === 1 + ? result.debuggerExtensions[0].installMessage + : undefined; + vscode.window.showWarningMessage(installMessage ?? attachDebuggerExtensionsRequired( + result.debuggerExtensions.map(extension => extension.label).join(', '))); + return { success: false, errorKind: 'ResourceNotAttachable' }; + case 'resourceNotRunning': + case 'unsupportedResource': + vscode.window.showWarningMessage(attachDebuggerUnavailable); + return { success: false, errorKind: 'ResourceNotAttachable' }; + case 'error': + if (result.errorKind === 'debuggerStartDeclined') { + vscode.window.showWarningMessage(attachDebuggerDeclined(element.resource.displayName ?? element.resource.name)); + return { success: false, errorKind: 'ResourceNotAttachable' }; + } + + vscode.window.showWarningMessage(attachDebuggerUnavailable); + return { success: false, errorKind: 'ResourceNotAttachable' }; + } + } + async viewResourceLogs(element: ResourceItem): Promise { // aspire logs accepts the resource display name, not the internal name const resourceName = element.resource.displayName ?? element.resource.name; diff --git a/extension/src/views/treeItems/resourceItems.ts b/extension/src/views/treeItems/resourceItems.ts index 8e900eb54bd..9c0f41ad39f 100644 --- a/extension/src/views/treeItems/resourceItems.ts +++ b/extension/src/views/treeItems/resourceItems.ts @@ -43,9 +43,13 @@ export class EndpointUrlItem extends vscode.TreeItem { } export class ResourcesGroupItem extends vscode.TreeItem { - constructor(public readonly resources: ResourceJson[], public readonly appHostPid: number) { + constructor( + public readonly resources: ResourceJson[], + public readonly appHostPid: number, + public readonly appHostPath: string, + ) { super(resourcesGroupLabel, vscode.TreeItemCollapsibleState.Expanded); - this.id = `resources:${appHostPid}`; + this.id = `resources:${getTreeItemOwnerId(appHostPath, appHostPid)}`; this.iconPath = new vscode.ThemeIcon('layers', new vscode.ThemeColor('aspire.brandPurple')); this.contextValue = 'resourcesGroup'; this.description = `(${resources.length})`; @@ -125,7 +129,8 @@ export class ResourceItem extends vscode.TreeItem { public readonly appHostPid: number | null, hasChildren: boolean, public readonly allResources?: readonly ResourceJson[], - public readonly appHostPath?: string + public readonly appHostPath?: string, + canAttachDebugger = false, ) { const label = resource.displayName ?? resource.name; const hasUrls = getVisibleResourceUrls(resource).length > 0; @@ -136,13 +141,16 @@ export class ResourceItem extends vscode.TreeItem { ? vscode.TreeItemCollapsibleState.Expanded : hasExpandableContent ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; super(label, collapsible); - const ownerId = appHostPid !== null - ? appHostPid.toString() - : appHostPath ? getComparisonKey(path.resolve(appHostPath)) : 'workspace'; - this.id = `resource:${ownerId}:${resource.name}`; + this.id = `resource:${getTreeItemOwnerId(appHostPath, appHostPid)}:${resource.name}`; this.iconPath = getResourceIcon(resource); this.description = buildResourceDescription(resource); this.tooltip = buildResourceTooltip(resource); - this.contextValue = getResourceContextValue(resource); + this.contextValue = getResourceContextValue(resource, canAttachDebugger); } } + +function getTreeItemOwnerId(appHostPath: string | undefined, appHostPid: number | null): string { + const pathId = appHostPath ? getComparisonKey(path.resolve(appHostPath)) : 'workspace'; + const processId = appHostPid ?? 'workspace'; + return `${pathId}:pid:${processId}`; +} diff --git a/extension/src/views/treePresentation.ts b/extension/src/views/treePresentation.ts index 045cbf411b9..846833db9d5 100644 --- a/extension/src/views/treePresentation.ts +++ b/extension/src/views/treePresentation.ts @@ -97,7 +97,7 @@ export function getParentResourceName(resource: ResourceJson): string | null { return resource.properties?.['resource.parentName'] ?? null; } -export function getResourceContextValue(resource: ResourceJson): string { +export function getResourceContextValue(resource: ResourceJson, canAttachDebugger: boolean = false): string { const commands = resource.commands; const parts = ['resource']; if (hasEnabledCommand(commands, 'start') || hasEnabledCommand(commands, 'resource-start')) { @@ -112,6 +112,9 @@ export function getResourceContextValue(resource: ResourceJson): string { if (isTerminalEnabled(resource)) { parts.push('canOpenTerminal'); } + if (canAttachDebugger) { + parts.push('canAttachDebugger'); + } return parts.join(':'); } diff --git a/extension/telemetry.json b/extension/telemetry.json index c3d359d2f96..045c41c3a68 100644 --- a/extension/telemetry.json +++ b/extension/telemetry.json @@ -307,6 +307,132 @@ "comment": "The numeric process exit code for the resource debug session." } }, + "aspire/vscode/resourcedebug/start": { + "source": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded extension surface that requested resource debugging: tree or languageModelTool." + }, + "requested_strategy": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded resource debug strategy requested by the caller: auto, attach, or invalid." + }, + "controller": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded controller responsible for resource debugging: editor." + } + }, + "aspire/vscode/resourcedebug/result": { + "source": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded extension surface that requested resource debugging: tree or languageModelTool." + }, + "provider": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded attach provider selected for the resource: dotnet, go, or none." + }, + "resource_type": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The coarse resource type bucket: project, executable, container, or other." + }, + "requested_strategy": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded resource debug strategy requested by the caller: auto, attach, or invalid." + }, + "effective_strategy": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded effective resource debug strategy: attach or none." + }, + "outcome": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The bounded ResourceDebugResult outcome." + }, + "controller": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded controller responsible for resource debugging: editor." + }, + "state": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The coarse resource state: running, notRunning, or unknown." + }, + "debugger_requirement": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "Whether the selected provider debugger requirement was installed, missing, or not applicable." + }, + "error_kind": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The bounded ResourceDebugErrorKind for an error result, or none." + }, + "resolution_duration_ms": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The non-negative duration of resource and attach resolution in milliseconds." + }, + "debug_start_duration_ms": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The non-negative duration of the VS Code debugger start request in milliseconds." + }, + "total_duration_ms": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The non-negative total resource debug operation duration in milliseconds." + } + }, + "aspire/vscode/resourcedebug/session/end": { + "source": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded extension surface that requested resource debugging: tree or languageModelTool." + }, + "provider": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded attach provider selected for the resource: dotnet or go." + }, + "resource_type": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The coarse resource type bucket: project, executable, container, or other." + }, + "requested_strategy": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded resource debug strategy requested by the caller: auto or attach." + }, + "effective_strategy": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded effective resource debug strategy: attach." + }, + "controller": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded controller responsible for resource debugging: editor." + }, + "session_end_reason": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The bounded reason the tracked independent attach session ended: terminated." + }, + "session_duration_ms": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The non-negative duration of the tracked independent attach session in milliseconds." + } + }, "aspire/vscode/dashboard/launch/resolved": { "behavior": { "classification": "SystemMetaData", diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index 657b36bc2d1..c7b7f504ac6 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -48,8 +48,12 @@ internal sealed class AppHostConnectionResolver( CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, ILogger logger, - ProfilingTelemetry profilingTelemetry) + ProfilingTelemetry profilingTelemetry, + Func>? findSockets = null) { + private readonly Func> _findSockets = + findSockets ?? AppHostSocketManager.FindSockets; + /// /// Resolves all running AppHost connections using socket-first discovery. /// Used when stopping all running AppHosts (e.g., via --all flag). @@ -89,6 +93,9 @@ public async Task ResolveAllConnectionsAsync( /// Whether AppHosts running in a different git worktree are hidden from interactive selection. /// AppHosts elsewhere in the same worktree remain selectable. /// + /// + /// Optional process ID that restricts an explicit project lookup to one AppHost instance. + /// /// The resolved connection, or null with an error message. public async Task ResolveConnectionAsync( FileInfo? projectFile, @@ -96,7 +103,8 @@ public async Task ResolveConnectionAsync( string selectPrompt, string notFoundMessage, CancellationToken cancellationToken, - bool restrictToCurrentWorktree = false) + bool restrictToCurrentWorktree = false, + int? appHostPid = null) { // Fast path: If --apphost was specified, check directly for its socket if (projectFile is not null) @@ -143,7 +151,7 @@ public async Task ResolveConnectionAsync( }; } - var matchingSockets = AppHostSocketManager.FindSockets( + var matchingSockets = _findSockets( projectFile.FullName, executionContext.HomeDirectory.FullName, Environment.ProcessId, @@ -158,6 +166,12 @@ public async Task ResolveConnectionAsync( appHostSocket, logger, profilingTelemetry, cancellationToken).ConfigureAwait(false); if (connection is not null) { + if (appHostPid is not null && connection.AppHostInfo?.ProcessId != appHostPid) + { + connection.Dispose(); + continue; + } + var result = new AppHostConnectionResult { Connection = connection }; StoreAppHostCliLogFilePath(result); return result; diff --git a/src/Aspire.Cli/Commands/DescribeCommand.cs b/src/Aspire.Cli/Commands/DescribeCommand.cs index 23437a425bb..adcb7ba3375 100644 --- a/src/Aspire.Cli/Commands/DescribeCommand.cs +++ b/src/Aspire.Cli/Commands/DescribeCommand.cs @@ -100,6 +100,10 @@ internal sealed class DescribeCommand : BaseCommand { Hidden = true }; + private static readonly Option s_appHostPidOption = new("--apphost-pid") + { + Hidden = true + }; public DescribeCommand( AppHostConnectionResolver connectionResolver, @@ -119,6 +123,21 @@ public DescribeCommand( Options.Add(s_formatOption); Options.Add(s_includeHiddenOption); Options.Add(s_includeDisabledCommandsOption); + Options.Add(s_appHostPidOption); + Validators.Add(result => + { + var appHostPid = result.GetValue(s_appHostPidOption); + if (appHostPid is <= 0) + { + result.AddError("--apphost-pid must be a positive process ID."); + } + else if (appHostPid is not null && + result.GetValue(s_appHostOption.InnerOption) is null && + result.GetValue(s_appHostOption.LegacyOption) is null) + { + result.AddError("--apphost-pid requires --apphost."); + } + }); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -131,13 +150,15 @@ protected override async Task ExecuteAsync(ParseResult parseResul var format = parseResult.GetValue(s_formatOption); var includeHidden = parseResult.GetValue(s_includeHiddenOption); var includeDisabledCommands = parseResult.GetValue(s_includeDisabledCommandsOption); + var appHostPid = parseResult.GetValue(s_appHostPidOption); var result = await _connectionResolver.ResolveConnectionAsync( passedAppHostProjectFile, SharedCommandStrings.ScanningForRunningAppHosts, string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, DescribeCommandStrings.SelectAppHostAction), SharedCommandStrings.AppHostNotRunning, - cancellationToken); + cancellationToken, + appHostPid: appHostPid); if (!result.Success) { diff --git a/src/Aspire.Cli/Utils/ExtensionHelper.cs b/src/Aspire.Cli/Utils/ExtensionHelper.cs index e5f19194796..c31fe78a196 100644 --- a/src/Aspire.Cli/Utils/ExtensionHelper.cs +++ b/src/Aspire.Cli/Utils/ExtensionHelper.cs @@ -45,6 +45,9 @@ internal static class KnownCapabilities // pass it and parse (localized) error output when an older CLI rejects it. public const string DescribeIncludeDisabledCommands = "describe-include-disabled-commands.v1"; + // Advertised so tooling can bind `aspire describe` to one same-path AppHost process. + public const string DescribeAppHostPid = "describe-apphost-pid.v1"; + // Advertised so tooling can detect that `aspire ls --format json --stream` is supported // before opting into newline-delimited JSON candidate discovery. public const string LsJsonStream = "ls-json-stream.v1"; @@ -60,5 +63,5 @@ internal static class KnownCapabilities /// /// Gets the set of capabilities this CLI advertises to extensions. /// - public static string[] GetAdvertisedCapabilities() => [DevKit, Project, BuildDotnetUsingCli, Baseline, SecretPrompts, FilePickers, Pipelines, PipelineStepListJson, DescribeIncludeDisabledCommands, LsJsonStream, IsolatedLaunch, LaunchProfile]; + public static string[] GetAdvertisedCapabilities() => [DevKit, Project, BuildDotnetUsingCli, Baseline, SecretPrompts, FilePickers, Pipelines, PipelineStepListJson, DescribeIncludeDisabledCommands, DescribeAppHostPid, LsJsonStream, IsolatedLaunch, LaunchProfile]; } diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchRecipe.cs b/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchRecipe.cs index 5f1c904118c..1c00b6b1069 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchRecipe.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchRecipe.cs @@ -193,7 +193,7 @@ internal sealed class ExecutableLaunchContext( /// /// The resolved environment variables for the executable. /// The serialized launch configurations supplied to an IDE. -/// The arguments projected into the dashboard command line. +/// The resolved arguments and their execution and dashboard projections. internal sealed class ExecutableLaunchPlan( string command, string workingDirectory, @@ -201,7 +201,7 @@ internal sealed class ExecutableLaunchPlan( IReadOnlyList? arguments, IEnumerable> environmentVariables, IEnumerable launchConfigurations, - IEnumerable displayArguments) + IEnumerable launchArguments) { /// /// Gets the executable path or command name. @@ -235,10 +235,16 @@ internal sealed class ExecutableLaunchPlan( /// public IReadOnlyList LaunchConfigurations { get; } = launchConfigurations.ToArray(); + /// + /// Gets the resolved arguments and their execution and dashboard projections. + /// + public IReadOnlyList LaunchArguments { get; } = launchArguments.ToArray(); + /// /// Gets the arguments projected into the dashboard command line. /// - public IReadOnlyList DisplayArguments { get; } = displayArguments.ToArray(); + public IReadOnlyList DisplayArguments { get; } = + launchArguments.Where(static argument => argument.Display).ToArray(); } /// @@ -327,7 +333,7 @@ context.Decision.DebugSupport is { } activeDebugSupport && var omittedLaunchToolArgumentCount = omitLaunchToolArguments ? launchToolArgumentCount : 0; var executableArguments = new List(arguments.Count - omittedLaunchToolArgumentCount); - var displayArguments = new List(arguments.Count); + var launchArguments = new List(arguments.Count); var nextExecutableArgumentIndex = 0; for (var i = 0; i < arguments.Count; i++) @@ -343,16 +349,13 @@ context.Decision.DebugSupport is { } activeDebugSupport && executableArguments.Add(argument.Value); } - if (display) - { - displayArguments.Add(new( - argument.Value, - argument.IsSensitive, - executable, - display, - effectiveArgumentIndex, - isLaunchToolArgument ? ExecutableLaunchArgumentRole.LaunchTool : ExecutableLaunchArgumentRole.Application)); - } + launchArguments.Add(new( + argument.Value, + argument.IsSensitive, + executable, + display, + effectiveArgumentIndex, + isLaunchToolArgument ? ExecutableLaunchArgumentRole.LaunchTool : ExecutableLaunchArgumentRole.Application)); } var launchConfigurations = await CreateLaunchConfigurationsAsync(context).ConfigureAwait(false); @@ -364,7 +367,7 @@ context.Decision.DebugSupport is { } activeDebugSupport && executableArguments.Count > 0 ? executableArguments : null, context.ExecutionConfiguration.EnvironmentVariables, launchConfigurations, - displayArguments); + launchArguments); } private static async Task> CreateLaunchConfigurationsAsync(ExecutableLaunchContext context) @@ -524,7 +527,7 @@ context.Decision.DebugSupport is { } activeDebugSupport && projectArguments.Count > 0 ? projectArguments : null, context.ExecutionConfiguration.EnvironmentVariables, launchConfigurations, - launchArguments.Where(static argument => argument.Display)); + launchArguments); } private static async Task> CreateLaunchConfigurationsAsync( diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 6c040d86d88..ea3aa8cd5aa 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -194,6 +194,13 @@ internal static void Render( argument.Value, argument.IsSensitive, argument.EffectiveArgumentIndex))); + // Hidden launch-tool arguments are intentionally absent from resource.appArgs, but launch metadata + // still needs their sensitivity without copying their resolved values into another DCP annotation. + executable.SetAnnotationAsObjectList( + Executable.SensitiveEffectiveArgumentIndexesAnnotation, + plan.LaunchArguments + .Where(static argument => argument.IsSensitive && argument.EffectiveArgumentIndex is not null) + .Select(static argument => argument.EffectiveArgumentIndex!.Value)); ApplyLifetime(renderedResource.ModelResource, spec); ApplyTerminal(renderedResource.ModelResource, executable, logger); diff --git a/src/Aspire.Hosting/Dcp/Model/Executable.cs b/src/Aspire.Hosting/Dcp/Model/Executable.cs index 14f76a7d09e..3d2c34f124d 100644 --- a/src/Aspire.Hosting/Dcp/Model/Executable.cs +++ b/src/Aspire.Hosting/Dcp/Model/Executable.cs @@ -284,6 +284,7 @@ internal static class ExecutableState internal sealed class Executable : CustomResource, IKubernetesStaticMetadata { public const string LaunchConfigurationsAnnotation = "executable.usvc-dev.developer.microsoft.com/launch-configurations"; + public const string SensitiveEffectiveArgumentIndexesAnnotation = "executable.usvc-dev.developer.microsoft.com/sensitive-effective-argument-indexes"; [JsonConstructor] public Executable(ExecutableSpec spec) : base(spec) { } diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index 9370383577a..3dbe24d0bf3 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable ASPIREEXTENSION001 // Debug support annotations are experimental. +#pragma warning disable ASPIREEXTENSION001 // Launch configuration metadata is experimental but needed for snapshot serialization. using System.Collections.Immutable; using Aspire.Dashboard.Model; @@ -173,6 +173,11 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn var properties = GetLaunchConfigurationType(appModelResource) is { } launchConfigurationType ? previous.Properties.SetResourceProperty(KnownProperties.Resource.LaunchConfigurationType, launchConfigurationType) : previous.Properties.RemoveResourceProperty(KnownProperties.Resource.LaunchConfigurationType); + properties = properties + .RemoveResourceProperty(KnownProperties.Project.LaunchCommand) + .RemoveResourceProperty(KnownProperties.Project.Configuration) + .RemoveResourceProperty(KnownProperties.Project.TargetFramework); + var dotNetLaunchProperties = GetDotNetLaunchProperties(executable, executable.Spec.ExecutablePath, effectiveArgs); if (projectPath is not null) { @@ -190,6 +195,7 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile, launchProfileName), new(KnownProperties.Resource.AppArgs, launchArguments?.Args) { IsSensitive = launchArguments?.IsSensitive ?? false }, new(KnownProperties.Resource.AppArgsSensitivity, launchArguments?.ArgsAreSensitive) { IsSensitive = launchArguments?.IsSensitive ?? false }, + .. dotNetLaunchProperties, ]), EnvironmentVariables = environment, CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToUniversalTime(), @@ -242,6 +248,179 @@ private static bool IsNotStartedExecutableState(string? state) return string.IsNullOrEmpty(state) || state == ExecutableState.Unknown; } + private static ImmutableArray GetDotNetLaunchProperties( + CustomResource resource, + string? executablePath, + IReadOnlyList? effectiveArgs) + { + var executableName = Path.GetFileName(executablePath); + if (!string.Equals(executableName, "dotnet", StringComparison.OrdinalIgnoreCase) && + !string.Equals(executableName, "dotnet.exe", StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + if (effectiveArgs is null || + FindDotNetProjectCommand(effectiveArgs) is not { } commandInfo) + { + return [new(KnownProperties.Project.LaunchCommand, null)]; + } + + var (command, commandIndex) = commandInfo; + var sensitiveArgumentIndexes = GetSensitiveEffectiveArgumentIndexes(resource); + if (sensitiveArgumentIndexes.Contains(commandIndex)) + { + return [new(KnownProperties.Project.LaunchCommand, null)]; + } + + string? configuration = null; + string? targetFramework = null; + + // DCP reports dotnet launch arguments as: + // ["watch", "--project", "/repo/api.csproj", "--configuration", "Release", "--framework=net10.0", "--", ...appArgs] + // ["[env:NAME=value]", "--diagnostics", "run", "--project", "/repo/api.csproj"] + // ["-d", "watch", "--project", "/repo/api.csproj"] + // Only launcher arguments before "--" are safe to publish as launch metadata. Application + // arguments after the separator can contain unrelated values and remain sensitive in executable.args. + // See https://learn.microsoft.com/dotnet/core/tools/dotnet and + // https://github.com/dotnet/command-line-api/blob/main/src/System.CommandLine/EnvironmentVariablesDirective.cs. + for (var index = commandIndex + 1; index < effectiveArgs.Count; index++) + { + var argument = effectiveArgs[index]; + if (argument == "--") + { + break; + } + + if (TryReadOptionValue(argument, "--configuration", "-c", out var inlineConfiguration)) + { + configuration = sensitiveArgumentIndexes.Contains(index) ? null : inlineConfiguration; + continue; + } + + if (TryReadOptionValue(argument, "--framework", "-f", out var inlineTargetFramework)) + { + targetFramework = sensitiveArgumentIndexes.Contains(index) ? null : inlineTargetFramework; + continue; + } + + if (argument is "--configuration" or "-c") + { + configuration = ReadNextValue(effectiveArgs, sensitiveArgumentIndexes, ref index); + continue; + } + + if (argument is "--framework" or "-f") + { + targetFramework = ReadNextValue(effectiveArgs, sensitiveArgumentIndexes, ref index); + } + } + + var launchCommand = command.ToLowerInvariant(); + var properties = ImmutableArray.CreateBuilder(3); + properties.Add(new(KnownProperties.Project.LaunchCommand, launchCommand)); + if (configuration is not null) + { + properties.Add(new(KnownProperties.Project.Configuration, configuration)); + } + + if (targetFramework is not null) + { + properties.Add(new(KnownProperties.Project.TargetFramework, targetFramework)); + } + + return properties.ToImmutable(); + + static (string Command, int Index)? FindDotNetProjectCommand(IReadOnlyList arguments) + { + var index = 0; + var hasEnvironmentVariableDirective = false; + while (index < arguments.Count && + (string.Equals(arguments[index], "[env]", StringComparison.OrdinalIgnoreCase) || + arguments[index].StartsWith("[env:", StringComparison.OrdinalIgnoreCase) && arguments[index].EndsWith(']'))) + { + hasEnvironmentVariableDirective = true; + index++; + } + + while (index < arguments.Count && arguments[index] is "-d" or "--diagnostics") + { + index++; + } + + if (index >= arguments.Count) + { + return null; + } + + return arguments[index].ToLowerInvariant() switch + { + "run" => ("run", index), + // .NET 10 cannot resolve the external watch command through an environment directive. + "watch" when !hasEnvironmentVariableDirective => ("watch", index), + _ => null, + }; + } + + static bool TryReadOptionValue(string argument, string longOption, string shortOption, out string? value) + { + foreach (var option in new[] { longOption, shortOption }) + { + var prefix = option + "="; + if (argument.StartsWith(prefix, StringComparison.Ordinal)) + { + value = NormalizeValue(argument[prefix.Length..]); + return true; + } + } + + value = null; + return false; + } + + static string? ReadNextValue(IReadOnlyList arguments, HashSet sensitiveArgumentIndexes, ref int index) + { + var optionIsSensitive = sensitiveArgumentIndexes.Contains(index); + if (index + 1 >= arguments.Count || arguments[index + 1] == "--") + { + return null; + } + + index++; + return optionIsSensitive || sensitiveArgumentIndexes.Contains(index) + ? null + : NormalizeValue(arguments[index]); + } + + static string? NormalizeValue(string value) + { + var normalized = value.Trim(); + return normalized.Length > 0 ? normalized : null; + } + } + + private static HashSet GetSensitiveEffectiveArgumentIndexes(CustomResource resource) + { + if (resource.TryGetAnnotationAsObjectList( + Executable.SensitiveEffectiveArgumentIndexesAnnotation, + out List? sensitiveEffectiveArgumentIndexes)) + { + return sensitiveEffectiveArgumentIndexes.ToHashSet(); + } + + if (!resource.TryGetAnnotationAsObjectList( + CustomResource.ResourceAppArgsAnnotation, + out List? launchArgumentAnnotations)) + { + return []; + } + + return launchArgumentAnnotations + .Where(static annotation => annotation.IsSensitive && annotation.EffectiveArgumentIndex is not null) + .Select(static annotation => annotation.EffectiveArgumentIndex!.Value) + .ToHashSet(); + } + private static (ImmutableArray Args, ImmutableArray? ArgsAreSensitive, bool IsSensitive)? GetLaunchArgs(CustomResource resource, IReadOnlyList? effectiveArgs) { if (!resource.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out List? launchArgumentAnnotations)) diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index e6a6e14b924..5623554fc43 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -28,11 +28,11 @@ public static class Resource public const string ConnectionString = "resource.connectionString"; public const string ConnectionProperties = "resource.connectionProperties"; public const string ParentName = "resource.parentName"; + public const string LaunchConfigurationType = "resource.launchConfigurationType"; public const string AppArgs = "resource.appArgs"; public const string AppArgsSensitivity = "resource.appArgsSensitivity"; public const string ExcludeFromMcp = "resource.excludeFromMcp"; public const string WaitingFor = "resource.waitingFor"; - public const string LaunchConfigurationType = "resource.launchConfigurationType"; } public static class Container @@ -57,6 +57,9 @@ public static class Project { public const string Path = "project.path"; public const string LaunchProfile = "project.launchProfile"; + public const string LaunchCommand = "project.launchCommand"; + public const string Configuration = "project.configuration"; + public const string TargetFramework = "project.targetFramework"; } public static class Terminal diff --git a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs index 662f9d48a3f..56bb5aa150e 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Net; +using System.Net.Sockets; using Aspire.Cli.Backchannel; using Aspire.Cli.Projects; using Aspire.Cli.Resources; @@ -12,6 +14,7 @@ using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; +using StreamJsonRpc; namespace Aspire.Cli.Tests.Backchannel; @@ -89,6 +92,42 @@ public async Task ResolveConnectionAsync_WithExplicitProjectFile_DeletesDeadPidS Assert.False(File.Exists(socketPath)); } + [Fact] + public async Task ResolveConnectionAsync_WithExplicitProjectFileAndPid_SelectsMatchingInstance() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectFile = CreateProjectFile(workspace.WorkspaceRoot, "TestAppHost", "TestAppHost.csproj"); + using var otherServer = TestResolverBackchannelServer.Start(projectFile.FullName, processId: 1111); + using var requestedServer = TestResolverBackchannelServer.Start(projectFile.FullName, processId: 2222); + var resolver = new AppHostConnectionResolver( + new TestAuxiliaryBackchannelMonitor(), + new TestInteractionService(), + new TestProjectLocator(), + executionContext, + TestHelpers.CreateInteractiveHostEnvironment(), + NullLogger.Instance, + new ProfilingTelemetry(new ConfigurationBuilder().Build()), + (_, _, _, _) => [otherServer.AppHostSocket, requestedServer.AppHostSocket]); + + var result = await resolver.ResolveConnectionAsync( + projectFile, + "Scanning", + "Select", + SharedCommandStrings.AppHostNotRunning, + TestContext.Current.CancellationToken, + appHostPid: 2222); + + Assert.True(result.Success); + Assert.Equal(2222, result.Connection.AppHostInfo?.ProcessId); + await otherServer.WaitForClientDisconnectAsync().WaitAsync(TimeSpan.FromSeconds(5)); + var appHostInfo = await result.Connection.GetAppHostInfoV2Async(TestContext.Current.CancellationToken); + Assert.Equal("2222", appHostInfo?.Pid); + + result.Connection.Dispose(); + await requestedServer.WaitForClientDisconnectAsync().WaitAsync(TimeSpan.FromSeconds(5)); + } + [Fact] public void IsProjectResolutionError_WithNonProjectResolutionExitCode_ReturnsFalse() { @@ -580,4 +619,108 @@ private static string CreateSocketFileForKey(string socketKeyPath, DirectoryInfo File.WriteAllText(socketPath, ""); return socketPath; } + + private sealed class TestResolverBackchannelServer : IDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _cancellationSource = new(); + private readonly List _disposables = []; + private readonly TaskCompletionSource _clientDisconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private TestResolverBackchannelServer(string appHostPath, int processId) + { + _listener.Start(); + AppHostSocket = new TestAppHostSocket($"test-socket-{processId}") + { + ConnectAsyncCallback = ConnectAsync + }; + _ = AcceptClientAsync(appHostPath, processId); + } + + public TestAppHostSocket AppHostSocket { get; } + + public static TestResolverBackchannelServer Start(string appHostPath, int processId) + => new(appHostPath, processId); + + public Task WaitForClientDisconnectAsync() => _clientDisconnected.Task; + + public void Dispose() + { + _cancellationSource.Cancel(); + foreach (var disposable in _disposables) + { + disposable.Dispose(); + } + + _listener.Stop(); + _cancellationSource.Dispose(); + } + + private async ValueTask ConnectAsync(CancellationToken cancellationToken) + { + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + try + { + await socket.ConnectAsync((IPEndPoint)_listener.LocalEndpoint, cancellationToken); + return socket; + } + catch + { + socket.Dispose(); + throw; + } + } + + private async Task AcceptClientAsync(string appHostPath, int processId) + { + var socket = await _listener.AcceptSocketAsync(_cancellationSource.Token); + var stream = new NetworkStream(socket, ownsSocket: true); + var messageHandler = new HeaderDelimitedMessageHandler( + stream, + stream, + BackchannelJsonSerializerContext.CreateRpcMessageFormatter()); + var rpc = new JsonRpc(messageHandler, new TestResolverRpcTarget(appHostPath, processId)); + rpc.Disconnected += (_, _) => _clientDisconnected.TrySetResult(); + rpc.StartListening(); + _disposables.Add(rpc); + _disposables.Add(messageHandler); + _disposables.Add(stream); + } + } + + private sealed class TestResolverRpcTarget(string appHostPath, int processId) + { + private readonly string[] _capabilities = + [ + AuxiliaryBackchannelCapabilities.V1, + AuxiliaryBackchannelCapabilities.V2 + ]; + + public Task GetAppHostInformationAsync() + => Task.FromResult(new AppHostInformation + { + AppHostPath = appHostPath, + ProcessId = processId + }); + + public Task GetCapabilitiesAsync(GetCapabilitiesRequest? request = null) + { + _ = request; + return Task.FromResult(new GetCapabilitiesResponse + { + Capabilities = _capabilities + }); + } + + public Task GetAppHostInfoAsync(GetAppHostInfoRequest? request = null) + { + _ = request; + return Task.FromResult(new GetAppHostInfoResponse + { + Pid = processId.ToString(CultureInfo.InvariantCulture), + AppHostPath = appHostPath, + AspireHostVersion = "test" + }); + } + } } diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs index 83c02f3da3f..8e635926743 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -5,6 +5,7 @@ using System.Text.Json.Nodes; using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; +using Aspire.Dashboard.Model; using Aspire.Shared.Model.Serialization; namespace Aspire.Cli.Tests.Backchannel; @@ -31,6 +32,38 @@ public void ResourceSnapshotDeserialization_WithNumericPropertyValue_PreservesJs Assert.Equal(12345, pid.GetValue()); } + [Fact] + public void MapToResourceJson_WithDebugProperties_PreservesProperties() + { + var snapshot = new ResourceSnapshot + { + Name = "mauiapp-android-emulator", + DisplayName = "MAUI", + ResourceType = "Project", + State = "Running", + Properties = + { + [KnownProperties.Executable.Args] = null, + [KnownProperties.Project.Path] = JsonValue.Create("/repo/maui/MauiApp.csproj"), + [KnownProperties.Project.LaunchProfile] = JsonValue.Create("AndroidEmulator"), + [KnownProperties.Project.LaunchCommand] = JsonValue.Create("watch"), + [KnownProperties.Project.Configuration] = JsonValue.Create("Release"), + [KnownProperties.Project.TargetFramework] = JsonValue.Create("net10.0"), + [KnownProperties.Resource.LaunchConfigurationType] = JsonValue.Create("maui"), + [KnownProperties.Resource.ParentName] = JsonValue.Create("mauiapp"), + } + }; + + var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, [snapshot]); + + Assert.Null(result.Properties![KnownProperties.Executable.Args]); + Assert.Equal("maui", result.Properties![KnownProperties.Resource.LaunchConfigurationType]!.GetValue()); + Assert.Equal("watch", result.Properties[KnownProperties.Project.LaunchCommand]!.GetValue()); + Assert.Equal("Release", result.Properties[KnownProperties.Project.Configuration]!.GetValue()); + Assert.Equal("net10.0", result.Properties[KnownProperties.Project.TargetFramework]!.GetValue()); + Assert.Equal("MauiApp.csproj", result.Source); + } + [Fact] public void MapToResourceJson_WithPopulatedProperties_MapsCorrectly() { diff --git a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs index 7c8f968c14a..8e4725ab03b 100644 --- a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs @@ -25,6 +25,12 @@ public void ConfigInfo_AdvertisesLsJsonStream() Assert.Contains(KnownCapabilities.LsJsonStream, KnownCapabilities.GetAdvertisedCapabilities()); } + [Fact] + public void ConfigInfo_AdvertisesDescribeAppHostPid() + { + Assert.Contains(KnownCapabilities.DescribeAppHostPid, KnownCapabilities.GetAdvertisedCapabilities()); + } + [Fact] public void ConfigInfo_AdvertisesIsolatedLaunch() { diff --git a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs index cebc9e01f8d..6559416f2a0 100644 --- a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs @@ -48,6 +48,24 @@ public async Task DescribeCommand_WhenNoAppHostRunning_ReturnsSuccess() Assert.Equal(CliExitCodes.Success, exitCode); } + [Theory] + [InlineData("describe --apphost-pid 42")] + [InlineData("describe --apphost missing.csproj --apphost-pid 0")] + [InlineData("describe --apphost missing.csproj --apphost-pid -1")] + public async Task DescribeCommand_AppHostPid_RejectsUnboundOrInvalidValues(string commandLine) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse(commandLine); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.NotEqual(CliExitCodes.Success, exitCode); + } + [Theory] [InlineData("json")] [InlineData("Json")] @@ -257,6 +275,98 @@ public void DescribeCommand_SnapshotFormat_OutputsWrappedJsonArray() Assert.Equal("frontend", deserialized.Resources[0].Name); } + [Fact] + public void DescribeCommand_SnapshotFormat_IncludesDebugPropertiesForParentedProjectAndMauiResources() + { + var resourcesOutput = new ResourcesOutput + { + Resources = + [ + new ResourceJson + { + Name = "api-grouped", + DisplayName = "API", + ResourceType = "Project", + State = "Running", + Source = "Api.csproj", + Properties = new Dictionary + { + [KnownProperties.Executable.Args] = null, + [KnownProperties.Executable.Path] = JsonValue.Create("dotnet"), + [KnownProperties.Project.Configuration] = JsonValue.Create("Release"), + [KnownProperties.Project.LaunchCommand] = JsonValue.Create("watch"), + [KnownProperties.Project.LaunchProfile] = JsonValue.Create("https"), + [KnownProperties.Project.Path] = JsonValue.Create("/repo/api/Api.csproj"), + [KnownProperties.Project.TargetFramework] = JsonValue.Create("net10.0"), + [KnownProperties.Resource.LaunchConfigurationType] = JsonValue.Create("project"), + [KnownProperties.Resource.ParentName] = JsonValue.Create("group"), + } + }, + new ResourceJson + { + Name = "mauiapp-android-emulator", + DisplayName = "MAUI", + ResourceType = "Project", + State = "Running", + Source = "MauiApp.csproj", + Properties = new Dictionary + { + [KnownProperties.Executable.Args] = null, + [KnownProperties.Executable.Path] = JsonValue.Create("dotnet"), + [KnownProperties.Executable.Pid] = JsonValue.Create(1234), + [KnownProperties.Project.LaunchProfile] = JsonValue.Create("AndroidEmulator"), + [KnownProperties.Project.Path] = JsonValue.Create("/repo/maui/MauiApp.csproj"), + [KnownProperties.Resource.LaunchConfigurationType] = JsonValue.Create("maui"), + [KnownProperties.Resource.ParentName] = JsonValue.Create("mauiapp"), + } + } + ] + }; + + var json = JsonSerializer.Serialize(resourcesOutput, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput); + + Assert.Equal(""" + { + "resources": [ + { + "name": "api-grouped", + "displayName": "API", + "resourceType": "Project", + "state": "Running", + "source": "Api.csproj", + "properties": { + "executable.args": null, + "executable.path": "dotnet", + "project.configuration": "Release", + "project.launchCommand": "watch", + "project.launchProfile": "https", + "project.path": "/repo/api/Api.csproj", + "project.targetFramework": "net10.0", + "resource.launchConfigurationType": "project", + "resource.parentName": "group" + } + }, + { + "name": "mauiapp-android-emulator", + "displayName": "MAUI", + "resourceType": "Project", + "state": "Running", + "source": "MauiApp.csproj", + "properties": { + "executable.args": null, + "executable.path": "dotnet", + "executable.pid": 1234, + "project.launchProfile": "AndroidEmulator", + "project.path": "/repo/maui/MauiApp.csproj", + "resource.launchConfigurationType": "maui", + "resource.parentName": "mauiapp" + } + } + ] + } + """, json); + } + [Fact] public async Task DescribeCommand_Follow_JsonFormat_DeduplicatesIdenticalSnapshots() { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostSocket.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostSocket.cs index 697f88b4249..af0922877a5 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostSocket.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostSocket.cs @@ -12,8 +12,15 @@ internal sealed class TestAppHostSocket(string socketPath) : IAppHostSocket public int? ProcessId { get; init; } = BackchannelConstants.ExtractPid(socketPath); + public Func>? ConnectAsyncCallback { get; init; } + public async ValueTask ConnectAsync(CancellationToken cancellationToken) { + if (ConnectAsyncCallback is not null) + { + return await ConnectAsyncCallback(cancellationToken); + } + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); try { diff --git a/tests/Aspire.Hosting.Tests/Dcp/ExecutableLaunchPlanTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ExecutableLaunchPlanTests.cs index 47475558dcb..34c05de6e8d 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ExecutableLaunchPlanTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ExecutableLaunchPlanTests.cs @@ -210,6 +210,41 @@ [new ExecutableLaunchArgument( }); } + [Fact] + public void RendererPreservesSensitivityForHiddenEffectiveArguments() + { + var resource = new ExecutableResource("app", "dotnet", "/tmp"); + var executable = Executable.Create("app-12345678", "stale-tool"); + var renderedResource = new RenderedModelResource(resource, executable); + var plan = new ExecutableLaunchPlan( + "dotnet", + "/tmp", + ExecutableLaunchMechanism.Process, + ["run", "--configuration", "resolved-secret"], + [], + [], + [ + new("run", isSensitive: false, executable: true, display: false, effectiveArgumentIndex: 0, role: ExecutableLaunchArgumentRole.LaunchTool), + new("--configuration", isSensitive: false, executable: true, display: false, effectiveArgumentIndex: 1, role: ExecutableLaunchArgumentRole.LaunchTool), + new("resolved-secret", isSensitive: true, executable: true, display: false, effectiveArgumentIndex: 2, role: ExecutableLaunchArgumentRole.LaunchTool), + ]); + + ExecutableCreator.Render( + renderedResource, + plan, + pemCertificates: null, + NullLogger.Instance); + + Assert.True(executable.TryGetAnnotationAsObjectList( + Executable.SensitiveEffectiveArgumentIndexesAnnotation, + out var sensitiveEffectiveArgumentIndexes)); + Assert.Equal([2], sensitiveEffectiveArgumentIndexes); + Assert.True(executable.TryGetAnnotationAsObjectList( + CustomResource.ResourceAppArgsAnnotation, + out var displayedArguments)); + Assert.Empty(displayedArguments); + } + [Fact] public async Task ResolverRejectsMultipleLaunchRecipes() { diff --git a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs index 6a0531436ac..43b2c4dd9de 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPROJECTS001 // Project launch defaults are experimental but needed to verify snapshot emission. #pragma warning disable ASPIREEXTENSION001 // Debug support annotations are experimental. using Aspire.Dashboard.Model; @@ -86,6 +87,248 @@ public void ProjectSnapshotAddsDisplayMetadataForDashboardProperties() AssertHighlightedProperty(snapshot, KnownProperties.Executable.Pid, "Process ID", isSensitive: false, sortOrder: 2); } + [Fact] + public void ProjectSnapshotIncludesLaunchConfigurationTypeForDebuggableProject() + { + var builder = DistributedApplication.CreateBuilder(); + var project = builder.AddResource(new ProjectResource("project")); + project.Resource.Annotations.Add(new TestProjectMetadata()); + var configuredProject = project.WithProjectDefaults(new ProjectResourceOptions { ExcludeLaunchProfile = true }); + + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, configuredProject.Resource.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = ["run"], + ProcessId = 1234 + }; + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [configuredProject.Resource.Name] = configuredProject.Resource + }).ToSnapshot(executable, CreatePreviousSnapshot()); + + var launchConfigurationType = Assert.Single(snapshot.Properties, p => p.Name == KnownProperties.Resource.LaunchConfigurationType); + Assert.Equal("project", Assert.IsType(launchConfigurationType.Value)); + } + + [Theory] + [InlineData("Run", "run", "--configuration", "--framework=net10.0")] + [InlineData("run", "run", "-c", "-f")] + [InlineData("run", "run", "--configuration=Release", "--framework")] + [InlineData("run", "run", "-c=Release", "-f=net10.0")] + [InlineData("WATCH", "watch", "--configuration", "--framework=net10.0")] + [InlineData("watch", "watch", "-c", "-f")] + [InlineData("watch", "watch", "--configuration=Release", "--framework")] + [InlineData("watch", "watch", "-c=Release", "-f=net10.0")] + public void ProjectSnapshotIncludesSafeDotNetLaunchMetadata( + string launchCommand, + string expectedLaunchCommand, + string configurationArgument, + string targetFrameworkArgument) + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata()); + + var effectiveArgs = new List + { + launchCommand, + "--project", + "/app/project.csproj", + configurationArgument + }; + if (!configurationArgument.Contains('=')) + { + effectiveArgs.Add("Release"); + } + effectiveArgs.Add(targetFrameworkArgument); + if (!targetFrameworkArgument.Contains('=')) + { + effectiveArgs.Add("net10.0"); + } + effectiveArgs.AddRange(["--", "--configuration", "Private", "--framework", "private"]); + + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = effectiveArgs, + ProcessId = 1234 + }; + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, CreatePreviousSnapshot()); + + Assert.Equal(expectedLaunchCommand, Assert.IsType(GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value)); + Assert.Equal("Release", Assert.IsType(GetProperty(snapshot, KnownProperties.Project.Configuration).Value)); + Assert.Equal("net10.0", Assert.IsType(GetProperty(snapshot, KnownProperties.Project.TargetFramework).Value)); + Assert.True(GetProperty(snapshot, KnownProperties.Executable.Args).IsSensitive); + Assert.False(GetProperty(snapshot, KnownProperties.Project.LaunchCommand).IsSensitive); + Assert.False(GetProperty(snapshot, KnownProperties.Project.Configuration).IsSensitive); + Assert.False(GetProperty(snapshot, KnownProperties.Project.TargetFramework).IsSensitive); + } + + [Fact] + public void ProjectSnapshotOmitsSensitiveHiddenLaunchToolMetadata() + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata()); + + var effectiveArgs = new List + { + "run", + "--project", + "/app/project.csproj", + "--configuration", + "resolved-configuration-secret", + "--framework=resolved-framework-secret", + }; + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = effectiveArgs, + ProcessId = 1234 + }; + executable.SetAnnotationAsObjectList(DcpCustomResource.ResourceAppArgsAnnotation, Array.Empty()); + executable.SetAnnotationAsObjectList(Executable.SensitiveEffectiveArgumentIndexesAnnotation, [4, 5]); + + var previousSnapshot = CreatePreviousSnapshot() with + { + Properties = + [ + new(KnownProperties.Project.Configuration, "stale-configuration"), + new(KnownProperties.Project.TargetFramework, "stale-framework"), + ] + }; + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, previousSnapshot); + + Assert.Equal("run", GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value); + Assert.Empty(snapshot.Properties.Where(property => property.Name == KnownProperties.Project.Configuration)); + Assert.Empty(snapshot.Properties.Where(property => property.Name == KnownProperties.Project.TargetFramework)); + } + + [Fact] + public void ProjectSnapshotDoesNotPublishDotNetLaunchMetadataBeforeSensitiveOverride() + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata()); + + var effectiveArgs = new List + { + "run", + "--configuration", + "Release", + "--configuration", + "resolved-configuration-secret", + }; + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = effectiveArgs, + ProcessId = 1234 + }; + executable.SetAnnotationAsObjectList( + DcpCustomResource.ResourceAppArgsAnnotation, + effectiveArgs.Select((argument, index) => new AppLaunchArgumentAnnotation( + argument, + isSensitive: false, + effectiveArgumentIndex: index))); + executable.SetAnnotationAsObjectList(Executable.SensitiveEffectiveArgumentIndexesAnnotation, [4]); + + var previousSnapshot = CreatePreviousSnapshot() with + { + Properties = [new(KnownProperties.Project.Configuration, "stale-configuration")] + }; + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, previousSnapshot); + + Assert.Empty(snapshot.Properties.Where(property => property.Name == KnownProperties.Project.Configuration)); + } + + [Theory] + [InlineData("run", "[env:ASPNETCORE_ENVIRONMENT=Development]", "--diagnostics")] + [InlineData("watch", "-d")] + public void ProjectSnapshotIncludesLaunchMetadataAfterSupportedDotNetPrefixes( + string launchCommand, + params string[] prefixes) + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata()); + + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = [.. prefixes, launchCommand, "--configuration", "Release", "--framework", "net10.0"], + ProcessId = 1234 + }; + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, CreatePreviousSnapshot()); + + Assert.Equal(launchCommand, GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value); + Assert.Equal("Release", GetProperty(snapshot, KnownProperties.Project.Configuration).Value); + Assert.Equal("net10.0", GetProperty(snapshot, KnownProperties.Project.TargetFramework).Value); + } + + [Fact] + public void ProjectSnapshotIncludesNullLaunchCommandWhenDotNetArgumentsAreMissing() + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata()); + + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + ProcessId = 1234 + }; + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, CreatePreviousSnapshot()); + + var launchCommand = GetProperty(snapshot, KnownProperties.Project.LaunchCommand); + Assert.Null(launchCommand.Value); + Assert.False(launchCommand.IsSensitive); + } + + [Fact] + public void ProjectSnapshotIncludesNullLaunchCommandWhenDotNetCommandIsUnsupported() + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata()); + + var executable = Executable.Create("project", "dotnet.exe"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = ["publish", "--configuration", "Release"], + ProcessId = 1234 + }; + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, CreatePreviousSnapshot()); + + var launchCommand = GetProperty(snapshot, KnownProperties.Project.LaunchCommand); + Assert.Null(launchCommand.Value); + Assert.False(launchCommand.IsSensitive); + } + [Fact] public void ExecutableSnapshotPublishesLaunchConfigurationTypeOnlyWhenInstallingDebuggerCanEnableDebugging() { @@ -118,7 +361,6 @@ public void ExecutableSnapshotPublishesLaunchConfigurationTypeOnlyWhenInstalling Lifetime = ContainerLifetime.Persistent }); snapshot = snapshotBuilder.ToSnapshot(executable, snapshot); - Assert.Empty(snapshot.Properties.Where(property => property.Name == propertyName)); }