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