diff --git a/.github/workflows/build-cli-native-archives.yml b/.github/workflows/build-cli-native-archives.yml index 685e4d89b6b..b79f81a87fd 100644 --- a/.github/workflows/build-cli-native-archives.yml +++ b/.github/workflows/build-cli-native-archives.yml @@ -104,7 +104,7 @@ jobs: -Rid $rid ` -ArchivePath $archive[0].FullName - # Upload DCP, Dashboard, and CLI tool NuGets so test/polyglot jobs can download them + # Upload DCP, Dashboard, TerminalHost, and CLI tool NuGets so test/polyglot jobs can download them - name: Upload RID-specific NuGets uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: @@ -112,6 +112,7 @@ jobs: path: | artifacts/packages/${{ inputs.configuration }}/Shipping/Aspire.Hosting.Orchestration.${{ matrix.targets.rids }}.*.nupkg artifacts/packages/${{ inputs.configuration }}/Shipping/Aspire.Dashboard.Sdk.${{ matrix.targets.rids }}.*.nupkg + artifacts/packages/${{ inputs.configuration }}/Shipping/Aspire.TerminalHost.Sdk.${{ matrix.targets.rids }}.*.nupkg artifacts/packages/${{ inputs.configuration }}/Shipping/Aspire.Cli.*.nupkg if-no-files-found: error retention-days: 15 diff --git a/Aspire.slnx b/Aspire.slnx index 18fa49a110f..ae6c1d52972 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -79,6 +79,7 @@ + @@ -359,6 +360,10 @@ + + + + @@ -511,6 +516,7 @@ + diff --git a/Directory.Build.props b/Directory.Build.props index e06b96f9811..6c6863a2911 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -32,6 +32,7 @@ true $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'DashboardArtifacts', '$(Configuration)')) + $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'TerminalHostArtifacts', '$(Configuration)')) $(ArtifactsShippingPackagesDir) @@ -56,6 +57,7 @@ $(MSBuildThisFileDirectory)/artifacts/bin/Aspire.Dashboard/$(Configuration)/net8.0/ + $(MSBuildThisFileDirectory)/artifacts/bin/Aspire.TerminalHost/$(Configuration)/net8.0/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 5e00068373c..f8a29a786f7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -100,9 +100,9 @@ - - - + + + diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md new file mode 100644 index 00000000000..cf3cdf3d7be --- /dev/null +++ b/docs/specs/with-terminal.md @@ -0,0 +1,156 @@ +# `WithTerminal()` — Aspire interactive terminal architecture + +**Status:** Implemented for Aspire 13.4 (Windows executables). +**Issue:** [microsoft/aspire#16317](https://github.com/microsoft/aspire/issues/16317) +**DCP integration:** [microsoft/dcp#133](https://github.com/microsoft/dcp/pull/133) + +## Goal + +Let an Aspire AppHost author opt any executable or container resource into +interactive terminal access: + +```csharp +builder.AddProject("agent") + .WithReplicas(2) + .WithTerminal(); +``` + +The dashboard then renders an xterm.js terminal per replica, and the CLI +exposes the same session as `aspire terminal agent --replica 0`. + +## Process topology + +```text + ┌────────────────────────────┐ + │ AppHost (dotnet run) │ + │ - Aspire.Hosting │ + │ - DCP control plane │ + │ - per-resource: │ + │ Aspire.TerminalHost │ (1 process / WithTerminal()) + └─────────────┬──────────────┘ + │ spawn + ▼ +┌──────────────────────┐ ┌───────────────────────┐ ┌────────────────────────┐ +│ DCP-launched │ PTY (Win) │ TerminalHost │ HMP v1 UDS │ Consumers │ +│ replica process │ ─────────────▶ │ (Hex1b HMP v1 broker) │ ─────────────▶ │ - Dashboard │ +│ (executable, repl…) │ ◀───── stdin ─ │ │ ◀───── input ─ │ /api/terminal proxy │ +└──────────────────────┘ └───────────────────────┘ │ - aspire CLI │ + └────────────────────────┘ +``` + +Three actors and three socket roles: + +| Actor | Socket | Direction | Lifetime | +|----------------|---------------------|----------------------------------|----------| +| **DCP** | `producerUdsPath` | DCP → host (PTY bytes + control) | Per replica | +| **TerminalHost** | `consumerUdsPath` | host → consumers (broadcast) | Per replica | +| **TerminalHost** | `controlUdsPath` | AppHost → host (lifecycle, stats)| Per resource | + +The producer/consumer split lets multiple consumers (dashboard + multiple CLI +sessions) attach simultaneously without coupling DCP to consumer counts. + +## Wire protocol + +We do **not** define a custom protocol. The terminal traffic uses +[Hex1b](https://github.com/dotnet/hex1b)'s `HMP v1` (Hex Multiplex Protocol, +version 1), which already handles: + +- VT byte streaming with backpressure +- Resize requests in both directions +- Hello/StateSync replay so a late-attaching consumer sees the current + scrollback +- Connection lifecycle (close, disconnect, reconnect) +- Authenticated stream factory hooks (we only use Unix-socket transport + today) + +The `Hmp1WorkloadAdapter` is what the AppHost-side terminal host uses to +multiplex DCP's PTY traffic to the consumer-facing listener; the +`Hmp1PresentationAdapter` is what consumers (Dashboard WebSocket proxy and +the CLI) use to attach. + +## Property contract (gRPC `ResourceService` snapshots) + +When `WithTerminal()` is applied to a resource, every replica snapshot +emitted by the dashboard service carries four properties: + +| Key | Sensitivity | Meaning | +|---------------------------|-----------------|----------------------------------------------| +| `terminal.enabled` | non-sensitive | Marker. `"true"` when the replica has a PTY. | +| `terminal.replicaIndex` | non-sensitive | 0-based stable index from `DcpInstancesAnnotation`. | +| `terminal.replicaCount` | non-sensitive | Total replicas for the parent resource. | +| `terminal.consumerUdsPath`| **sensitive** | The local UDS that consumers connect to. | + +The consumer UDS path is marked `IsSensitive=true` so the dashboard UI masks +the value in the property list. The path still rides the gRPC stream because +the dashboard's WebSocket proxy needs it server-side to resolve +`?resource=&replica=` query parameters into a real socket; the path is never +echoed back to the browser. + +## Dashboard `/api/terminal` WebSocket endpoint + +Authenticated (`RequireAuthorization(FrontendAuthorizationDefaults.PolicyName)`) +endpoint at `/api/terminal?resource=&replica=`. + +`TerminalWebSocketProxy` resolves the connection entirely server-side: + +1. `ITerminalConnectionResolver.ConnectAsync(resourceName, replicaIndex, ct)` + walks `IDashboardClient.GetResources()`, matches by `DisplayName` + + `TryGetTerminalReplicaInfo`, and connects via + `Hmp1Transports.ConnectUnixSocket(consumerUdsPath, ct)`. +2. The proxy wraps the resulting stream in `Hmp1WorkloadAdapter` and runs + two pumps: + - **Inbound (browser → producer):** binary frames are forwarded as HMP v1 + `Input` (keystrokes); text frames are parsed as JSON resize control + messages (`{"type":"resize","cols":N,"rows":N}`). + - **Outbound (producer → browser):** VT bytes from the producer become + binary WebSocket frames; resize hints from the producer become JSON + text frames. +3. Frame type — not content — distinguishes keystroke from control. This + keeps the proxy's parser cheap and avoids ambiguity around binary input + that happens to look like JSON. +4. Multi-fragment WS reads are reassembled in `ReassembledFrame` using + `ArrayPool`. + +The browser never sees `consumerUdsPath` and cannot induce the dashboard +to connect to an arbitrary local socket — it can only ask for +`(resource, replica)` pairs that are present in the resource snapshot +stream. + +## CLI + +`aspire terminal [--replica N]` (`Aspire.Cli/Commands/TerminalCommand.cs`) +opens its own `Hmp1PresentationAdapter` against the consumer UDS path +returned by `IBackchannel.GetTerminalInfoAsync(resource, replica)` and +renders frames into the host terminal via Hex1b's `Hex1bTerminal`. When the +resource has more than one replica and the CLI is interactive, it prompts +for a selection; in non-interactive mode the `--replica` flag is required. + +## DCP integration + +DCP allocates a Windows ConPTY per replica when the spec carries +`terminal.enabled=true` and the supplied `terminal.producerUdsPath`, +`terminal.consumerUdsPath`, and `terminal.controlUdsPath` properties. DCP +opens the producer-side UDS and writes PTY traffic into it; the +TerminalHost owns the consumer-side listener and broadcasts to attached +clients. + +Linux / macOS / container PTY support is tracked as Phase 3 follow-ups in +the parent issue; for the 13.4 release, the playground guards +non-Windows code paths with `OperatingSystem.IsWindows()` checks. + +## Files of interest + +| Concern | File | +|--------------------------------------|---------------------------------------------------------------------| +| Public API entry point | `src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs` | +| Per-resource hidden host resource | `src/Aspire.Hosting/ApplicationModel/TerminalHostResource.cs` | +| DCP wire-up | `src/Aspire.Hosting/Dcp/ExecutableCreator.cs` | +| Backchannel `GetTerminalInfoAsync` | `src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs` | +| Snapshot stamping | `src/Aspire.Hosting/Dashboard/DashboardServiceData.cs` | +| TerminalHost process | `src/Aspire.TerminalHost/` | +| CLI command | `src/Aspire.Cli/Commands/TerminalCommand.cs` | +| Dashboard WebSocket proxy | `src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs` | +| Dashboard resolver | `src/Aspire.Dashboard/Terminal/DefaultTerminalConnectionResolver.cs`| +| `TerminalView` (xterm.js host) | `src/Aspire.Dashboard/Components/Controls/TerminalView.razor.*` | +| Property keys | `src/Shared/Model/KnownProperties.cs` (`Terminal.*`) | +| Playground sample | `playground/Terminals/Terminals.AppHost/AppHost.cs` | diff --git a/eng/Build.props b/eng/Build.props index a89ad888d1c..a89db94af31 100644 --- a/eng/Build.props +++ b/eng/Build.props @@ -7,8 +7,8 @@ BuildTestsOnly - if 'true', builds only test projects, defaults to 'false' SkipTestProjects - if 'true', skips building test projects, defaults to 'false' SkipPlaygroundProjects - if 'true', skips building playground projects, defaults to 'false' - SkipBundleDeps - if 'true', skips building eng/dcppack, eng/dashboardpack, Aspire.Dashboard, and Aspire.Cli projects during the main managed build, defaults to 'false' - BuildBundleDepsOnly - if 'true', builds only the RID-specific eng/dcppack and eng/dashboardpack projects for $(TargetRids), plus Aspire.Dashboard for restore, defaults to 'false' + SkipBundleDeps - if 'true', skips building eng/dcppack, eng/dashboardpack, eng/terminalhostpack, Aspire.Dashboard, Aspire.TerminalHost, and Aspire.Cli projects during the main managed build, defaults to 'false' + BuildBundleDepsOnly - if 'true', builds only the RID-specific eng/dcppack, eng/dashboardpack, and eng/terminalhostpack projects for $(TargetRids), plus Aspire.Dashboard and Aspire.TerminalHost for restore, defaults to 'false' TargetRids - colon separated list of RIDs to build native projects for --> @@ -23,21 +23,25 @@ $(BuildRid) - <_BundleDependencyTargetRid Include="$(TargetRids.Split(':'))" /> <_BundleDependencyProject Include="@(_BundleDependencyTargetRid -> '$(RepoRoot)eng\dcppack\Aspire.Hosting.Orchestration.%(Identity).csproj')" /> <_BundleDependencyProject Include="@(_BundleDependencyTargetRid -> '$(RepoRoot)eng\dashboardpack\Aspire.Dashboard.Sdk.%(Identity).csproj')" /> + <_BundleDependencyProject Include="@(_BundleDependencyTargetRid -> '$(RepoRoot)eng\terminalhostpack\Aspire.TerminalHost.Sdk.%(Identity).csproj')" /> + + @@ -48,8 +52,10 @@ + + $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'DashboardArtifacts', '$(Configuration)')) + $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'TerminalHostArtifacts', '$(Configuration)')) <_InstallersToPublish Include="$(ArtifactsDir)**\*.wixpack.zip" Condition="'$(PostBuildSign)' == 'true'" /> <_InstallerManifestFilesToPublish Include="$(ArtifactsDir)VSSetup\$(Configuration)\Insertion\**\*.zip" /> <_DashboardFilesToPublish Include="$(DashboardPublishedArtifactsOutputDir)\**\*.zip" /> + <_TerminalHostFilesToPublish Include="$(TerminalHostPublishedArtifactsOutputDir)\**\*.zip" /> <_CliInstallScriptsToPublish Include="$(RepoRoot)eng\scripts\get-aspire-cli.sh" /> <_CliInstallScriptsToPublish Include="$(RepoRoot)eng\scripts\get-aspire-cli.ps1" /> @@ -168,6 +170,11 @@ true $(_UploadPathRoot)/$(_PackageVersion)/%(Filename)%(Extension) + + true + true + $(_UploadPathRoot)/$(_PackageVersion)/%(Filename)%(Extension) + false true diff --git a/eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-arm64.csproj b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-arm64.csproj new file mode 100644 index 00000000000..da627acbe65 --- /dev/null +++ b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-arm64.csproj @@ -0,0 +1,9 @@ + + + linux-arm64 + Unix + + + + + diff --git a/eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-musl-x64.csproj b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-musl-x64.csproj new file mode 100644 index 00000000000..47afdf6f419 --- /dev/null +++ b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-musl-x64.csproj @@ -0,0 +1,9 @@ + + + linux-musl-x64 + Unix + + + + + diff --git a/eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-x64.csproj b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-x64.csproj new file mode 100644 index 00000000000..ed9fffd6d04 --- /dev/null +++ b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-x64.csproj @@ -0,0 +1,9 @@ + + + linux-x64 + Unix + + + + + diff --git a/eng/terminalhostpack/Aspire.TerminalHost.Sdk.osx-arm64.csproj b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.osx-arm64.csproj new file mode 100644 index 00000000000..48e53b126c9 --- /dev/null +++ b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.osx-arm64.csproj @@ -0,0 +1,9 @@ + + + osx-arm64 + Unix + + + + + diff --git a/eng/terminalhostpack/Aspire.TerminalHost.Sdk.osx-x64.csproj b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.osx-x64.csproj new file mode 100644 index 00000000000..2ab3b124938 --- /dev/null +++ b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.osx-x64.csproj @@ -0,0 +1,9 @@ + + + osx-x64 + Unix + + + + + diff --git a/eng/terminalhostpack/Aspire.TerminalHost.Sdk.win-arm64.csproj b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.win-arm64.csproj new file mode 100644 index 00000000000..b8b10540915 --- /dev/null +++ b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.win-arm64.csproj @@ -0,0 +1,9 @@ + + + win-arm64 + Windows + + + + + diff --git a/eng/terminalhostpack/Aspire.TerminalHost.Sdk.win-x64.csproj b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.win-x64.csproj new file mode 100644 index 00000000000..c323b409e21 --- /dev/null +++ b/eng/terminalhostpack/Aspire.TerminalHost.Sdk.win-x64.csproj @@ -0,0 +1,9 @@ + + + win-x64 + Windows + + + + + diff --git a/eng/terminalhostpack/AutoImport.props b/eng/terminalhostpack/AutoImport.props new file mode 100644 index 00000000000..058246e4086 --- /dev/null +++ b/eng/terminalhostpack/AutoImport.props @@ -0,0 +1 @@ + diff --git a/eng/terminalhostpack/Common.projitems b/eng/terminalhostpack/Common.projitems new file mode 100644 index 00000000000..6919c4c8697 --- /dev/null +++ b/eng/terminalhostpack/Common.projitems @@ -0,0 +1,65 @@ + + + + + + + net8.0 + true + + + true + true + false + false + $(ArtifactsShippingPackagesDir) + + + + Per-replica terminal host process used by Aspire to back WithTerminal()-enabled resources. + + + + + + + + + + + + + + + + + + + + + + <_PublishItems + Include="$(DotNetOutputPath)Aspire.TerminalHost/$(TerminalHostRuntime)/$(Configuration)/$(TargetFramework)/$(TerminalHostRuntime)/publish/**/*" /> + + + + + + + + + + + + + + + + + diff --git a/eng/terminalhostpack/Sdk.props b/eng/terminalhostpack/Sdk.props new file mode 100644 index 00000000000..058246e4086 --- /dev/null +++ b/eng/terminalhostpack/Sdk.props @@ -0,0 +1 @@ + diff --git a/eng/terminalhostpack/Sdk.targets b/eng/terminalhostpack/Sdk.targets new file mode 100644 index 00000000000..0a152474d06 --- /dev/null +++ b/eng/terminalhostpack/Sdk.targets @@ -0,0 +1,19 @@ + + + + + + $([MSBuild]::NormalizeDirectory($(MSBuildThisFileDirectory), '..', 'tools')) + $([MSBuild]::EnsureTrailingSlash('$(AspireTerminalHostDir)')) + $([MSBuild]::NormalizePath($(AspireTerminalHostDir), 'Aspire.TerminalHost')) + $(AspireTerminalHostPath).exe + $(AspireTerminalHostPath).dll + + + + + + + + + diff --git a/eng/terminalhostpack/UnixFilePermissions.xml b/eng/terminalhostpack/UnixFilePermissions.xml new file mode 100644 index 00000000000..61c49b10b19 --- /dev/null +++ b/eng/terminalhostpack/UnixFilePermissions.xml @@ -0,0 +1,3 @@ + + + diff --git a/eng/terminalhostpack/buildMultiTargeting/Aspire.TerminalHost.Sdk.in.props b/eng/terminalhostpack/buildMultiTargeting/Aspire.TerminalHost.Sdk.in.props new file mode 100644 index 00000000000..f678d612dce --- /dev/null +++ b/eng/terminalhostpack/buildMultiTargeting/Aspire.TerminalHost.Sdk.in.props @@ -0,0 +1,3 @@ + + + diff --git a/eng/terminalhostpack/buildMultiTargeting/Aspire.TerminalHost.Sdk.in.targets b/eng/terminalhostpack/buildMultiTargeting/Aspire.TerminalHost.Sdk.in.targets new file mode 100644 index 00000000000..128512826b6 --- /dev/null +++ b/eng/terminalhostpack/buildMultiTargeting/Aspire.TerminalHost.Sdk.in.targets @@ -0,0 +1,3 @@ + + + diff --git a/eng/terminalhostpack/buildTransitive/Aspire.TerminalHost.Sdk.in.props b/eng/terminalhostpack/buildTransitive/Aspire.TerminalHost.Sdk.in.props new file mode 100644 index 00000000000..f678d612dce --- /dev/null +++ b/eng/terminalhostpack/buildTransitive/Aspire.TerminalHost.Sdk.in.props @@ -0,0 +1,3 @@ + + + diff --git a/eng/terminalhostpack/buildTransitive/Aspire.TerminalHost.Sdk.in.targets b/eng/terminalhostpack/buildTransitive/Aspire.TerminalHost.Sdk.in.targets new file mode 100644 index 00000000000..128512826b6 --- /dev/null +++ b/eng/terminalhostpack/buildTransitive/Aspire.TerminalHost.Sdk.in.targets @@ -0,0 +1,3 @@ + + + diff --git a/playground/Terminals/Terminals.AppHost/AppHost.cs b/playground/Terminals/Terminals.AppHost/AppHost.cs new file mode 100644 index 00000000000..254f53fa093 --- /dev/null +++ b/playground/Terminals/Terminals.AppHost/AppHost.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +var builder = DistributedApplication.CreateBuilder(args); + +// A multi-replica project that calls `WithTerminal()` so each replica gets its +// own pseudo-terminal and the dashboard can attach to any of them via +// `/api/terminal?resource=repl&replica=`. The replica index is forwarded as +// an environment variable so the REPL can stamp it on its banner. +builder.AddProject("repl") + .WithReplicas(2) + .WithEnvironment("ASPIRE_RESOURCE_NAME", "repl") + .WithTerminal(options => + { + options.Columns = 120; + options.Rows = 32; + }); + +if (OperatingSystem.IsWindows()) +{ + // Single-replica executable wrapping cmd.exe to demonstrate that + // WithTerminal() also works for arbitrary executables, not just projects. + // DCP's PTY allocator currently only supports Windows, so this branch is + // gated behind an OS check; future Phase 3 follow-ups will extend it to + // Linux and macOS. + builder.AddExecutable("shell", "cmd.exe", ".") + .WithTerminal(); + + // A/B control resource: same cmd.exe but WITHOUT WithTerminal(). Used to + // bisect whether the "Stop kills the dashboard" symptom is specific to + // PTY-attached resources or applies to any DCP-managed Windows process. + // Without a PTY, cmd.exe with redirected stdin would exit immediately, so + // we wrap it in `/c ping -t 127.0.0.1` (continuous ping to localhost + // until killed) so the resource stays in the Running state long enough + // for "Stop" to be clicked from the dashboard. + builder.AddExecutable("shell2", "cmd.exe", ".", "/c", "ping -t 127.0.0.1 > NUL"); + + // Container resource exercising the container-side WithTerminal() path. + // Launches a long-running Node.js LTS image with an interactive bash so + // the terminal attaches to a shell where `npx`, `node`, and friends are + // available. We override the image CMD (not the entrypoint!) so the + // image's docker-entrypoint.sh keeps running and exec's bash for us; + // overriding the entrypoint instead would leave the image's CMD + // ("node") appended after bash and immediately exit. + builder.AddContainer("nodebox", "node", "lts") + .WithArgs("bash", "-l") + .WithTerminal(); +} + +#if !SKIP_DASHBOARD_REFERENCE +// This project is only added in playground projects to support development/debugging +// of the dashboard. It is not required in end developer code. Comment out this code +// or build with `/p:SkipDashboardReference=true`, to test end developer +// dashboard launch experience, Refer to Directory.Build.props for the path to +// the dashboard binary (defaults to the Aspire.Dashboard bin output in the +// artifacts dir). +builder.AddProject(KnownResourceNames.AspireDashboard); +#endif + +builder.Build().Run(); + diff --git a/playground/Terminals/Terminals.AppHost/Properties/launchSettings.json b/playground/Terminals/Terminals.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..7ef26265507 --- /dev/null +++ b/playground/Terminals/Terminals.AppHost/Properties/launchSettings.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:16180;http://localhost:17120", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17119", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17119", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:16181", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17120", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17120", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + } + } +} diff --git a/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj b/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj new file mode 100644 index 00000000000..2188093f6b6 --- /dev/null +++ b/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj @@ -0,0 +1,23 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + + + + + + + + + diff --git a/playground/Terminals/Terminals.AppHost/appsettings.Development.json b/playground/Terminals/Terminals.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/playground/Terminals/Terminals.AppHost/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/playground/Terminals/Terminals.AppHost/appsettings.json b/playground/Terminals/Terminals.AppHost/appsettings.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/Terminals/Terminals.AppHost/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/Terminals/Terminals.Repl/Program.cs b/playground/Terminals/Terminals.Repl/Program.cs new file mode 100644 index 00000000000..17a957cbd9c --- /dev/null +++ b/playground/Terminals/Terminals.Repl/Program.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; + +const string Reset = "\u001b[0m"; +const string Bold = "\u001b[1m"; +const string Cyan = "\u001b[36m"; +const string Green = "\u001b[32m"; +const string Yellow = "\u001b[33m"; +const string Magenta = "\u001b[35m"; + +var resourceName = Environment.GetEnvironmentVariable("ASPIRE_RESOURCE_NAME") ?? "repl"; +var processId = Environment.ProcessId.ToString(CultureInfo.InvariantCulture); + +PrintBanner(resourceName, processId); + +while (true) +{ + Console.Write($"{Bold}{Magenta}{resourceName}#{processId}{Reset}{Cyan}>{Reset} "); + var line = Console.ReadLine(); + if (line is null) + { + // PTY closed. + break; + } + + var trimmed = line.Trim(); + if (trimmed.Length == 0) + { + continue; + } + + var parts = trimmed.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var command = parts[0].ToLowerInvariant(); + var rest = parts.Length > 1 ? parts[1] : string.Empty; + + switch (command) + { + case "help" or "?": + PrintHelp(); + break; + case "exit" or "quit": + Console.WriteLine($"{Yellow}Goodbye from {resourceName} pid {processId}.{Reset}"); + return 0; + case "clear" or "cls": + // ANSI clear screen + cursor to home. + Console.Write("\u001b[2J\u001b[H"); + break; + case "time": + Console.WriteLine($"{Green}{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture)}{Reset}"); + break; + case "size": + Console.WriteLine($"{Green}{Console.WindowWidth} cols x {Console.WindowHeight} rows{Reset}"); + break; + case "rainbow": + PrintRainbow(rest.Length > 0 ? rest : "Hello from Aspire!"); + break; + case "echo": + Console.WriteLine(rest); + break; + case "whoami": + Console.WriteLine($"{Bold}{Cyan}{resourceName}{Reset} pid {Bold}{processId}{Reset}"); + break; + default: + Console.WriteLine($"{Yellow}Unknown command:{Reset} {trimmed}. Type {Bold}help{Reset} for a list."); + break; + } +} + +return 0; + +static void PrintBanner(string resourceName, string processId) +{ + Console.WriteLine(); + Console.WriteLine($"{Cyan}┌─────────────────────────────────────────────────────┐{Reset}"); + Console.WriteLine($"{Cyan}│{Reset} {Bold}Aspire WithTerminal demo REPL{Reset} {Cyan}│{Reset}"); + Console.WriteLine($"{Cyan}│{Reset} resource: {Bold}{Magenta}{resourceName,-20}{Reset} pid: {Bold}{processId,-7}{Reset} {Cyan}│{Reset}"); + Console.WriteLine($"{Cyan}└─────────────────────────────────────────────────────┘{Reset}"); + Console.WriteLine($"Type {Bold}help{Reset} to see available commands. Type {Bold}exit{Reset} to leave."); + Console.WriteLine(); +} + +static void PrintHelp() +{ + Console.WriteLine($"{Bold}Available commands:{Reset}"); + Console.WriteLine($" {Cyan}help{Reset} Show this help"); + Console.WriteLine($" {Cyan}whoami{Reset} Show resource name + process id"); + Console.WriteLine($" {Cyan}time{Reset} Show local time"); + Console.WriteLine($" {Cyan}size{Reset} Show terminal dimensions (resize the window!)"); + Console.WriteLine($" {Cyan}echo {Reset} Echo a line back"); + Console.WriteLine($" {Cyan}rainbow [text]{Reset} Print rainbow text"); + Console.WriteLine($" {Cyan}clear{Reset} Clear the screen"); + Console.WriteLine($" {Cyan}exit{Reset} Quit the REPL"); +} + +static void PrintRainbow(string text) +{ + string[] colors = + [ + "\u001b[31m", "\u001b[33m", "\u001b[32m", "\u001b[36m", "\u001b[34m", "\u001b[35m", + ]; + + var sb = new System.Text.StringBuilder(); + for (var i = 0; i < text.Length; i++) + { + sb.Append(colors[i % colors.Length]).Append(text[i]); + } + + sb.Append(Reset); + Console.WriteLine(sb.ToString()); +} diff --git a/playground/Terminals/Terminals.Repl/Terminals.Repl.csproj b/playground/Terminals/Terminals.Repl/Terminals.Repl.csproj new file mode 100644 index 00000000000..d826a2af06f --- /dev/null +++ b/playground/Terminals/Terminals.Repl/Terminals.Repl.csproj @@ -0,0 +1,11 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + Terminals.Repl + + + diff --git a/playground/Terminals/aspire.config.json b/playground/Terminals/aspire.config.json new file mode 100644 index 00000000000..f8d56eba651 --- /dev/null +++ b/playground/Terminals/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "Terminals.AppHost/Terminals.AppHost.csproj" + } +} \ No newline at end of file diff --git a/src/Aspire.AppHost.Sdk/SDK/Sdk.in.targets b/src/Aspire.AppHost.Sdk/SDK/Sdk.in.targets index edf319925cb..4a965836732 100644 --- a/src/Aspire.AppHost.Sdk/SDK/Sdk.in.targets +++ b/src/Aspire.AppHost.Sdk/SDK/Sdk.in.targets @@ -69,9 +69,10 @@ + a reference to Aspire.Dashboard.Sdk, Aspire.Hosting.Orchestration, and Aspire.TerminalHost.Sdk + for the build-time platform using the same version. This is done here dynamically to avoid having + to pull in DCP, Dashboard, and TerminalHost packages for all of the platforms. This mechanism can + be disabled by setting `SkipAddAspireDefaultReferences` to `true` --> + win-x64;win-arm64;linux-x64;linux-arm64;linux-musl-x64;osx-x64;osx-arm64 + enable + enable + Major + + $(NoWarn);CS1591;CS8002;CA2007 + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Aspire.TerminalHost/DcpUpstreamAdapter.cs b/src/Aspire.TerminalHost/DcpUpstreamAdapter.cs new file mode 100644 index 00000000000..0ac8ad0723b --- /dev/null +++ b/src/Aspire.TerminalHost/DcpUpstreamAdapter.cs @@ -0,0 +1,486 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Threading.Channels; +using Hex1b; +using Microsoft.Extensions.Logging; + +namespace Aspire.TerminalHost; + +/// +/// A minimal HMP v1 client workload adapter dedicated to talking to DCP's +/// single-peer HMP1 server. Plugged into Hex1bTerminal via +/// WithWorkload(adapter) in place of the multi-head-aware +/// Hex1b.Hmp1WorkloadAdapter. +/// +/// +/// +/// DCP runs a deliberately tiny HMP1 server (Go, +/// internal/hmp1/server.go) that only handles FrameInput (0x04) +/// and FrameResize (0x05). It never sends a multi-head Hello with a +/// PrimaryPeerId, never broadcasts RoleChange, and silently +/// ignores FrameRequestPrimary. As a consequence, Hex1b's stock +/// Hmp1WorkloadAdapter would observe IsPrimary == false for the +/// entire connection lifetime and silently no-op every ResizeAsync +/// call, leaving the upstream PTY stuck at the size DCP started it with — +/// regardless of how the dashboard or CLI consumer is sized. That broke +/// resize forwarding end-to-end (REPL resize always reported its +/// initial dims). +/// +/// +/// This adapter takes the opposite tradeoff: there is exactly one peer on +/// this connection (us, talking to DCP), so there is no role to negotiate. +/// writes a raw FrameResize upstream +/// unconditionally; the consumer-side multi-head server in the same +/// terminal host owns the consumer-facing role state. +/// +/// +/// Frame format (HMP v1): +/// [type:1B][length:4B LE][payload:N bytes], max payload 16 MiB. +/// Frames originating from the producer (DCP) that we consume: +/// FrameHello (0x01) — JSON producer-info, payload kept opaque; +/// FrameStateSync (0x02) and FrameOutput (0x03) — both raw +/// terminal output bytes, fed into the output channel verbatim; +/// FrameExit (0x06) — workload exit signal, optionally carrying a +/// little-endian int32 exit code. Anything else is logged and ignored. +/// +/// +internal sealed class DcpUpstreamAdapter : IHex1bTerminalWorkloadAdapter +{ + private const byte FrameHello = 0x01; + private const byte FrameStateSync = 0x02; + private const byte FrameOutput = 0x03; + private const byte FrameInput = 0x04; + private const byte FrameResize = 0x05; + private const byte FrameExit = 0x06; + private const int MaxPayloadLength = 16 * 1024 * 1024; + private const int FrameHeaderLength = 5; + + private readonly Func> _streamFactory; + private readonly ILogger _logger; + private readonly Channel> _outputChannel; + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly SemaphoreSlim _connectLock = new(1, 1); + private readonly CancellationTokenSource _disposeCts = new(); + private readonly TaskCompletionSource _connectedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly object _pendingResizeGate = new(); + + private Stream? _stream; + private Task? _readPump; + private int _completed; + private int _pendingResizeWidth; + private int _pendingResizeHeight; + private volatile bool _disposed; + + /// + public event Action? Disconnected; + + public DcpUpstreamAdapter( + Func> streamFactory, + ILogger logger) + { + _streamFactory = streamFactory ?? throw new ArgumentNullException(nameof(streamFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _outputChannel = Channel.CreateBounded>( + new BoundedChannelOptions(1000) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = true, + }); + } + + /// + public async ValueTask> ReadOutputAsync(CancellationToken ct = default) + { + if (_disposed) + { + return ReadOnlyMemory.Empty; + } + + try + { + await EnsureConnectedAsync(ct).ConfigureAwait(false); + } + catch + { + return ReadOnlyMemory.Empty; + } + + try + { + if (!await _outputChannel.Reader.WaitToReadAsync(ct).ConfigureAwait(false)) + { + return ReadOnlyMemory.Empty; + } + if (!_outputChannel.Reader.TryRead(out var first)) + { + return ReadOnlyMemory.Empty; + } + return first; + } + catch (ChannelClosedException) + { + return ReadOnlyMemory.Empty; + } + } + + /// + public async ValueTask WriteInputAsync(ReadOnlyMemory data, CancellationToken ct = default) + { + if (data.IsEmpty || _disposed) + { + return; + } + + if (!_connectedTcs.Task.IsCompletedSuccessfully) + { + try + { + await _connectedTcs.Task.WaitAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + } + + await WriteFrameAsync(FrameInput, data, ct).ConfigureAwait(false); + } + + /// + public async ValueTask ResizeAsync(int width, int height, CancellationToken ct = default) + { + if (_disposed) + { + return; + } + if (width <= 0 || height <= 0) + { + _logger.LogDebug( + "DcpUpstreamAdapter: ignoring invalid resize ({Width}x{Height}).", + width, height); + return; + } + + // Coalesce: if we haven't accepted DCP's dial yet, stash the latest dims and let + // EnsureConnectedAsync apply them once the upstream stream is live. Avoids + // blocking the consumer-side OnResized callback indefinitely waiting for the + // upstream to dial in (which can take an arbitrary amount of time during + // recycle / DCP restart). + if (!_connectedTcs.Task.IsCompletedSuccessfully) + { + lock (_pendingResizeGate) + { + _pendingResizeWidth = width; + _pendingResizeHeight = height; + } + return; + } + + var payload = new byte[8]; + BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(0, 4), width); + BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(4, 4), height); + await WriteFrameAsync(FrameResize, payload, ct).ConfigureAwait(false); + } + + private async Task EnsureConnectedAsync(CancellationToken ct) + { + if (_connectedTcs.Task.IsCompletedSuccessfully) + { + return; + } + + await _connectLock.WaitAsync(ct).ConfigureAwait(false); + try + { + if (_connectedTcs.Task.IsCompletedSuccessfully) + { + return; + } + if (_disposed) + { + _connectedTcs.TrySetCanceled(_disposeCts.Token); + throw new ObjectDisposedException(nameof(DcpUpstreamAdapter)); + } + + // Always pass disposal CT to the factory so the listener can be torn + // down even when the adapter consumer's per-call CT has not fired. + // Otherwise a long-lived listen could outlive the surrounding terminal. + Stream stream; + try + { + stream = await _streamFactory(_disposeCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_disposeCts.IsCancellationRequested) + { + _connectedTcs.TrySetCanceled(_disposeCts.Token); + Complete(); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "DcpUpstreamAdapter: streamFactory failed."); + _connectedTcs.TrySetException(ex); + Complete(ex); + throw; + } + + _stream = stream; + // Use the disposal CT for the pump's lifetime; the per-call CT (`ct`) here + // is just for waiting on the connect lock and the streamFactory call. + // The pump must outlive any single caller's token. + _readPump = Task.Run(() => ReadPumpAsync(_disposeCts.Token), _disposeCts.Token); + _connectedTcs.TrySetResult(); + + // Apply any pending resize that arrived before we connected. Fire-and-forget + // because EnsureConnectedAsync may itself be on the read path; we don't want + // to make our caller wait on a write. + int pendingW, pendingH; + lock (_pendingResizeGate) + { + pendingW = _pendingResizeWidth; + pendingH = _pendingResizeHeight; + _pendingResizeWidth = 0; + _pendingResizeHeight = 0; + } + if (pendingW > 0 && pendingH > 0) + { + _ = ApplyPendingResizeAsync(pendingW, pendingH); + } + } + finally + { + _connectLock.Release(); + } + } + + private async Task ApplyPendingResizeAsync(int width, int height) + { + try + { + var payload = new byte[8]; + BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(0, 4), width); + BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(4, 4), height); + await WriteFrameAsync(FrameResize, payload, _disposeCts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, + "DcpUpstreamAdapter: applying coalesced post-connect resize ({Width}x{Height}) failed.", + width, height); + } + } + + private async Task WriteFrameAsync(byte type, ReadOnlyMemory payload, CancellationToken ct) + { + if (payload.Length > MaxPayloadLength) + { + throw new ArgumentException( + $"Payload length {payload.Length} exceeds maximum {MaxPayloadLength}.", + nameof(payload)); + } + + await _writeLock.WaitAsync(ct).ConfigureAwait(false); + try + { + var stream = _stream; + if (stream is null || _disposed) + { + return; + } + + var header = new byte[FrameHeaderLength]; + header[0] = type; + BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(1, 4), payload.Length); + + // Once the writeLock is held, the FULL frame must be emitted atomically. + // Honoring the caller's per-call CancellationToken between header and + // payload writes would corrupt the upstream stream — DCP would parse the + // next bytes as the previous frame's payload. Use the disposal CT only. + try + { + await stream.WriteAsync(header, _disposeCts.Token).ConfigureAwait(false); + if (payload.Length > 0) + { + await stream.WriteAsync(payload, _disposeCts.Token).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is IOException + or ObjectDisposedException + or OperationCanceledException) + { + _logger.LogDebug(ex, + "DcpUpstreamAdapter: upstream write failed (type=0x{Type:X2}); marking disconnected.", + type); + Complete(ex); + } + } + finally + { + _writeLock.Release(); + } + } + + private async Task ReadPumpAsync(CancellationToken ct) + { + try + { + var header = new byte[FrameHeaderLength]; + while (!ct.IsCancellationRequested) + { + var stream = _stream; + if (stream is null) + { + break; + } + + if (!await ReadExactAsync(stream, header, ct).ConfigureAwait(false)) + { + break; + } + + var type = header[0]; + var length = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(1, 4)); + if (length < 0 || length > MaxPayloadLength) + { + _logger.LogError( + "DcpUpstreamAdapter: malformed frame length {Length} (type 0x{Type:X2}); aborting.", + length, type); + break; + } + + var payload = length == 0 ? Array.Empty() : new byte[length]; + if (length > 0 && !await ReadExactAsync(stream, payload, ct).ConfigureAwait(false)) + { + break; + } + + switch (type) + { + case FrameOutput: + case FrameStateSync: + if (payload.Length > 0) + { + try + { + await _outputChannel.Writer.WriteAsync(payload, ct).ConfigureAwait(false); + } + catch (ChannelClosedException) + { + return; + } + } + break; + case FrameHello: + // Producer info; opaque to us. + break; + case FrameExit: + if (payload.Length >= 4) + { + var exitCode = BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(0, 4)); + _logger.LogDebug( + "DcpUpstreamAdapter: producer reported exit code {ExitCode}.", exitCode); + } + return; + default: + _logger.LogDebug( + "DcpUpstreamAdapter: ignoring unexpected frame type 0x{Type:X2} (length {Length}).", + type, length); + break; + } + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Disposal-driven shutdown. + } + catch (Exception ex) + { + _logger.LogDebug(ex, "DcpUpstreamAdapter: read pump terminated unexpectedly."); + } + finally + { + Complete(); + } + } + + private static async Task ReadExactAsync(Stream stream, byte[] buffer, CancellationToken ct) + { + var read = 0; + while (read < buffer.Length) + { + var n = await stream.ReadAsync(buffer.AsMemory(read), ct).ConfigureAwait(false); + if (n == 0) + { + return false; + } + read += n; + } + return true; + } + + private void Complete(Exception? error = null) + { + if (Interlocked.Exchange(ref _completed, 1) != 0) + { + return; + } + _outputChannel.Writer.TryComplete(error); + try + { + Disconnected?.Invoke(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "DcpUpstreamAdapter: Disconnected handler threw (ignored)."); + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + _disposed = true; + + try + { + await _disposeCts.CancelAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // Already cancelled. + } + + if (_readPump is { } pump) + { + try + { + await pump.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + } + catch + { + // Best-effort wait. The CTS cancellation should unwind the pump shortly; + // if it doesn't, we still proceed with disposal so we don't hang the host. + } + } + + try + { + _stream?.Dispose(); + } + catch + { + // Already in dispose path; ignore. + } + + Complete(); + + _disposeCts.Dispose(); + _writeLock.Dispose(); + _connectLock.Dispose(); + } +} diff --git a/src/Aspire.TerminalHost/Program.cs b/src/Aspire.TerminalHost/Program.cs new file mode 100644 index 00000000000..574703b0f35 --- /dev/null +++ b/src/Aspire.TerminalHost/Program.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.TerminalHost; + +using var cts = new CancellationTokenSource(); + +Console.CancelKeyPress += (_, e) => +{ + e.Cancel = true; + cts.Cancel(); +}; + +return await TerminalHostApp.RunAsync(args, cts.Token).ConfigureAwait(false); diff --git a/src/Aspire.TerminalHost/StderrLoggerProvider.cs b/src/Aspire.TerminalHost/StderrLoggerProvider.cs new file mode 100644 index 00000000000..85cf990203a --- /dev/null +++ b/src/Aspire.TerminalHost/StderrLoggerProvider.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Aspire.TerminalHost; + +/// +/// Minimal logger provider that writes a single line per record to stderr. +/// Used by the terminal host so it doesn't pull in +/// Microsoft.Extensions.Logging.Console (which is not centrally managed in this repo). +/// +internal sealed class StderrLoggerProvider : ILoggerProvider +{ + public ILogger CreateLogger(string categoryName) => new StderrLogger(categoryName); + + public void Dispose() + { + } + + private sealed class StderrLogger(string category) : ILogger + { + IDisposable? ILogger.BeginScope(TState state) => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var ts = DateTimeOffset.UtcNow.ToString("HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture); + var msg = formatter(state, exception); + var line = $"{ts} [{logLevel,-11}] {category}: {msg}"; + + try + { + Console.Error.WriteLine(line); + if (exception is not null) + { + Console.Error.WriteLine(exception); + } + } + catch + { + // stderr unavailable — give up silently rather than tearing down the host. + } + } + } +} diff --git a/src/Aspire.TerminalHost/TerminalHostApp.cs b/src/Aspire.TerminalHost/TerminalHostApp.cs new file mode 100644 index 00000000000..2d2ae7a6233 --- /dev/null +++ b/src/Aspire.TerminalHost/TerminalHostApp.cs @@ -0,0 +1,252 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Shared.TerminalHost; +using Microsoft.Extensions.Logging; + +namespace Aspire.TerminalHost; + +/// +/// In-process entry point for the Aspire terminal host. Owns the single +/// per-replica relay terminal, the control listener, and the lifecycle/shutdown +/// handshake. +/// +/// Each aspire.terminalhost process serves exactly one replica. Replica +/// fan-out happens at the AppHost level: a target resource with N replicas +/// causes N independent terminal host processes to be spawned, each with its +/// own producer/consumer/control UDS triple. The host has no notion of its +/// global replica index — that's encoded in the UDS paths and is opaque here. +/// +/// Exposed as a class so tests can drive the host without spawning a process. +/// +public sealed class TerminalHostApp : IAsyncDisposable +{ + private readonly TerminalHostArgs _args; + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + private readonly CancellationTokenSource _shutdownCts = new(); + private readonly object _gate = new(); + private TerminalReplica? _replica; + private TerminalHostControlListener? _controlListener; + private bool _disposed; + + internal TerminalHostApp(TerminalHostArgs args, ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(loggerFactory); + + _args = args; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); + } + + /// + /// Snapshot of the host's single replica session, suitable for marshalling + /// to the AppHost via the control protocol. + /// + internal TerminalHostSessionInfo SnapshotSession() + { + TerminalReplica? replica; + lock (_gate) + { + replica = _replica; + } + + if (replica is null) + { + // Pre-start (replica not yet created). Report a placeholder consistent with + // "no producer connected" so callers can still read configured paths. + return new TerminalHostSessionInfo + { + ProducerUdsPath = _args.ProducerUdsPath, + ConsumerUdsPath = _args.ConsumerUdsPath, + IsAlive = false, + ExitCode = null, + ProducerConnected = false, + RestartCount = 0, + CurrentColumns = _args.Columns, + CurrentRows = _args.Rows, + AttachedPeerCount = 0, + Peers = Array.Empty(), + }; + } + + return new TerminalHostSessionInfo + { + ProducerUdsPath = replica.ProducerUdsPath, + ConsumerUdsPath = replica.ConsumerUdsPath, + IsAlive = replica.IsAlive, + ExitCode = replica.ExitCode, + ProducerConnected = replica.ProducerConnected, + RestartCount = replica.RestartCount, + CurrentColumns = replica.CurrentColumns, + CurrentRows = replica.CurrentRows, + AttachedPeerCount = replica.AttachedPeerCount, + Peers = replica.SnapshotPeers(), + }; + } + + /// + /// Starts the replica relay and the control listener, then waits for either + /// the external cancellation token or a shutdown request to fire. Returns the + /// process exit code. + /// + public async Task RunAsync(CancellationToken cancellationToken) + { + if (_args.Shell is { } shell) + { + _logger.LogInformation( + "Aspire terminal host starting: shell hint='{Shell}', size={Cols}x{Rows}, producer='{Producer}', consumer='{Consumer}'.", + shell, _args.Columns, _args.Rows, _args.ProducerUdsPath, _args.ConsumerUdsPath); + } + else + { + _logger.LogInformation( + "Aspire terminal host starting: size={Cols}x{Rows}, producer='{Producer}', consumer='{Consumer}'.", + _args.Columns, _args.Rows, _args.ProducerUdsPath, _args.ConsumerUdsPath); + } + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, _shutdownCts.Token); + var token = linkedCts.Token; + + try + { + // Start the replica first, then the control listener; that way as soon as + // the AppHost can connect to control, the consumer UDS is bound. + var replicaLogger = _loggerFactory.CreateLogger("Aspire.TerminalHost.Replica"); + var replica = TerminalReplica.Start( + _args.ProducerUdsPath, + _args.ConsumerUdsPath, + _args.Columns, + _args.Rows, + replicaLogger, + token); + lock (_gate) + { + _replica = replica; + } + + _controlListener = new TerminalHostControlListener( + _args.ControlUdsPath, + new TerminalHostControlRpcTarget(this), + _loggerFactory.CreateLogger()); + await _controlListener.StartAsync().ConfigureAwait(false); + + _logger.LogInformation("Terminal host ready."); + + await WaitForShutdownAsync(token).ConfigureAwait(false); + return 0; + } + catch (OperationCanceledException) + { + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Terminal host failed."); + return 1; + } + finally + { + await TearDownAsync().ConfigureAwait(false); + } + } + + private static async Task WaitForShutdownAsync(CancellationToken cancellationToken) + { + // Wait for external cancellation or an explicit shutdown request via the + // control protocol. We do not auto-exit when the replica's producer disconnects: + // that is recoverable in normal operation (DCP may relaunch the upstream PTY), + // and DCP is responsible for tearing down the host when the resource is fully + // stopped. + try + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + /// + /// Signals a graceful shutdown. Returns immediately; + /// will exit shortly after. + /// + public void RequestShutdown() + { + if (!_shutdownCts.IsCancellationRequested) + { + _logger.LogInformation("Shutdown requested."); + _shutdownCts.Cancel(); + } + } + + private async Task TearDownAsync() + { + if (_controlListener is not null) + { + await _controlListener.DisposeAsync().ConfigureAwait(false); + _controlListener = null; + } + + TerminalReplica? toDispose; + lock (_gate) + { + toDispose = _replica; + _replica = null; + } + if (toDispose is not null) + { + try + { + await toDispose.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error while disposing replica."); + } + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + RequestShutdown(); + await TearDownAsync().ConfigureAwait(false); + _shutdownCts.Dispose(); + } + + /// + /// Convenience entry point used by both Program.Main and tests. + /// Catches argument-parsing errors and writes a friendly message to stderr. + /// + public static async Task RunAsync(string[] args, CancellationToken cancellationToken) + { + TerminalHostArgs parsed; + try + { + parsed = TerminalHostArgs.Parse(args); + } + catch (TerminalHostArgsException ex) + { + await Console.Error.WriteLineAsync($"[Aspire.TerminalHost] {ex.Message}") + .ConfigureAwait(false); + return 64; // EX_USAGE + } + + using var loggerFactory = LoggerFactory.Create(builder => + { + builder.AddProvider(new StderrLoggerProvider()); + builder.SetMinimumLevel(LogLevel.Information); + }); + + await using var app = new TerminalHostApp(parsed, loggerFactory); + return await app.RunAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Aspire.TerminalHost/TerminalHostArgs.cs b/src/Aspire.TerminalHost/TerminalHostArgs.cs new file mode 100644 index 00000000000..642c09b865b --- /dev/null +++ b/src/Aspire.TerminalHost/TerminalHostArgs.cs @@ -0,0 +1,160 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; + +namespace Aspire.TerminalHost; + +/// +/// Parsed command-line arguments for the Aspire terminal host. +/// +/// +/// +/// Each aspire.terminalhost process serves exactly one replica's +/// terminal session. The "which replica is this?" question is intentionally opaque to the +/// host: the AppHost encodes the replica identity in the UDS paths it passes in (typically +/// as a per-replica directory like {base}/{i}/dcp.sock) and the host just listens +/// on whatever paths it's told. If a target resource has N replicas, the AppHost +/// spawns N independent terminal host processes, each with its own +/// producer/consumer/control UDS triple. +/// +/// +/// Connection direction note: on the producer side the terminal host listens +/// and DCP dials. On the consumer side the terminal host listens +/// and viewers (Dashboard, CLI) dial. Same shape on both ends. +/// +/// +internal sealed class TerminalHostArgs +{ + public required string ProducerUdsPath { get; init; } + public required string ConsumerUdsPath { get; init; } + public required string ControlUdsPath { get; init; } + public int Columns { get; init; } = 120; + public int Rows { get; init; } = 30; + + /// + /// Optional shell name. Informational only (the host does not spawn a PTY itself — + /// that is DCP's responsibility); included so the host can log it on startup. + /// + public string? Shell { get; init; } + + /// + /// Parses command-line arguments. The argument shape is: + /// + /// --producer-uds PATH (required) — path the host LISTENS on; DCP dials. + /// --consumer-uds PATH (required) — path the host LISTENS on; viewers dial. + /// --control-uds PATH (required) — path the host LISTENS on; AppHost dials for status/shutdown RPC. + /// --columns N (optional, default 120) + /// --rows N (optional, default 30) + /// --shell NAME (optional, informational) + /// + /// Throws with a human-readable message on any + /// parse error so the host can write a friendly message to stderr. + /// + public static TerminalHostArgs Parse(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + + string? producer = null; + string? consumer = null; + string? control = null; + int columns = 120; + int rows = 30; + string? shell = null; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + switch (arg) + { + case "--producer-uds": + if (producer is not null) + { + throw new TerminalHostArgsException( + "--producer-uds may only be specified once. Each terminal host serves exactly one replica."); + } + producer = ParseString(args, ref i, "--producer-uds"); + break; + case "--consumer-uds": + if (consumer is not null) + { + throw new TerminalHostArgsException( + "--consumer-uds may only be specified once. Each terminal host serves exactly one replica."); + } + consumer = ParseString(args, ref i, "--consumer-uds"); + break; + case "--control-uds": + control = ParseString(args, ref i, "--control-uds"); + break; + case "--columns": + columns = ParseInt(args, ref i, "--columns"); + break; + case "--rows": + rows = ParseInt(args, ref i, "--rows"); + break; + case "--shell": + shell = ParseString(args, ref i, "--shell"); + break; + default: + throw new TerminalHostArgsException($"Unknown argument: '{arg}'."); + } + } + + if (string.IsNullOrEmpty(producer)) + { + throw new TerminalHostArgsException("Missing required argument: --producer-uds."); + } + + if (string.IsNullOrEmpty(consumer)) + { + throw new TerminalHostArgsException("Missing required argument: --consumer-uds."); + } + + if (string.IsNullOrEmpty(control)) + { + throw new TerminalHostArgsException("Missing required argument: --control-uds."); + } + + if (columns < 1 || rows < 1) + { + throw new TerminalHostArgsException( + $"--columns and --rows must be >= 1 (got {columns}x{rows})."); + } + + return new TerminalHostArgs + { + ProducerUdsPath = producer, + ConsumerUdsPath = consumer, + ControlUdsPath = control, + Columns = columns, + Rows = rows, + Shell = shell, + }; + } + + private static string ParseString(string[] args, ref int i, string name) + { + if (i + 1 >= args.Length) + { + throw new TerminalHostArgsException($"Missing value for argument '{name}'."); + } + + return args[++i]; + } + + private static int ParseInt(string[] args, ref int i, string name) + { + var raw = ParseString(args, ref i, name); + if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + { + throw new TerminalHostArgsException($"Argument '{name}' expects an integer (got '{raw}')."); + } + + return value; + } +} + +/// +/// Thrown when the terminal host receives malformed command-line arguments. +/// +internal sealed class TerminalHostArgsException(string message) : Exception(message); diff --git a/src/Aspire.TerminalHost/TerminalHostControlListener.cs b/src/Aspire.TerminalHost/TerminalHostControlListener.cs new file mode 100644 index 00000000000..1ec5816ec29 --- /dev/null +++ b/src/Aspire.TerminalHost/TerminalHostControlListener.cs @@ -0,0 +1,215 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using Aspire.Shared.TerminalHost; +using Microsoft.Extensions.Logging; +using StreamJsonRpc; + +namespace Aspire.TerminalHost; + +/// +/// Listens on the control UDS and serves a +/// over StreamJsonRpc to each connecting client (typically the Aspire AppHost). +/// +internal sealed class TerminalHostControlListener : IAsyncDisposable +{ + private readonly string _socketPath; + private readonly TerminalHostControlRpcTarget _target; + private readonly ILogger _logger; + private Socket? _socket; + private Task? _acceptLoop; + private readonly CancellationTokenSource _disposeCts = new(); + private readonly List _activeRpcs = new(); + private readonly object _gate = new(); + private bool _disposed; + + public TerminalHostControlListener( + string socketPath, + TerminalHostControlRpcTarget target, + ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(socketPath); + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(logger); + + _socketPath = socketPath; + _target = target; + _logger = logger; + } + + /// + /// Binds the UDS and starts the background accept loop. + /// + public Task StartAsync() + { + var dir = Path.GetDirectoryName(_socketPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + if (File.Exists(_socketPath)) + { + try + { + File.Delete(_socketPath); + } + catch (IOException) + { + // Best effort — fall through and let Bind report the error. + } + } + + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + socket.Bind(new UnixDomainSocketEndPoint(_socketPath)); + socket.Listen(backlog: 5); + _socket = socket; + + _logger.LogInformation("Control listener bound to '{Path}'.", _socketPath); + + _acceptLoop = Task.Run(() => AcceptLoopAsync(_disposeCts.Token)); + return Task.CompletedTask; + } + + private async Task AcceptLoopAsync(CancellationToken cancellationToken) + { + if (_socket is null) + { + return; + } + + while (!cancellationToken.IsCancellationRequested) + { + Socket client; + try + { + client = await _socket.AcceptAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException ex) + { + _logger.LogDebug(ex, "Control listener accept failed; stopping."); + return; + } + + _ = Task.Run(() => ServeClientAsync(client, cancellationToken), cancellationToken); + } + } + + private async Task ServeClientAsync(Socket client, CancellationToken cancellationToken) + { + await using var stream = new NetworkStream(client, ownsSocket: true); + + var formatter = new SystemTextJsonFormatter(); + var handler = new HeaderDelimitedMessageHandler(stream, stream, formatter); + + var rpc = new JsonRpc(handler); + rpc.AddLocalRpcMethod( + TerminalHostControlProtocol.GetSessionMethod, + _target.GetType().GetMethod(nameof(TerminalHostControlRpcTarget.GetSessionAsync))!, + _target); + rpc.AddLocalRpcMethod( + TerminalHostControlProtocol.GetInfoMethod, + _target.GetType().GetMethod(nameof(TerminalHostControlRpcTarget.GetInfoAsync))!, + _target); + rpc.AddLocalRpcMethod( + TerminalHostControlProtocol.ShutdownMethod, + _target.GetType().GetMethod(nameof(TerminalHostControlRpcTarget.ShutdownAsync))!, + _target); + + lock (_gate) + { + if (_disposed) + { + rpc.Dispose(); + return; + } + _activeRpcs.Add(rpc); + } + + try + { + rpc.StartListening(); + await rpc.Completion.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Control RPC connection ended with an error."); + } + finally + { + lock (_gate) + { + _activeRpcs.Remove(rpc); + } + rpc.Dispose(); + } + } + + public async ValueTask DisposeAsync() + { + lock (_gate) + { + if (_disposed) + { + return; + } + _disposed = true; + } + + await _disposeCts.CancelAsync().ConfigureAwait(false); + + try + { + _socket?.Dispose(); + } + catch + { + // Best effort. + } + + if (_acceptLoop is not null) + { + try + { + await _acceptLoop.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + List rpcs; + lock (_gate) + { + rpcs = [.. _activeRpcs]; + _activeRpcs.Clear(); + } + foreach (var rpc in rpcs) + { + rpc.Dispose(); + } + + _disposeCts.Dispose(); + + try + { + File.Delete(_socketPath); + } + catch + { + // Best effort. + } + } +} diff --git a/src/Aspire.TerminalHost/TerminalHostControlRpcTarget.cs b/src/Aspire.TerminalHost/TerminalHostControlRpcTarget.cs new file mode 100644 index 00000000000..67047a55066 --- /dev/null +++ b/src/Aspire.TerminalHost/TerminalHostControlRpcTarget.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Shared.TerminalHost; + +namespace Aspire.TerminalHost; + +/// +/// StreamJsonRpc target exposed over the terminal host's control UDS. Handles +/// status queries and shutdown requests from the AppHost. +/// +/// +/// Each terminal host process serves a single replica, so this target exposes a +/// single-session view rather than a list. The AppHost iterates per-replica hosts +/// and aggregates their responses to build cross-resource state. +/// +internal sealed class TerminalHostControlRpcTarget +{ + private readonly TerminalHostApp _app; + + public TerminalHostControlRpcTarget(TerminalHostApp app) + { + _app = app; + } + + /// + /// Returns the host's single replica session and its current liveness state. + /// + public Task GetSessionAsync(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + return Task.FromResult(_app.SnapshotSession()); + } + + /// + /// Returns the host's protocol version. Useful as a fast liveness probe and to + /// negotiate future protocol upgrades. + /// + /// + /// Kept as an instance method (not static) so that StreamJsonRpc's + /// AddLocalRpcTarget(this) enumeration discovers it alongside the other + /// session-bound methods. CA1822 is suppressed for this reason. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", + Justification = "Must remain an instance method so StreamJsonRpc.AddLocalRpcTarget(this) registers it as an RPC method.")] + public Task GetInfoAsync(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + return Task.FromResult(new TerminalHostInfoResponse + { + ProtocolVersion = TerminalHostControlProtocol.ProtocolVersion, + }); + } + + /// + /// Requests a clean shutdown of the terminal host. The host will tear down + /// its replica relay and exit shortly after this call returns. + /// + public Task ShutdownAsync(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + _app.RequestShutdown(); + return Task.CompletedTask; + } +} diff --git a/src/Aspire.TerminalHost/TerminalReplica.cs b/src/Aspire.TerminalHost/TerminalReplica.cs new file mode 100644 index 00000000000..631a2a324d1 --- /dev/null +++ b/src/Aspire.TerminalHost/TerminalReplica.cs @@ -0,0 +1,478 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Shared.TerminalHost; +using Hex1b; +using Microsoft.Extensions.Logging; + +namespace Aspire.TerminalHost; + +/// +/// The single replica relay session inside a terminal host process. Each +/// aspire.terminalhost process owns exactly one +/// — replica fan-out happens at the process level, not inside the host. The +/// replica's identity (which parent-resource replica it serves) is encoded in +/// the UDS paths and is opaque to the host. +/// +/// The replica owns a recycle loop that builds successive +/// instances over the lifetime of the host process. +/// +/// Each iteration of the loop: +/// +/// Builds a fresh Hex1bTerminal that LISTENS on the producer UDS and +/// serves on the consumer UDS. +/// Waits for DCP (the upstream PTY owner) to DIAL the producer UDS. +/// When that connection is accepted, +/// flips to true. +/// Forwards bytes between producer and any number of viewers (Dashboard, +/// CLI) via Hex1b's HMP v1 multiplexing. +/// When the producer disconnects (process exit, transport error, etc.) +/// the inner +/// returns. The terminal is disposed (which releases the UDS bindings +/// and tears down any attached viewer sessions), +/// is updated, is incremented, and the loop +/// iterates to bind the same UDS paths again — ready for DCP to relaunch +/// the underlying process and dial back in. +/// +/// +/// Connection direction note: the producer side has the terminal host LISTENING +/// and DCP DIALING, not the other way around. This guarantees the host is +/// receiving from the very first byte the PTY emits, and also lets the host +/// recycle without DCP having to re-coordinate; DCP just dials the same path +/// again and Hex1b's existing connect-retry semantics on the producer side +/// take care of the brief unbound window during a recycle. +/// +internal sealed class TerminalReplica : IAsyncDisposable +{ + private readonly ILogger _logger; + private readonly Task _runTask; + private readonly CancellationTokenSource _stopCts; + private readonly object _gate = new(); + private readonly Dictionary _peers = new(StringComparer.Ordinal); + private Hex1bTerminal? _currentTerminal; + private bool _producerConnected; + private int? _lastExitCode; + private int _restartCount; + private int _currentColumns; + private int _currentRows; + private bool _disposed; + + public string ProducerUdsPath { get; } + public string ConsumerUdsPath { get; } + public int Columns { get; } + public int Rows { get; } + + /// + /// True while the current Hex1bTerminal has an attached producer (DCP has + /// dialed in and the upstream PTY is delivering bytes). False between + /// recycles, before the first producer ever connects, or after the + /// replica has been torn down. + /// + public bool ProducerConnected + { + get { lock (_gate) { return _producerConnected; } } + } + + /// + /// Exit code from the most recently-completed Hex1bTerminal cycle, or + /// null if no cycle has completed yet. Updated each time the + /// producer disconnects. + /// + public int? LastExitCode + { + get { lock (_gate) { return _lastExitCode; } } + } + + /// + /// Number of completed Hex1bTerminal cycles (i.e. number of times the + /// producer has connected and then disconnected). Useful as a diagnostic + /// signal for "has this resource restarted unexpectedly?". + /// + public int RestartCount + { + get { lock (_gate) { return _restartCount; } } + } + + /// + /// Backwards-compatible alias for callers that historically asked + /// "is this replica running?". Today that question really means "is the + /// upstream producer currently attached?", which is what we report. + /// The replica object itself outlives any single Hex1bTerminal cycle. + /// + public bool IsAlive => ProducerConnected; + + /// + /// Backwards-compatible alias for . + /// + public int? ExitCode => LastExitCode; + + /// + /// Current terminal grid width in columns, as last negotiated by the active HMP1 + /// primary peer (via OnResized). Initialized from the AppHost-configured + /// width and updated on every downstream resize. + /// + public int CurrentColumns + { + get { lock (_gate) { return _currentColumns; } } + } + + /// + /// Current terminal grid height in rows. See . + /// + public int CurrentRows + { + get { lock (_gate) { return _currentRows; } } + } + + /// + /// Number of HMP1 viewer peers currently attached to the consumer UDS. + /// Maintained from OnClientConnected / OnClientDisconnected callbacks. + /// Zero between cycles or before the first peer connects. + /// + public int AttachedPeerCount + { + get { lock (_gate) { return _peers.Count; } } + } + + /// + /// Snapshot of currently-attached HMP1 viewer peers, in dictionary order. + /// + public TerminalHostPeerInfo[] SnapshotPeers() + { + lock (_gate) + { + if (_peers.Count == 0) + { + return Array.Empty(); + } + var snap = new TerminalHostPeerInfo[_peers.Count]; + var i = 0; + foreach (var peer in _peers.Values) + { + snap[i++] = peer; + } + return snap; + } + } + + /// + /// Task that completes when the replica's recycle loop exits (i.e. when + /// the host is shutting down or the replica is being disposed). + /// + public Task RunTask => _runTask; + + private TerminalReplica( + string producerUdsPath, + string consumerUdsPath, + int columns, + int rows, + ILogger logger, + CancellationToken cancellationToken) + { + ProducerUdsPath = producerUdsPath; + ConsumerUdsPath = consumerUdsPath; + Columns = columns; + Rows = rows; + _currentColumns = columns; + _currentRows = rows; + _logger = logger; + _stopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _runTask = Task.Run(() => RecycleLoopAsync(_stopCts.Token), _stopCts.Token); + } + + /// + /// Builds the relay terminal and starts its recycle loop. The loop runs + /// in the background and only exits on cancellation or dispose. + /// + public static TerminalReplica Start( + string producerUdsPath, + string consumerUdsPath, + int columns, + int rows, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(producerUdsPath); + ArgumentException.ThrowIfNullOrWhiteSpace(consumerUdsPath); + ArgumentNullException.ThrowIfNull(logger); + + logger.LogInformation( + "Starting replica: producer='{Producer}', consumer='{Consumer}'", + producerUdsPath, consumerUdsPath); + + return new TerminalReplica( + producerUdsPath, consumerUdsPath, columns, rows, logger, cancellationToken); + } + + /// + /// Top-level loop. One iteration = one Hex1bTerminal lifetime. The loop + /// only exits on cancellation; producer disconnects are an expected + /// recoverable transition that triggers an immediate rebind. + /// + private async Task RecycleLoopAsync(CancellationToken ct) + { + var consecutiveFailures = 0; + while (!ct.IsCancellationRequested) + { + Hex1bTerminal? terminal = null; + int exitCode; + var failed = false; + + try + { + try + { + terminal = BuildTerminal(); + } + catch (Exception ex) + { + // Building the Hex1bTerminal can fail for transient reasons + // (UDS path temporarily unwritable, transient I/O during + // disposal of the previous instance, etc.). Treat as a + // failed cycle and let the backoff handle it instead of + // letting the exception kill the recycle loop. + _logger.LogError(ex, "Replica BuildTerminal threw."); + exitCode = -1; + failed = true; + goto AfterRun; + } + + lock (_gate) + { + _currentTerminal = terminal; + _producerConnected = false; + } + + try + { + exitCode = await terminal.RunAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Host is shutting down. Exit the loop without recording a cycle. + return; + } + catch (Exception ex) + { + _logger.LogError(ex, "Replica cycle threw."); + exitCode = -1; + failed = true; + } + + AfterRun:; + } + finally + { + if (terminal is not null) + { + try + { + await terminal.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Replica terminal dispose threw (ignored)."); + } + } + + lock (_gate) + { + _currentTerminal = null; + _producerConnected = false; + // Hex1b's HMP1 server fires OnClientDisconnected for every peer when the + // terminal is disposed, so _peers should already be empty here. Clear + // defensively to avoid carrying stale entries into the next cycle if any + // disconnect callback raced past dispose. + _peers.Clear(); + } + } + + // We got here because RunAsync returned (producer disconnected or + // an error occurred), not because we were cancelled. + lock (_gate) + { + _lastExitCode = exitCode; + _restartCount++; + } + + if (failed) + { + consecutiveFailures++; + _logger.LogInformation( + "Replica cycle ended with failure (consecutive={Count}); will rebind.", + consecutiveFailures); + } + else + { + consecutiveFailures = 0; + _logger.LogInformation( + "Replica producer disconnected (exit code {ExitCode}); rebinding for next producer.", + exitCode); + } + + if (ct.IsCancellationRequested) + { + return; + } + + // Backoff: stay snappy on a clean producer-disconnect (typical case + // when the resource is being restarted) but escalate when the cycle + // keeps failing fast, so a wedged transport doesn't burn CPU/log. + var delay = consecutiveFailures switch + { + 0 => TimeSpan.FromMilliseconds(100), + 1 => TimeSpan.FromMilliseconds(250), + 2 => TimeSpan.FromMilliseconds(500), + 3 => TimeSpan.FromSeconds(1), + 4 => TimeSpan.FromSeconds(2), + _ => TimeSpan.FromSeconds(5), + }; + + try + { + await Task.Delay(delay, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + } + } + + /// + /// Builds a fresh Hex1bTerminal bound to this replica's UDS paths. Each + /// call returns an unrelated, undisposed instance; the recycle loop owns + /// dispose timing. + /// + private Hex1bTerminal BuildTerminal() + { + // Build the upstream workload adapter ourselves so we can plumb downstream + // resize events (from the consumer-side multi-head server below) into + // unconditional FrameResize writes upstream to DCP. See DcpUpstreamAdapter + // for why Hex1b's stock Hmp1WorkloadAdapter cannot be used here (DCP is + // a single-peer producer that doesn't speak multi-head, so the adapter's + // IsPrimary gate would silently drop every resize forever). + var upstream = new DcpUpstreamAdapter( + async cct => + { + await foreach (var stream in Hmp1Transports.ListenUnixSocket(ProducerUdsPath, cct).ConfigureAwait(false)) + { + lock (_gate) + { + _producerConnected = true; + } + return stream; + } + throw new OperationCanceledException("Producer UDS listener was cancelled before any client connected."); + }, + _logger); + + upstream.Disconnected += () => + { + lock (_gate) + { + _producerConnected = false; + } + }; + + return Hex1bTerminal.CreateBuilder() + .WithDimensions(Columns, Rows) + .WithWorkload(upstream) + .WithHmp1UdsServer( + ConsumerUdsPath, + srvOpts => + { + // Track every HMP1 peer that connects/disconnects so the host can answer + // "who's currently attached to this replica?" via the control RPC. PeerId is + // assigned by Hex1b at handshake time and is unique per connection; DisplayName + // is the optional ClientHello label (e.g. "aspire-cli:1234", "dashboard:abc12345"). + srvOpts.OnClientConnected = (e, _) => + { + lock (_gate) + { + _peers[e.PeerId] = new TerminalHostPeerInfo + { + PeerId = e.PeerId, + DisplayName = e.DisplayName, + }; + } + return Task.CompletedTask; + }; + srvOpts.OnClientDisconnected = (e, _) => + { + lock (_gate) + { + _peers.Remove(e.PeerId); + } + return Task.CompletedTask; + }; + + // Bridge downstream → upstream resize. The consumer-side multi-head + // server fires OnResized whenever the current primary peer's dims + // change (RequestPrimary or explicit Resize from primary). Forward + // those dims as a raw FrameResize upstream so DCP runs ConPty.Resize + // and the underlying workload sees the new TIOCSWINSZ value. Without + // this hook the consumer-side presentation reflects the new dims but + // the actual PTY stays at whatever DCP started it at. + // + // Also persist the latest dimensions so `aspire terminal ps` and the + // dashboard can report the current grid size without round-tripping to + // every attached viewer. + srvOpts.OnResized = async (e, ct) => + { + lock (_gate) + { + _currentColumns = e.Width; + _currentRows = e.Height; + } + + try + { + await upstream.ResizeAsync(e.Width, e.Height, ct).ConfigureAwait(false); + _logger.LogDebug( + "Replica: forwarded downstream resize ({Width}x{Height}) to upstream PTY.", + e.Width, e.Height); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Recycle loop is shutting down; drop quietly. + } + catch (Exception ex) + { + _logger.LogDebug(ex, + "Replica: forwarding downstream resize ({Width}x{Height}) upstream failed.", + e.Width, e.Height); + } + }; + }) + .Build(); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + try + { + await _stopCts.CancelAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) { } + + try + { + await _runTask.ConfigureAwait(false); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.LogDebug(ex, "Replica recycle loop terminated with an unexpected error."); + } + + _stopCts.Dispose(); + } +} diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index db917b32720..7ed695138cc 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -56,6 +56,14 @@ public static class Project public const string LaunchProfile = "project.launchProfile"; } + public static class Terminal + { + public const string Enabled = "terminal.enabled"; + public const string ReplicaIndex = "terminal.replicaIndex"; + public const string ReplicaCount = "terminal.replicaCount"; + public const string ConsumerUdsPath = "terminal.consumerUdsPath"; + } + public static class Parameter { public const string Value = "Value"; diff --git a/src/Shared/TerminalHost/TerminalHostControlProtocol.cs b/src/Shared/TerminalHost/TerminalHostControlProtocol.cs new file mode 100644 index 00000000000..aa4105f149a --- /dev/null +++ b/src/Shared/TerminalHost/TerminalHostControlProtocol.cs @@ -0,0 +1,148 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Shared.TerminalHost; + +/// +/// Wire-types exchanged over the terminal host's control UDS via StreamJsonRpc. +/// Shared between the AppHost (caller) and the Aspire.TerminalHost (callee). +/// +/// +/// Each aspire.terminalhost process serves exactly one replica's session, so the +/// control protocol describes a single session per host. To enumerate all replicas of a +/// target resource, the AppHost iterates its per-replica hosts and queries each one's +/// control UDS independently. +/// +internal static class TerminalHostControlProtocol +{ + /// + /// Current control protocol version. Incremented on breaking changes. + /// + /// + /// Bumped to 2 when the protocol shifted from "one host with N replica sessions" + /// to "one host per replica with a single session" (renamed getReplicas to + /// getSession and dropped the replica count from getInfo). + /// + public const int ProtocolVersion = 2; + + /// + /// JSON-RPC method name for retrieving the host's single replica session state. + /// Returns a . + /// + public const string GetSessionMethod = "getSession"; + + /// + /// JSON-RPC method name for requesting a clean shutdown of the terminal host. + /// + public const string ShutdownMethod = "shutdown"; + + /// + /// JSON-RPC method name for retrieving the protocol/host version. + /// + public const string GetInfoMethod = "getInfo"; +} + +/// +/// Information about the single replica session managed by one terminal host process. +/// +/// +/// The host has no notion of its global replica index — that's encoded in the UDS paths +/// the AppHost passes in and is reattached by the AppHost when it aggregates per-host +/// state into the per-resource view shown by the Dashboard and aspire terminal ps. +/// +internal sealed class TerminalHostSessionInfo +{ + /// + /// Path to the producer-side UDS the host is LISTENING on. DCP dials this path to + /// stream PTY traffic into the host. Echoed for diagnostics; the AppHost is the + /// source of truth for the path layout. + /// + public required string ProducerUdsPath { get; init; } + + /// + /// Path to the consumer-side UDS the host is LISTENING on. Viewers (Dashboard, CLI) + /// dial this path to attach. Echoed for diagnostics. + /// + public required string ConsumerUdsPath { get; init; } + + /// + /// True while the host's most recent Hex1bTerminal cycle has an attached + /// upstream producer. Becomes false transiently between recycles (when DCP relaunches + /// the underlying process), and permanently when the host is torn down. + /// + public required bool IsAlive { get; init; } + + /// + /// Exit code from the most recently-completed Hex1bTerminal cycle, or null if + /// no cycle has completed yet. + /// + public int? ExitCode { get; init; } + + /// + /// True when the host's current cycle has an attached upstream producer. Identical + /// in meaning to ; exposed under a clearer name for callers + /// that want explicit "is the producer connected right now?" semantics. + /// + public bool ProducerConnected { get; init; } + + /// + /// Number of completed Hex1bTerminal cycles. Increments each time the producer + /// disconnects and the host rebinds. Zero on first cycle. + /// + public int RestartCount { get; init; } + + /// + /// Current terminal grid width in columns, as last negotiated by the active HMP1 + /// primary peer. Falls back to the AppHost-configured initial width when no peer has + /// driven a resize. Optional: nullable so older clients deserialize cleanly. + /// + public int? CurrentColumns { get; init; } + + /// + /// Current terminal grid height in rows. See . + /// + public int? CurrentRows { get; init; } + + /// + /// Number of HMP1 peers currently connected to the consumer UDS. Optional for + /// back-compat with older hosts. + /// + public int? AttachedPeerCount { get; init; } + + /// + /// Per-peer identification for currently-connected HMP1 viewers, in connect order. + /// Optional for back-compat with older hosts. + /// + public TerminalHostPeerInfo[]? Peers { get; init; } +} + +/// +/// Per-peer identification for an HMP1 client currently connected to a host's consumer +/// UDS. The HMP1 server assigns the at handshake; the +/// is whatever the client passed in its ClientHello (e.g. +/// aspire-cli:1234 or dashboard:abc12345). +/// +internal sealed class TerminalHostPeerInfo +{ + /// + /// HMP1-assigned stable peer identifier for the lifetime of the connection. + /// + public required string PeerId { get; init; } + + /// + /// Free-form label the peer reported in its ClientHello, or null if the + /// peer didn't supply one. + /// + public string? DisplayName { get; init; } +} + +/// +/// Response from . +/// +internal sealed class TerminalHostInfoResponse +{ + /// + /// Control protocol version. See . + /// + public required int ProtocolVersion { get; init; } +} diff --git a/tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs b/tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs index 1abd3387b67..f2519a63de5 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs @@ -87,4 +87,94 @@ public void JsonSerializerOptionsCanDeserializePublishingActivityWithoutHierarch Assert.Null(activity.Data.CompletionMessage); Assert.Equal(CompletionStates.InProgress, activity.Data.CompletionState); } + + [Fact] + public void TerminalReplicaInfo_OldPayloadWithoutNewFields_DeserializesWithNulls() + { + // Back-compat: an older AppHost (pre-terminals.ps.v1) that only knows about the original + // TerminalReplicaInfo shape will not emit CurrentColumns/CurrentRows/AttachedPeerCount/Peers. + // The CLI must accept that payload and treat the new fields as null. See + // docs/specs/cli-backchannel.md §3 for the per-feature capability strategy. + var options = BackchannelJsonSerializerContext.CreateJsonSerializerOptions(); + var json = + """ + { + "ReplicaIndex": 0, + "Label": "myresource-0", + "ConsumerUdsPath": "/tmp/r0.sock", + "IsAlive": true, + "ProducerConnected": true, + "RestartCount": 0 + } + """; + + var replica = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(replica); + Assert.Equal(0, replica.ReplicaIndex); + Assert.Equal("myresource-0", replica.Label); + Assert.True(replica.IsAlive); + Assert.Null(replica.CurrentColumns); + Assert.Null(replica.CurrentRows); + Assert.Null(replica.AttachedPeerCount); + Assert.Null(replica.Peers); + } + + [Fact] + public void ListTerminalsResponse_RoundTripsThroughSerializer() + { + var options = BackchannelJsonSerializerContext.CreateJsonSerializerOptions(); + var response = new ListTerminalsResponse + { + Terminals = + [ + new TerminalSummary + { + ResourceName = "myresource", + DisplayName = "myresource", + ConfiguredColumns = 120, + ConfiguredRows = 30, + IsHostReachable = true, + Replicas = + [ + new TerminalReplicaInfo + { + ReplicaIndex = 0, + Label = "myresource-0", + ConsumerUdsPath = "/tmp/r0.sock", + IsAlive = true, + CurrentColumns = 130, + CurrentRows = 32, + AttachedPeerCount = 1, + Peers = + [ + new TerminalPeerInfo { PeerId = "peer-1", DisplayName = "viewer-1" } + ] + } + ] + } + ] + }; + + var json = JsonSerializer.Serialize(response, options); + var roundTripped = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(roundTripped); + Assert.Single(roundTripped.Terminals); + + var terminal = roundTripped.Terminals[0]; + Assert.Equal("myresource", terminal.ResourceName); + Assert.True(terminal.IsHostReachable); + Assert.NotNull(terminal.Replicas); + Assert.Single(terminal.Replicas); + + var replica = terminal.Replicas[0]; + Assert.Equal(130, replica.CurrentColumns); + Assert.Equal(32, replica.CurrentRows); + Assert.Equal(1, replica.AttachedPeerCount); + Assert.NotNull(replica.Peers); + Assert.Single(replica.Peers); + Assert.Equal("peer-1", replica.Peers[0].PeerId); + Assert.Equal("viewer-1", replica.Peers[0].DisplayName); + } } diff --git a/tests/Aspire.Cli.Tests/Commands/TerminalCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TerminalCommandTests.cs new file mode 100644 index 00000000000..e8fbbab7950 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/TerminalCommandTests.cs @@ -0,0 +1,519 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Backchannel; +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class TerminalCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task TerminalCommand_Help_Works() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("terminal --help"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task TerminalCommand_WhenNoSubcommand_PrintsHelpAndFails() + { + // The 'terminal' parent command is non-runnable; it prints help when invoked + // alone and returns InvalidCommand to mirror the DashboardCommand pattern. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("terminal"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.NotEqual(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task TerminalAttachCommand_WhenNoResourceArgument_FailsParsing() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("terminal attach"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.NotEqual(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task TerminalCommand_WhenNoAppHostRunning_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("terminal attach myresource"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + // Mirrors the LogsCommand behavior: no running AppHost is informational, not an error. + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task TerminalCommand_WhenAppHostLacksTerminalsV1Capability_ReturnsAppHostIncompatible() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var (provider, _) = CreateProviderWithBackchannel( + workspace, + backchannel => + { + backchannel.SupportsTerminalsV1 = false; + }); + using (provider) + { + var command = provider.GetRequiredService(); + var result = command.Parse("terminal attach myresource"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.AppHostIncompatible, exitCode); + } + } + + [Fact] + public async Task TerminalCommand_WhenResourceNotFound_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var (provider, _) = CreateProviderWithBackchannel( + workspace, + backchannel => + { + backchannel.ResourceSnapshots = []; + }); + using (provider) + { + var command = provider.GetRequiredService(); + var result = command.Parse("terminal attach does-not-exist"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + } + + [Fact] + public async Task TerminalCommand_WhenTerminalNotAvailable_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var (provider, _) = CreateProviderWithBackchannel( + workspace, + backchannel => + { + backchannel.ResourceSnapshots = [CreateSnapshot("myresource")]; + backchannel.TerminalInfoResponse = new GetTerminalInfoResponse + { + IsAvailable = false, + Replicas = null + }; + }); + using (provider) + { + var command = provider.GetRequiredService(); + var result = command.Parse("terminal attach myresource"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + } + + [Fact] + public async Task TerminalCommand_WhenReplicasArrayEmpty_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var (provider, _) = CreateProviderWithBackchannel( + workspace, + backchannel => + { + backchannel.ResourceSnapshots = [CreateSnapshot("myresource")]; + backchannel.TerminalInfoResponse = new GetTerminalInfoResponse + { + IsAvailable = true, + Replicas = [] + }; + }); + using (provider) + { + var command = provider.GetRequiredService(); + var result = command.Parse("terminal attach myresource"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + } + + [Fact] + public async Task TerminalCommand_WhenReplicaIndexOutOfRange_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var (provider, _) = CreateProviderWithBackchannel( + workspace, + backchannel => + { + backchannel.ResourceSnapshots = [CreateSnapshot("myresource")]; + backchannel.TerminalInfoResponse = new GetTerminalInfoResponse + { + IsAvailable = true, + Replicas = + [ + new TerminalReplicaInfo + { + ReplicaIndex = 0, + Label = "myresource-0", + ConsumerUdsPath = "/tmp/does-not-exist-0.sock", + IsAlive = true + }, + new TerminalReplicaInfo + { + ReplicaIndex = 1, + Label = "myresource-1", + ConsumerUdsPath = "/tmp/does-not-exist-1.sock", + IsAlive = true + } + ] + }; + }); + using (provider) + { + var command = provider.GetRequiredService(); + var result = command.Parse("terminal attach myresource --replica 99"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + } + + [Fact] + public async Task TerminalCommand_DisplayNameMatchesParentResource() + { + // Replicated resources share a DisplayName equal to the parent resource that + // carries the TerminalAnnotation. Passing the parent name on the CLI must + // resolve to the same canonical name when looking up terminal info. + using var workspace = TemporaryWorkspace.Create(outputHelper); + string? capturedResourceName = null; + + var (provider, backchannel) = CreateProviderWithBackchannel( + workspace, + bc => + { + bc.ResourceSnapshots = + [ + CreateSnapshot("myresource-0", displayName: "myresource"), + CreateSnapshot("myresource-1", displayName: "myresource") + ]; + bc.TerminalInfoResponse = new GetTerminalInfoResponse + { + IsAvailable = false + }; + }); + using (provider) + { + // Wrap the test backchannel's terminal info call to capture the canonical name. + var monitor = (TestAuxiliaryBackchannelMonitor)provider.GetRequiredService(); + var capturing = new CapturingTerminalAppHostBackchannel(backchannel, name => capturedResourceName = name); + monitor.ClearConnections(); + monitor.AddConnection("hash1", "socket.hash1", capturing); + + var command = provider.GetRequiredService(); + var result = command.Parse("terminal attach myresource"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + // IsAvailable=false → InvalidCommand, but we should see the canonical + // parent name "myresource" passed to GetTerminalInfoAsync, not "myresource-0". + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + Assert.Equal("myresource", capturedResourceName); + } + } + + [Fact] + public async Task TerminalCommand_NonInteractiveMultiReplicaWithoutFlag_ReturnsInvalidCommand() + { + // When stdin or stdout is redirected and the resource has more than one replica, + // the command must require --replica explicitly (rather than try to prompt). + using var workspace = TemporaryWorkspace.Create(outputHelper); + var (provider, _) = CreateProviderWithBackchannel( + workspace, + backchannel => + { + backchannel.ResourceSnapshots = [CreateSnapshot("myresource")]; + backchannel.TerminalInfoResponse = new GetTerminalInfoResponse + { + IsAvailable = true, + Replicas = + [ + new TerminalReplicaInfo + { + ReplicaIndex = 0, + Label = "myresource-0", + ConsumerUdsPath = "/tmp/does-not-exist-0.sock", + IsAlive = true + }, + new TerminalReplicaInfo + { + ReplicaIndex = 1, + Label = "myresource-1", + ConsumerUdsPath = "/tmp/does-not-exist-1.sock", + IsAlive = true + } + ] + }; + }); + using (provider) + { + var command = provider.GetRequiredService(); + var result = command.Parse("terminal attach myresource"); + // Tests run with both stdout and stdin redirected (xUnit pipes them), so + // Console.IsInputRedirected and Console.IsOutputRedirected are both true. + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + } + + [Fact] + public async Task TerminalPsCommand_WhenNoAppHostRunning_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("terminal ps"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + // Mirrors TerminalAttachCommand: no running AppHost is informational, not an error. + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task TerminalPsCommand_WhenAppHostLacksTerminalsPsV1Capability_ReturnsAppHostIncompatible() + { + // Older AppHosts that pre-date the 'terminals.ps.v1' capability return + // SupportsTerminalsPsV1=false; the command must surface that explicitly rather + // than misleadingly listing nothing. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var (provider, _) = CreateProviderWithBackchannel( + workspace, + backchannel => + { + backchannel.SupportsTerminalsPsV1 = false; + }); + using (provider) + { + var command = provider.GetRequiredService(); + var result = command.Parse("terminal ps"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.AppHostIncompatible, exitCode); + } + } + + [Fact] + public async Task TerminalPsCommand_WhenNoTerminalsRegistered_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var (provider, _) = CreateProviderWithBackchannel( + workspace, + backchannel => + { + backchannel.ListTerminalsResponse = new ListTerminalsResponse + { + Terminals = Array.Empty() + }; + }); + using (provider) + { + var command = provider.GetRequiredService(); + var result = command.Parse("terminal ps"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + } + + [Fact] + public async Task TerminalPsCommand_WhenTerminalsPresent_RendersTableSuccessfully() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var (provider, _) = CreateProviderWithBackchannel( + workspace, + backchannel => + { + backchannel.ListTerminalsResponse = new ListTerminalsResponse + { + Terminals = + [ + new TerminalSummary + { + ResourceName = "myresource", + DisplayName = "myresource", + ConfiguredColumns = 120, + ConfiguredRows = 30, + IsHostReachable = true, + Replicas = + [ + new TerminalReplicaInfo + { + ReplicaIndex = 0, + Label = "myresource-0", + ConsumerUdsPath = "/tmp/r0.sock", + IsAlive = true, + CurrentColumns = 130, + CurrentRows = 32, + AttachedPeerCount = 2 + } + ] + } + ] + }; + }); + using (provider) + { + var command = provider.GetRequiredService(); + var result = command.Parse("terminal ps"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + } + + [Fact] + public async Task TerminalPsCommand_JsonFormat_WhenEmpty_EmitsEmptyArray() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var (provider, _) = CreateProviderWithBackchannel( + workspace, + backchannel => + { + backchannel.ListTerminalsResponse = new ListTerminalsResponse + { + Terminals = Array.Empty() + }; + }); + using (provider) + { + var command = provider.GetRequiredService(); + var result = command.Parse("terminal ps --format json"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + } + + private (ServiceProvider Provider, TestAppHostAuxiliaryBackchannel Backchannel) CreateProviderWithBackchannel( + TemporaryWorkspace workspace, + Action configure) + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var backchannel = new TestAppHostAuxiliaryBackchannel + { + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + ProcessId = 1234 + }, + SupportsTerminalsV1 = true + }; + configure(backchannel); + monitor.AddConnection("hash1", "socket.hash1", backchannel); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + }); + + return (services.BuildServiceProvider(), backchannel); + } + + private static ResourceSnapshot CreateSnapshot(string name, string? displayName = null) + { + return new ResourceSnapshot + { + Name = name, + DisplayName = displayName, + ResourceType = "Project", + State = "Running" + }; + } + + /// + /// Wraps an inner backchannel and captures the resource name passed to + /// . All other calls + /// delegate to the inner instance. + /// + private sealed class CapturingTerminalAppHostBackchannel : IAppHostAuxiliaryBackchannel + { + private readonly TestAppHostAuxiliaryBackchannel _inner; + private readonly Action _onGetTerminalInfo; + + public CapturingTerminalAppHostBackchannel(TestAppHostAuxiliaryBackchannel inner, Action onGetTerminalInfo) + { + _inner = inner; + _onGetTerminalInfo = onGetTerminalInfo; + } + + public string Hash => _inner.Hash; + public string SocketPath => _inner.SocketPath; + public AppHostInformation? AppHostInfo => _inner.AppHostInfo; + public bool IsInScope => _inner.IsInScope; + public DateTimeOffset ConnectedAt => _inner.ConnectedAt; + public bool SupportsV2 => _inner.SupportsV2; + public bool SupportsTerminalsV1 => _inner.SupportsTerminalsV1; + public bool SupportsTerminalsPsV1 => _inner.SupportsTerminalsPsV1; + + public Task GetTerminalInfoAsync(string resourceName, CancellationToken cancellationToken = default) + { + _onGetTerminalInfo(resourceName); + return _inner.GetTerminalInfoAsync(resourceName, cancellationToken); + } + + public Task ListTerminalsAsync(CancellationToken cancellationToken = default) + { + return _inner.ListTerminalsAsync(cancellationToken); + } + + public Task GetDashboardUrlsAsync(CancellationToken cancellationToken = default) + => _inner.GetDashboardUrlsAsync(cancellationToken); + public Task> GetResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default) + => _inner.GetResourceSnapshotsAsync(includeHidden, cancellationToken); + public IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default) + => _inner.WatchResourceSnapshotsAsync(includeHidden, cancellationToken); + public IAsyncEnumerable GetResourceLogsAsync(string? resourceName = null, bool follow = false, CancellationToken cancellationToken = default) + => _inner.GetResourceLogsAsync(resourceName, follow, cancellationToken); + public Task StopAppHostAsync(CancellationToken cancellationToken = default) + => _inner.StopAppHostAsync(cancellationToken); + public Task ExecuteResourceCommandAsync(string resourceName, string commandName, ExecuteResourceCommandOptions? options = null, CancellationToken cancellationToken = default) + => _inner.ExecuteResourceCommandAsync(resourceName, commandName, options, cancellationToken); + public Task WaitForResourceAsync(string resourceName, string status, int timeoutSeconds, CancellationToken cancellationToken = default) + => _inner.WaitForResourceAsync(resourceName, status, timeoutSeconds, cancellationToken); + public Task CallResourceMcpToolAsync(string resourceName, string toolName, IReadOnlyDictionary? arguments, CancellationToken cancellationToken = default) + => _inner.CallResourceMcpToolAsync(resourceName, toolName, arguments, cancellationToken); + public Task GetDashboardInfoV2Async(CancellationToken cancellationToken = default) + => _inner.GetDashboardInfoV2Async(cancellationToken); + + public Task GetAppHostInfoV2Async(CancellationToken cancellationToken = default) + => _inner.GetAppHostInfoV2Async(cancellationToken); + + public void Dispose() => _inner.Dispose(); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/TerminalCommandViewerOptionTests.cs b/tests/Aspire.Cli.Tests/Commands/TerminalCommandViewerOptionTests.cs new file mode 100644 index 00000000000..72f40df3742 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/TerminalCommandViewerOptionTests.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +/// +/// Phase 11 (multi-head HMP1 wire-up) unit tests for the parsing surface of the +/// terminal command. The --viewer flag toggles whether the CLI takes +/// primary on connect or stays secondary; protocol-level emission of ClientHello and +/// RequestPrimary is exercised by Hex1b's own multi-head test suite. +/// +public class TerminalCommandViewerOptionTests(ITestOutputHelper outputHelper) +{ + [Fact] + public void ViewerOption_Help_DescribesPrimarySecondaryBehaviour() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("terminal attach --help"); + + var output = CaptureHelpOutput(() => result.Invoke()); + Assert.Contains("--viewer", output, StringComparison.Ordinal); + Assert.Contains("primary", output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ViewerOption_DefaultIsFalse_WhenNotSpecified() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("terminal attach myresource"); + + Assert.Empty(result.Errors); + var viewerValue = result.GetValue("--viewer"); + Assert.False(viewerValue); + } + + [Fact] + public void ViewerOption_ParsesToTrue_WhenSpecified() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("terminal attach myresource --viewer"); + + Assert.Empty(result.Errors); + var viewerValue = result.GetValue("--viewer"); + Assert.True(viewerValue); + } + + private static string CaptureHelpOutput(Action invoke) + { + var originalOut = Console.Out; + using var sw = new StringWriter(); + Console.SetOut(sw); + try + { + invoke(); + } + finally + { + Console.SetOut(originalOut); + } + return sw.ToString(); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs index 603e8e6a588..d8d4aff6d22 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs @@ -20,6 +20,8 @@ internal sealed class TestAppHostAuxiliaryBackchannel : IAppHostAuxiliaryBackcha public bool IsInScope { get; set; } = true; public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow; public bool SupportsV2 { get; set; } = true; + public bool SupportsTerminalsV1 { get; set; } = true; + public bool SupportsTerminalsPsV1 { get; set; } = true; /// /// Gets or sets the resource snapshots to return from GetResourceSnapshotsAsync and WatchResourceSnapshotsAsync. @@ -229,6 +231,27 @@ public Task CallResourceMcpToolAsync( return Task.FromResult(DashboardInfoResponse); } + /// + /// Gets or sets the terminal info response to return from GetTerminalInfoAsync. + /// + public GetTerminalInfoResponse TerminalInfoResponse { get; set; } = new GetTerminalInfoResponse { IsAvailable = false }; + + public Task GetTerminalInfoAsync(string resourceName, CancellationToken cancellationToken = default) + { + return Task.FromResult(TerminalInfoResponse); + } + + /// + /// Gets or sets the response returned by ListTerminalsAsync. Defaults to an empty list so + /// existing tests that don't care about the new RPC don't have to set anything. + /// + public ListTerminalsResponse ListTerminalsResponse { get; set; } = new ListTerminalsResponse { Terminals = Array.Empty() }; + + public Task ListTerminalsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(ListTerminalsResponse); + } + public void Dispose() { // Nothing to dispose in the test implementation diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 1b332b2a4ac..f77843fa315 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -197,6 +197,9 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelExtensionsTerminalTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelExtensionsTerminalTests.cs new file mode 100644 index 00000000000..16704596fba --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelExtensionsTerminalTests.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model; +using Aspire.Tests.Shared.DashboardModel; +using Xunit; +using Value = Google.Protobuf.WellKnownTypes.Value; + +namespace Aspire.Dashboard.Tests.Model; + +public class ResourceViewModelExtensionsTerminalTests +{ + [Fact] + public void HasTerminal_TrueWhenEnabledMarkerPresent() + { + var resource = ModelTestHelpers.CreateResource( + properties: new Dictionary + { + [KnownProperties.Terminal.Enabled] = StringProperty(KnownProperties.Terminal.Enabled, "true"), + }); + + Assert.True(resource.HasTerminal()); + } + + [Fact] + public void HasTerminal_FalseWhenEnabledMarkerAbsent() + { + var resource = ModelTestHelpers.CreateResource(); + + Assert.False(resource.HasTerminal()); + } + + [Fact] + public void TryGetTerminalReplicaInfo_ReturnsParsedValues() + { + var resource = ModelTestHelpers.CreateResource( + properties: new Dictionary + { + [KnownProperties.Terminal.ReplicaIndex] = StringProperty(KnownProperties.Terminal.ReplicaIndex, "2"), + [KnownProperties.Terminal.ReplicaCount] = StringProperty(KnownProperties.Terminal.ReplicaCount, "5"), + }); + + Assert.True(resource.TryGetTerminalReplicaInfo(out var index, out var count)); + Assert.Equal(2, index); + Assert.Equal(5, count); + } + + [Fact] + public void TryGetTerminalReplicaInfo_FalseWhenIndexMissing() + { + var resource = ModelTestHelpers.CreateResource( + properties: new Dictionary + { + [KnownProperties.Terminal.ReplicaCount] = StringProperty(KnownProperties.Terminal.ReplicaCount, "1"), + }); + + Assert.False(resource.TryGetTerminalReplicaInfo(out _, out _)); + } + + [Fact] + public void TryGetTerminalReplicaInfo_FalseWhenIndexUnparseable() + { + var resource = ModelTestHelpers.CreateResource( + properties: new Dictionary + { + [KnownProperties.Terminal.ReplicaIndex] = StringProperty(KnownProperties.Terminal.ReplicaIndex, "not-a-number"), + [KnownProperties.Terminal.ReplicaCount] = StringProperty(KnownProperties.Terminal.ReplicaCount, "1"), + }); + + Assert.False(resource.TryGetTerminalReplicaInfo(out _, out _)); + } + + [Fact] + public void TryGetTerminalConsumerUdsPath_ReturnsValueWhenPresent() + { + const string path = "/tmp/aspire-term/svc-r0.sock"; + var resource = ModelTestHelpers.CreateResource( + properties: new Dictionary + { + [KnownProperties.Terminal.ConsumerUdsPath] = StringProperty(KnownProperties.Terminal.ConsumerUdsPath, path), + }); + + Assert.True(resource.TryGetTerminalConsumerUdsPath(out var actual)); + Assert.Equal(path, actual); + } + + [Fact] + public void TryGetTerminalConsumerUdsPath_FalseWhenAbsent() + { + var resource = ModelTestHelpers.CreateResource(); + + Assert.False(resource.TryGetTerminalConsumerUdsPath(out var actual)); + Assert.Null(actual); + } + + private static ResourcePropertyViewModel StringProperty(string name, string value) + { + return new ResourcePropertyViewModel( + name, + new Value { StringValue = value }, + isValueSensitive: false, + knownProperty: null, + priority: 0); + } +} diff --git a/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs new file mode 100644 index 00000000000..ae5966ddbcf --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs @@ -0,0 +1,153 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Terminal; +using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; +using Aspire.DashboardService.Proto.V1; +using Aspire.Tests.Shared.DashboardModel; +using Xunit; +using Value = Google.Protobuf.WellKnownTypes.Value; + +namespace Aspire.Dashboard.Tests.Terminal; + +public class DefaultTerminalConnectionResolverTests +{ + [Fact] + public async Task ConnectAsync_WhenClientNotEnabled_ReturnsNull() + { + var client = new DisabledDashboardClient(); + var resolver = new DefaultTerminalConnectionResolver(client); + + var stream = await resolver.ConnectAsync("anything", 0, CancellationToken.None); + + Assert.Null(stream); + } + + [Fact] + public async Task ConnectAsync_WhenResourceNotFound_ReturnsNull() + { + var client = new MockDashboardClient(resources: + [ + CreateTerminalResource("other-abc", displayName: "other", replicaIndex: 0, replicaCount: 1, udsPath: "/tmp/other-r0.sock"), + ]); + var resolver = new DefaultTerminalConnectionResolver(client); + + var stream = await resolver.ConnectAsync("missing", 0, CancellationToken.None); + + Assert.Null(stream); + } + + [Fact] + public async Task ConnectAsync_WhenReplicaIndexDoesNotMatch_ReturnsNull() + { + var client = new MockDashboardClient(resources: + [ + CreateTerminalResource("svc-abc", displayName: "svc", replicaIndex: 0, replicaCount: 2, udsPath: "/tmp/svc-r0.sock"), + CreateTerminalResource("svc-def", displayName: "svc", replicaIndex: 1, replicaCount: 2, udsPath: "/tmp/svc-r1.sock"), + ]); + var resolver = new DefaultTerminalConnectionResolver(client); + + var stream = await resolver.ConnectAsync("svc", 5, CancellationToken.None); + + Assert.Null(stream); + } + + [Fact] + public async Task ConnectAsync_WhenSnapshotMissingTerminalEnabledMarker_ReturnsNull() + { + var resource = ModelTestHelpers.CreateResource( + resourceName: "svc-abc", + displayName: "svc", + properties: new Dictionary + { + [KnownProperties.Terminal.ReplicaIndex] = StringProperty(KnownProperties.Terminal.ReplicaIndex, "0"), + [KnownProperties.Terminal.ReplicaCount] = StringProperty(KnownProperties.Terminal.ReplicaCount, "1"), + [KnownProperties.Terminal.ConsumerUdsPath] = StringProperty(KnownProperties.Terminal.ConsumerUdsPath, "/tmp/svc.sock"), + }); + var client = new MockDashboardClient(resources: [resource]); + var resolver = new DefaultTerminalConnectionResolver(client); + + var stream = await resolver.ConnectAsync("svc", 0, CancellationToken.None); + + Assert.Null(stream); + } + + [Fact] + public async Task ConnectAsync_WhenSnapshotMissingConsumerUdsPath_ReturnsNull() + { + var resource = ModelTestHelpers.CreateResource( + resourceName: "svc-abc", + displayName: "svc", + properties: new Dictionary + { + [KnownProperties.Terminal.Enabled] = StringProperty(KnownProperties.Terminal.Enabled, "true"), + [KnownProperties.Terminal.ReplicaIndex] = StringProperty(KnownProperties.Terminal.ReplicaIndex, "0"), + [KnownProperties.Terminal.ReplicaCount] = StringProperty(KnownProperties.Terminal.ReplicaCount, "1"), + }); + var client = new MockDashboardClient(resources: [resource]); + var resolver = new DefaultTerminalConnectionResolver(client); + + var stream = await resolver.ConnectAsync("svc", 0, CancellationToken.None); + + Assert.Null(stream); + } + + [Fact] + public async Task ConnectAsync_WhenUdsPathDoesNotExist_FailsToConnect() + { + // Resolver locates the snapshot and the path; the actual UDS connect throws + // because the path is not a live socket. We just assert that the resolver + // attempted the connection — transport errors bubble up to the WS proxy. + var resource = CreateTerminalResource( + resourceName: "svc-abc", + displayName: "svc", + replicaIndex: 0, + replicaCount: 1, + udsPath: Path.Combine(Path.GetTempPath(), "nonexistent-aspire-term-" + Guid.NewGuid().ToString("N") + ".sock")); + var client = new MockDashboardClient(resources: [resource]); + var resolver = new DefaultTerminalConnectionResolver(client); + + await Assert.ThrowsAnyAsync(() => resolver.ConnectAsync("svc", 0, CancellationToken.None)); + } + + private static ResourceViewModel CreateTerminalResource(string resourceName, string displayName, int replicaIndex, int replicaCount, string udsPath) + { + return ModelTestHelpers.CreateResource( + resourceName: resourceName, + displayName: displayName, + properties: new Dictionary + { + [KnownProperties.Terminal.Enabled] = StringProperty(KnownProperties.Terminal.Enabled, "true"), + [KnownProperties.Terminal.ReplicaIndex] = StringProperty(KnownProperties.Terminal.ReplicaIndex, replicaIndex.ToString()), + [KnownProperties.Terminal.ReplicaCount] = StringProperty(KnownProperties.Terminal.ReplicaCount, replicaCount.ToString()), + [KnownProperties.Terminal.ConsumerUdsPath] = StringProperty(KnownProperties.Terminal.ConsumerUdsPath, udsPath), + }); + } + + private static ResourcePropertyViewModel StringProperty(string name, string value) + { + return new ResourcePropertyViewModel( + name, + new Value { StringValue = value }, + isValueSensitive: false, + knownProperty: null, + priority: 0); + } + + private sealed class DisabledDashboardClient : IDashboardClient + { + public bool IsEnabled => false; + public Task WhenConnected => Task.CompletedTask; + public string ApplicationName => "Disabled"; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, ExecuteResourceCommandOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); + public IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task SubscribeResourcesAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); + public IAsyncEnumerable SubscribeInteractionsAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken) => throw new NotImplementedException(); + public ResourceViewModel? GetResource(string resourceName) => null; + public IReadOnlyList GetResources() => []; + } +} diff --git a/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs index 9ce409a6212..45cc1961dd8 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs @@ -30,6 +30,9 @@ public class BackchannelContractTests typeof(McpToolContentItem), typeof(StopAppHostRequest), typeof(StopAppHostResponse), + typeof(GetTerminalInfoRequest), + typeof(GetTerminalInfoResponse), + typeof(TerminalReplicaInfo), typeof(ResourceSnapshot), typeof(ResourceSnapshotUrl), typeof(ResourceSnapshotUrlDisplayProperties), @@ -76,7 +79,8 @@ public void BackchannelTypes_FollowContractRules() // Rule 6: Naming convention (skip helper types) if (!type.Name.StartsWith("ResourceSnapshot") && type.Name != "McpToolContentItem" && - type.Name != "ResourceLogLine") + type.Name != "ResourceLogLine" && + type.Name != "TerminalReplicaInfo") { if (!type.Name.EndsWith("Request") && !type.Name.EndsWith("Response")) { diff --git a/tests/Aspire.Hosting.Tests/Backchannel/GetTerminalInfoAsyncTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/GetTerminalInfoAsyncTests.cs new file mode 100644 index 00000000000..b23a719085b --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Backchannel/GetTerminalInfoAsyncTests.cs @@ -0,0 +1,364 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using Aspire.Hosting.Backchannel; +using Aspire.Shared.TerminalHost; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using StreamJsonRpc; + +namespace Aspire.Hosting.Tests.Backchannel; + +[Trait("Partition", "4")] +public class GetTerminalInfoAsyncTests : IAsyncDisposable +{ + private readonly List _toDispose = []; + private readonly List _tempDirs = []; + + [Fact] + public async Task ReturnsUnavailable_WhenResourceDoesNotExist() + { + var (model, _) = BuildModel(replicaCount: 1, controlListeners: null); + + var target = CreateTarget(model); + + var result = await target.GetTerminalInfoAsync( + new GetTerminalInfoRequest { ResourceName = "nope" }).DefaultTimeout(); + + Assert.False(result.IsAvailable); + Assert.Null(result.Replicas); + } + + [Fact] + public async Task ReturnsUnavailable_WhenResourceHasNoTerminalAnnotation() + { + var model = new DistributedApplicationModel(new ResourceCollection + { + new CustomResource("plain"), + }); + + var target = CreateTarget(model); + + var result = await target.GetTerminalInfoAsync( + new GetTerminalInfoRequest { ResourceName = "plain" }).DefaultTimeout(); + + Assert.False(result.IsAvailable); + Assert.Null(result.Replicas); + } + + [Fact] + public async Task ReturnsAvailableWithDegradedReplicas_WhenAllControlSocketsAreUnreachable() + { + // Build a layout pointing at control sockets nobody is listening on. The new + // fan-out model treats each per-replica host independently — every host's + // control RPC will time out, but the call still returns IsAvailable=true with + // one degraded TerminalReplicaInfo per replica (IsAlive=false, AppHost-known + // ConsumerUdsPath populated). This mirrors the user-visible behavior in + // `aspire terminal ps` where the row still appears for each replica even + // when its host hasn't started yet. + var (model, hosts) = BuildModel(replicaCount: 2, controlListeners: null); + + var target = CreateTarget(model); + + var result = await target.GetTerminalInfoAsync( + new GetTerminalInfoRequest { ResourceName = "myapp" }).DefaultTimeout(TimeSpan.FromSeconds(15)); + + Assert.True(result.IsAvailable); + Assert.NotNull(result.Replicas); + Assert.Equal(2, result.Replicas!.Length); + for (var i = 0; i < 2; i++) + { + Assert.Equal(i, result.Replicas[i].ReplicaIndex); + Assert.False(result.Replicas[i].IsAlive); + Assert.Equal(hosts[i].Layout.ConsumerUdsPath, result.Replicas[i].ConsumerUdsPath); + } + } + + [Fact] + public async Task ReturnsPerReplicaInfo_WhenAllHostsReachable() + { + // Each per-replica terminal host serves a single session. The AppHost's + // GetTerminalInfo fans out across them in parallel and assembles the + // per-replica response array. ReplicaIndex on the wire comes from the + // AppHost's layout (TerminalHostLayout.ParentReplicaIndex), not from the + // host's reply, so a misbehaving host can never confuse the AppHost's view. + var fakeHost0 = await StartFakeControlHostAsync(new TerminalHostSessionInfo + { + ProducerUdsPath = "host-claim-p0", + ConsumerUdsPath = "host-claim-r0", + IsAlive = true, + ProducerConnected = true, + }).DefaultTimeout(); + + var fakeHost1 = await StartFakeControlHostAsync(new TerminalHostSessionInfo + { + ProducerUdsPath = "host-claim-p1", + ConsumerUdsPath = "host-claim-r1", + IsAlive = false, + ExitCode = 7, + }).DefaultTimeout(); + + var (model, hosts) = BuildModel( + replicaCount: 2, + controlListeners: [fakeHost0, fakeHost1]); + + var target = CreateTarget(model); + + var result = await target.GetTerminalInfoAsync( + new GetTerminalInfoRequest { ResourceName = "myapp" }).DefaultTimeout(TimeSpan.FromSeconds(10)); + + Assert.True(result.IsAvailable); + Assert.Equal(132, result.Columns); + Assert.Equal(40, result.Rows); + Assert.Null(result.SocketPath); + + Assert.NotNull(result.Replicas); + Assert.Equal(2, result.Replicas!.Length); + + Assert.Equal(0, result.Replicas[0].ReplicaIndex); + Assert.Equal("replica 0", result.Replicas[0].Label); + // AppHost is the source of truth for the consumer UDS path even though the host + // echoed back its own claim — verify we trust the layout. + Assert.Equal(hosts[0].Layout.ConsumerUdsPath, result.Replicas[0].ConsumerUdsPath); + Assert.True(result.Replicas[0].IsAlive); + Assert.Null(result.Replicas[0].ExitCode); + + Assert.Equal(1, result.Replicas[1].ReplicaIndex); + Assert.Equal("replica 1", result.Replicas[1].Label); + Assert.Equal(hosts[1].Layout.ConsumerUdsPath, result.Replicas[1].ConsumerUdsPath); + Assert.False(result.Replicas[1].IsAlive); + Assert.Equal(7, result.Replicas[1].ExitCode); + } + + [Fact] + public async Task DegradedReplicaReportedWhenSomeHostsUnreachable() + { + // Mixed scenario: replica 0's host is reachable, replica 1's is not. + // Both show up in the result; replica 1 has IsAlive=false but the + // AppHost-known consumer path is still reported so the row stays visible. + var fakeHost0 = await StartFakeControlHostAsync(new TerminalHostSessionInfo + { + ProducerUdsPath = "host-claim-p0", + ConsumerUdsPath = "host-claim-r0", + IsAlive = true, + ProducerConnected = true, + }).DefaultTimeout(); + + var (model, hosts) = BuildModel( + replicaCount: 2, + controlListeners: [fakeHost0, null]); + + var target = CreateTarget(model); + + var result = await target.GetTerminalInfoAsync( + new GetTerminalInfoRequest { ResourceName = "myapp" }).DefaultTimeout(TimeSpan.FromSeconds(15)); + + Assert.True(result.IsAvailable); + Assert.NotNull(result.Replicas); + Assert.Equal(2, result.Replicas!.Length); + Assert.True(result.Replicas[0].IsAlive); + Assert.False(result.Replicas[1].IsAlive); + Assert.Equal(hosts[0].Layout.ConsumerUdsPath, result.Replicas[0].ConsumerUdsPath); + Assert.Equal(hosts[1].Layout.ConsumerUdsPath, result.Replicas[1].ConsumerUdsPath); + } + + [Fact] + public async Task GetCapabilities_AdvertisesTerminalsV1() + { + var model = new DistributedApplicationModel(new ResourceCollection()); + var target = CreateTarget(model); + + var result = await target.GetCapabilitiesAsync().DefaultTimeout(); + + Assert.Contains(AuxiliaryBackchannelCapabilities.V1, result.Capabilities); + Assert.Contains(AuxiliaryBackchannelCapabilities.V2, result.Capabilities); + Assert.Contains(AuxiliaryBackchannelCapabilities.Terminals_V1, result.Capabilities); + } + + /// + /// Builds a target resource with a wired to one + /// per-replica per replica. + /// is matched by index against the hosts: a non-null entry points the corresponding layout + /// at that fake host's listening UDS, a null entry leaves the layout pointing at a path + /// nobody is listening on (so the per-host RPC degrades gracefully). + /// + private (DistributedApplicationModel Model, IReadOnlyList Hosts) BuildModel( + int replicaCount, + IReadOnlyList? controlListeners) + { + var baseDir = CreateShortTempDir(); + + var target = new CustomResource("myapp"); + var hosts = new TerminalHostResource[replicaCount]; + for (var i = 0; i < replicaCount; i++) + { + var perReplicaDir = Path.Combine(baseDir, i.ToString(System.Globalization.CultureInfo.InvariantCulture)); + Directory.CreateDirectory(perReplicaDir); + + var producer = Path.Combine(perReplicaDir, "dcp.sock"); + var consumer = Path.Combine(perReplicaDir, "host.sock"); + // If a fake host is supplied at this index, use its real listening path so + // the AppHost-side fan-out actually reaches it. Otherwise leave it pointing + // at a path that does not exist. + var control = controlListeners is not null && i < controlListeners.Count && controlListeners[i] is { } listener + ? listener.SocketPath + : Path.Combine(perReplicaDir, "control.sock"); + + var layout = new TerminalHostLayout( + baseDirectory: baseDir, + parentReplicaIndex: i, + producerUdsPath: producer, + consumerUdsPath: consumer, + controlUdsPath: control); + + hosts[i] = new TerminalHostResource($"myapp-terminalhost-{i}", target, layout); + } + + var annotation = new TerminalAnnotation(new TerminalOptions { Columns = 132, Rows = 40 }); + annotation.Initialize(baseDir, hosts); + target.Annotations.Add(annotation); + + var resources = new ResourceCollection { target }; + foreach (var h in hosts) + { + resources.Add(h); + } + + return (new DistributedApplicationModel(resources), hosts); + } + + private static AuxiliaryBackchannelRpcTarget CreateTarget(DistributedApplicationModel model) + { + var services = new ServiceCollection(); + services.AddSingleton(model); + var sp = services.BuildServiceProvider(); + return new AuxiliaryBackchannelRpcTarget(NullLogger.Instance, sp); + } + + private async Task StartFakeControlHostAsync(TerminalHostSessionInfo session) + { + var dir = CreateShortTempDir(); + var socketPath = Path.Combine(dir, "ctrl.sock"); + var host = new FakeControlHost(socketPath, session); + await host.StartAsync().ConfigureAwait(false); + _toDispose.Add(host); + return host; + } + + private string CreateShortTempDir() + { + // Windows has a 108-byte limit on AF_UNIX paths (and 104 on macOS), and the default + // %TEMP% can be deep. Allocate a short subdirectory we control. + var dir = Directory.CreateTempSubdirectory("at-").FullName; + _tempDirs.Add(dir); + return dir; + } + + public async ValueTask DisposeAsync() + { + foreach (var d in _toDispose) + { + try { await d.DisposeAsync().ConfigureAwait(false); } + catch { } + } + foreach (var dir in _tempDirs) + { + try { Directory.Delete(dir, recursive: true); } + catch { } + } + } + + /// + /// Stand-in for an aspire.terminalhost process: binds the control UDS and + /// exposes a single that + /// returns the canned . + /// + private sealed class FakeControlHost(string socketPath, TerminalHostSessionInfo session) : IAsyncDisposable + { + private Socket? _listenSocket; + private CancellationTokenSource? _cts; + private Task? _acceptLoop; + private readonly List _rpcs = []; + + public string SocketPath { get; } = socketPath; + + public Task StartAsync() + { + var dir = Path.GetDirectoryName(SocketPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + var sock = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + sock.Bind(new UnixDomainSocketEndPoint(SocketPath)); + sock.Listen(8); + _listenSocket = sock; + + _cts = new CancellationTokenSource(); + _acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token)); + return Task.CompletedTask; + } + + private async Task AcceptLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + Socket client; + try + { + client = await _listenSocket!.AcceptAsync(ct).ConfigureAwait(false); + } + catch + { + return; + } + + _ = Task.Run(async () => + { + var stream = new NetworkStream(client, ownsSocket: true); + var formatter = new SystemTextJsonFormatter(); + var handler = new HeaderDelimitedMessageHandler(stream, stream, formatter); + var rpc = new JsonRpc(handler); + + rpc.AddLocalRpcMethod( + TerminalHostControlProtocol.GetSessionMethod, + new Func(() => session)); + + lock (_rpcs) + { + _rpcs.Add(rpc); + } + + rpc.StartListening(); + try { await rpc.Completion.ConfigureAwait(false); } + catch { } + }, ct); + } + } + + public async ValueTask DisposeAsync() + { + try { _cts?.Cancel(); } catch { } + try { _listenSocket?.Dispose(); } catch { } + if (_acceptLoop is not null) + { + try { await _acceptLoop.ConfigureAwait(false); } catch { } + } + lock (_rpcs) + { + foreach (var rpc in _rpcs) + { + try { rpc.Dispose(); } catch { } + } + _rpcs.Clear(); + } + try { File.Delete(SocketPath); } catch { } + _cts?.Dispose(); + } + } + + private sealed class CustomResource(string name) : Resource(name); +} diff --git a/tests/Aspire.Hosting.Tests/Dcp/ConfigureDefaultDcpOptionsTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ConfigureDefaultDcpOptionsTests.cs new file mode 100644 index 00000000000..fb718e468c4 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Dcp/ConfigureDefaultDcpOptionsTests.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Dcp; +using Microsoft.Extensions.Configuration; + +namespace Aspire.Hosting.Tests.Dcp; + +public class ConfigureDefaultDcpOptionsTests +{ + [Fact] + public void TerminalHostFallsBackToAspireManagedDashboardPath() + { + // The CLI bundle launcher (PrebuiltAppHostServer/DotNetAppHostProject) sets + // ASPIRE_DASHBOARD_PATH to point at the multi-mode aspire-managed exe but doesn't + // set ASPIRE_TERMINAL_HOST_PATH. This regression test pins the fallback that + // reuses the aspire-managed binary as the terminal host with "terminalhost" as + // its dispatcher arg so .WithTerminal() works in TS-based and other prebuilt + // AppHost scenarios. + var managedExe = OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"; + var managedPath = Path.Combine(Path.GetTempPath(), "aspire-fake-bundle", "managed", managedExe); + + var options = ConfigureWithDcpPublisher(new() + { + ["DcpPublisher:DashboardPath"] = managedPath, + }); + + Assert.Equal(managedPath, options.DashboardPath); + Assert.Equal(managedPath, options.TerminalHostPath); + Assert.Equal("terminalhost", options.TerminalHostInvocationArgs); + } + + [Fact] + public void TerminalHostFallbackDoesNotApplyWhenDashboardPathIsNotAspireManaged() + { + // Standalone NuGet-package scenario: DashboardPath points at a per-RID dashboard + // binary. The fallback must not hijack TerminalHostPath in that case (the standalone + // terminal host nupkg supplies its own aspireterminalhostpath assembly metadata). + var dashboardDll = Path.Combine(Path.GetTempPath(), "fake-dashboard", "Aspire.Dashboard.dll"); + + var options = ConfigureWithDcpPublisher(new() + { + ["DcpPublisher:DashboardPath"] = dashboardDll, + // Neutralize any ambient aspireterminalhostpath assembly metadata in the test + // assembly so we can observe the fallback's null behaviour cleanly. + ["DcpPublisher:TerminalHostPath"] = " ", + }); + + Assert.Equal(dashboardDll, options.DashboardPath); + // Either the explicit whitespace value falls through to assembly metadata (dev-only, + // varies by environment) or stays empty. The point is the fallback didn't fire — i.e. + // TerminalHostPath !== DashboardPath and InvocationArgs wasn't auto-set to "terminalhost". + Assert.NotEqual(dashboardDll, options.TerminalHostPath); + Assert.NotEqual("terminalhost", options.TerminalHostInvocationArgs); + } + + [Fact] + public void TerminalHostFallbackDoesNotOverrideExplicitTerminalHostPath() + { + // If both paths are explicitly set (e.g. an integrator points at a separate + // terminal host binary while still using bundled aspire-managed for the dashboard), + // honour the explicit TerminalHostPath and don't auto-rewrite it. + var managedExe = OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"; + var managedPath = Path.Combine(Path.GetTempPath(), "aspire-fake-bundle", "managed", managedExe); + var explicitTerminalHostPath = Path.Combine(Path.GetTempPath(), "custom-terminalhost", "Aspire.TerminalHost.exe"); + + var options = ConfigureWithDcpPublisher(new() + { + ["DcpPublisher:DashboardPath"] = managedPath, + ["DcpPublisher:TerminalHostPath"] = explicitTerminalHostPath, + }); + + Assert.Equal(managedPath, options.DashboardPath); + Assert.Equal(explicitTerminalHostPath, options.TerminalHostPath); + // InvocationArgs left untouched (the explicit path may or may not be aspire-managed; + // the consumer is responsible for supplying its own dispatcher arg if needed). + Assert.NotEqual("terminalhost", options.TerminalHostInvocationArgs); + } + + [Fact] + public void TerminalHostFallbackHonoursExplicitInvocationArgs() + { + // If the consumer explicitly sets the InvocationArgs, the bundle fallback must NOT + // clobber it — the explicit value wins even when we synthesise the path from the + // dashboard. + var managedExe = OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"; + var managedPath = Path.Combine(Path.GetTempPath(), "aspire-fake-bundle", "managed", managedExe); + + var options = ConfigureWithDcpPublisher(new() + { + ["DcpPublisher:DashboardPath"] = managedPath, + ["DcpPublisher:TerminalHostInvocationArgs"] = "custom-subcommand", + }); + + Assert.Equal(managedPath, options.TerminalHostPath); + Assert.Equal("custom-subcommand", options.TerminalHostInvocationArgs); + } + + private static DcpOptions ConfigureWithDcpPublisher(Dictionary dcpPublisherSettings) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(dcpPublisherSettings) + .Build(); + + // Point AssemblyName at Aspire.Hosting itself so the resolver picks up an assembly + // that has no aspire{dashboard,terminalhost}path metadata — without this, the test + // runner's own assembly metadata (added by the build for inner-loop dev) leaks in + // and short-circuits the explicit configuration. + var appOptions = new DistributedApplicationOptions + { + AssemblyName = typeof(DcpOptions).Assembly.GetName().Name, + }; + var configurer = new ConfigureDefaultDcpOptions(appOptions, configuration); + var options = new DcpOptions(); + configurer.Configure(options); + + return options; + } +} + diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 7a8f95a33d0..f645c6fa61e 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -3603,6 +3603,126 @@ public async Task Project_NonProjectLaunchConfig_UnsupportedByExtension_RunsInPr Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); } + [Fact] + public async Task Project_WithTerminal_PopulatesPerReplicaTerminalSpecOnWindows() + { + // Phase 4 wire-up: when a project resource is configured with WithTerminal() + // (and runs on Windows where DCP currently supports PTY), each replica's + // Executable spec should carry a Terminal block whose UdsPath matches the + // per-replica producer path from TerminalHostLayout.ProducerUdsPaths. + Assert.SkipUnless(OperatingSystem.IsWindows(), "DCP terminal support is currently Windows-only."); + + var builder = DistributedApplication.CreateBuilder(); + var resource = builder.AddProject("ServiceA") + .WithReplicas(2) + .WithTerminal(options => + { + options.Columns = 100; + options.Rows = 30; + }) + .Resource; + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + // The per-replica TerminalHostResources are now materialized inside BeforeStartEvent + // (see TerminalResourceBuilderExtensions.WithTerminal). DcpExecutor.RunApplicationAsync + // does not raise that event itself, so the test publishes it manually before running + // the executor — otherwise TerminalAnnotation.TerminalHosts would still be empty when + // ExecutableCreator looks up the per-replica producer UDS path. + await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, distributedAppModel)); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + await appExecutor.RunApplicationAsync(); + + var exes = kubernetesService.CreatedResources.OfType() + .Where(e => e.AppModelResourceName == "ServiceA") + .OrderBy(e => e.Metadata.Annotations?[CustomResource.ResourceReplicaIndex], StringComparer.Ordinal) + .ToList(); + Assert.Equal(2, exes.Count); + + // Each parent replica gets its own per-replica TerminalHostResource — the + // annotation now carries the list, and the Executable for replica i must + // dial the producer UDS owned by TerminalHosts[i]. + var hosts = resource.Annotations.OfType().Single().TerminalHosts; + Assert.Equal(2, hosts.Count); + + for (var i = 0; i < exes.Count; i++) + { + var spec = exes[i].Spec.Terminal; + Assert.NotNull(spec); + Assert.True(spec!.Enabled); + Assert.Equal(hosts[i].Layout.ProducerUdsPath, spec.UdsPath); + Assert.Equal(100, spec.Cols); + Assert.Equal(30, spec.Rows); + } + } + + [Fact] + public async Task Project_WithoutTerminal_HasNullTerminalSpec() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddProject("ServiceA"); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + await appExecutor.RunApplicationAsync(); + + var exe = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "ServiceA"); + Assert.Null(exe.Spec.Terminal); + } + + [Fact] + public async Task PlainExecutable_WithTerminal_PopulatesTerminalSpecOnWindows() + { + // Regression test: plain executables added via AddExecutable() were missing + // the ResourceReplicaIndex/ResourceReplicaCount annotations, which caused + // BuildExecutableConfiguration to skip the spec.Terminal wire-up entirely + // (the per-replica producer UDS path lookup in TerminalHostLayout was guarded + // by a successful TryGetReplicaIndex). + Assert.SkipUnless(OperatingSystem.IsWindows(), "DCP terminal support is currently Windows-only."); + + var builder = DistributedApplication.CreateBuilder(); + var resource = builder.AddExecutable("shell", "cmd.exe", ".") + .WithTerminal(options => + { + options.Columns = 100; + options.Rows = 30; + }) + .Resource; + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + // BeforeStartEvent is where the per-replica TerminalHostResources are now + // materialized. See the matching note in Project_WithTerminal_PopulatesPerReplicaTerminalSpecOnWindows. + await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, distributedAppModel)); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + await appExecutor.RunApplicationAsync(); + + var exe = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "shell"); + // Plain executables are always single-replica — there's exactly one host + // at index 0, owning the only producer UDS. + var host = Assert.Single(resource.Annotations.OfType().Single().TerminalHosts); + + Assert.NotNull(exe.Spec.Terminal); + Assert.True(exe.Spec.Terminal!.Enabled); + Assert.Equal(host.Layout.ProducerUdsPath, exe.Spec.Terminal.UdsPath); + Assert.Equal(100, exe.Spec.Terminal.Cols); + Assert.Equal(30, exe.Spec.Terminal.Rows); + + // Plain executables are always single-replica today; both annotations must + // be present for the per-replica lookup to succeed. + Assert.Equal("1", exe.Metadata.Annotations?[CustomResource.ResourceReplicaCount]); + Assert.Equal("0", exe.Metadata.Annotations?[CustomResource.ResourceReplicaIndex]); + } + private static DcpExecutor CreateAppExecutor( DistributedApplicationModel distributedAppModel, IHostEnvironment? hostEnvironment = null, diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs index 42f6d50a5e8..5b2c8afbee6 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs @@ -56,7 +56,8 @@ public void BuilderAddsDefaultServices() eventingSubscribers, s => Assert.IsType(s), s => Assert.IsType(s), - s => Assert.IsType(s) + s => Assert.IsType(s), + s => Assert.IsType(s) ); var options = app.Services.GetRequiredService>(); diff --git a/tests/Aspire.Hosting.Tests/WithTerminalTests.cs b/tests/Aspire.Hosting.Tests/WithTerminalTests.cs new file mode 100644 index 00000000000..2b2c43a52f3 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/WithTerminalTests.cs @@ -0,0 +1,356 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Testing; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Tests; + +public class WithTerminalTests +{ + [Fact] + public async Task WithTerminalAddsTerminalAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", "."); + + resource.WithTerminal(); + + var annotation = resource.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + Assert.Equal(120, annotation.Options.Columns); + Assert.Equal(30, annotation.Options.Rows); + Assert.Null(annotation.Options.Shell); + + // Until BeforeStartEvent fires the per-replica hosts are not yet materialized: + // TerminalHosts is empty and IsInitialized is false. This deferral is what + // allows WithReplicas(N) to be honoured even when called AFTER WithTerminal(). + Assert.False(annotation.IsInitialized); + Assert.Empty(annotation.TerminalHosts); + + await PublishBeforeStartAsync(builder); + + Assert.True(annotation.IsInitialized); + Assert.Single(annotation.TerminalHosts); + } + + [Fact] + public void WithTerminalAcceptsCustomOptions() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", "."); + + resource.WithTerminal(options => + { + options.Columns = 200; + options.Rows = 50; + options.Shell = "/bin/bash"; + }); + + var annotation = resource.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + Assert.Equal(200, annotation.Options.Columns); + Assert.Equal(50, annotation.Options.Rows); + Assert.Equal("/bin/bash", annotation.Options.Shell); + } + + [Fact] + public async Task WithTerminalCreatesPerReplicaHiddenTerminalHostResources() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", "."); + + resource.WithTerminal(); + + var (_, model) = await BuildAndPublishBeforeStartAsync(builder); + + var hosts = model.Resources.OfType().ToList(); + var single = Assert.Single(hosts); + // Default name pattern is "{parent}-terminalhost-{i}" where i is the parent + // replica index. With the default replica count of 1, the only host is index 0. + Assert.Equal("myapp-terminalhost-0", single.Name); + Assert.Same(resource.Resource, single.Parent); + Assert.Equal(0, single.ParentReplicaIndex); + } + + [Fact] + public async Task WithTerminalLinksAnnotationToHostResources() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", "."); + + resource.WithTerminal(); + + var (_, model) = await BuildAndPublishBeforeStartAsync(builder); + + var annotation = resource.Resource.Annotations.OfType().Single(); + var hostFromModel = model.Resources.OfType().Single(); + Assert.Same(hostFromModel, Assert.Single(annotation.TerminalHosts)); + } + + [Fact] + public async Task WithTerminalAddsWaitAnnotationForEachTerminalHost() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", "."); + + resource.WithTerminal(); + + await PublishBeforeStartAsync(builder); + + var waitAnnotations = resource.Resource.Annotations.OfType() + .Where(w => w.Resource is TerminalHostResource) + .ToList(); + var single = Assert.Single(waitAnnotations); + Assert.Equal(WaitType.WaitUntilStarted, single.WaitType); + } + + [Fact] + public void WithTerminalCanBeChained() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", "."); + + var result = resource.WithTerminal(); + + Assert.Same(resource, result); + } + + [Fact] + public async Task WithTerminalWorksOnContainerResources() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var container = builder.AddContainer("mycontainer", "myimage"); + + container.WithTerminal(); + + var annotation = container.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + + var (_, model) = await BuildAndPublishBeforeStartAsync(builder); + + var hosts = model.Resources.OfType().ToList(); + var single = Assert.Single(hosts); + Assert.Equal("mycontainer-terminalhost-0", single.Name); + } + + [Fact] + public async Task TerminalHostResourcesAreExcludedFromManifest() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", "."); + + resource.WithTerminal(); + + var (_, model) = await BuildAndPublishBeforeStartAsync(builder); + + foreach (var host in model.Resources.OfType()) + { + var manifestAnnotation = host.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(manifestAnnotation); + } + } + + [Fact] + public void WithTerminalThrowsForNullBuilder() + { + IResourceBuilder builder = null!; + + Assert.Throws(() => builder.WithTerminal()); + } + + [Fact] + public void WithTerminalThrowsWhenCalledTwiceOnSameResource() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", "."); + + resource.WithTerminal(); + + Assert.Throws(() => resource.WithTerminal()); + } + + [Fact] + public async Task WithTerminalDefaultsToOneTerminalHost() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", "."); + + resource.WithTerminal(); + + await PublishBeforeStartAsync(builder); + + var hosts = resource.Resource.Annotations.OfType().Single().TerminalHosts; + var single = Assert.Single(hosts); + Assert.Equal(0, single.ParentReplicaIndex); + Assert.NotEmpty(single.Layout.ProducerUdsPath); + Assert.NotEmpty(single.Layout.ConsumerUdsPath); + Assert.NotEmpty(single.Layout.ControlUdsPath); + } + + [Fact] + public async Task WithTerminalAfterWithReplicasCreatesOneTerminalHostPerReplica() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", ".") + .WithAnnotation(new ReplicaAnnotation(3)); + + resource.WithTerminal(); + + await PublishBeforeStartAsync(builder); + + var hosts = resource.Resource.Annotations.OfType().Single().TerminalHosts; + Assert.Equal(3, hosts.Count); + + for (var i = 0; i < 3; i++) + { + Assert.Equal(i, hosts[i].ParentReplicaIndex); + // The parent replica index is encoded into the per-replica directory of + // the layout — DCP and viewers don't need to know that, but path uniqueness + // is what keeps the per-replica hosts from colliding on the same UDS. + Assert.Contains($"{Path.DirectorySeparatorChar}{i}{Path.DirectorySeparatorChar}", hosts[i].Layout.ProducerUdsPath); + Assert.Contains($"{Path.DirectorySeparatorChar}{i}{Path.DirectorySeparatorChar}", hosts[i].Layout.ConsumerUdsPath); + Assert.Equal($"myapp-terminalhost-{i}", hosts[i].Name); + } + } + + [Fact] + public async Task WithReplicasAfterWithTerminalCreatesOneTerminalHostPerReplica() + { + // Regression test for the original ordering bug: previously WithTerminal() read + // the parent's ReplicaAnnotation eagerly, so calling WithReplicas(N) AFTER + // WithTerminal() resulted in only one terminal host being created. With deferred + // host materialization in BeforeStartEvent, the order is now irrelevant. + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", "."); + + resource.WithTerminal(); + resource.WithAnnotation(new ReplicaAnnotation(3)); + + await PublishBeforeStartAsync(builder); + + var hosts = resource.Resource.Annotations.OfType().Single().TerminalHosts; + Assert.Equal(3, hosts.Count); + + for (var i = 0; i < 3; i++) + { + Assert.Equal(i, hosts[i].ParentReplicaIndex); + Assert.Equal($"myapp-terminalhost-{i}", hosts[i].Name); + } + } + + [Fact] + public async Task TerminalHostLayoutPathsAreUnderTheSameTempBaseDirectory() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", ".") + .WithAnnotation(new ReplicaAnnotation(2)); + + resource.WithTerminal(); + + await PublishBeforeStartAsync(builder); + + var hosts = resource.Resource.Annotations.OfType().Single().TerminalHosts; + var sharedBase = hosts[0].Layout.BaseDirectory; + + foreach (var host in hosts) + { + // All per-replica hosts share the same per-target base directory so a + // single recursive delete cleans up every replica's sockets. + Assert.Equal(sharedBase, host.Layout.BaseDirectory); + Assert.StartsWith(sharedBase, host.Layout.ProducerUdsPath); + Assert.StartsWith(sharedBase, host.Layout.ConsumerUdsPath); + Assert.StartsWith(sharedBase, host.Layout.ControlUdsPath); + } + } + + [Fact] + public async Task TerminalHostHasCommandLineArgsForLayoutPaths() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", ".") + .WithAnnotation(new ReplicaAnnotation(2)); + + resource.WithTerminal(options => + { + options.Columns = 200; + options.Rows = 50; + options.Shell = "/bin/bash"; + }); + + await PublishBeforeStartAsync(builder); + + var hosts = resource.Resource.Annotations.OfType().Single().TerminalHosts; + // Each per-replica host serves exactly one replica, so its argv carries + // exactly one --producer-uds / --consumer-uds / --control-uds value. + // --replica-count is intentionally absent in the new single-replica shape. + foreach (var host in hosts) + { + var args = await GetResolvedCommandLineArgsAsync(host); + + Assert.DoesNotContain("--replica-count", args); + Assert.Single(args, a => a == "--producer-uds"); + Assert.Single(args, a => a == "--consumer-uds"); + Assert.Single(args, a => a == "--control-uds"); + + Assert.Contains(host.Layout.ProducerUdsPath, args); + Assert.Contains(host.Layout.ConsumerUdsPath, args); + Assert.Contains(host.Layout.ControlUdsPath, args); + + Assert.Contains("--columns", args); + Assert.Contains("200", args); + Assert.Contains("--rows", args); + Assert.Contains("50", args); + Assert.Contains("--shell", args); + Assert.Contains("/bin/bash", args); + } + } + + [Fact] + public async Task TerminalHostResourcesHaveUnresolvedCommandUntilTerminalHostPathIsConfigured() + { + // The host process binary path is filled in by TerminalHostEventingSubscriber + // from DcpOptions during BeforeStartEvent. The test environment doesn't ship a + // real terminalhost binary, so the placeholder remains after the event fires. + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("myapp", "myapp", "."); + + resource.WithTerminal(); + + await PublishBeforeStartAsync(builder); + + foreach (var host in resource.Resource.Annotations.OfType().Single().TerminalHosts) + { + Assert.Equal(TerminalHostResource.UnresolvedCommand, host.Command); + } + } + + private static async Task> GetResolvedCommandLineArgsAsync(TerminalHostResource host) + { + var argsList = new List(); + foreach (var callbackAnnotation in host.Annotations.OfType()) + { + await callbackAnnotation.Callback(new CommandLineArgsCallbackContext(argsList, CancellationToken.None)); + } + return argsList.Select(a => a?.ToString() ?? string.Empty).ToList(); + } + + private static async Task PublishBeforeStartAsync(IDistributedApplicationTestingBuilder builder) + { + // BeforeStartEvent is the seam where WithTerminal() now materializes its per-replica + // hosts. Tests that observe TerminalHosts/host annotations have to publish it manually + // because the test harness doesn't go through DistributedApplication.RunApplicationAsync. + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, model)); + } + + private static async Task<(DistributedApplication App, DistributedApplicationModel Model)> BuildAndPublishBeforeStartAsync(IDistributedApplicationTestingBuilder builder) + { + var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, model)); + return (app, model); + } +} diff --git a/tests/Aspire.Templates.Tests/README.md b/tests/Aspire.Templates.Tests/README.md index 4cbb8d238bd..07562e96604 100644 --- a/tests/Aspire.Templates.Tests/README.md +++ b/tests/Aspire.Templates.Tests/README.md @@ -59,7 +59,7 @@ The SDK in `artifacts/bin/dotnet-tests` is usable outside the repo at this point There are three categories of NuGet packages used by the workload: -1. `Aspire.Dashboard.Sdk.osx-arm64`, `Aspire.Hosting.Orchestration.osx-arm64`, and `Aspire.AppHost.Sdk` +1. `Aspire.Dashboard.Sdk.osx-arm64`, `Aspire.TerminalHost.Sdk.osx-arm64`, `Aspire.Hosting.Orchestration.osx-arm64`, and `Aspire.AppHost.Sdk` - these are installed in `artifacts/bin/dotnet-tests/packs/` - Once the workload is installed, these are never updated automatically, so any changes made locally won't show up in the tests diff --git a/tests/Aspire.TerminalHost.Tests/Aspire.TerminalHost.Tests.csproj b/tests/Aspire.TerminalHost.Tests/Aspire.TerminalHost.Tests.csproj new file mode 100644 index 00000000000..faf96d24f0e --- /dev/null +++ b/tests/Aspire.TerminalHost.Tests/Aspire.TerminalHost.Tests.csproj @@ -0,0 +1,38 @@ + + + + $(DefaultTargetFramework) + enable + enable + false + + + $(NoWarn);CS8002 + + + false + false + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs b/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs new file mode 100644 index 00000000000..b88af355390 --- /dev/null +++ b/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs @@ -0,0 +1,641 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using Aspire.Shared.TerminalHost; +using Microsoft.Extensions.Logging.Abstractions; +using StreamJsonRpc; + +namespace Aspire.TerminalHost.Tests; + +[CollectionDefinition(nameof(TerminalHostAppTestsCollection), DisableParallelization = true)] +public sealed class TerminalHostAppTestsCollection; + +[Collection(nameof(TerminalHostAppTestsCollection))] +public class TerminalHostAppTests +{ + /// + /// Builds a single-replica argument set for the host. Each terminal host process + /// serves exactly one replica, so the AppHost (and these tests) just hand it one + /// producer/consumer/control UDS path triple. The replica index is opaque to the + /// host — callers encode it however they like in the path layout. + /// + private static (TerminalHostArgs args, TestTempDirectory tmp, string controlPath) BuildArgs() + { + var tmp = new TestTempDirectory(); + var dcpDir = Path.Combine(tmp.Path, "dcp"); + var hostDir = Path.Combine(tmp.Path, "host"); + var ctrlDir = Path.Combine(tmp.Path, "control"); + Directory.CreateDirectory(dcpDir); + Directory.CreateDirectory(hostDir); + Directory.CreateDirectory(ctrlDir); + + var producer = Path.Combine(dcpDir, "r.sock"); + var consumer = Path.Combine(hostDir, "r.sock"); + var control = Path.Combine(ctrlDir, "ctrl.sock"); + + var args = TerminalHostArgs.Parse([ + "--producer-uds", producer, + "--consumer-uds", consumer, + "--control-uds", control, + ]); + + return (args, tmp, control); + } + + [Fact] + public async Task RunAsyncBindsControlListenerWhenStarted() + { + var (args, tmp, control) = BuildArgs(); + using var disp = tmp; + + await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance); + using var hostCts = new CancellationTokenSource(); + var hostTask = app.RunAsync(hostCts.Token); + + try + { + await WaitForFileAsync(control, TimeSpan.FromSeconds(10)); + Assert.True(File.Exists(control)); + } + finally + { + app.RequestShutdown(); + hostCts.Cancel(); + await hostTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public async Task ControlEndpointReturnsSessionInfo() + { + var (args, tmp, control) = BuildArgs(); + using var disp = tmp; + + await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance); + using var hostCts = new CancellationTokenSource(); + var hostTask = app.RunAsync(hostCts.Token); + + try + { + await WaitForFileAsync(control, TimeSpan.FromSeconds(10)); + + using var rpc = await OpenControlRpcAsync(control); + var info = await rpc.InvokeAsync( + TerminalHostControlProtocol.GetInfoMethod); + var session = await rpc.InvokeAsync( + TerminalHostControlProtocol.GetSessionMethod); + + Assert.Equal(TerminalHostControlProtocol.ProtocolVersion, info.ProtocolVersion); + Assert.Equal(args.ProducerUdsPath, session.ProducerUdsPath); + Assert.Equal(args.ConsumerUdsPath, session.ConsumerUdsPath); + } + finally + { + app.RequestShutdown(); + hostCts.Cancel(); + await hostTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public async Task ShutdownRequestCausesRunAsyncToReturn() + { + var (args, tmp, control) = BuildArgs(); + using var disp = tmp; + + await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance); + using var hostCts = new CancellationTokenSource(); + var hostTask = app.RunAsync(hostCts.Token); + + await WaitForFileAsync(control, TimeSpan.FromSeconds(10)); + + using (var rpc = await OpenControlRpcAsync(control)) + { + // Fire and forget — the host may close the socket before the RPC ack arrives. + _ = rpc.InvokeAsync(TerminalHostControlProtocol.ShutdownMethod); + } + + var exitCode = await hostTask.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task SnapshotSessionReportsConfiguredPaths() + { + var (args, tmp, control) = BuildArgs(); + using var disp = tmp; + + await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance); + using var hostCts = new CancellationTokenSource(); + var hostTask = app.RunAsync(hostCts.Token); + + try + { + await WaitForFileAsync(control, TimeSpan.FromSeconds(10)); + + var snap = app.SnapshotSession(); + Assert.Equal(args.ProducerUdsPath, snap.ProducerUdsPath); + Assert.Equal(args.ConsumerUdsPath, snap.ConsumerUdsPath); + } + finally + { + app.RequestShutdown(); + hostCts.Cancel(); + await hostTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public async Task RunAsyncWithBadArgsViaStaticEntryPointReturnsExUsage() + { + var exitCode = await TerminalHostApp.RunAsync(["--bogus"], CancellationToken.None); + Assert.Equal(64, exitCode); // EX_USAGE + } + + [Fact] + public async Task SessionRecyclesAfterProducerDisconnect() + { + // End-to-end check of the recycle loop: the host should stay running + // across a producer disconnect and accept a fresh producer on the + // same UDS path, with ProducerConnected and RestartCount tracking + // each cycle. This exercises the path DCP exercises in production + // when the underlying process exits and gets relaunched. + var (args, tmp, control) = BuildArgs(); + using var disp = tmp; + + await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance); + using var hostCts = new CancellationTokenSource(); + var hostTask = app.RunAsync(hostCts.Token); + + try + { + await WaitForFileAsync(control, TimeSpan.FromSeconds(10)); + + // Initial state: host is up but no producer has dialed in yet. + var initial = app.SnapshotSession(); + Assert.False(initial.ProducerConnected, "Session should report no producer before any connect."); + Assert.False(initial.IsAlive, "Legacy IsAlive should mirror ProducerConnected."); + Assert.Equal(0, initial.RestartCount); + + // Cycle 1: connect, get accepted, disconnect. + await using (var producer = await ConnectProducerAsync(args.ProducerUdsPath, TimeSpan.FromSeconds(5))) + { + await producer.SendHelloAsync(80, 24, default); + await producer.SendOutputAsync("first cycle"u8.ToArray(), default); + await WaitForAsync( + () => app.SnapshotSession().ProducerConnected, + TimeSpan.FromSeconds(5), + "ProducerConnected should flip to true after producer dials in."); + } + + await WaitForAsync( + () => + { + var s = app.SnapshotSession(); + return !s.ProducerConnected && s.RestartCount >= 1; + }, + TimeSpan.FromSeconds(10), + "After producer disconnect, ProducerConnected should clear and RestartCount should advance."); + + var afterCycle1 = app.SnapshotSession(); + Assert.Equal(1, afterCycle1.RestartCount); + + // Cycle 2: a fresh producer should be able to dial the same UDS path. + // This is the critical DCP-restart scenario. + await using (var producer = await ConnectProducerAsync(args.ProducerUdsPath, TimeSpan.FromSeconds(10))) + { + await producer.SendHelloAsync(80, 24, default); + await producer.SendOutputAsync("second cycle"u8.ToArray(), default); + await WaitForAsync( + () => app.SnapshotSession().ProducerConnected, + TimeSpan.FromSeconds(5), + "ProducerConnected should flip true again after the second producer dials in."); + } + + await WaitForAsync( + () => + { + var s = app.SnapshotSession(); + return !s.ProducerConnected && s.RestartCount >= 2; + }, + TimeSpan.FromSeconds(10), + "After the second producer disconnects, ProducerConnected should clear and RestartCount should reach 2."); + + // Session itself is still there — IsAlive/ProducerConnected being false + // is transient, the snapshot continues to report the same UDS paths. + var afterCycle2 = app.SnapshotSession(); + Assert.Equal(args.ProducerUdsPath, afterCycle2.ProducerUdsPath); + Assert.Equal(args.ConsumerUdsPath, afterCycle2.ConsumerUdsPath); + } + finally + { + app.RequestShutdown(); + hostCts.Cancel(); + await hostTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public async Task SessionSnapshotIncludesNewFields() + { + // Even before any producer has connected, the snapshot must populate + // the new fields so older AppHost wire deserialisation never sees a + // missing-required-property error. + var (args, tmp, control) = BuildArgs(); + using var disp = tmp; + + await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance); + using var hostCts = new CancellationTokenSource(); + var hostTask = app.RunAsync(hostCts.Token); + + try + { + await WaitForFileAsync(control, TimeSpan.FromSeconds(10)); + + var snap = app.SnapshotSession(); + Assert.False(snap.ProducerConnected); + Assert.False(snap.IsAlive); + Assert.Equal(0, snap.RestartCount); + Assert.Null(snap.ExitCode); + } + finally + { + app.RequestShutdown(); + hostCts.Cancel(); + await hostTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public async Task DownstreamPrimaryResizeIsForwardedUpstreamAsRawResizeFrame() + { + // Regression: the consumer-side multi-head server fires its OnResized + // event whenever the current primary peer changes the producer dims + // (RequestPrimary or explicit Resize from primary). The terminal host + // bridges that event to a raw HMP1 FrameResize (0x05) on the upstream + // (DCP-facing) connection — bypassing Hex1b's stock Hmp1WorkloadAdapter + // IsPrimary gate, which would silently drop every resize because DCP's + // minimal HMP1 server never sends Hello.PrimaryPeerId or RoleChange. + // Without this bridge, the underlying PTY stayed at its DCP-initial + // dims forever. + var (args, tmp, control) = BuildArgs(); + using var disp = tmp; + + await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance); + using var hostCts = new CancellationTokenSource(); + var hostTask = app.RunAsync(hostCts.Token); + + try + { + await WaitForFileAsync(control, TimeSpan.FromSeconds(10)); + + // Stand up the upstream "DCP" first. Send Hello so the host's + // upstream-side adapter handshakes successfully and ProducerConnected + // flips before we attempt the consumer-side connect — otherwise the + // resize broadcast would race the upstream stream's existence. + await using var producer = await ConnectProducerAsync(args.ProducerUdsPath, TimeSpan.FromSeconds(5)); + await producer.SendHelloAsync(80, 24, default); + + await WaitForAsync( + () => app.SnapshotSession().ProducerConnected, + TimeSpan.FromSeconds(5), + "ProducerConnected should flip to true after producer dials in."); + + // Wait for the consumer-side UDS server to bind so the dial below + // doesn't race a not-yet-listening socket. + await WaitForFileAsync(args.ConsumerUdsPath, TimeSpan.FromSeconds(5)); + + // Now connect a minimal raw-frame HMP1 client to the consumer UDS: + // ClientHello + RequestPrimary, then keep the stream open. We don't + // need a full Hex1bTerminal because all we're verifying is that the + // server-side OnResized event (which fires when RequestPrimary + // promotes us and applies the requested dims) is bridged upstream. + const int requestedWidth = 123; + const int requestedHeight = 45; + + await using var consumer = await TestHmp1Consumer.ConnectAsync( + args.ConsumerUdsPath, TimeSpan.FromSeconds(5)); + await consumer.SendClientHelloAsync("test-consumer", "primary", default); + await consumer.SendRequestPrimaryAsync(requestedWidth, requestedHeight, default); + + // The frame the test producer should observe upstream: + // [type=0x05 Resize][length=8 LE][width:4B LE][height:4B LE] + // + // Use the predicate variant because the host's Hex1bTerminal may + // emit additional resize frames upstream during startup or in + // response to incoming Hello dims (the consumer-side server's + // Resized event also triggers Hex1bTerminal's own + // workload.ResizeAsync). We only care that SOME FrameResize lands + // upstream with the dims the consumer requested via RequestPrimary. + const byte FrameResize = 0x05; + var payload = await producer.WaitForMatchingFrameAsync( + FrameResize, + p => + { + if (p.Length != 8) + { + return false; + } + var w = p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24); + var h = p[4] | (p[5] << 8) | (p[6] << 16) | (p[7] << 24); + return w == requestedWidth && h == requestedHeight; + }, + TimeSpan.FromSeconds(10)); + + // Sanity: payload encodes exactly the requested dims (defensive + // — the predicate already filtered, but keeps the assertion intent + // explicit on the test surface). + Assert.Equal(8, payload.Length); + var observedWidth = payload[0] | (payload[1] << 8) | (payload[2] << 16) | (payload[3] << 24); + var observedHeight = payload[4] | (payload[5] << 8) | (payload[6] << 16) | (payload[7] << 24); + Assert.Equal(requestedWidth, observedWidth); + Assert.Equal(requestedHeight, observedHeight); + } + finally + { + app.RequestShutdown(); + hostCts.Cancel(); + await hostTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + + /// + /// A minimal HMP1 server-role producer for tests. Connects to the UDS path + /// the terminal host is listening on (producer side) and writes the bare + /// minimum frames the Hex1bTerminal client expects: a Hello, optional + /// Output frames, and EOF on dispose. + /// + private sealed class TestHmp1Producer : IAsyncDisposable + { + // HMP1 wire format: [type:1B][length:4B LE][payload:N bytes]. + private const byte FrameHello = 0x01; + private const byte FrameOutput = 0x03; + + private readonly Socket _socket; + private readonly NetworkStream _stream; + private bool _disposed; + + public TestHmp1Producer(Socket socket) + { + _socket = socket; + _stream = new NetworkStream(socket, ownsSocket: true); + } + + public async Task SendHelloAsync(int width, int height, CancellationToken ct) + { + var json = $"{{\"version\":1,\"width\":{width},\"height\":{height}}}"; + await SendFrameAsync(FrameHello, System.Text.Encoding.UTF8.GetBytes(json), ct).ConfigureAwait(false); + } + + public Task SendOutputAsync(byte[] payload, CancellationToken ct) => + SendFrameAsync(FrameOutput, payload, ct); + + /// + /// Drains HMP1 frames until a frame of the requested type whose + /// payload passes the predicate is found. + /// Useful when upstream may receive multiple frames of the same type + /// from different sources (e.g., terminal startup resize vs explicit + /// user resize). + /// + public async Task WaitForMatchingFrameAsync( + byte expectedType, Func match, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + while (true) + { + var (type, payload) = await ReadFrameAsync(cts.Token).ConfigureAwait(false); + if (type == expectedType && match(payload)) + { + return payload; + } + } + } + + /// Reads exactly one HMP1 frame and returns its (type, payload). + public async Task<(byte Type, byte[] Payload)> ReadFrameAsync(CancellationToken ct) + { + var header = new byte[5]; + await ReadExactlyAsync(header, ct).ConfigureAwait(false); + + var type = header[0]; + var length = (uint)(header[1] | (header[2] << 8) | (header[3] << 16) | (header[4] << 24)); + if (length > 16 * 1024 * 1024) + { + throw new InvalidOperationException($"Producer-side reader: frame length {length} exceeds 16MB cap."); + } + + var payload = new byte[length]; + if (length > 0) + { + await ReadExactlyAsync(payload, ct).ConfigureAwait(false); + } + return (type, payload); + } + + private async Task ReadExactlyAsync(byte[] buffer, CancellationToken ct) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = await _stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), ct).ConfigureAwait(false); + if (read == 0) + { + throw new EndOfStreamException( + $"Producer-side reader: stream EOF after {offset} of {buffer.Length} bytes."); + } + offset += read; + } + } + + private async Task SendFrameAsync(byte type, byte[] payload, CancellationToken ct) + { + var header = new byte[5]; + header[0] = type; + header[1] = (byte)(payload.Length & 0xFF); + header[2] = (byte)((payload.Length >> 8) & 0xFF); + header[3] = (byte)((payload.Length >> 16) & 0xFF); + header[4] = (byte)((payload.Length >> 24) & 0xFF); + await _stream.WriteAsync(header, ct).ConfigureAwait(false); + if (payload.Length > 0) + { + await _stream.WriteAsync(payload, ct).ConfigureAwait(false); + } + await _stream.FlushAsync(ct).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + _disposed = true; + try { _socket.Shutdown(SocketShutdown.Both); } catch { /* ignore */ } + await _stream.DisposeAsync().ConfigureAwait(false); + } + } + + private static async Task ConnectProducerAsync(string socketPath, TimeSpan timeout) + { + // Retry loop because there is a brief unbound window between recycle + // iterations on the host side; the test producer should ride through + // that the same way DCP does in production. + var sw = System.Diagnostics.Stopwatch.StartNew(); + Exception? last = null; + while (sw.Elapsed < timeout) + { + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath)).ConfigureAwait(false); + return new TestHmp1Producer(socket); + } + catch (Exception ex) + { + socket.Dispose(); + last = ex; + await Task.Delay(50).ConfigureAwait(false); + } + } + throw new TimeoutException( + $"Timed out connecting to producer UDS '{socketPath}' after {timeout.TotalSeconds:F1}s.", last); + } + + /// + /// A minimal HMP1 client-role consumer for tests. Connects to the consumer + /// UDS the terminal host is listening on, then writes raw HMP1 frames + /// (ClientHello, RequestPrimary). Avoids spinning up a full + /// Hex1bTerminal in a test process where no interactive console is + /// attached. + /// + private sealed class TestHmp1Consumer : IAsyncDisposable + { + private const byte FrameRequestPrimary = 0x07; + private const byte FrameClientHello = 0x0B; + + private readonly Socket _socket; + private readonly NetworkStream _stream; + private bool _disposed; + + private TestHmp1Consumer(Socket socket) + { + _socket = socket; + _stream = new NetworkStream(socket, ownsSocket: true); + } + + public static async Task ConnectAsync(string socketPath, TimeSpan timeout) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + Exception? last = null; + while (sw.Elapsed < timeout) + { + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath)).ConfigureAwait(false); + return new TestHmp1Consumer(socket); + } + catch (Exception ex) + { + socket.Dispose(); + last = ex; + await Task.Delay(50).ConfigureAwait(false); + } + } + throw new TimeoutException( + $"Timed out connecting to consumer UDS '{socketPath}' after {timeout.TotalSeconds:F1}s.", last); + } + + public async Task SendClientHelloAsync(string displayName, string defaultRole, CancellationToken ct) + { + // JSON keys are camelCase per Hmp1JsonContext.PropertyNamingPolicy. + // Roles on the wire are "primary" / "secondary" since Phase 15. + var json = $"{{\"displayName\":\"{displayName}\",\"defaultRole\":\"{defaultRole}\"}}"; + await SendFrameAsync(FrameClientHello, System.Text.Encoding.UTF8.GetBytes(json), ct).ConfigureAwait(false); + } + + public async Task SendRequestPrimaryAsync(int cols, int rows, CancellationToken ct) + { + var json = $"{{\"cols\":{cols},\"rows\":{rows}}}"; + await SendFrameAsync(FrameRequestPrimary, System.Text.Encoding.UTF8.GetBytes(json), ct).ConfigureAwait(false); + } + + private async Task SendFrameAsync(byte type, byte[] payload, CancellationToken ct) + { + var header = new byte[5]; + header[0] = type; + header[1] = (byte)(payload.Length & 0xFF); + header[2] = (byte)((payload.Length >> 8) & 0xFF); + header[3] = (byte)((payload.Length >> 16) & 0xFF); + header[4] = (byte)((payload.Length >> 24) & 0xFF); + await _stream.WriteAsync(header, ct).ConfigureAwait(false); + if (payload.Length > 0) + { + await _stream.WriteAsync(payload, ct).ConfigureAwait(false); + } + await _stream.FlushAsync(ct).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + _disposed = true; + try { _socket.Shutdown(SocketShutdown.Both); } catch { /* ignore */ } + await _stream.DisposeAsync().ConfigureAwait(false); + } + } + + private static async Task WaitForAsync(Func predicate, TimeSpan timeout, string failureMessage) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + if (predicate()) + { + return; + } + await Task.Delay(25).ConfigureAwait(false); + } + throw new TimeoutException($"{failureMessage} (waited {timeout.TotalSeconds:F1}s)."); + } + + private static async Task OpenControlRpcAsync(string socketPath) + { + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath)); + } + catch + { + socket.Dispose(); + throw; + } + + var stream = new NetworkStream(socket, ownsSocket: true); + var formatter = new SystemTextJsonFormatter(); + var handler = new HeaderDelimitedMessageHandler(stream, stream, formatter); + var rpc = new JsonRpc(handler); + rpc.StartListening(); + return rpc; + } + + private static async Task WaitForFileAsync(string path, TimeSpan timeout) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + if (File.Exists(path)) + { + return; + } + await Task.Delay(50); + } + + throw new TimeoutException($"Timed out waiting for '{path}' after {timeout.TotalSeconds:F1}s."); + } +} diff --git a/tests/Aspire.TerminalHost.Tests/TerminalHostArgsTests.cs b/tests/Aspire.TerminalHost.Tests/TerminalHostArgsTests.cs new file mode 100644 index 00000000000..7d0faf8523e --- /dev/null +++ b/tests/Aspire.TerminalHost.Tests/TerminalHostArgsTests.cs @@ -0,0 +1,157 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.TerminalHost.Tests; + +public class TerminalHostArgsTests +{ + [Fact] + public void ParseAllRequiredArgsSucceeds() + { + var args = TerminalHostArgs.Parse([ + "--producer-uds", "/tmp/p.sock", + "--consumer-uds", "/tmp/c.sock", + "--control-uds", "/tmp/ctrl.sock", + ]); + + Assert.Equal("/tmp/p.sock", args.ProducerUdsPath); + Assert.Equal("/tmp/c.sock", args.ConsumerUdsPath); + Assert.Equal("/tmp/ctrl.sock", args.ControlUdsPath); + Assert.Equal(120, args.Columns); + Assert.Equal(30, args.Rows); + Assert.Null(args.Shell); + } + + [Fact] + public void ParseAcceptsOptionalDimensionsAndShell() + { + var args = TerminalHostArgs.Parse([ + "--producer-uds", "/tmp/p.sock", + "--consumer-uds", "/tmp/c.sock", + "--control-uds", "/tmp/ctrl.sock", + "--columns", "200", + "--rows", "50", + "--shell", "/bin/bash", + ]); + + Assert.Equal(200, args.Columns); + Assert.Equal(50, args.Rows); + Assert.Equal("/bin/bash", args.Shell); + } + + [Fact] + public void ParseMissingProducerUdsThrows() + { + var ex = Assert.Throws(() => TerminalHostArgs.Parse([ + "--consumer-uds", "/tmp/c.sock", + "--control-uds", "/tmp/ctrl.sock", + ])); + + Assert.Contains("--producer-uds", ex.Message); + } + + [Fact] + public void ParseMissingConsumerUdsThrows() + { + var ex = Assert.Throws(() => TerminalHostArgs.Parse([ + "--producer-uds", "/tmp/p.sock", + "--control-uds", "/tmp/ctrl.sock", + ])); + + Assert.Contains("--consumer-uds", ex.Message); + } + + [Fact] + public void ParseMissingControlUdsThrows() + { + var ex = Assert.Throws(() => TerminalHostArgs.Parse([ + "--producer-uds", "/tmp/p.sock", + "--consumer-uds", "/tmp/c.sock", + ])); + + Assert.Contains("--control-uds", ex.Message); + } + + [Fact] + public void ParseDuplicateProducerUdsThrows() + { + // Each terminal host serves exactly one replica, so passing two producer-uds + // entries is interpreted as a misuse of the new single-replica argument shape. + var ex = Assert.Throws(() => TerminalHostArgs.Parse([ + "--producer-uds", "/tmp/p0.sock", + "--producer-uds", "/tmp/p1.sock", + "--consumer-uds", "/tmp/c.sock", + "--control-uds", "/tmp/ctrl.sock", + ])); + + Assert.Contains("--producer-uds", ex.Message); + } + + [Fact] + public void ParseDuplicateConsumerUdsThrows() + { + var ex = Assert.Throws(() => TerminalHostArgs.Parse([ + "--producer-uds", "/tmp/p.sock", + "--consumer-uds", "/tmp/c0.sock", + "--consumer-uds", "/tmp/c1.sock", + "--control-uds", "/tmp/ctrl.sock", + ])); + + Assert.Contains("--consumer-uds", ex.Message); + } + + [Fact] + public void ParseNegativeColumnsThrows() + { + var ex = Assert.Throws(() => TerminalHostArgs.Parse([ + "--producer-uds", "/tmp/p.sock", + "--consumer-uds", "/tmp/c.sock", + "--control-uds", "/tmp/ctrl.sock", + "--columns", "-5", + ])); + + Assert.Contains("--columns", ex.Message); + } + + [Fact] + public void ParseUnknownArgumentThrows() + { + var ex = Assert.Throws(() => TerminalHostArgs.Parse([ + "--producer-uds", "/tmp/p.sock", + "--consumer-uds", "/tmp/c.sock", + "--control-uds", "/tmp/ctrl.sock", + "--bogus", + ])); + + Assert.Contains("--bogus", ex.Message); + } + + [Fact] + public void ParseMissingValueForArgumentThrows() + { + var ex = Assert.Throws(() => TerminalHostArgs.Parse([ + "--producer-uds", + ])); + + Assert.Contains("--producer-uds", ex.Message); + } + + [Fact] + public void ParseNonIntegerForColumnsThrows() + { + var ex = Assert.Throws(() => TerminalHostArgs.Parse([ + "--producer-uds", "/tmp/p.sock", + "--consumer-uds", "/tmp/c.sock", + "--control-uds", "/tmp/ctrl.sock", + "--columns", "abc", + ])); + + Assert.Contains("--columns", ex.Message); + } + + [Fact] + public void ParseNullArgsThrows() + { + Assert.Throws(() => TerminalHostArgs.Parse(null!)); + } +} diff --git a/tests/Shared/Aspire.Templates.Testing.targets b/tests/Shared/Aspire.Templates.Testing.targets index 906d4a3b609..25f725fbd81 100644 --- a/tests/Shared/Aspire.Templates.Testing.targets +++ b/tests/Shared/Aspire.Templates.Testing.targets @@ -133,6 +133,7 @@ +