WithTerminal(): per-replica interactive terminal sessions (Aspire side, draft) - #16760
WithTerminal(): per-replica interactive terminal sessions (Aspire side, draft)#16760Mitch Denny (mitchdenny) wants to merge 43 commits into
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 16760Or
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 16760" |
|
Container support has been split out and stacked on top of this PR: #16762 (Aspire side) + microsoft/dcp#138 (DCP side). |
a019ae6 to
71a7e5a
Compare
There was a problem hiding this comment.
Remove this solution file add the playground projects to the main slnx files.
| public bool SupportsV2 => _capabilities.Contains(AuxiliaryBackchannelCapabilities.V2); | ||
|
|
||
| /// <inheritdoc /> | ||
| public bool SupportsTerminalsV1 => _capabilities.Contains(AuxiliaryBackchannelCapabilities.Terminals_V1); |
There was a problem hiding this comment.
Need to decide whether we rev the whole aux backchannel version for have a special capability just for this.
| [JsonSerializable(typeof(GetPipelineStepsResponse))] | ||
| [JsonSerializable(typeof(GetTerminalInfoRequest))] | ||
| [JsonSerializable(typeof(GetTerminalInfoResponse))] | ||
| [JsonSerializable(typeof(TerminalReplicaInfo))] |
There was a problem hiding this comment.
I think that we might end up consolidating each terminal into its own terminalhost or consolidating all terminals into one. So we can probably get rido f the idea of terminalreplicas and just have a 1:1 mappign to resources where we have resource names which include the replica details (like in the dashboard).
| /// embedded HMP v1 wire-up and the role-aware InfoBar TUI.</item> | ||
| /// </list> | ||
| /// </remarks> | ||
| internal sealed class TerminalAttachCommand : BaseCommand |
There was a problem hiding this comment.
We should also add a aspire terminal ps command. Regarding ps vs. ls - I think that ps makes sense because the terminal is someething that represents a running process.
The output should be a table with details about the resource, the terminal size, the number of attached frontends etc. This might require additional metadata to come across the backchannel.
| /// <see cref="Hmp1ClientOptions.OnConnected"/> callback. | ||
| /// </para> | ||
| /// </remarks> | ||
| internal sealed class TerminalViewerApp |
There was a problem hiding this comment.
This is the first full alt screen TUI experience in the app. For these I think we should drop drop the file in an Aspire.Cli.Tui namespace.
| { | ||
| await outer.RunAsync(_outerCts.Token).ConfigureAwait(false); | ||
| } | ||
| catch (OperationCanceledException) when (_outerCts.IsCancellationRequested) |
There was a problem hiding this comment.
We should add diagnostic logging here.
| await Task.WhenAny(disposeTask, timeout).ConfigureAwait(false); | ||
| } | ||
| } | ||
| catch { /* ignore */ } |
There was a problem hiding this comment.
Diagnostic logging.
| _embeddedCts.Dispose(); | ||
| } | ||
| } | ||
| catch { /* ignore */ } |
There was a problem hiding this comment.
Diagnotic logging.
| ShowResourcePrefix="@_isSubscribedToAll"/> | ||
| @if (_selectedResourceHasTerminal && _terminalResourceName is not null) | ||
| { | ||
| <TerminalView @ref="_terminalViewRef" ResourceName="@_terminalResourceName" ReplicaIndex="@_terminalReplicaIndex" /> |
There was a problem hiding this comment.
Format this line similar to LogViewer
| /// the terminal WebSocket because the proxy takes only | ||
| /// <c>resource</c>/<c>replica</c> identifiers. | ||
| /// </remarks> | ||
| internal sealed class DefaultTerminalConnectionResolver : ITerminalConnectionResolver |
There was a problem hiding this comment.
Do we need this. Assuming we can get the UDS path, we can probalby just use that with WithHex1b...(...) on the hex1bterminal builder.
| // down depending on the request state. Phase 9f hardening | ||
| // for the regression where Stop kills the dashboard. | ||
| logger.LogError(ex, "Terminal WebSocket handler {ConnectionId} crashed.", connectionId); | ||
| Console.Error.WriteLine($"[dashboard] terminal handler {connectionId} crashed: {ex.GetType().FullName}: {ex.Message}"); |
There was a problem hiding this comment.
Never use Console.Error.WriteLine ... always use the logger.
| [JsonPropertyName("terminal")] | ||
| public TerminalSpec? Terminal { get; set; } |
There was a problem hiding this comment.
Karol Zadora-Przylecki (@karolz-ms) and David Negstad (@danegsta) what else are we going to need in the terminal spec to support the full scope of what DCP can do we debugging with PTY attached?
| { | ||
| if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) | ||
| { | ||
| logger.LogWarning( |
There was a problem hiding this comment.
This is just because I have not yet tested on Linux. Should be able to do this early next week, I don't anticipate any problems as Linux support in Hex1b is stronger than Windows.
| continue; | ||
| } | ||
|
|
||
| if (!File.Exists(terminalHostPath)) |
There was a problem hiding this comment.
We need to package the terminal host like we package the dashboard - and resolve the terminal host path in a similar way.
71a7e5a to
5ebe903
Compare
Refactor the TerminalHost model so each parent replica gets its own
`aspire.terminalhost` process owning a single producer/consumer/control
UDS triple, instead of a single host process owning N replica slots.
Why
---
The previous "one host with N replicas" design baked the replica count
into the host's argv (`--replica-count` plus repeated
`--producer-uds`/`--consumer-uds` arrays) and required the host to
dispatch incoming dials to the right replica slot. That made the host
replica-aware, but in practice the only thing that needed to know the
replica index was the AppHost (which generates the paths and tells DCP
which UDS each replica should dial). The host itself just needs to listen
on whatever UDS it is told to listen on.
This commit flips the model so:
- The host is replica-opaque: one process, one producer UDS, one consumer
UDS, one control UDS. `--replica-count` is gone.
- The AppHost encodes the parent replica index into the per-replica
directory of the layout (`{base}/{i}/{producer,host,control}.sock`),
and creates one `TerminalHostResource` per replica named
`{parent}-terminalhost-{i}`.
- `TerminalAnnotation` now exposes `IReadOnlyList<TerminalHostResource>
TerminalHosts` (was a single `TerminalHost` reference).
- Backchannel `GetTerminalInfo`/`ListTerminals` fan out across the
per-replica hosts in parallel via a shared `CollectReplicaInfosAsync`
helper and degrade gracefully when individual hosts haven't started yet
(each unreachable host yields a degraded `TerminalReplicaInfo` rather
than failing the whole call).
Wire-shape changes
------------------
- `TerminalHostReplicasResponse` deleted from the protocol.
- `TerminalHostReplicaInfo` -> `TerminalHostSessionInfo` (no Index).
- `GetReplicasMethod` -> `GetSessionMethod`.
- `TerminalHostInfoResponse.ReplicaCount` removed.
- `TerminalHostControlProtocol.ProtocolVersion` bumped to 2.
- `TerminalSummary.IsHostReachable` semantics changed to "at least one
per-replica host responded" (was "the single control RPC succeeded").
- `TerminalReplicaInfo.ReplicaIndex` is now sourced from
`TerminalHostLayout.ParentReplicaIndex` rather than the host's reply.
Doc fixes
---------
The previous comments in `TerminalSpec.cs` claimed "DCP listens, host
dials". The truth is the opposite: TerminalHost LISTENS on the producer
UDS, and DCP DIALS into it. Same for the consumer UDS (viewers dial) and
the control UDS (AppHost dials). Comments are corrected to match.
The matching DCP-side comment fix in `terminal_types.go` is a separate
PR on the DCP repo that I'll do outside this session.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces draft Aspire-side support for interactive, per-replica terminal sessions via a new .WithTerminal() app model API, backed by a new Aspire.TerminalHost process that bridges DCP PTY I/O to viewers (Dashboard + CLI) over Hex1b HMP v1, plus packaging/build infrastructure and tests.
Changes:
- Add hosting/app-model API (
WithTerminal,TerminalAnnotation,TerminalHostResource/Layout) and wire DCP executable/container specs to include a per-replicaTerminalSpec(Windows-only today). - Add a new
Aspire.TerminalHostexecutable + control protocol, and surface terminal metadata through the AppHost auxiliary backchannel (including CLI commands to attach/list). - Add Dashboard terminal UI plumbing (xterm.js view + server-side WS bridge) and template/build updates to ship a new
Aspire.TerminalHost.Sdk.*pack.
Show a summary per file
| File | Description |
|---|---|
| tests/Shared/Aspire.Templates.Testing.targets | Excludes TerminalHost SDK packs from unexpected-package assertions. |
| tests/Aspire.TerminalHost.Tests/TerminalHostArgsTests.cs | Adds argument parsing unit tests for terminal host. |
| tests/Aspire.TerminalHost.Tests/Aspire.TerminalHost.Tests.csproj | New test project for Aspire.TerminalHost. |
| tests/Aspire.Templates.Tests/README.md | Updates template test docs to mention TerminalHost SDK packs. |
| tests/Aspire.Hosting.Tests/WithTerminalTests.cs | New unit tests covering WithTerminal annotation/resource/layout behavior. |
| tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs | Asserts TerminalHostEventingSubscriber is registered by default. |
| tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs | Adds DCP executor tests validating TerminalSpec wiring + replica annotations regression. |
| tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs | Extends backchannel contract type list for terminal types. |
| tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs | Adds tests for resolving terminal UDS connections from dashboard resource snapshots. |
| tests/Aspire.Dashboard.Tests/Model/ResourceViewModelExtensionsTerminalTests.cs | Adds tests for terminal-related ResourceViewModel extension helpers. |
| tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | Registers terminal-related CLI commands in test DI setup. |
| tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs | Extends test backchannel stub with terminal RPCs/capabilities. |
| tests/Aspire.Cli.Tests/Commands/TerminalCommandViewerOptionTests.cs | Tests parsing/help surface for terminal attach viewer mode. |
| tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs | Adds serializer back-compat/round-trip tests for terminal backchannel types. |
| src/Shared/TerminalHost/TerminalHostControlProtocol.cs | Adds shared terminal-host control protocol wire types/constants. |
| src/Shared/Model/KnownProperties.cs | Adds terminal-related known property keys for dashboard snapshots. |
| src/Aspire.TerminalHost/TerminalHostControlRpcTarget.cs | Implements StreamJsonRpc control target for terminal host. |
| src/Aspire.TerminalHost/TerminalHostControlListener.cs | Adds UDS listener hosting StreamJsonRpc control connections. |
| src/Aspire.TerminalHost/TerminalHostArgs.cs | Implements command-line parsing for terminal host. |
| src/Aspire.TerminalHost/TerminalHostApp.cs | Implements the terminal host app lifecycle + replica startup/shutdown. |
| src/Aspire.TerminalHost/StderrLoggerProvider.cs | Adds minimal stderr logger provider for terminal host. |
| src/Aspire.TerminalHost/Program.cs | Adds terminal host executable entry point. |
| src/Aspire.TerminalHost/Aspire.TerminalHost.csproj | New TerminalHost project definition (multi-RID publish). |
| src/Aspire.Managed/Program.cs | Adds terminalhost subcommand dispatch to aspire-managed. |
| src/Aspire.Managed/Aspire.Managed.csproj | References Aspire.TerminalHost from aspire-managed. |
| src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs | Adds .WithTerminal() API and wiring to create hidden terminal host resources. |
| src/Aspire.Hosting/Lifecycle/TerminalHostEventingSubscriber.cs | Resolves terminal host binary path/invocation args before DCP starts resources. |
| src/Aspire.Hosting/DistributedApplicationBuilder.cs | Registers TerminalHostEventingSubscriber in hosting defaults. |
| src/Aspire.Hosting/Dcp/Model/TerminalSpec.cs | Adds DCP model type for terminal configuration. |
| src/Aspire.Hosting/Dcp/Model/Executable.cs | Adds Terminal block to executable spec JSON model. |
| src/Aspire.Hosting/Dcp/Model/Container.cs | Adds Terminal block to container spec JSON model. |
| src/Aspire.Hosting/Dcp/ExecutableCreator.cs | Populates per-replica terminal spec + fixes plain executable replica annotations. |
| src/Aspire.Hosting/Dcp/DcpOptions.cs | Adds DcpOptions for terminal host discovery path/args and config resolution. |
| src/Aspire.Hosting/Dcp/ContainerCreator.cs | Wires terminal spec for containers (Windows-only) when annotated. |
| src/Aspire.Hosting/Dashboard/DashboardServiceData.cs | Stamps terminal properties into dashboard resource snapshots. |
| src/Aspire.Hosting/Backchannel/TerminalHostControlClient.cs | Adds AppHost-side control client to query terminal host replicas over UDS. |
| src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs | Adds terminal-related aux backchannel capability strings and payload types. |
| src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs | Implements GetTerminalInfo/ListTerminals RPCs for terminal discovery. |
| src/Aspire.Hosting/Aspire.Hosting.csproj | Links shared terminal host protocol types into Aspire.Hosting build. |
| src/Aspire.Hosting/ApplicationModel/TerminalHostResource.cs | Adds hidden executable resource representing the terminal host process. |
| src/Aspire.Hosting/ApplicationModel/TerminalHostLayout.cs | Defines per-replica producer/consumer/control UDS layout object. |
| src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs | Adds terminal annotation + configurable terminal options. |
| src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs | Extends bundle resolution outputs to include terminal host path/args. |
| src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets | Emits assembly metadata for terminal host discovery (path + invocation args). |
| src/Aspire.Dashboard/wwwroot/js/xterm/xterm.min.css | Adds xterm.js CSS asset to dashboard static content. |
| src/Aspire.Dashboard/wwwroot/js/xterm/addon-fit.min.js | Adds xterm.js fit addon static asset. |
| src/Aspire.Dashboard/Terminal/NullTerminalConnectionResolver.cs | Adds no-op resolver for cases where terminals aren’t available. |
| src/Aspire.Dashboard/Terminal/ITerminalConnectionResolver.cs | Adds abstraction for server-side per-replica terminal connection resolution. |
| src/Aspire.Dashboard/Terminal/DefaultTerminalConnectionResolver.cs | Implements resolver via dashboard resource snapshot stream + UDS connect. |
| src/Aspire.Dashboard/Program.cs | Adds dashboard lifecycle diagnostics and heartbeat logging. |
| src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs | Adds helpers for detecting terminal availability + extracting replica/path info. |
| src/Aspire.Dashboard/DashboardWebApplication.cs | Registers terminal resolver, enables websockets, and maps terminal WS endpoint. |
| src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs | Switches ConsoleLogs page logic to render terminal view when applicable. |
| src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor | Renders TerminalView instead of LogViewer for terminal-enabled resources. |
| src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs | Adds Blazor component to host xterm.js and reconnect across resource switches. |
| src/Aspire.Dashboard/Components/Controls/TerminalView.razor | Adds terminal container markup for xterm host element. |
| src/Aspire.Dashboard/Aspire.Dashboard.csproj | Adds Hex1b dependency needed for terminal WS bridging. |
| src/Aspire.Cli/Program.cs | Registers terminal commands in CLI host DI wiring. |
| src/Aspire.Cli/Commands/TerminalCommand.cs | Adds aspire terminal parent command. |
| src/Aspire.Cli/Commands/TerminalAttachCommand.cs | Implements aspire terminal attach resource/replica selection and connect logic. |
| src/Aspire.Cli/Commands/RootCommand.cs | Adds terminal command to CLI root command. |
| src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs | Extends auxiliary backchannel interface with terminal capabilities + RPCs. |
| src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs | Adds source-gen JSON types for terminal backchannel payloads. |
| src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs | Implements CLI-side terminal backchannel calls + capability flags. |
| src/Aspire.Cli/Aspire.Cli.csproj | Adds Hex1b dependency for CLI terminal attach. |
| src/Aspire.AppHost.Sdk/SDK/Sdk.in.targets | Adds implicit reference to Aspire.TerminalHost.Sdk.* alongside Dashboard/DCP packs. |
| playground/Terminals/Terminals.Repl/Terminals.Repl.csproj | Adds interactive REPL playground executable project. |
| playground/Terminals/Terminals.Repl/Program.cs | Implements demo ANSI REPL commands used for terminal testing. |
| playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj | Adds playground AppHost for terminal demo scenario. |
| playground/Terminals/Terminals.AppHost/Properties/launchSettings.json | Adds local launch settings for terminal playground AppHost. |
| playground/Terminals/Terminals.AppHost/appsettings.json | Adds base logging config for terminal playground. |
| playground/Terminals/Terminals.AppHost/appsettings.Development.json | Adds dev logging config including Aspire.Hosting.Dcp warnings. |
| playground/Terminals/Terminals.AppHost/AppHost.cs | Adds demo resources using WithTerminal (projects/executables/containers). |
| playground/Terminals/aspire.config.json | Adds aspire.config.json for the terminal playground. |
| eng/terminalhostpack/UnixFilePermissions.xml | Declares executable permissions for TerminalHost tool payload on Unix. |
| eng/terminalhostpack/Sdk.targets | Adds MSBuild props/targets for TerminalHost SDK pack consumption. |
| eng/terminalhostpack/Sdk.props | Adds empty SDK props placeholder. |
| eng/terminalhostpack/Common.projitems | Adds packing logic to publish TerminalHost and include it in SDK packs. |
| eng/terminalhostpack/buildTransitive/Aspire.TerminalHost.Sdk.in.targets | Enables buildTransitive import for TerminalHost SDK pack. |
| eng/terminalhostpack/buildTransitive/Aspire.TerminalHost.Sdk.in.props | Enables buildTransitive import for TerminalHost SDK pack. |
| eng/terminalhostpack/buildMultiTargeting/Aspire.TerminalHost.Sdk.in.targets | Enables buildMultiTargeting import for TerminalHost SDK pack. |
| eng/terminalhostpack/buildMultiTargeting/Aspire.TerminalHost.Sdk.in.props | Enables buildMultiTargeting import for TerminalHost SDK pack. |
| eng/terminalhostpack/AutoImport.props | Adds AutoImport placeholder for TerminalHost SDK pack. |
| eng/terminalhostpack/Aspire.TerminalHost.Sdk.win-x64.csproj | Adds RID-specific packaging project for win-x64 TerminalHost SDK. |
| eng/terminalhostpack/Aspire.TerminalHost.Sdk.win-arm64.csproj | Adds RID-specific packaging project for win-arm64 TerminalHost SDK. |
| eng/terminalhostpack/Aspire.TerminalHost.Sdk.osx-x64.csproj | Adds RID-specific packaging project for osx-x64 TerminalHost SDK. |
| eng/terminalhostpack/Aspire.TerminalHost.Sdk.osx-arm64.csproj | Adds RID-specific packaging project for osx-arm64 TerminalHost SDK. |
| eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-x64.csproj | Adds RID-specific packaging project for linux-x64 TerminalHost SDK. |
| eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-musl-x64.csproj | Adds RID-specific packaging project for linux-musl-x64 TerminalHost SDK. |
| eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-arm64.csproj | Adds RID-specific packaging project for linux-arm64 TerminalHost SDK. |
| eng/Publishing.props | Adds TerminalHost artifact output dir and blob-feed publishing of zips. |
| eng/Build.props | Adds terminalhostpack + TerminalHost project into bundle dependency build logic. |
| docs/specs/with-terminal.md | Adds architecture/spec documentation for WithTerminal design and contracts. |
| Directory.Packages.props | Updates Hex1b package versions to support terminal features. |
| Directory.Build.props | Adds TerminalHost artifacts output dir + inner-loop TerminalHost dir setting. |
| Aspire.slnx | Adds TerminalHost + terminal playground + terminal host tests to solution. |
| .github/workflows/build-cli-native-archives.yml | Uploads TerminalHost SDK packs as CI artifacts alongside existing packs. |
Copilot's findings
Comments suppressed due to low confidence (1)
tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs:45
- New auxiliary backchannel data types were added (e.g., TerminalPeerInfo, TerminalSummary, ListTerminalsRequest/Response), but they aren’t included in s_contractTypes. That means the contract rules test won’t validate these new request/response types for sealed/init-only/required/nullability rules. Please add the new terminal-related types to s_contractTypes (and update the naming-convention exclusions if needed).
- Files reviewed: 106/108 changed files
- Comments generated: 6
| // Heartbeat task. Background, daemon-style: dies when the runtime tears | ||
| // down. We never await it; the loop's only job is to leave a periodic | ||
| // timestamp in stderr so the absence of one bounds the time of death. | ||
| _ = Task.Run(async () => | ||
| { | ||
| var heartbeatInterval = TimeSpan.FromSeconds(10); | ||
| while (true) | ||
| { | ||
| try | ||
| { | ||
| Console.Error.WriteLine($"[dashboard] heartbeat {DateTimeOffset.UtcNow:O} pid={Environment.ProcessId}"); | ||
| Console.Error.Flush(); | ||
| await Task.Delay(heartbeatInterval).ConfigureAwait(false); | ||
| } |
| // The actual consumer UDS path is *intentionally* not surfaced in the | ||
| // snapshot. The dashboard resolves it server-side via | ||
| // ITerminalConnectionResolver so an authenticated browser cannot coerce | ||
| // the dashboard into connecting to arbitrary UDS endpoints. | ||
| var terminalAnnotation = resource.Annotations.OfType<TerminalAnnotation>().FirstOrDefault(); |
| /// The default registration is <see cref="NullTerminalConnectionResolver"/>, which | ||
| /// always returns <c>null</c>. The standalone dashboard uses this default since | ||
| /// terminal sessions are inherently a local-dev / DCP feature. The in-process | ||
| /// dashboard composition in <c>Aspire.Hosting</c> replaces this with a real | ||
| /// implementation that walks the resource graph and connects to the matching | ||
| /// <c>Hex1b.Hmp1</c> server. |
| public async Task<GetTerminalInfoResponse> GetTerminalInfoAsync(string resourceName, CancellationToken cancellationToken = default) | ||
| { | ||
| if (!SupportsV2) | ||
| { | ||
| return new GetTerminalInfoResponse { IsAvailable = false }; | ||
| } | ||
|
|
| if (firstRender) | ||
| { | ||
| await InitializeTerminalAsync(); | ||
| _connectedResourceName = ResourceName; | ||
| _connectedReplicaIndex = ReplicaIndex; | ||
| return; |
| var msg = await ws.ReceiveAsync(buffer, token).ConfigureAwait(false); | ||
| if (msg.MessageType == WebSocketMessageType.Close) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| if (msg.Count > 0) | ||
| { | ||
| await upstream.WriteAsync(buffer.AsMemory(0, msg.Count), token).ConfigureAwait(false); | ||
| await upstream.FlushAsync(token).ConfigureAwait(false); | ||
| } |
Previously WithTerminal() read the parent resource's ReplicaAnnotation eagerly and created the per-replica TerminalHostResources at the call site. That meant calling WithReplicas(N) AFTER WithTerminal() would only spawn one terminal host because the replica count was captured before the model was finalized. Move the materialization into a builder-phase BeforeStartEvent subscription so the final ReplicaAnnotation count is always honoured, regardless of call order. TerminalAnnotation is still added eagerly so downstream consumers (DCP creators, dashboard data, backchannel) can detect a configured terminal at WithTerminal() time; its TerminalHosts collection is empty until BeforeStartEvent fires. Builder-phase event subscriptions fire ahead of DI-registered IDistributedApplicationEventingSubscriber instances, so TerminalHostEventingSubscriber still sees every per-replica host in the model when it runs and resolves their binary paths. The replica- count drift warning in TerminalHostEventingSubscriber is removed because it can no longer trigger. Tests publish BeforeStartEvent manually before observing TerminalHosts. A new regression test (WithReplicasAfterWithTerminalCreatesOneTerminalHostPerReplica) exercises the bug fix directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds an internal sibling WithTerminalForPolyglot<T>(builder) decorated with [AspireExport("withTerminal")] so non-C# AppHosts can call WithTerminal().
The original public WithTerminal<T>(builder, Action<TerminalOptions>?) overload keeps [AspireExportIgnore] (now pointing at the dispatcher) because Action<T> delegate parameters require ATS exporting TerminalOptions, which we deliberately keep out of the polyglot surface for now. Polyglot AppHosts that need to customise columns/rows/shell can fall back to per-resource environment variables until a future DTO-shaped overload lands.
Drive-by: removes a stray 'using Aspire.Hosting.Eventing' from WithTerminalTests.cs and DcpExecutorTests.cs that became unused after BeforeStartEvent moved to Aspire.Hosting.ApplicationModel; both files were failing IDE0005 as warnings-as-errors.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…n bundle mode When a TS-based AppHost (or any pre-built dotnet AppHost) is launched from the CLI bundle via PrebuiltAppHostServer/DotNetAppHostProject, the launcher only sets ASPIRE_DCP_PATH and ASPIRE_DASHBOARD_PATH (the latter pointing at the multi-mode aspire-managed exe). It does not set ASPIRE_TERMINAL_HOST_PATH, and the assembly-metadata fallback (aspireterminalhostpath) is empty for prebuilt AppHosts because the SDK targets that emit it never run in the TS scenario. Result: TerminalHostEventingSubscriber sees an empty TerminalHostPath, logs a warning, and silently skips launching the per-replica terminal hosts — so .WithTerminal() resources have no terminal in bundle mode. This change adds the same kind of bundle-aware fallback that DashboardEventHandlers has had since day one (where it detects IsAspireManagedBinary(DashboardPath) and prepends 'dashboard' as the dispatcher arg). After the explicit lookup chain runs, if TerminalHostPath is still empty AND DashboardPath points at aspire-managed, default TerminalHostPath = DashboardPath and TerminalHostInvocationArgs = 'terminalhost'. Standalone per-RID NuGet packages keep using their dedicated terminal host binary via assembly metadata and never hit this fallback. Explicit ASPIRE_TERMINAL_HOST_PATH / ASPIRE_TERMINAL_HOST_INVOCATION_ARGS still win. New ConfigureDefaultDcpOptionsTests pin all four cases: bundle fallback fires, fallback does not fire for non-aspire-managed dashboards, explicit terminal host path is preserved, and explicit invocation args are preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Jose Perez Rodriguez (@joperezr) can I get your feedback on the SDK related changes in this PR. I want to land this change early in the 13.5 cycle so we've got time to work as many of the kinks out. This PR depands on a custom build of DCP at the moment as well. It might be that this goes in in a broken state until that arrives, but provided it is not a problem to existing functionality that should be fine. |
Address Mitch's PR review comments on #16760 grouped under low-risk cosmetic + logger-discipline fixes: * TerminalWebSocketProxy: drop Console.Error.WriteLine fallback. The preceding logger.LogError already captures the exception with stack trace; the stderr writes were a belt-and-braces leftover from when ILogger plumbing was uncertain. Comment updated to reflect that. * ConsoleLogs.razor: reformat the TerminalView element to match the multi-line attribute style used by the sibling LogViewer block. * Move TerminalViewerApp from Aspire.Cli.Commands to a new Aspire.Cli.Tui namespace (and matching src/Aspire.Cli/Tui/ folder). This is the first full alt-screen TUI experience in the CLI; future TUI shells should land here too. TerminalAttachCommand picks it up via a new using directive. * Replace the silent 'catch { /* ignore */ }' blocks in TerminalViewerApp.RunAsync with LogDebug-emitting catches so we have visibility when the embedded CTS teardown or Hex1bTerminal dispose actually fails (typical: object-disposed races, transport faults during shutdown, the 2s dispose-timeout masking a stuck pump). Also log when the outer Hex1bApp cancellation fires so we can distinguish embedded-fault vs caller-cancellation. * Remove playground/Terminals/Terminals.sln; add the two playground projects (Terminals.AppHost, Terminals.Repl) to Aspire.slnx in a new /playground/Terminals/ folder slot, alphabetically between Stress and Testing, matching the convention used by every other playground. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
James Newton-King (@JamesNK) can I get your feedback on the Dashboard modifications that have been made in this PR. This is a pretty big PR that I want to get in early to derisk it for 13.5 (also depends on a DCP change). |
Mitch Denny (mitchdenny)
left a comment
There was a problem hiding this comment.
Code review findings from a multi-area sweep (hosting / terminal-host / dashboard / CLI / tests).
Summary: 20 issues — 4 high (1 critical security: WS hijack), 8 medium, 8 low.
Posting as tracking comments since this PR is marked draft. None of these are blocking by themselves; the WS-origin and control-socket-perms ones should land before this leaves draft.
| } | ||
| } | ||
| } | ||
| }).RequireAuthorization(FrontendAuthorizationDefaults.PolicyName); |
There was a problem hiding this comment.
[Critical — Security] Cross-Site WebSocket Hijacking on /api/terminal
The terminal WS endpoint requires the standard frontend cookie-auth policy, but nothing validates the WebSocket upgrade's Origin header. Browsers do not apply the same-origin policy to WebSockets, and UseAntiforgery() does not gate WS upgrades (they're GET … Connection: Upgrade). With BrowserToken/Unsecured auth, any page loaded in the logged-in user's browser can new WebSocket("wss://localhost:<port>/api/terminal?resource=foo"), ride the auth cookie, and gain full read+write of any WithTerminal() shell — effectively RCE against the developer's machine.
app.UseWebSockets() (in DashboardWebApplication.cs:519) is called with no WebSocketOptions.AllowedOrigins; HandleAsync reads only resource/replica from the query string and never inspects context.Request.Headers.Origin.
Fix: populate WebSocketOptions.AllowedOrigins with the dashboard's own origin(s), or compare Request.Headers.Origin to the request's own scheme+host (and the configured frontend URLs) before calling AcceptWebSocketAsync, returning 403 on mismatch.
| var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); | ||
| socket.Bind(new UnixDomainSocketEndPoint(_socketPath)); | ||
| socket.Listen(backlog: 5); | ||
| _socket = socket; |
There was a problem hiding this comment.
[High — Security] Control UDS has no file-permission hardening — any local user can call shutdown (or getSession to leak peer display names)
The control socket is created and bound with no chmod and no explicit umask. Whatever the inherited process umask happens to be is what gates access. On a developer box with a permissive umask (e.g. 002/022) any local user with traverse + read access to the socket path can dial it and invoke ShutdownAsync (no auth check) or GetSessionAsync (echoes peer DisplayNames).
Fix: after Bind, call File.SetUnixFileMode(_socketPath, UserRead|UserWrite) (0600). Consider the same for the producer/consumer paths if Hex1b doesn't already do it.
| // One temp base dir per parent: per-replica hosts get sub-directories beneath it | ||
| // (`{base}/{i}/...`) so the AppHost can clean up every host's sockets with a single | ||
| // recursive delete when the run ends. | ||
| var baseDir = Directory.CreateTempSubdirectory("aspire-term-").FullName; |
There was a problem hiding this comment.
[High — Resource leak] Temp directory + UDS sockets are leaked every run
MaterializeTerminalHosts creates aspire-term-XXXXXXXX/ via Directory.CreateTempSubdirectory(...) and writes three .sock paths per replica beneath it. The XML docs on TerminalAnnotation.BaseDirectory and TerminalHostLayout explicitly state "the AppHost cleans up every host's sockets with a single recursive delete when the run ends" — but no such cleanup exists. There is no IDisposable/IAsyncDisposable on TerminalAnnotation, no shutdown event subscriber, and no try/finally around the directory creation. Each AppHost run leaks one directory plus 3×N stale UDS endpoints.
Compounds the sun_path-length issue below: stale paths near the limit make future runs nondeterministic.
Fix: wire AfterStopEvent (or IHostApplicationLifetime.ApplicationStopping) in TerminalHostEventingSubscriber to recursively delete terminalAnnotation.BaseDirectory.
|
|
||
| return Hex1bTerminal.CreateBuilder() | ||
| .WithDimensions(Columns, Rows) | ||
| .WithWorkload(upstream) |
There was a problem hiding this comment.
[High] Producer & consumer .sock files are never explicitly pre-deleted or post-removed by the host
Only the control listener pre-deletes its socket path (TerminalHostControlListener.cs:52-62) and post-deletes on dispose (line 208). For the producer and consumer UDS we trust Hex1b to do both, but the recycle-loop design depends on rebinding the same paths after a producer drop — if the previous bind left a stale file (process killed, OOM, crash before clean teardown), the next BuildTerminal will fail with EADDRINUSE and the loop will go into 5-second back-off forever while still reporting ready to the AppHost (see also TerminalHostApp.RunAsync finding).
The control-listener code proves the author knew this idiom was needed; the symmetry is missing on the two transport paths.
Fix: pre-delete the producer and consumer paths before invoking Hmp1Transports.ListenUnixSocket / WithHmp1UdsServer, and post-delete in TerminalReplica.DisposeAsync (best-effort, swallow IOException).
| /// the same parent get unique paths while still sharing the parent's <paramref name="baseDir"/> | ||
| /// (which makes cleanup a single recursive delete). | ||
| /// </summary> | ||
| private static TerminalHostLayout CreateTerminalHostLayout(string baseDir, int replicaIndex) |
There was a problem hiding this comment.
[Medium] UDS sun_path length is never validated
Paths are built as {tmpBase}/aspire-term-XXXXXXXX/{replicaIndex}/{dcp|host|control}.sock. On macOS sockaddr_un.sun_path is 104 bytes (108 on Linux). With a default macOS $TMPDIR of /var/folders/xx/<28-char-hash>/T/ (~50 chars) + aspire-term- (12) + 8-char random (~20 total) + /0/control.sock (15) you're already around ~85 bytes — and once $TMPDIR is overridden (e.g. TMPDIR=/Users/<long-name>/Library/Caches/SomeTool/tmp) or replica indices reach 2+ digits, this can silently exceed the limit. Socket.Bind on the terminal-host side and Socket.ConnectAsync in TerminalHostControlClient will throw SocketException/EINVAL, masked by retry loops until timeout.
Fix: in CreateTerminalHostLayout, validate each produced path against the platform limit (OperatingSystem.IsMacOS() ? 104 : 108) and throw InvalidOperationException early, or fall back to a shorter base such as Path.Combine("/tmp", "asp-t-" + ShortHash()).
| // size including SIGWINCH; the outer Hex1bTerminal is bound to | ||
| // those dims when running interactively. | ||
| var availW = Math.Max(1, Console.WindowWidth); | ||
| var availH = Math.Max(1, Console.WindowHeight - 1); |
There was a problem hiding this comment.
[Low] Render reads Console.WindowWidth/Height without the IOException guard used elsewhere
TryGetLocalDimensions (line 573-584) deliberately catches IOException because Console.WindowWidth throws in non-console contexts, but the per-frame Render reads the same properties unguarded. If aspire terminal attach is invoked with output redirected (e.g., piping to tee), or the controlling TTY is lost mid-session, an IOException propagates out of the render callback. There is also no precondition check rejecting redirected stdin/stdout before the TUI is launched for the single-replica path.
Fix: either wrap the Console.WindowWidth/Height reads in Render with the same try/catch fallback used by TryGetLocalDimensions, or add an IsInputRedirected || IsOutputRedirected precondition in TerminalAttachCommand.ExecuteAsync before constructing TerminalViewerApp and emit a clear error.
| var model = app.Services.GetRequiredService<DistributedApplicationModel>(); | ||
| await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, model)); | ||
| return (app, model); | ||
| } |
There was a problem hiding this comment.
[Low — Resource leak] BuildAndPublishBeforeStartAsync leaks the built DistributedApplication
The helper builds var app = builder.Build() and returns it but callers (lines 66, 85, 131, 146) discard it via var (_, model) = …. DistributedApplication is IAsyncDisposable; without disposal its background services, DCP-related objects, and any pooled handles stay live until GC finalization. The sibling helper PublishBeforeStartAsync correctly does using var app. Inconsistency aside, it accumulates undisposed DistributedApplication instances across the WithTerminal test suite.
Fix: return an IAsyncDisposable wrapper or have the helper take a callback (Func<DistributedApplicationModel, Task>) so using var app happens inside.
| Assert.Equal(200, annotation.Options.Columns); | ||
| Assert.Equal(50, annotation.Options.Rows); | ||
| Assert.Equal("/bin/bash", annotation.Options.Shell); | ||
| } |
There was a problem hiding this comment.
[Low — Test misleading] WithTerminal_AcceptsCustomOptions never verifies the custom options propagate
The test sets Columns=200, Rows=50, Shell=/bin/bash, then only asserts those values on the annotation's Options — never publishing BeforeStartEvent and never checking that the per-replica TerminalHostResource actually surfaces them. The downstream propagation test (TerminalHostHasCommandLineArgsForLayoutPaths, line 268) does cover this, so this is not a coverage gap, but the test name overpromises what it actually validates. Flagging because this is the kind of test that gets cited later as proof "custom options are honored" when it only checks they're stored.
Fix: drop the test (the propagation test already covers the contract), or extend it to assert the materialized host carries the custom values.
| { | ||
| return ReadOnlyMemory<byte>.Empty; | ||
| } | ||
| } |
There was a problem hiding this comment.
[Low — Uncertain] ReadOutputAsync collapses three distinct end-states into ReadOnlyMemory<byte>.Empty
Empty is returned for: (a) already-disposed, (b) connect failure, (c) WaitToReadAsync returning false (channel completed = EOF), (d) TryRead race miss, and (e) ChannelClosedException. If IHex1bTerminalWorkloadAdapter's contract is that Empty means "EOF / shut down the workload", case (d) — a benign race after a wakeup with no successful TryRead — will cause Hex1b to tear down the workload spuriously. If Empty means "spurious, call me again", cases (b)/(c)/(e) will turn into a tight busy-loop. Couldn't verify the Hex1b contract from this repo.
Fix: confirm IHex1bTerminalWorkloadAdapter.ReadOutputAsync semantics; for (d) loop on TryRead until success or exit the wait; for (c)/(e) ensure the return value matches Hex1b's "workload terminated" sentinel.
| CS8002: Disable strong name signing because Hex1b package is not signed. | ||
| CA2007: Do not directly await a Task in this exe code (we control the synchronization context). | ||
| --> | ||
| <NoWarn>$(NoWarn);CS1591;CS8002;CA2007</NoWarn> |
There was a problem hiding this comment.
[Low — Code comment] Aspire.TerminalHost.csproj suppresses CA2007 with an incorrect justification.
The comment says "we control the synchronization context" — but this is a console exe with no installed SynchronizationContext, so the suppression is fine, the rationale is wrong. The real reason is "no SyncContext, ConfigureAwait(false) is a no-op." Every await in this project still uses .ConfigureAwait(false) anyway, so the suppression is also redundant. Not a bug; flagging because it'll mislead the next reader trying to reason about thread-affinity invariants.
|
Closing in favour of a new PR from the team branch All review feedback from this PR will be re-posted as inline comments on the new PR, with resolution details for the 4 Critical/High items that have already been fixed (commits |
Address Mitch's PR review comments on #16760 grouped under low-risk cosmetic + logger-discipline fixes: * TerminalWebSocketProxy: drop Console.Error.WriteLine fallback. The preceding logger.LogError already captures the exception with stack trace; the stderr writes were a belt-and-braces leftover from when ILogger plumbing was uncertain. Comment updated to reflect that. * ConsoleLogs.razor: reformat the TerminalView element to match the multi-line attribute style used by the sibling LogViewer block. * Move TerminalViewerApp from Aspire.Cli.Commands to a new Aspire.Cli.Tui namespace (and matching src/Aspire.Cli/Tui/ folder). This is the first full alt-screen TUI experience in the CLI; future TUI shells should land here too. TerminalAttachCommand picks it up via a new using directive. * Replace the silent 'catch { /* ignore */ }' blocks in TerminalViewerApp.RunAsync with LogDebug-emitting catches so we have visibility when the embedded CTS teardown or Hex1bTerminal dispose actually fails (typical: object-disposed races, transport faults during shutdown, the 2s dispose-timeout masking a stuck pump). Also log when the outer Hex1bApp cancellation fires so we can distinguish embedded-fault vs caller-cancellation. * Remove playground/Terminals/Terminals.sln; add the two playground projects (Terminals.AppHost, Terminals.Repl) to Aspire.slnx in a new /playground/Terminals/ folder slot, alphabetically between Stress and Testing, matching the convention used by every other playground. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e, draft) (#17866) * Add WithTerminal API: TerminalAnnotation, TerminalHostResource, and extension method Implements Phase 1 of the live terminal support feature (#16317). - TerminalAnnotation: IResourceAnnotation with TerminalOptions (Columns, Rows, Shell) and a SocketPath property for the UDS path set by the orchestrator. - TerminalHostResource: Internal hidden resource (IResourceWithParent) that will manage the Hex1b-based terminal host process for a parent resource. - WithTerminal<T>(): Extension method that adds TerminalAnnotation to a resource, creates a hidden TerminalHostResource, and adds a WaitAnnotation so the parent waits for the terminal host to be started. - Tests: 8 unit tests covering annotation creation, custom options, hidden resource creation, wait annotation wiring, chaining, container support, manifest exclusion, and null argument handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Aspire Terminal Protocol spec, shared codec, and playground - docs/specs/terminal-protocol.md: Full protocol specification defining the binary framing format for terminal I/O over Unix domain sockets. Covers HELLO, DATA, RESIZE, EXIT, and CLOSE message types with wire examples and implementation notes for DCP (Go) and Aspire (C#). - src/Shared/Terminal/: Shared protocol types (TerminalProtocol constants, TerminalFrameReader, TerminalFrameWriter, TerminalFrame) designed to be linked into Aspire.Hosting, Dashboard, and CLI projects. - playground/Terminals/: Two-project playground demonstrating WithTerminal without DCP: - Terminals.TerminalHost: .NET console app using Hex1b with a custom IHex1bTerminalPresentationAdapter that implements the Aspire Terminal Protocol over UDS. Receives socket path via TERMINAL_SOCKET_PATH env var. - Terminals.AppHost: Aspire AppHost that launches the terminal host as a child process with a custom resource lifecycle (OnInitializeResource), demonstrating the full WithTerminal flow before DCP PTY support lands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add terminal protocol test client and verify end-to-end Terminals.Client: Standalone console app that connects to an Aspire Terminal Protocol UDS server, performs the HELLO handshake, puts the local console in raw mode, and bridges stdin/stdout bidirectionally. Supports Ctrl+] to detach. Verified end-to-end: TerminalHost starts pwsh with Hex1b PTY, listens on UDS. Client connects, receives HELLO(v1, 80x24, Pty), and gets a fully interactive PowerShell session with prompt rendering and command execution working correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add DCP model TerminalSpec, backchannel RPC, and CLI terminal command DCP Model: - TerminalSpec: New model type with enabled, socketPath, columns, rows - Added Terminal property to ExecutableSpec and ContainerSpec - ExecutableCreator populates TerminalSpec from TerminalAnnotation Backchannel: - GetTerminalInfoRequest/Response in BackchannelDataTypes - GetTerminalInfoAsync on AuxiliaryBackchannelRpcTarget (server) - GetTerminalInfoAsync on AppHostAuxiliaryBackchannel (client) - Added to IAppHostAuxiliaryBackchannel interface CLI: - New 'aspire terminal <resource>' command (TerminalCommand.cs) - Connects to AppHost backchannel, gets terminal UDS path - Connects to UDS, performs HELLO handshake - Puts console in raw mode, bridges stdin/stdout bidirectionally - Ctrl+] to detach, handles EXIT/CLOSE frames - Registered in RootCommand and DI container Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add WithTerminal custom socket path provider overload Adds a second WithTerminal overload that accepts a Func<CancellationToken, Task<string>> socketPathProvider for resources that manage their own terminal server (e.g., remote SSH, cloud resources). Unlike the standard overload, this does NOT create a hidden TerminalHostResource — the caller is responsible for running a server that speaks the Aspire Terminal Protocol on the provided socket path. Also adds SocketPathProvider property to TerminalAnnotation and two new tests (10 total, all passing). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Dashboard terminal support: xterm.js, WebSocket proxy, ConsoleLogs integration Dashboard terminal view that replaces Console Logs for terminal-enabled resources: - TerminalView.razor: Blazor component wrapping xterm.js via JS interop - TerminalView.razor.js: xterm.js initialization, WebSocket connection, resize handling - TerminalWebSocketProxy.cs: ASP.NET Core middleware at /api/terminal that bridges browser WebSocket to UDS using the Aspire Terminal Protocol (HELLO/DATA/RESIZE/EXIT/CLOSE) - Vendored xterm.js 5.5.0 + fit addon in wwwroot/js/xterm/ ConsoleLogs integration: - ConsoleLogs.razor: Conditionally renders TerminalView instead of LogViewer when the selected resource has terminal.enabled property - ConsoleLogs.razor.cs: Detects terminal resources in SubscribeAsync, skips console log subscription for terminal resources Infrastructure: - KnownProperties.Terminal.Enabled/SocketPath constants in shared model - DashboardServiceData: Injects terminal.enabled and terminal.socketPath properties into resource snapshots when TerminalAnnotation is present - ResourceViewModelExtensions: HasTerminal() and TryGetTerminalSocketPath() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Dashboard terminal: use script tags for xterm.js and int IDs for JS interop Two fixes for the Blazor unhandled error: 1. xterm.min.js is UMD format, not ES module — cannot use dynamic import(). Changed to load via script tags into window.Terminal / window.FitAddon. 2. initTerminal returned a plain JS object which can't be marshaled as IJSObjectReference. Changed to return an int ID and use a Map-based registry on the JS side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add production Aspire.TerminalHost and wire into AppHost discovery New project: src/Aspire.TerminalHost/ - Console app using Hex1b with UnixDomainSocketPresentationAdapter - Speaks Aspire Terminal Protocol over UDS - Receives config via TERMINAL_SOCKET_PATH, TERMINAL_COLUMNS/ROWS/SHELL env vars - Bridges PTY shell ↔ UDS clients AppHost discovery (following Dashboard pattern): - DcpOptions.TerminalHostPath for path resolution - Three-tier discovery: env var (ASPIRE_TERMINAL_HOST_PATH) → config → assembly metadata - Assembly metadata key: 'aspireterminalhostpath' - MSBuild target SetTerminalHostDiscoveryAttributes in AppHost.in.targets - Development path: artifacts/bin/Aspire.TerminalHost/{Config}/net8.0/ WithTerminal lifecycle: - AddTerminalHostResource now generates UDS path and sets it on TerminalAnnotation - OnInitializeResource resolves terminal host binary via DcpOptions - Launches terminal host as child process with env var configuration - Forwards stderr to resource logs - Manages process lifecycle with clean shutdown DCP flow: - TerminalAnnotation.SocketPath → TerminalSpec on ExecutableSpec/ContainerSpec - DCP receives terminal.enabled + terminal.socketPath in the CRD spec - DCP can use socketPath to forward PTY I/O (Go implementation separate) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix playground: use custom socket path overload to avoid dual terminal host The playground's TerminalDemoResource manages its own terminal host process lifecycle, so it should use WithTerminal(socketPathProvider) instead of the bare WithTerminal() which now also launches a terminal host. Using the custom overload avoids creating a conflicting hidden TerminalHostResource. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Redesign terminal host for reconnection with state replay Replaces the presentation adapter approach with a presentation filter architecture (modeled after Hex1b's DiagnosticsSocketListener): - Terminal runs headless (Hex1b manages internal screen state) - TerminalSocketServer is an IHex1bTerminalPresentationFilter that intercepts all output via OnOutputAsync and broadcasts to clients - On client connect: CreateSnapshot().ToAnsi() captures current screen state and sends it as the first DATA frame after HELLO (with REPLAY flag) - On client disconnect: terminal keeps running, accepts new connections - On reconnect: fresh snapshot replayed, then live streaming resumes This enables navigating away from the terminal in the Dashboard and returning to find the same terminal state preserved. Key changes: - New TerminalSocketServer.cs (filter-based, session management) - Program.cs: WithHeadless() + AddPresentationFilter(server) instead of WithPresentation(adapter) - Old UnixDomainSocketPresentationAdapter kept for playground compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WithTerminal Phase 1: refactor to per-replica UDS pair design Refactor the WithTerminal API to match the new architecture decided for the 13.4 end-to-end work: - TerminalAnnotation: drop SocketPath / SocketPathProvider; carry a strong reference to the hidden TerminalHostResource and the user-supplied TerminalOptions instead. The host owns its own UDS layout. - TerminalHostResource: now public and derives from ExecutableResource so DCP launches it as a regular hidden executable. Carries a Parent reference plus the per-resource TerminalHostLayout. Constructed with a placeholder command (UnresolvedCommand) so we can rewrite it later. - TerminalHostLayout (new): per-resource, per-run UDS layout — N producer paths under {tmp}/aspire-term-{guid}/dcp/, N consumer paths under host/, and one control.sock. Built via Directory.CreateTempSubdirectory per the repo temp-directory convention. - TerminalHostEventingSubscriber (new): subscribes to BeforeStartEvent and resolves the real terminal host binary from DcpOptions.TerminalHostPath before DCP launches the resource. Emits a warning if the path is unset or if the parent's replica count drifted between WithTerminal() and start. Registered via TryAddEventingSubscriber in the builder. - TerminalResourceBuilderExtensions: single overload, eager UDS layout, hidden host as ExecutableResource, args wired via a callback (--replica-count, --producer-uds xN, --consumer-uds xN, --control-uds, --columns, --rows, --shell). Adds a WaitAnnotation on the host (WaitUntilStarted for now; Phase 2 will upgrade to WaitUntilHealthy once the host exposes a health probe). Throws on double WithTerminal call. - Stub leftovers in ExecutableCreator, AuxiliaryBackchannelRpcTarget, and DashboardServiceData for proper wire-up in Phases 4/5/7. Each stub is marked with a comment. - Update WithTerminalTests to cover the new design (15 tests, all green). - Drop playground/Terminals/Terminals.AppHost/TerminalDemoResource.cs and stub Terminals.AppHost itself; full playground rebuild lands in Phase 8. - Add a GetTerminalInfoAsync stub on TestAppHostAuxiliaryBackchannel so the CLI test fake satisfies the interface. Build is green with /p:SkipNativeBuild=true. WithTerminalTests pass 15/15. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WithTerminal Phase 2: Aspire.TerminalHost on Hex1b HMP v1 Replaces the custom binary frame protocol with Hex1b 0.137 HMP v1 and adopts the per-replica UDS pair design from Phase 1. The host now creates one independent Hex1bTerminal per replica using WithHmp1UdsClient(producerUds[i]).WithHmp1UdsServer(consumerUds[i]). DCP runs the HMP v1 producer side; viewers (CLI / Dashboard) connect to the consumer side. State replay on reconnect is handled by Hex1b. A small StreamJsonRpc control listener on a separate UDS exposes GetReplicas() and Shutdown() so the AppHost backchannel can populate GetTerminalInfoAsync() without sharing the data plane. The host no longer auto-exits when all replicas exit -- DCP owns the host lifetime via cancellation or the control protocol. Removed: src/Shared/Terminal/* (custom protocol), TerminalSocketServer, UnixDomainSocketPresentationAdapter, playground/Terminals.TerminalHost. Added: tests/Aspire.TerminalHost.Tests with 17 tests (12 args, 5 app) covering arg parsing edge cases plus end-to-end control-listener + replica startup over real UDS sockets. All 17 pass on Windows. Phase 1 WithTerminalTests (15/15) still pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WithTerminal Phase 4: wire per-replica TerminalSpec into DCP ExecutableCreator ExecutableCreator now populates spec.Terminal per replica when the resource has a TerminalAnnotation, indexing into TerminalHostLayout.ProducerUdsPaths via the ResourceReplicaIndex annotation. Gated on Windows for the 13.4 ship; logs a warning + skips on other platforms (Linux/macOS PTY support tracked as a follow-up). TerminalSpec.cs aligned with the Go-side DCP API in microsoft/dcp PR #133: enabled / udsPath / cols / rows JSON tags with no client-side defaults (DCP applies 80x24 if zero). Adds two DcpExecutorTests cases: Project_WithTerminal_PopulatesPerReplicaTerminalSpecOnWindows (assertion-rich, replica-ordered, Windows-gated) and Project_WithoutTerminal_HasNullTerminalSpec (negative case). Tracking: microsoft/aspire#16317 + microsoft/dcp#6 + DCP PR microsoft/dcp#133. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WithTerminal Phase 5: backchannel exposes per-replica terminal endpoints GetTerminalInfoAsync now opens the hidden terminal host's control UDS, calls getReplicas, and returns a TerminalReplicaInfo[] populated with the AppHost-canonical consumer UDS path for each replica. Connection retries are bounded by a 3 s budget so a request issued while DCP is still launching the host doesn't fail-fast. Wire shape evolution is additive: SocketPath/Columns/Rows are preserved, Replicas is added as a new optional array. Older CLI builds that only check IsAvailable continue to work. New clients gate UI on the new terminals.v1 capability advertised by GetCapabilitiesAsync. Out-of-range replica indices reported by the host are skipped with a warning so a buggy host can never crash a backchannel call. Tests cover the resource-not-found, no-annotation, unreachable-host, happy-path, out-of-range-index, and capability-advertisement scenarios. Tracking: microsoft/aspire#16317. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WithTerminal Phase 6: aspire terminal CLI command on Hex1b HMP v1 Replaces the placeholder TerminalCommand with the full discovery, selection, and attach flow defined in the Phase 6 plan. - Add IAppHostAuxiliaryBackchannel.SupportsTerminalsV1 capability gate alongside the existing SupportsV2 surface so the CLI can fail fast against pre-13.4 AppHosts with a clear "Update Aspire.Hosting" message instead of a misleading "resource not found" or socket-connect error. - Add Hex1b PackageReference to Aspire.Cli (already pinned via Directory.Packages.props; Hex1b 0.135+ is fully Native AOT-compatible so no new IL/AOT warnings are introduced versus the baseline). - TerminalCommand flow: 1. Resolve AppHost via AppHostConnectionResolver. 2. Verify SupportsTerminalsV1; otherwise return AppHostIncompatible. 3. Look up the resource via ResourceSnapshotMapper.WhereMatchesResourceName (matches by Name OR DisplayName so users can target replicated resources by the parent name). 4. Canonicalise to the parent name (matches[0].DisplayName ?? Name) so GetTerminalInfoAsync receives the same identifier the AppHost knows. 5. Call backchannel.GetTerminalInfoAsync. 6. Pick a replica: - --replica/-r N → exact match (out-of-range → InvalidCommand) - 1 replica → auto-pick - non-interactive → require --replica explicitly - interactive multi → PromptForSelectionAsync 7. If the chosen replica has exited, warn and continue (the historical buffer is still served via HMP v1 StateSync). 8. Hand off to Hex1bTerminal.CreateBuilder().WithHmp1UdsClient(...).Build() and await RunAsync. Ctrl+C cancels via the SCL cancellation token. - Catches OperationCanceledException, SocketException, and IOException(SocketException) explicitly so connect failures and mid-session disconnects produce friendly messages instead of stack traces. Tests (10 new in TerminalCommandTests): - Help works. - Missing resource argument fails parsing. - No running AppHost returns Success (matches LogsCommand convention). - Lacking SupportsTerminalsV1 returns AppHostIncompatible. - Resource not found returns InvalidCommand. - IsAvailable=false / empty replicas array return InvalidCommand. - --replica out-of-range returns InvalidCommand. - DisplayName lookup canonicalises to the parent resource name when calling GetTerminalInfoAsync (verified via a CapturingTerminalAppHostBackchannel decorator). - Non-interactive multi-replica without --replica returns InvalidCommand. The CLI test helper now registers TerminalCommand alongside the other commands so the new tests can resolve the RootCommand. * Phase 7: Dashboard /api/terminal WebSocket proxy + per-replica TerminalView Wires the Aspire Dashboard end-to-end with the per-replica HMP v1 producer endpoints introduced in Phase 1-5: the dashboard now exposes an authenticated WebSocket endpoint at /api/terminal that proxies xterm.js byte streams to the correct per-replica UDS without trusting any browser-supplied filesystem path. Server side: * New abstraction: Aspire.Dashboard.Terminal.ITerminalConnectionResolver exposes (resourceName, replicaIndex) -> Stream resolution. The default implementation (DefaultTerminalConnectionResolver) walks the live IDashboardClient.GetResources() snapshot, matches by display name + TryGetTerminalReplicaInfo, and connects via Hex1b Hmp1Transports.ConnectUnixSocket. NullTerminalConnectionResolver is kept as a hook for tests / unsupported hosts. * TerminalWebSocketProxy is rewritten: - Endpoint /api/terminal is mapped with RequireAuthorization(Frontend) so only authenticated dashboard users can open a session. - Query string ?resource=&replica= is the only client-controlled state; the consumer UDS path is resolved server-side via the resolver, not accepted from the browser. - Two pumps: * inbound (browser -> producer): binary frames carry keystroke bytes (forwarded as HMP v1 Input); text frames carry JSON resize control messages parsed via Utf8JsonReader. * outbound (producer -> browser): VT byte stream from the Hmp1WorkloadAdapter is sent as binary WS frames; resize hints from the producer become JSON text frames. - ReassembledFrame uses ArrayPool<byte> to handle multi-fragment WS reads without per-message allocation. - Graceful close via WebSocket.TryCloseAsync; resolver/protocol errors return an HTTP 5xx instead of leaking diagnostic detail. * DefaultTerminalConnectionResolver registered as singleton in DashboardWebApplication.cs alongside the other resource-snapshot-aware services. * Hex1b PackageReference added to Aspire.Dashboard.csproj (Hex1b 0.137, pinned via Directory.Packages.props). Property contract: * KnownProperties.Terminal: replaced the legacy single SocketPath constant with per-replica ReplicaIndex, ReplicaCount, and ConsumerUdsPath. The per-replica index is resolved from DcpInstancesAnnotation (DCP-allocated, stable) rather than parsing the random-suffixed resource name. * ResourceViewModelExtensions: TryGetTerminalSocketPath is replaced by TryGetTerminalReplicaInfo(out int replicaIndex, out int replicaCount) and TryGetTerminalConsumerUdsPath(out string?). * Aspire.Hosting/Dashboard/DashboardServiceData stamps these per-replica properties on each snapshot. ConsumerUdsPath is marked IsSensitive=true so the dashboard UI masks it; the value still rides the gRPC stream because it is required server-side, but it is never echoed back to the browser through the WS endpoint. Browser side: * TerminalView.razor.cs takes ResourceName + ReplicaIndex parameters instead of SocketPath; builds the WS URL as /api/terminal?resource=...&replica=... using the request's authority. ReconnectAsync(string?, int) is the new reconnect signature. * TerminalView.razor.js sends keystrokes as binary frames via TextEncoder; resize messages remain text JSON. Framing is WS-frame-type-driven, not content sniffed. * ConsoleLogs.razor / ConsoleLogs.razor.cs forward DisplayName + ReplicaIndex into TerminalView (no socket path leaves the server). Tests: * Aspire.Dashboard.Tests.Terminal.DefaultTerminalConnectionResolverTests covers: client-disabled, resource-not-found, replica mismatch, missing terminal-enabled marker, missing UDS path, and the bad-path-throws negative case. * Aspire.Dashboard.Tests.Model.ResourceViewModelExtensionsTerminalTests covers HasTerminal, TryGetTerminalReplicaInfo, and TryGetTerminalConsumerUdsPath positive / negative paths. Verified: * dotnet build src/Aspire.Dashboard -> 0 warnings, 0 errors * dotnet build src/Aspire.Hosting -> 0 warnings, 0 errors * full ./build.cmd -> 0 warnings, 0 errors * Aspire.Hosting.Tests *WithTerminal* -> 16/16 passing * Aspire.Cli.Tests *Terminal* -> 11/11 passing * Aspire.Dashboard.Tests *Terminal* -> 13/13 passing * Aspire.Dashboard.Tests (excl Playwright) -> 1255/1255 passing * Aspire.Hosting.Tests Dashboard ns -> 98/98 passing * AOT publish: no new IL warnings from Terminal/* or TerminalView* (pre-existing Dashboard AOT warnings are unchanged; Dashboard is not AOT-compiled in the ship pipeline) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Phase 8: WithTerminal end-to-end playground + plain-executable fix Wires up the Terminals playground end-to-end so WithTerminal() can be exercised against both a project-style resource (REPL, 2 replicas) and a plain executable (cmd.exe on Windows). Validated full pipeline: DCP ConPTY -> HMP1 producer UDS -> Aspire.TerminalHost (per-replica Hex1bTerminal) -> HMP1 consumer UDS -> external viewer. Changes: * New playground project Terminals.Repl: interactive ANSI REPL with help/whoami/time/size/echo/rainbow/clear/exit. Produces ANSI banner + prompt suitable for exercising terminal emulation through the full WithTerminal pipeline. * Terminals.AppHost: add the REPL with WithReplicas(2) and WithTerminal(120x32); add a Windows-gated 'shell' resource (AddExecutable cmd.exe + WithTerminal()) to cover the plain-executable path. * Bug fix in ExecutableCreator.PreparePlainExecutables: plain executables added via AddExecutable() were missing both ResourceReplicaIndex and ResourceReplicaCount annotations, which caused BuildExecutableConfiguration's per-replica producer UDS lookup to fail silently and skip the spec.Terminal wire-up entirely. Added regression test PlainExecutable_WithTerminal_PopulatesTerminalSpecOnWindows. * docs/specs/with-terminal.md: replaces the deleted terminal-protocol.md (which described an obsolete custom protocol) with the current Aspire-side architecture spec for WithTerminal(). Validation: * Aspire.Hosting + Terminals.AppHost + Terminals.Repl all build clean (0 warnings 0 errors). * All 15 WithTerminalTests pass. * New PlainExecutable_WithTerminal_PopulatesTerminalSpecOnWindows test passes alongside existing Project_WithTerminal_/WithoutTerminal_ tests. * Manually validated end-to-end with a small HMP1 probe: connecting to each consumer UDS yields a Hello frame and a multi-KB StateSync frame containing the live PTY output (cmd.exe banner / REPL banner). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Phase 8 fix: TerminalView reacts to resource/replica parameter changes The Dashboard ConsoleLogs page reuses the same TerminalView instance when the user switches between terminal-enabled resources (or between replicas of the same resource), so firstRender is never true again after the first switch. The original implementation only initialized the xterm.js / WebSocket bridge on firstRender, which left the view stuck on whichever resource was initially selected. Track the (resource, replica) pair we last connected to, and call the existing ReconnectAsync path from OnAfterRenderAsync whenever the parameters change. The JS side already has reconnectTerminal which closes the old WebSocket, clears the screen, and opens a new connection — the StateSync replay from the new producer fills the buffer with the right replica's content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WithTerminal: container support + Node.js playground ContainerCreator now mirrors the per-resource TerminalAnnotation -> DCP TerminalSpec wiring that ExecutableCreator already does. Containers are single-replica in DCP, so we always reference index 0 of the host's UDS layout (the same layout the executable path uses for replica index 0). The companion DCP-side change is in microsoft/dcp#138 (stacked on microsoft/dcp#133): when ContainerSpec.Terminal is set, DCP creates the container with `-t -i` and runs `docker start --attach --interactive` under a host ConPTY exposing the resulting byte stream as an HMP v1 producer at TerminalSpec.UDSPath. The Aspire-side terminal host then connects as an HMP v1 client, identical to the executable case. Like the executable path, this is currently gated behind a Windows OS check; on other platforms ContainerCreator logs a warning and leaves TerminalSpec unset so the container runs without an attachable terminal. Playground: adds a `nodebox` container resource (node:lts) with `WithEntrypoint(""/bin/bash"")` so users can attach the dashboard terminal and use `npx` / `node` interactively. Also re-enables `WithTerminal()` on the existing `shell` (cmd.exe) executable that was commented out for IDE-debug investigation in Phase 8. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Terminals playground: nodebox fix - override CMD instead of entrypoint WithEntrypoint(""/bin/bash"") only overrides the image's ENTRYPOINT; the image's CMD (""node"") is still inherited, so docker actually executes `/bin/bash node` which makes bash treat `node` as a missing script file and exit immediately - the container is gone before the dashboard's Terminal tab even gets a chance to attach. Switching to `WithArgs(""bash"", ""-l"")` keeps the image's docker-entrypoint.sh in place and overrides the CMD, so the entrypoint exec's an interactive login bash, which sticks around for the terminal session and exits cleanly when the user types `exit` (which then propagates through the host PTY's docker-start-attach process and signals container exit via Session.Done()). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Flip producer-side connection direction in Aspire.TerminalHost Pairs with the matching DCP change. Previously the terminal host dialed DCP (WithHmp1UdsClient) on the producer UDS; now the terminal host LISTENS on that UDS and DCP dials in. This guarantees the host is receiving from the very first byte the PTY emits, so a long-running shell's initial prompt makes it into the host's scrollback even for dashboard viewers that attach later. The HMP v1 protocol roles are unchanged: DCP holds the PTY and so must remain the HMP1 server; the terminal host remains the HMP1 client. Hex1b's WithHmp1UdsClient/WithHmp1UdsServer convenience helpers couple the HMP1 protocol role with the TCP role, which we don't want here. We compose the lower-level WithHmp1Client(Func<CT, Task<Stream>>) with Hmp1Transports.ListenUnixSocket(...) instead, taking the first stream the listener accepts and using that as the HMP1 client transport. The consumer side (WithHmp1UdsServer) is unchanged - the terminal host keeps listening on the consumer UDS for dashboard/CLI viewers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Dashboard terminal: send resize on WebSocket open before render xterm.js was constructed at the default 80x24 and only ever reported the viewer's true dimensions to the host through term.onResize, which fires when xterm INTERNAL dimensions change. The first fit() ran before the WebSocket was open, so the resulting onResize was silently dropped (state.ws null). Subsequent fits saw the same dimensions and didn't fire onResize again. Net effect: the host received no Resize from the viewer and replayed its initial StateSync at producer dimensions, so when the viewer's xterm grid was larger than 80x24 the replayed content appeared squeezed into a corner. Fix: in ws.onopen, re-fit and explicitly send a resize JSON control frame using the post-fit term.cols/term.rows. This is the first thing the viewer sends to the host, guaranteeing that any post-handshake StateSync re-emission (and all subsequent output) is rendered at the viewer's actual viewport. Refactored the onResize-driven send into a shared sendResize(state) helper and reused it from both the onopen path and the term.onResize hook. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WithTerminal: process-restart support + Stop-regression hardening (Phase 9d-9f) Phase 9d - process-restart support: TerminalReplica becomes a recycle loop that disposes its Hex1bTerminal when the producer disconnects (process exit or DCP-driven Stop), then immediately rebinds the same UDS paths so DCP can relaunch the underlying process and the next producer dial reattaches viewers without any out-of-band coordination. * Hex1b transports.ListenUnixSocket already unlinks stale socket files before binding so rebinding the same path is safe across cycles. * Adds ProducerConnected (live) and RestartCount (cumulative) state under a single lock; legacy IsAlive/ExitCode aliases retained for the CLI/AppHost callers - semantics shift from "permanently terminated" to "producer currently attached" but the slot itself outlives any single cycle, so the alias is still meaningful. * Escalating backoff (100ms -> 5s) on consecutive failed cycles so a wedged transport doesn't burn CPU/log indefinitely. * BuildTerminal() wrapped in its own try/catch with goto AfterRun; a transient build failure is treated as a failed cycle, not a permanently-disabled replica slot. Phase 9d - control wire and clients: * TerminalHostReplicaInfo gains ProducerConnected and RestartCount (additive JSON, no protocol bump). * AuxiliaryBackchannelRpcTarget plumbs both fields through into TerminalReplicaInfo so the CLI and Dashboard can react to recycles. Phase 9d - dashboard JS reconnect state machine: * TerminalView.razor.js rewritten with a single auto-reconnect loop and a generation token, so a late onclose from socket N can't schedule a reconnect on top of a freshly-connected socket N+1, and an explicit reconnectTerminal() (replica-switch path) safely interleaves with any pending auto-reconnect timer. * Each new connection clears xterm and reconstructs the stateful UTF-8 TextDecoder so StateSync replay paints into a clean buffer and tail bytes from the previous stream don't bleed into the next. * disposeTerminal() flips reconnect.enabled = false and bumps the generation so any late callbacks no-op. Phase 9e/9f - hardening for user-reported "clicking Stop on a resource kills the dashboard" regression: * TerminalWebSocketProxy.cs: - Removed fire-and-forget _ = adapter.ResizeAsync(...).AsTask(). A resize that arrived during the producer-recycle window could throw an exception type (OperationCanceledException, InvalidOperationException from Hmp1Protocol mid-frame, etc.) that ResizeAsync's internal IOException/ObjectDisposedException catch list doesn't cover, leaving an unobserved task exception in the dashboard process. TryHandleControlFrame now returns a Task and is awaited from the inbound pump. - Broadened both pump exception filters from "WebSocketException or IOException or ObjectDisposedException" to a catch-all (with OperationCanceledException filtered as expected shutdown). The narrow filter was fine for the happy-path WS close but didn't cover an HMP1 protocol exception when the producer reset mid-frame (e.g. abrupt cmd.exe TerminateProcess on Windows). - 5-second handshake timeout via linked CTS so a wedged or mid-recycle host can't tie up WS handlers indefinitely while the JS retries in lockstep. - Wrapped BuildTerminal() inside the recycle loop in its own try/catch (Phase 9e). * TerminalView.razor.cs: OnAfterRenderAsync wraps ReconnectAsync in a catch-all (with JSDisconnectedException distinguished as benign); ReconnectAsync itself catches JSDisconnectedException around its InvokeVoidAsync calls so a JS-side error during the user-switched-resource path can't fail the SignalR circuit and tear down the entire dashboard tab. * TerminalView.razor.js: MAX_RECONNECT_ATTEMPTS = 30 cap on the auto-reconnect loop with a one-line "[terminal disconnected]" hint written into xterm so a permanently-stopped resource doesn't have the JS hammering the WS forever; state.term.clear() and state.term.dispose() wrapped in try/catch. * Program.cs: Wired AppDomain.UnhandledException and TaskScheduler.UnobservedTaskException to write the full type + message + stack trace to Console.Error so a future "dashboard silently died" report has breadcrumbs in the AppHost log instead of just silence; e.SetObserved() to neutralise the exception. * DashboardWebApplication.Run() catch-all now writes ex.ToString() not just ex.Message so a startup or run-time fatal is fully traceable from the AppHost log without a debugger attach. All 7 TerminalHostAppTests pass. Aspire.Dashboard / Aspire.TerminalHost / Aspire.Hosting build clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Phase 11: CLI wires up HMP1 multi-head primary/secondary protocol The aspire terminal CLI now passes a displayName ('aspire-cli') and defaultRole ('viewer' or 'interactive') in the HMP1 client hello, and optionally requests primary on connect to drive PTY dimensions. Behaviour: - Default: connect as interactive and auto-RequestPrimary on handshake using local console dimensions (preserves single-head dogfood UX). - `--viewer`: attach as a passive secondary; do not disturb whoever currently holds primary (typically the dashboard). Subscribes to RoleChanged / PeerJoined / PeerLeft events for diagnostic logging at debug level so multi-head behaviour can be traced without adding production noise. Bumps Hex1b/Hex1b.McpServer/Hex1b.Tool to 0.144.1-multihead1 from the local-hex1b feed (PR microsoft/hex1b#xxx). Once Hex1b cuts a tagged release containing the multi-head changes, this version + the local-hex1b NuGet source override revert. Tests: TerminalCommandViewerOptionTests verifies --viewer option parsing and help text. Protocol-level frame emission is covered by Hex1b's own multi-head test suite (Tier 2/3 in tests/Hex1b.Tests/Hmp1/Hmp1MultiHead*.cs). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * TerminalHost: bridge consumer-side resize to raw DCP HMP1 frame When a primary peer (e.g. CLI viewer or dashboard) sent RequestPrimary to the consumer-side server, the resize never reached DCP. The previous attempt cast the upstream workload to Hex1b's Hmp1WorkloadAdapter and called ResizeAsync, but that method short-circuits with 'if (!IsPrimary) return;' and IsPrimary only flips true on multi-head frames the minimal DCP HMP1 server never sends. Replace that path with DcpUpstreamAdapter, a narrow IHex1bTerminalWorkloadAdapter that speaks raw HMP1 (Input + Resize outbound; Output/Hello/Exit inbound) and writes FrameResize unconditionally. Wire the consumer-side srvOpts.OnResized hook to upstream.ResizeAsync so primary-driven resizes flow all the way through to DCP and the underlying PTY. Adapter invariants: - Single-shot connect via SemaphoreSlim + TaskCompletionSource. - Atomic frame writes use the disposal CT (not caller CT) so a caller cancel mid-frame can't split header/payload and corrupt the stream. - Disconnect fires exactly once (Interlocked.Exchange gate). - Pre-connection resize coalesced under a gate; latest dims applied fire-and-forget after connect. - Output channel uses BoundedChannelFullMode.Wait (terminal bytes are not message-independent; ANSI escapes can span buffers). Regression test DownstreamPrimaryResizeIsForwardedUpstreamAsRawResizeFrame dials a minimal raw-HMP1 producer and consumer to a real TerminalReplica, sends ClientHello + RequestPrimary{cols=123,rows=45}, and asserts a FrameResize matching those dims arrives upstream. Uses the new WaitForMatchingFrameAsync helper to drain unrelated noise frames (host's Hex1bTerminal also fires its own resize during the same flow). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Hex1b to 0.147.0 + transient nuget.org Hex1b-only mapping Bumps Hex1b / Hex1b.McpServer / Hex1b.Tool from 0.144.1-multihead1 to 0.147.0 (multi-head GA wire — primary/secondary roles, ClientHello, RequestPrimary, RoleChange, PeerJoin/PeerLeave). Wire is backward compatible with the basic 0x01-0x06 frame subset that DCP's HMP1 server speaks; the new 0x07-0x0B frames are aspire-internal multi-head coordination only. Adds a transient packageSourceMapping that scopes nuget.org to Hex1b packages only, so we can consume 0.147.0 before the dotnet-public mirror has caught up. Note in the XML comment makes the transience explicit and the source/mapping should be removed once the internal mirror has 0.147.0+. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Phase 17 Dashboard: transplant WebMuxerDemo terminal chrome + dumb byte-pipe pivot Rewrites the dashboard terminal frontend around a new browser-side HMP1 client that speaks the multi-head wire directly to the dashboard WebSocket, which is now a dumb byte-pipe proxying straight to the upstream terminal host's per-replica consumer UDS. Removes the prior JS-interop frame translation layer. Frontend (TerminalView.razor.js + new wwwroot/js/hmp1-client.js): - Lifts the WebMuxerDemo experience: role infobar (primary / secondary pill + Take control button), font-size adjustment in the toolbar, resize-to-grid scaling that follows the host's Hello/Resize dims. - Reconnect state machine, generation token, stateful UTF-8 decoder (re-created per connection), term.clear() on reconnect for StateSync replay. - ES module imported via script tag with type=module; diagnostics gated by window.__aspireTerminalDebug = true. Backend (TerminalWebSocketProxy.cs + Program.cs): - Proxy is now a duplex byte-pipe: WS to upstream stream and back, no HMP1 parsing on the dashboard. Two-task pump with mutual cancellation; either side closing/erroring tears down the other. - Logs at Information for forensics on Stop-cascade scenarios. - Best-effort graceful WS close on bridge exit using CT.None so a shutdown abort doesn't skip the courtesy close. All 13 dashboard terminal tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Phase 17 CLI: split TerminalCommand and lift WebMuxerDemo viewer experience Splits the single `aspire terminal` command into a dispatcher (TerminalCommand) plus a dedicated `aspire terminal attach` subcommand (TerminalAttachCommand) that owns the interactive embedded-terminal flow. New TerminalViewerApp class encapsulates the full WebMuxerDemo viewer experience: role infobar (primary / secondary), Take-control chord, font-size and presentation options, scrollback widget, clean secondary detach when another peer takes primary. TerminalCommand.cs is reduced to its dispatcher role only (322 lines removed). TerminalAttachCommand.cs (new) wires the full HMP1 multi-head client into Hex1bTerminal via the canonical `.WithScrollback().WithTerminalWidget(out handle).Build()` pattern, which avoids the WindowsConsoleDriver dependency in non-interactive contexts. TerminalViewerApp.cs (new) implements the Hex1b widget chain and wires Hmp1Client events into UI updates. DI registrations updated in Program.cs and CliTestHelper.cs to register the new TerminalAttachCommand alongside the existing TerminalCommand. All 14 TerminalCommand* tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Terminals playground: shell2 control resource for Stop bisection + sln file Adds a non-PTY 'shell2' executable (cmd.exe wrapping continuous ping) to the Terminals.AppHost as an A/B control: same DCP-managed Windows process model as 'shell' but WITHOUT WithTerminal(). Lets us bisect whether 'Stop kills the dashboard' symptoms are PTY-attached-resource specific or apply to any DCP-managed Windows process. Adds Terminals.sln so the playground projects can be opened/managed as a solution unit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Hex1b 0.147.0 -> 0.150.0; drop transient nuget.org mapping Hex1b 0.150.0 is now available on the internal dotnet-public feed, so we can remove the transient nuget-org-hex1b source and its scoped packageSourceMapping that was added to bridge the gap when Hex1b 0.147.0 was introduced. NuGet.config now matches origin/main; only Directory.Packages.props carries the version bump. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Adapt to Hex1b 0.150.0 and rebased main Three small drift fixes surfaced by the post-rebase build: 1. Hex1b 0.150.0 renamed the input-binding fluent helper. `BackgroundPanelWidget.WithInputBindings` (Hex1b 0.147) was renamed to `InputBindings` in 0.150 (the breaking-change diff lists `InputBindingExtensions.WithInputBindings` removed and `InputBindings` added; signatures are otherwise identical). 2. `IDashboardClient.ExecuteResourceCommandAsync` gained an `ExecuteResourceCommandOptions options` parameter on main (#16903 "Support named resource command options"). The `DisabledDashboardClient` test fake in `DefaultTerminalConnectionResolverTests` needed the new signature. 3. `IAppHostAuxiliaryBackchannel.ExecuteResourceCommandAsync` likewise gained `ExecuteResourceCommandOptions? options` on main. The `CapturingTerminalAppHostBackchannel` test wrapper in `TerminalCommandTests` needed the new signature and to forward the new parameter through to the inner backchannel. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WithTerminal Phase 18: package Aspire.TerminalHost as per-RID NuGet packages Mirror the eng/dashboardpack/ pattern to ship Aspire.TerminalHost as Aspire.TerminalHost.Sdk.<rid> packages alongside Aspire.Dashboard.Sdk.<rid>. New eng/terminalhostpack/ contains: * 7 per-RID stub csprojs (win-x64/arm64, linux-x64/arm64/musl-x64, osx-x64/arm64) * Common.projitems that publishes Aspire.TerminalHost for the RID, packs the publish output under tools/, and emits a per-RID zip * Sdk.props/Sdk.targets/AutoImport.props markers * Sdk.targets sets AspireTerminalHostDir/AspireTerminalHostPath only when not already set (preserves inner-loop overrides) * UnixFilePermissions.xml grants 755 to tools/Aspire.TerminalHost * buildTransitive/ + buildMultiTargeting/ template wrappers Wire-up: * Directory.Build.props: TerminalHostPublishedArtifactsOutputDir * eng/Publishing.props: duplicate property + publish glob + blob feed * eng/Build.props: BuildBundleDepsOnly + SkipBundleDeps + ProjectToBuild * src/Aspire.AppHost.Sdk: implicit Aspire.TerminalHost.Sdk.<rid> PackageReference inside AddReferenceToDashboardAndDCP * src/Aspire.TerminalHost.csproj: RuntimeIdentifiers + ReturnPackageVersion target so the per-RID pack projects can MSBuild it for publish Inner-loop debugging is preserved by three independent layers: 1. Directory.Build.props sets AspireTerminalHostDir to artifacts/bin/... 2. In-repo playgrounds use Microsoft.NET.Sdk so AddReferenceToDashboardAndDCP never fires for them 3. Sdk.targets guards each set with Condition checking that the property is empty Verified: built Aspire.TerminalHost.Sdk.win-x64 standalone, inspected the .nupkg, and confirmed tools/Aspire.TerminalHost.exe + tools/hex1bpty.exe + build/.../*.props|targets are all packaged with token substitution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WithTerminal Phase 19: integrate Aspire.TerminalHost into the polyglot CLI bundle The CLI bundle ships a single self-contained aspire-managed.exe that dispatches into Dashboard / RemoteHost / NuGet helper modes via top-level switch in Aspire.Managed/Program.cs. Add a fourth ["terminalhost", ...] arm so the same binary handles WithTerminal()-spawned TerminalHost replicas in polyglot apps. Bundle dispatcher: * Aspire.TerminalHost: TerminalHostApp class lifted to public so Aspire.Managed can call the static RunAsync(args, ct) entry point. Constructor and SnapshotReplicas remain internal so internal-only types (TerminalHostArgs, TerminalHostReplicaInfo) stay out of the public surface. CS1591 added to NoWarn since the assembly only ships as tools/ payload, not as a public API. * Aspire.Managed.csproj: ProjectReference to Aspire.TerminalHost (cross-TFM: Managed = net10.0 -> TerminalHost = net8.0, same as the existing Dashboard ProjectReference). * Aspire.Managed/Program.cs: ["terminalhost", .. var rest] arm calling TerminalHostApp.RunAsync; ShowUsage updated to advertise the new mode. AppHost discovery flow: * Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs: 3 new outputs AspireTerminalHostDir / AspireTerminalHostPath / AspireTerminalHostInvocationArgs. Path = ManagedPath (same aspire-managed.exe); InvocationArgs = "terminalhost". * Aspire.Hosting.AppHost.in.targets: ResolveAspireCliBundlePaths flows the new properties; SetTerminalHostDiscoveryAttributes also bakes an aspireterminalhostinvocationargs AssemblyMetadata when set. * Aspire.Hosting/Dcp/DcpOptions.cs: TerminalHostInvocationArgs property + metadata key + ASPIRE_TERMINAL_HOST_INVOCATION_ARGS env var resolution. * Aspire.Hosting/Lifecycle/TerminalHostEventingSubscriber.cs: when invocation args are set, prepend each via a CommandLineArgsCallbackAnnotation. Mirrors the Dashboard pattern (DashboardEventHandlers args.Insert(0, "dashboard")). Inner-loop preservation: the per-RID Aspire.TerminalHost.exe path remains the inner-loop default. AspireTerminalHostInvocationArgs is empty unless the bundle discovery task ran (AspireUseCliBundle=true), so the prepend is a no-op for in-repo playgrounds. Verified: * Built playground/Terminals/Terminals.AppHost and inspected its assembly metadata: aspireterminalhostpath = artifacts/bin/Aspire.TerminalHost/.../Aspire.TerminalHost.exe (no aspireterminalhostinvocationargs, as expected for inner-loop). * Published Aspire.Managed for win-x64 self-contained and invoked aspire-managed.exe terminalhost --help: dispatcher correctly routed into TerminalHostApp.RunAsync (which replied with its own argument-parsing error, proving the arm wires through end-to-end). aspire-managed.exe with no args also lists the new "terminalhost" subcommand in its usage banner. * Targeted tests: Aspire.TerminalHost.Tests 20/20, Aspire.Hosting.Tests terminal subset 15/15, Aspire.Cli.Tests terminal subset 14/14, Aspire.Dashboard.Tests terminal subset 6/6, Aspire.Managed.Tests 3/3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs/specs/with-terminal: fix MD040 fenced-code-language Add `text` language to the unlabelled process-topology fence at line 23 so the markdownlint job (CI / Markdownlint) passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WithTerminal Phase 20: fix CI failures from Phase 18/19 Three small follow-ups to make CI green: 1. .github/workflows/build-cli-native-archives.yml: add Aspire.TerminalHost.Sdk.<rid>.*.nupkg to the upload-artifact glob. The packages were being produced by the build (eng/Build.props wires terminalhostpack into the bundle-deps build) but stripped from the per-RID artifact, so the Templates tests' built-local feed never saw them and dotnet restore failed with NU1101: Unable to find package Aspire.TerminalHost.Sdk.linux-x64. 2. tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs: extend the BuilderAddsDefaultServices Assert.Collection to include the new TerminalHostEventingSubscriber registered by DistributedApplicationBuilder.cs (Phase 19). 3. tests/Shared/Aspire.Templates.Testing.targets: add Aspire.TerminalHost.Sdk. to the UnexpectedPackages exclusion list, mirroring the existing Aspire.Dashboard.Sdk., Aspire.Hosting.Orchestration., and Aspire.Cli. exclusions. Plus the README mention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WithTerminal Phase 22: review feedback cleanup pass 1 Address Mitch's PR review comments on #16760 grouped under low-risk cosmetic + logger-discipline fixes: * TerminalWebSocketProxy: drop Console.Error.WriteLine fallback. The preceding logger.LogError already captures the exception with stack trace; the stderr writes were a belt-and-braces leftover from when ILogger plumbing was uncertain. Comment updated to reflect that. * ConsoleLogs.razor: reformat the TerminalView element to match the multi-line attribute style used by the sibling LogViewer block. * Move TerminalViewerApp from Aspire.Cli.Commands to a new Aspire.Cli.Tui namespace (and matching src/Aspire.Cli/Tui/ folder). This is the first full alt-screen TUI experience in the CLI; future TUI shells should land here too. TerminalAttachCommand picks it up via a new using directive. * Replace the silent 'catch { /* ignore */ }' blocks in TerminalViewerApp.RunAsync with LogDebug-emitting catches so we have visibility when the embedded CTS teardown or Hex1bTerminal dispose actually fails (typical: object-disposed races, transport faults during shutdown, the 2s dispose-timeout masking a stuck pump). Also log when the outer Hex1bApp cancellation fires so we can distinguish embedded-fault vs caller-cancellation. * Remove playground/Terminals/Terminals.sln; add the two playground projects (Terminals.AppHost, Terminals.Repl) to Aspire.slnx in a new /playground/Terminals/ folder slot, alphabetically between Stress and Testing, matching the convention used by every other playground. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WithTerminal Phase 22: review feedback documentation pass Address Mitch's PR review comments that resolve to 'document the existing decision/architecture' rather than code changes: * AppHostAuxiliaryBackchannel.cs: explain the per-feature capability flag pattern (Terminals_V1) and link to docs/specs/cli-backchannel.md §3, which already prescribes capability strings over monolithic version revs. The current implementation already follows that, but the rationale was not visible from the code itself, leading to the 'rev whole channel vs. per-feature?' question on review. * TerminalWebSocketProxy.cs: add a 'Why a custom proxy and not Hex1b's Hmp1PresentationAdapter?' section to the class XML doc. Explains that Hmp1PresentationAdapter is the *server* side of HMP1 (lives in the process owning the terminal), and WebSocketPresentationAdapter is for in-process Hex1b apps that render *themselves* via WS — the dashboard fits neither role because it sits between two HMP1 endpoints and relays at the byte level. The original doc cross- referenced WebMuxerDemo's WebSocketProxy.BridgeAsync, which has been removed from Hex1b; that line is dropped. * DefaultTerminalConnectionResolver.cs: explain why the dashboard reaches for the lower-level Hmp1Transports.ConnectUnixSocket helper instead of the WithHmp1UdsClient builder (the builder embeds an HMP1 stream into a Hex1b terminal, which is the CLI viewer's pattern — the dashboard doesn't run a terminal). No behavioural changes; doc-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WithTerminal Phase 23: aspire terminal ps command + per-replica metadata Adds 'aspire terminal ps' which lists every WithTerminal()-enabled resource in the connected AppHost with current grid size, attached-peer count, and per-replica health. Wire formats / capabilities - New 'terminals.ps.v1' capability advertised by AuxiliaryBackchannelRpcTarget alongside the existing 'terminals.v1'. Older AppHosts that only know 'terminals.v1' continue to work for 'terminal attach' but the CLI surfaces a clear AppHostIncompatible error when 'terminal ps' is invoked against them. - TerminalReplicaInfo gains four nullable optional fields (CurrentColumns, CurrentRows, AttachedPeerCount, Peers). Old payloads round-trip with null new fields; new payloads round-trip with values populated. See docs/specs/cli-backchannel.md sect 3 for the per-feature capability rationale. - New TerminalPeerInfo, ListTerminalsRequest, TerminalSummary, ListTerminalsResponse types registered in BackchannelJsonSerializerContext. TerminalHost-side metadata tracking - TerminalReplica wires Hex1b's Hmp1ServerOptions OnClientConnected / OnClientDisconnected / OnResized callbacks via WithHmp1UdsServer to maintain a peer dictionary and current dimensions (under a lock). The OnResized callback preserves the existing upstream resize-forwarding behavior. Peers are cleared defensively in cycle teardown to handle a late disconnect callback. - TerminalHostApp.SnapshotReplicas surfaces the new per-replica info. AppHost RPC - New ListTerminalsAsync method on AuxiliaryBackchannelRpcTarget iterates every TerminalAnnotation-bearing resource and aggregates per-replica info. Per-resource try/catch with a 3s timeout: a single host that errors becomes IsHostReachable=false rather than failing the listing. CLI - New TerminalPsCommand in src/Aspire.Cli/Commands/. Mirrors the TerminalAttachCommand resolver pattern, supports --apphost / --project, --format text|json (mirroring PsCommand's OutputFormat enum), and --verbose|-v which adds a second per-peer details table. Empty list short-circuits with a friendly text/JSON message. Spectre table columns: Resource | Replica | Status | Size | Peers | Restarts. - TerminalPsCommand registered in DI in Program.cs and wired into TerminalCommand as a subcommand alongside TerminalAttachCommand. Tests - 5 new TerminalPsCommand tests in TerminalCommandTests covering: no AppHost running, capability gate, empty list, populated list, and --format json on empty. - 2 new BackchannelJsonSerializerContext tests: old payload without new fields deserializes with nulls (back-compat), and full ListTerminalsResponse with new fields round-trips cleanly. - TestAppHostAuxiliaryBackchannel and the inline CapturingTerminal fake in TerminalCommandTests grew SupportsTerminalsPsV1 + ListTerminalsAsync stubs to satisfy the new interface members. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Per-replica TerminalHost: one host process per parent replica Refactor the TerminalHost model so each parent replica gets its own `aspire.terminalhost` process owning a single producer/consumer/control UDS triple, instead of a single host process owning N replica slots. Why --- The previous "one host with N replicas" design baked the replica count into the host's argv (`--replica-count` plus repeated `--producer-uds`/`--consumer-uds` arrays) and required the host to dispatch incoming dials to the right replica slot. That made the host replica-aware, but in practice the only thing that needed to know the replica index was the AppHost (which generates the paths and tells DCP which UDS each replica should dial). The host itself just needs to listen on whatever UDS it is told to listen on. This commit flips the model so: - The host is replica-opaque: one process, one producer UDS, one consumer UDS, one control UDS. `--replica-count` is gone. - The AppHost encodes the parent replica index into the per-replica directory of the layout (`{base}/{i}/{producer,host,control}.sock`), and creates one `TerminalHostResource` per replica named `{parent}-terminalhost-{i}`. - `TerminalAnnotation` now exposes `IReadOnlyList<TerminalHostResource> TerminalHosts` (was a single `TerminalHost` reference). - Backchannel `GetTerminalInfo`/`ListTerminals` fan out across the per-replica hosts in parallel via a shared `CollectReplicaInfosAsync` helper and degrade gracefully when individual hosts haven't started yet (each unreachable host yields a degraded `TerminalReplicaInfo` rather than failing the whole call). Wire-shape changes ------------------ - `TerminalHostReplicasResponse` deleted from the protocol. - `TerminalHostReplicaInfo` -> `TerminalHostSessionInfo` (no Index). - `GetReplicasMethod` -> `GetSessionMethod`. - `TerminalHostInfoResponse.ReplicaCount` removed. - `TerminalHostControlProtocol.ProtocolVersion` bumped to 2. - `TerminalSummary.IsHostReachable` semantics changed to "at least one per-replica host responded" (was "the single control RPC succeeded"). - `TerminalReplicaInfo.ReplicaIndex` is now sourced from `TerminalHostLayout.ParentReplicaIndex` rather than the host's reply. Doc fixes --------- The previous comments in `TerminalSpec.cs` claimed "DCP listens, host dials". The truth is the opposite: TerminalHost LISTENS on the producer UDS, and DCP DIALS into it. Same for the consumer UDS (viewers dial) and the control UDS (AppHost dials). Comments are corrected to match. The matching DCP-side comment fix in `terminal_types.go` is a separate PR on the DCP repo that I'll do outside this session. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: whitespace nudge to retrigger CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Defer per-replica TerminalHost creation to BeforeStartEvent Previously WithTerminal() read the parent resource's ReplicaAnnotation eagerly and created the per-replica TerminalHostResources at the call site. That meant calling WithReplicas(N) AFTER WithTerminal() would only spawn one terminal host because the replica count was captured before the model was finalized. Move the materialization into a builder-phase BeforeStartEvent subscription so the final ReplicaAnnotation count is always honoured, regardless of call order. TerminalAnnotation is still added eagerly so downstream consumers (DCP creators, dashboard data, backchannel) can detect a configured terminal at WithTerminal() time; its TerminalHosts collection is empty until BeforeStartEvent fires. Builder-phase event subscriptions fire ahead of DI-registered IDistributedApplicationEventingSubscriber instances, so TerminalHostEventingSubscriber still sees every per-replica host in the model when it runs and resolves their binary paths. The replica- count drift warning in TerminalHostEventingSubscriber is removed because it can no longer trigger. Tests publish BeforeStartEvent manually before observing TerminalHosts. A new regression test (WithReplicasAfterWithTerminalCreatesOneTerminalHostPerReplica) exercises the bug fix directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add ATS export for WithTerminal via parameterless dispatcher overload Adds an internal sibling WithTerminalForPolyglot<T>(builder) decorated with [AspireExport("withTerminal")] so non-C# AppHosts can call WithTerminal(). The original public WithTerminal<T>(builder, Action<TerminalOptions>?) overload keeps [AspireExportIgnore] (now pointing at the dispatcher) because Action<T> delegate parameters require ATS exporting TerminalOptions, which we deliberately keep out of the polyglot surface for now. Polyglot AppHosts that need to customise columns/rows/shell can fall back to per-resource environment variables until a future DTO-shaped overload lands. Drive-by: removes a stray 'using Aspire.Hosting.Eventing' from WithTerminalTests.cs and DcpExecutorTests.cs that became unused after BeforeStartEvent moved to Aspire.Hosting.ApplicationModel; both files were failing IDE0005 as warnings-as-errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fall back to ASPIRE_DASHBOARD_PATH when terminal host path is unset in bundle mode When a TS-based AppHost (or any pre-built dotnet AppHost) is launched from the CLI bundle via PrebuiltAppHostServer/DotNetAppHostProject, the launcher only sets ASPIRE_DCP_PATH and ASPIRE_DASHBOARD_PATH (the latter pointing at the multi-mode aspire-managed exe). It does not set ASPIRE_TERMINAL_HOST_PATH, and the assembly-metadata fallback (aspireterminalhostpath) is empty for prebuilt AppHosts because the SDK targets that emit it never run in the TS scenario. Result: TerminalHostEventingSubscriber sees an empty TerminalHostPath, logs a warning, and silently skips launching the per-replica terminal hosts — so .WithTerminal() resources have no terminal in bundle mode. This change adds the same kind of bundle-aware fallback that DashboardEventHandlers has had since day one (where it detects IsAspireManagedBinary(DashboardPath) and prepends 'dashboard' as the dispatcher arg). After the explicit lookup chain runs, if TerminalHostPath is still empty AND DashboardPath points at aspire-managed, default TerminalHostPath = DashboardPath and TerminalHostInvocationArgs = 'terminalhost'. Standalone per-RID NuGet packages keep using their dedicated terminal host binary via assembly metadata and never hit this fallback. Explicit ASPIRE_TERMINAL_HOST_PATH / ASPIRE_TERMINAL_HOST_INVOCATION_ARGS still win. New ConfigureDefaultDcpOptionsTests pin all four cases: bundle fallback fires, fallback does not fire for non-aspire-managed dashboards, explicit terminal host path is preserved, and explicit invocation args are preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Drop TerminalSpec.Enabled and Windows-only gate Apply Karol's review feedback from microsoft/dcp#133: A.1 — Drop the Enabled flag on TerminalSpec. Presence of the field on an Executable or Container spec is now sufficient to activate the terminal path; the parallel "Enabled = false" state had no defined semantics. ExecutableCreator and ContainerCreator stop setting it. The DCP-side companion change in api/v1/terminal_types.go removes the field. While here, drop the IsOSPlatform(OSPlatform.Windows) gate that was suppressing spec.Terminal on Linux/macOS — DCP now implements PTY allocation on all three host platforms (ConPTY on Windows, /dev/ptmx on Unix). The previous-warning behaviour on non-Windows is gone; if the running DCP build does not support terminal allocation the executable will fail to start with termpty.ErrTerminalNotSupported surfaced through the reconciler instead. The two DcpExecutor tests that asserted on .Enabled have been updated to assert presence of the spec instead, and their stale Windows-only SkipUnless guards have been removed (they run against TestKubernetesService and never required real DCP). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * playground: add Unix bash shell + auto-wire TerminalHost project ref - AppHost.cs: add an 'shell' executable on the non-Windows branch that spawns an interactive login bash (-i -l), mirroring the cmd.exe branch on Windows so the macOS/Linux playground exercises the executable PTY path. Drop the now-stale comment about DCP being Windows-only. - Terminals.AppHost.csproj: conditionally project-reference Aspire.TerminalHost (ReferenceOutputAssembly=false) and stamp AspireTerminalHostPath as an assembly metadata attribute so the AppHost finds the freshly-built terminal host binary without anyone having to set ASPIRE_TERMINAL_HOST_PATH or DcpPublisher:TerminalHostPath manually. Mirrors the existing SkipDashboardProjectReference pattern; same CI/out-of-repo guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Post-rebase fixups + bump Hex1b 0.154 → 0.161 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Dashboard: lift terminal chrome into ConsoleLogs toolbar Move the status badge, take-control button, font ± controls, and size dropdown out of the in-frame terminal chrome and into the existing ConsoleLogs page toolbar. The toolbar block is gated on the resource having a terminal annotation, so log-only resources are unaffected. - TerminalView.razor.js: strip footer/controls bar + CSS; replace updateChrome/updateFooterControls with a RAF-coalesced, change-detected notifyToolbar that pushes snapshots up via DotNetObjectReference. Add exported wrappers (takePrimaryFromHost, setFontSizeFromHost, setSizeModeFromHost, getSizePresets, getToolbarState). initTerminal now takes (element, wsUrl, dotNetRef). - TerminalView.razor.cs: add DotNe…
Description
Adds the Aspire-side implementation of
WithTerminal(...)— letting anAppHost author opt a resource into a real interactive terminal session
that the dashboard, the
aspireCLI, or any other HMP v1 viewer canattach to and detach from at will.
End-to-end pipeline (Windows executables today; container/Linux/macOS
follow-ups tracked separately):
This is a draft because:
Tracks #16317.
What's in this PR
Public API (
src/Aspire.Hosting/ApplicationModel)WithTerminal()extension method +TerminalAnnotation/TerminalOptions/TerminalHostResource. Per-replica producer + consumer UDS layout owned bythe
TerminalHostResource; lifecycle is wired throughTerminalHostEventingSubscriber.DCP wire-up (
src/Aspire.Hosting/Dcp/Model/TerminalSpec.cs,src/Aspire.Hosting/Dcp/ExecutableCreator.cs)TerminalSpecmirrors the DCP API (Enabled / UdsPath / Cols / Rows).ExecutableCreatorpopulatesspec.Terminalper replica from the layouton Windows when a
TerminalAnnotationis present.PreparePlainExecutables: plain executables now getResourceReplicaCount=1/ResourceReplicaIndex=0annotations so theper-replica UDS lookup succeeds (without this,
WithTerminal()onAddExecutable(...)silently no-op'd).Out-of-process Terminal Host (
src/Aspire.TerminalHost)Hex1bTerminalper replica acting as an HMP v1 client to DCP and anHMP v1 server to viewers. Native AOT-compatible.
Backchannel (
src/Aspire.Hosting.Cli/...+src/Aspire.Cli/...)consumer UDS path) over the existing CLI backchannel.
CLI (
src/Aspire.Cli/Commands/TerminalCommand.cs)aspire terminal <resource> [--replica N]connects to the consumer UDSvia Hex1b's HMP v1 client and bridges the local terminal.
Dashboard (
src/Aspire.Dashboard/...)TerminalViewBlazor component (xterm.js + a thin JS module)./api/terminalWebSocket proxy (Terminal/TerminalWebSocketProxy.cs)bridging the browser WebSocket to the per-replica consumer UDS.
ConsoleLogspage swaps the log viewer for the terminal view when theselected resource has a terminal session, and now correctly rebinds the
view when the user switches between terminal-enabled resources/replicas.
Playground (
playground/Terminals/...)Terminals.Repl— interactive ANSI REPL (help,whoami,time,size,echo,rainbow,clear,exit).Terminals.AppHost— hosts the REPL withWithReplicas(2)+WithTerminal(120x32)and a Windows-gatedshellresource(
AddExecutable("cmd.exe") + WithTerminal()).Spec
docs/specs/with-terminal.mddocuments the architecture, the per-replicaUDS layout, and the lifecycle.
Validation
End-to-end requires the matching DCP build from
microsoft/dcp#133 and the
freshly-built
Aspire.TerminalHost, both pointed at via env vars:Expected:
shell,repl-r0,repl-r1. Each entry's "Console Logs" tab renders a livexterm.js terminal instead of the log viewer.
attached terminal in place (state replay courtesy of
Hex1bTerminal).aspire terminal repl --replica 1from a real conhost session attachesto replica 1 of the REPL with full interactivity.
%LocalAppData%\Temp\aspire-dcp*\resource-executable-{guid}.logshow
Starting process under PTY...andTerminal session listening.Unit / integration tests:
dotnet test tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj -- --filter-class "*.WithTerminalTests" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true"(15 pass)--filter-method "*.Project_WithTerminal_PopulatesPerReplicaTerminalSpecOnWindows" --filter-method "*.Project_WithoutTerminal_HasNullTerminalSpec" --filter-method "*.PlainExecutable_WithTerminal_PopulatesTerminalSpecOnWindows"(3 pass)Known limitations / non-goals (deferred)
ExecutionType.IDEis in effect (projectresources running under VS / VS Code), DCP's IDE runner ignores
spec.Terminaland forwards the launch to the IDE, which wiresstdin/stdout to its own debug console. The terminal view in the
dashboard will be empty in that case. The
Processrunner fallback(no-debug, CLI scenarios) honors
spec.Terminaland works as designed.Proper fix is cross-component (Aspire + DCP + VS / VSCode extension).
(
creack/pty).docker/podman --tty).width:80,height:24even when
WithTerminal(Cols=120,Rows=32)is set — needsinvestigation of whether DCP's PTY allocation or Hex1b's headless
presentation is overriding the configured dims. Doesn't break
rendering (xterm.js auto-fits), but should be fixed before shipping.
here, follow-up.
Checklist
Linux/macOS, containers, debugger-attach support).
<remarks />and<code />elements on your triple slash comments?aspire.devissue: TBDCompanion PR
DCP-side: microsoft/dcp#133 — Add Windows PTY support for executables (HMP v1 over UDS)