Skip to content

Add Windows PTY support for executables and containers (HMP v1 over UDS) - #133

Closed
Mitch Denny (mitchdenny) wants to merge 3 commits into
mainfrom
mitchdenny/with-terminal-pty
Closed

Add Windows PTY support for executables and containers (HMP v1 over UDS)#133
Mitch Denny (mitchdenny) wants to merge 3 commits into
mainfrom
mitchdenny/with-terminal-pty

Conversation

@mitchdenny

@mitchdennyMitch Denny (mitchdenny) commented May 4, 2026

Copy link
Copy Markdown
Member

Implements PTY support for DCP Executable resources on Windows via ConPTY, exposing the PTY to consumers (Aspire terminal host) over a per-executable Unix domain socket using HMP v1 (the Hex1b Multiplex Protocol).This is the DCP-side slice of aspire/16317 ΓÇö WithTerminal(...), tracked in dcp/6.## Scope| Slice | This PR | Follow-up ||---|---|---|| Windows executables (ConPTY) | Yes | ΓÇö || Linux/macOS executables (creack/pty) | No | Yes || Containers (docker/podman --tty) | No | Yes |The Aspire side (WithTerminal() API + terminal host that consumes HMP v1) is being prepared in parallel and will land against this DCP version.## APIapi/v1/terminal_types.go adds TerminalSpec:gotype TerminalSpec struct { Enabled bool UDSPath string Cols int32 Rows int32}````api/v1/executable_types.go` adds `ExecutableSpec.Terminal *TerminalSpec` (nil = current log-streaming behavior, set = PTY-attached + HMP v1 server).## Implementation- `internal/hmp1/server.go` ΓÇö HMP v1 protocol server: `[type:1B][length:4B LE][payload:N]` framing, frame types Hello/StateSync/Output/Input/Resize/Exit. Bidirectional pumps with graceful teardown via context-cancellation read-deadline nudge.- `internal/exerunners/pty_windows.go` ΓÇö wraps the `UserExistsError/conpty` package as `hmp1.PTY`. Includes Windows argv-quoting for `CreateProcessW`.- `internal/exerunners/pty_other.go` ΓÇö non-Windows stub returning `ErrTerminalNotSupported`.- `internal/exerunners/terminal_session.go` ΓÇö per-executable UDS listener + accept loop. Single-viewer model (next connection bumps the previous). Graceful shutdown on natural process exit waits for in-flight handlers to flush their `Exit` frame before closing the listener.- `internal/exerunners/process_executable_runner.go` ΓÇö branches on `exe.Spec.Terminal.Enabled` in `StartRun` and tears the session down in `StopRun`. Real exit code propagates through `OnRunCompleted`.## Tests- 6 HMP v1 protocol unit tests (`internal/hmp1/server_test.go`) ΓÇö Hello/StateSync emission, output forwarding, input/resize delivery, Exit on PTY exit, oversize-frame rejection.- End-to-end smoke test (`internal/exerunners/terminal_session_windows_test.go`, build-tagged `windows`) ΓÇö spawns `cmd.exe /c "echo hello-world-from-conpty && exit 0"` under ConPTY at 100x30, dials the UDS as an HMP v1 client, asserts the full `Hello -> StateSync -> Output -> Exit(code=0)` sequence with the literal echoed text in the captured Output stream.## Validation# Windowsgo build ./...go test ./internal/hmp1 ./internal/exerunners -count 3```(Run with -count 3 deliberately ΓÇö the HMP v1 server has a few timing-sensitive paths (deadline nudges, graceful shutdown) that single-pass runs would not reliably catch.)## Not in this PR- Containers (`Container.Spec.Terminal`).- Linux/macOS executables.- Aspire-side `WithTerminal()` plumbing.- DCP version bump in Aspire.

Companion PR

Aspire side: microsoft/aspire#16760 — WithTerminal(): per-replica interactive terminal sessions (Aspire side, draft)

@mitchdenny

Copy link
Copy Markdown
MemberAuthor

Container support has been split out and stacked on top of this PR: #138 (DCP side) + microsoft/aspire#16762 (Aspire side).

@mitchdennyMitch Denny (mitchdenny) changed the title Add Windows PTY support for executables (HMP v1 over UDS)Add Windows PTY support for executables and containers (HMP v1 over UDS)May 5, 2026
@mitchdenny

Copy link
Copy Markdown
MemberAuthor

Fix: cascade kill on Stop of WithTerminal-attached resource

Pushed 38f88c9 to address a Windows-specific bug discovered during end-to-end testing of WithTerminal in the Aspire repo.

Symptom

Clicking Stop in the Aspire dashboard on a resource started under .WithTerminal(...) would kill not just that resource, but every other DCP-managed child in the same run: the Aspire dashboard itself, all �spire.terminalhost replicas, and every sibling executable/container — leaving only DCP, the AppHost, and the CLI alive. Stopping a non-PTY resource never reproduced this. A 10-second heartbeat in the dashboard process would stop emitting between consecutive ticks (i.e. process death between heartbeats), with no managed exception, no ProcessExit, no UnobservedTaskException.

Root cause

\Session.Close()\ and \watchExit()\ both raced to close the same conpty handles (\PseudoConsole, \pi.Process, \pi.Thread, the four PTY pipe handles) while \cp.Wait()\ was concurrently polling \WaitForSingleObject(pi.Process)\ in a 1-second loop (\internal/termpty/session.go:185-212\ and \pkg/mod/github.com/UserExistsError/conpty@v0.1.4/conpty.go:252-261).

The double-close pattern is dangerous on Windows. When a HANDLE value is closed and the kernel recycles it for an unrelated kernel object in the same process, a second \CloseHandle\ on the original numeric value closes the unrelated object. If that recycled handle happens to be DCP's process cleanup job handle (created in \pkg/process/os_executor_windows.go:209-238\ with \JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE), the kernel immediately terminates every process assigned to that job — which is exactly what we saw.

Fix

Refactor \watchExit()\ to be the sole owner of session teardown:

  • The PTY wait is isolated to a private inner goroutine that is the only code that ever observes \pi.Process.
  • \watchExit\ selects on (waitDone, stopCh): natural exit takes the first branch; explicit \Close()\ takes the second by closing \stopCh.
  • Both branches converge on a single \PTY.Close\ call, then drain the inner wait goroutine, then give the in-flight HMP v1 handler a bounded window to flush its Exit frame, then close the connection.
  • \Close()\ no longer touches any conpty handles or the connection. It just signals teardown via \stopCh\ (guarded by \sync.Once) and blocks on \doneCh\ with a 5s safety timeout.

Tests: \go test ./internal/termpty/... ./internal/exerunners/... ./internal/hmp1/... ./controllers/...\ all green; \go vet ./...\ clean. Will validate against the live Aspire repro next.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notes for myself to check/clean things up

// capture. Terminal mode requires Windows on the host (ConPTY) for the
// initial slice; non-Windows hosts return ErrTerminalNotSupported at
// startup.
Terminal *TerminalSpec `json:"terminal,omitempty"`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be included in the validation (for both initial object creation and object update) and in the lifecycle key calculation.

Comment threadapi/v1/terminal_types.go Outdated
//
// When Enabled is true, DCP allocates a PTY for the underlying process and
// listens on UDSPath (a Unix Domain Socket path on Linux/macOS, or a named pipe
// path on Windows in a follow-up). When the terminal host opens an HMP v1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not Unix domain sockets on Windows? We use them for pumping DCP logs into Aspire dashboard already...

Comment threadapi/v1/terminal_types.go Outdated
// - Process exit -> HMP v1 Exit frame, then close
//
// The HMP v1 wire format is defined by the Aspire dashboard's terminal host
// (see Hex1b's Hmp1Protocol). DCP's responsibility is limited to PTY

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a URL to the HMP spec here.

Comment threadapi/v1/terminal_types.go Outdated
type TerminalSpec struct {
// Enabled controls whether DCP allocates a PTY for the process and exposes
// an HMP v1 producer endpoint at UDSPath.
Enabled bool `json:"enabled,omitempty"`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this flag? What does it mean to have TerminalSpec present in an Executable or Controller spec (*Terminal is not nil) but Enabled is false? Is this a valid spec and if so, what is the semantics?


// UDSPath is the Unix Domain Socket path that DCP listens on for the
// terminal host's HMP v1 client connection. Required when Enabled is true.
UDSPath string `json:"udsPath,omitempty"`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to support changing UDSPath for existing Container or Executable? If yes, we should handle it in the controller(s). If not we should prevent that change via validation.

// uses. Empty strings, strings containing whitespace, or strings containing
// quotes get wrapped in double quotes; backslashes preceding a quote are
// doubled.
func quoteWindowsArg(arg string) string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is Windows API to do this

Comment threadinternal/termpty/pty.go Outdated
// CommandLine is the full command line in CreateProcessW form (a single
// string with arguments embedded and quoted as appropriate). Callers can
// build this via BuildWindowsCommandLine.
CommandLine string

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not... should prefer arg array

Comment threadinternal/termpty/pty.go Outdated

// Env is the environment block passed to the child process (each entry
// is "KEY=VALUE"). May be nil to inherit the parent's environment.
Env []string

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

map

Comment threadinternal/termpty/session.go Outdated
every = 100 * time.Millisecond
)
var lastErr error
for i := 0; i < attempts; i++ {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have reliability package for retries


tp *Process

mu sync.Mutex

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a lot of synchronization primitives used here. Need to dig into this component and simplify it.

Mitch Denny (mitchdenny) added a commit to mitchdenny/aspire that referenced this pull request May 8, 2026
…leCreator
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 microsoft#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#16317 + microsoft/dcp#6 + DCP PR microsoft/dcp#133.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mitch Denny (mitchdenny) added a commit to mitchdenny/aspire that referenced this pull request May 8, 2026
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>
@karolz-ms
Karol Zadora-Przylecki (karolz-ms) marked this pull request as ready for review May 8, 2026 23:51
CopilotAI review requested due to automatic review settings May 8, 2026 23:51

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces interactive terminal support by attaching Windows processes to a ConPTY-backed pseudo-terminal and bridging the PTY over an HMP v1 framed stream carried via a per-resource Unix domain socket connection. It extends the DCP API surface with TerminalSpec and wires terminal-mode execution into the executable runner, with additional container-side terminal attach scaffolding.

Changes:

  • Added HMP v1 server implementation (internal/hmp1) plus unit tests for framing and PTY/connection pumping.
  • Added Windows ConPTY PTY process support and a terminal session bridge (internal/termpty), and integrated it into the executable runner when ExecutableSpec.Terminal.Enabled is set.
  • Added container terminal fields and initial wiring (docker/podman -t -i, controller attach/session lifecycle) around ContainerSpec.Terminal.

Reviewed changes

Copilot reviewed 22 out of 23 changed files in this pull request and generated 8 comments.

Show a summary per file
FileDescription
pkg/process/waitable_process.goTracks PID/start time/exit code on waitable processes; uses shared exit-code extraction helper.
pkg/process/os_executor.goRefactors exit-code extraction helper signature to accept *os.ProcessState.
internal/hmp1/server.goImplements HMP v1 server framing and bidirectional pumps between PTY and socket connection.
internal/hmp1/server_test.goUnit tests for Hello/StateSync, Output, Input, Resize, Exit, and oversize-frame handling.
internal/termpty/config.goAdds session configuration helpers for terminal sessions.
internal/termpty/pty.goDefines the PTY-attached process abstraction and cross-platform StartProcess API.
internal/termpty/pty_windows.goWindows ConPTY-backed PTY process implementation + Windows argv quoting helper.
internal/termpty/pty_other.goNon-Windows stub implementation (currently does not compile as written).
internal/termpty/session.goDials the terminal-host UDS and serves HMP v1 over that connection while managing teardown/exit reporting.
internal/termpty/session_windows_test.goWindows end-to-end test: spawn cmd.exe under ConPTY and validate HMP v1 frame sequence over UDS.
internal/exerunners/process_executable_runner.goRoutes terminal-enabled executables to a PTY-based start path; stops terminal runs via session shutdown.
internal/exerunners/process_executable_runner_terminal.goImplements PTY-based executable startup and run-completion signaling via termpty.Session.
internal/docker/cli_orchestrator.goAdds -t -i on container create when terminal mode is enabled.
internal/podman/cli_orchestrator.goAdds -t -i on container create when terminal mode is enabled.
controllers/container_terminal.goAdds container CLI attach-under-PTY + HMP session setup helper.
controllers/container_controller.goAttaches terminal session after container reaches Running and tears it down on delete.
controllers/running_container_data.goTracks and clones container terminal session pointer; adds teardown helper.
api/v1/terminal_types.goIntroduces TerminalSpec API type with validation, deepcopy, and equality helpers.
api/v1/executable_types.goAdds ExecutableSpec.Terminal with equality + validation wiring.
api/v1/container_types.goAdds ContainerSpec.Terminal with equality wiring and documentation.
api/v1/zz_generated.deepcopy.goUpdates generated deep-copies to include TerminalSpec on Executable/Container specs.
go.modAdds github.com/UserExistsError/conpty dependency.
go.sumAdds checksums for the new ConPTY dependency.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadinternal/termpty/pty_other.go Outdated
Comment on lines +12 to +17
)

// startProcessImpl is the non-Windows fallback. The initial slice
// (Aspire 13.4) implements PTY support on Windows via ConPTY only; Linux and
// macOS support is tracked as follow-up work.
func startProcessImpl(_ context.Context, _ CommandSpec) (*Process, error) {
Comment on lines +236 to +237
case <-waitDone:
case <-time.After(2 * time.Second):

// Most likely we are not the parent of the process, so the ordinary Wait() call does not work.
// Could also be that we already started waiting for this process, but that would be a programming error.
// Ether way, we can still poll-wait, we just won't get the exit code.
Comment threadinternal/hmp1/server.go Outdated
Comment on lines +145 to +146
// readFrame's io.ReadFull. We do not close conn (the caller owns its
// lifetime), we just expire its read deadline.
Comment on lines +8 to +13
// SessionConfig captures the parameters the HMP v1 listener needs to accept
// terminal viewer connections. It mirrors the relevant subset of
// apiv1.TerminalSpec but lives in this package so it can be constructed
// from any caller (executable runner, container reconciler, ...).
type SessionConfig struct {
// UDSPath is the Unix domain socket path the listener binds to.
Comment threadapi/v1/terminal_types.go Outdated
Comment on lines +16 to +19
// When Enabled is true, DCP allocates a PTY for the underlying process and
// listens on UDSPath (a Unix Domain Socket path on Linux/macOS, or a named pipe
// path on Windows in a follow-up). When the terminal host opens an HMP v1
// connection, DCP starts an HMP v1 server on the connection and bridges:
// Wait failures are best-effort: the connection's about to
// close anyway. Surface UnknownExitCode to make the situation
// visible in the terminal host.
return -1
Comment on lines +19 to +43
// startContainerTerminalSession runs the container runtime CLI's `attach`
// command against an already-running container under a host PTY, then
// stands up an HMP v1 listener at spec.UDSPath that bridges viewer
// connections to that PTY. The returned Session owns the lifetime of both
// the CLI process and the listener; callers must Close it during teardown.
//
// We use `<runtime> attach` (not `<runtime> start --attach --interactive`)
// because the container is already started by the time this is called via
// the reconciler's normal `docker container start <id>` path. Running
// `docker start --attach --interactive` against a running container is a
// no-op and would leave the container's primary process with no host-side
// stdin/stdout connection.
//
// `--sig-proxy=false` prevents the attach process from forwarding signals
// (e.g. SIGINT from the dashboard) to the container; signals are delivered
// in-band via the HMP v1 input channel as keystrokes (Ctrl-C → 0x03 byte).
//
// The container must have been created with `-t -i` (allocate TTY + keep
// stdin open) for the attach to deliver a usable terminal; this is handled
// automatically when ContainerSpec.Terminal != nil && Enabled by the docker
// and podman orchestrators' applyCreateContainerOptions helper.
//
// On hosts where DCP does not yet implement PTY allocation (currently
// non-Windows) this returns termpty.ErrTerminalNotSupported.
func startContainerTerminalSession(

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 23 changed files in this pull request and generated 8 comments.

Comment threadinternal/termpty/pty_other.go Outdated
Comment on lines +12 to +17
)

// startProcessImpl is the non-Windows fallback. The initial slice
// (Aspire 13.4) implements PTY support on Windows via ConPTY only; Linux and
// macOS support is tracked as follow-up work.
func startProcessImpl(_ context.Context, _ CommandSpec) (*Process, error) {

// Most likely we are not the parent of the process, so the ordinary Wait() call does not work.
// Could also be that we already started waiting for this process, but that would be a programming error.
// Ether way, we can still poll-wait, we just won't get the exit code.
Comment on lines +259 to +272
func writeFrameLocked(w io.Writer, t FrameType, payload []byte) error {
if len(payload) > MaxPayloadLength {
return fmt.Errorf("hmp1: payload exceeds maximum (%d > %d)", len(payload), MaxPayloadLength)
}
header := [5]byte{}
header[0] = byte(t)
binary.LittleEndian.PutUint32(header[1:5], uint32(len(payload)))
if _, err := w.Write(header[:]); err != nil {
return err
}
if len(payload) > 0 {
if _, err := w.Write(payload); err != nil {
return err
}
Comment threadinternal/hmp1/server.go Outdated
Comment on lines +145 to +146
// readFrame's io.ReadFull. We do not close conn (the caller owns its
// lifetime), we just expire its read deadline.
Comment on lines +8 to +13
// SessionConfig captures the parameters the HMP v1 listener needs to accept
// terminal viewer connections. It mirrors the relevant subset of
// apiv1.TerminalSpec but lives in this package so it can be constructed
// from any caller (executable runner, container reconciler, ...).
type SessionConfig struct {
// UDSPath is the Unix domain socket path the listener binds to.
Comment on lines +303 to +311
func (s *Session) Close() error {
// Best-effort: publish a 0 exit code if we don't have a real one yet, so
// any in-flight Serve invocation doesn't block forever inside waitExit.
s.signalProcessExit(0)

// Trigger watchExit's stop branch. Idempotent.
s.stopOnce.Do(func() {
close(s.stopCh)
})
Comment on lines +210 to +220
var (
exitCode int32
waitDrained bool
)
select {
case exitCode = <-waitDone:
waitDrained = true
s.log.Info("Terminal-attached process exited", "exitCode", exitCode)
case <-s.stopCh:
s.log.V(1).Info("Terminal session received explicit stop request")
}
Comment on lines +1603 to +1615
// ensureContainerTerminalSession attaches a host-side PTY to the running
// container and starts the HMP v1 listener at the configured UDS path,
// storing the resulting session on rcd. No-op if the container does not
// have terminal enabled or a session is already active.
//
// The container must have been created with `-t -i` for the attach to
// deliver a usable terminal; that is handled by applyCreateContainerOptions
// in the docker/podman orchestrator when ContainerSpec.Terminal is set.
//
// Errors here are non-fatal to the container lifecycle: the container is
// already running by the time this is called. The caller is expected to
// log the error and move on.
func (r *ContainerReconciler) ensureContainerTerminalSession(
Mitch Denny (mitchdenny) added a commit to mitchdenny/aspire that referenced this pull request May 11, 2026
…leCreator
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 microsoft#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#16317 + microsoft/dcp#6 + DCP PR microsoft/dcp#133.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mitch Denny (mitchdenny) added a commit to mitchdenny/aspire that referenced this pull request May 11, 2026
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>
Mitch Denny (mitchdenny) added a commit to microsoft/aspire that referenced this pull request May 11, 2026
…leCreator
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: #16317 + microsoft/dcp#6 + DCP PR microsoft/dcp#133.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mitch Denny (mitchdenny) added a commit to microsoft/aspire that referenced this pull request May 11, 2026
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>
Mitch Denny (mitchdenny) added a commit that referenced this pull request May 13, 2026
Addresses #133 review:
- A.1/A.2: drop TerminalSpec.Enabled; presence of the field activates the
terminal path. UDSPath is now unconditionally required. Doc rewritten,
named-pipe-on-Windows aside removed (impl is UDS only on all platforms).
- A.3: TerminalSpec.ValidateUpdate forbids any post-creation change to
the terminal (including add/remove); the runtime cannot tear down and
re-establish a PTY session mid-flight.
- A.5: Container/Executable Validate + ValidateUpdate now flow into the
embedded TerminalSpec, and Container.GetLifecycleKey gob-encodes the
TerminalSpec so terminal changes force a rollover. TerminalSpec is
registered with initializeHashEncoder for deterministic gob type IDs.
- C.14: drop redundant <-session.Done() after Session.Close() in the
ctx-cancel branch of the executable terminal runner. Close() already
blocks on doneCh.
- D.15: replace the cancelWatcher goroutine in hmp1.Server with
context.AfterFunc.
- D.16: introduce frameReader with a reusable scratch buffer so the
per-frame allocation in the input pump is amortised; the package-level
readFrame helper is preserved as a copying wrapper for back-compat.
- E.21: replace the hand-rolled retry loop in dialUDSWithRetry with
resiliency.RetryGet + backoff.WithMaxRetries(ConstantBackOff).
Mitch Denny (mitchdenny) added a commit that referenced this pull request May 13, 2026
Addresses #133 review E.19/E.20: replace the embedded
exec.Cmd in termpty.CommandSpec with explicit Cmd []string,
Env map[string]string, and Dir string. The platform-specific PTY
backend now decides how to materialise the command:
- pty_other.go uses exec.Command(name, args...). Because exec.Command
sets cmd.lookPathErr internally, cmd.Start() now resolves the program
via PATH for free. This eliminates the previous manual exec.LookPath
workaround that the embedded-exec.Cmd shape required.
- pty_windows.go drops the commandLineFromCmd helper and builds the
CreateProcessW command line directly from spec.Cmd[0] + spec.Cmd[1:];
spec.Env is converted to the conpty []string form via envMapToSlice.
Callers updated accordingly:
- executableTerminalCommandSpec builds the argv slice from
exe.Spec.ExecutablePath + exe.Status.EffectiveArgs and the env map
from exe.Status.EffectiveEnv.
- container_terminal.go converts the *exec.Cmd produced by
runner.MakeCommand into the argv slice via cmd.Args (which is already
[program, arg1, ...] thanks to exec.Command).
Also adds an end-to-end Unix PTY smoke test (pty_other_test.go) to cover
the new shape against /dev/ptmx.
CopilotAI review requested due to automatic review settings May 13, 2026 14:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

Files not reviewed (1)
  • api/v1/zz_generated.deepcopy.go: Language not supported
Comments suppressed due to low confidence (1)

internal/termpty/session.go:306

  • Close() preemptively calls signalProcessExit(0) to unblock waitExit, which can cause forced shutdowns to be reported as successful exit code 0 even when the real exit code is unknown/non-zero. Using an explicit “unknown” sentinel (e.g. -1 / UnknownExitCode) would avoid misleading consumers while still preventing a deadlock in waitExit.
func (s *Session) Close() error {
// Best-effort: publish a 0 exit code if we don't have a real one yet, so
// any in-flight Serve invocation doesn't block forever inside waitExit.
s.signalProcessExit(0)
// Trigger watchExit's stop branch. Idempotent.
s.stopOnce.Do(func() {
close(s.stopCh)
})
  • Files reviewed: 22/25 changed files
  • Comments generated: 13

Comment on lines +56 to +63
tp, err := StartProcess(ctx, CommandSpec{
Cmd: exec.Cmd{
Path: `C:\Windows\System32\cmd.exe`,
Args: []string{`C:\Windows\System32\cmd.exe`, "/c", "echo hello-world-from-conpty && exit 0"},
},
Cols: 100,
Rows: 30,
})
Comment on lines +16 to +19
// Presence of this field on an Executable or Container spec activates the
// terminal path: DCP allocates a PTY for the underlying process and listens on
// UDSPath. When the terminal host opens an HMP v1 connection, DCP starts an
// HMP v1 server on the connection and bridges:
Comment on lines +33 to +37
type TerminalSpec struct {
// UDSPath is the Unix Domain Socket path that DCP listens on for the
// terminal host's HMP v1 client connection. Required.
UDSPath string `json:"udsPath,omitempty"`

Comment on lines +8 to +13
// SessionConfig captures the parameters the HMP v1 listener needs to accept
// terminal viewer connections. It mirrors the relevant subset of
// apiv1.TerminalSpec but lives in this package so it can be constructed
// from any caller (executable runner, container reconciler, ...).
type SessionConfig struct {
// UDSPath is the Unix domain socket path the listener binds to.
Comment on lines +205 to +215
var (
exitCode int32
waitDrained bool
)
select {
case exitCode = <-waitDone:
waitDrained = true
s.log.Info("Terminal-attached process exited", "exitCode", exitCode)
case <-s.stopCh:
s.log.V(1).Info("Terminal session received explicit stop request")
}
Comment on lines +260 to +274
func writeFrameLocked(w io.Writer, t FrameType, payload []byte) error {
if len(payload) > MaxPayloadLength {
return fmt.Errorf("hmp1: payload exceeds maximum (%d > %d)", len(payload), MaxPayloadLength)
}
header := [5]byte{}
header[0] = byte(t)
binary.LittleEndian.PutUint32(header[1:5], uint32(len(payload)))
if _, err := w.Write(header[:]); err != nil {
return err
}
if len(payload) > 0 {
if _, err := w.Write(payload); err != nil {
return err
}
}
Comment on lines +19 to +40
// startContainerTerminalSession runs the container runtime CLI's `attach`
// command against an already-running container under a host PTY, then
// stands up an HMP v1 listener at spec.UDSPath that bridges viewer
// connections to that PTY. The returned Session owns the lifetime of both
// the CLI process and the listener; callers must Close it during teardown.
//
// We use `<runtime> attach` (not `<runtime> start --attach --interactive`)
// because the container is already started by the time this is called via
// the reconciler's normal `docker container start <id>` path. Running
// `docker start --attach --interactive` against a running container is a
// no-op and would leave the container's primary process with no host-side
// stdin/stdout connection.
//
// `--sig-proxy=false` prevents the attach process from forwarding signals
// (e.g. SIGINT from the dashboard) to the container; signals are delivered
// in-band via the HMP v1 input channel as keystrokes (Ctrl-C → 0x03 byte).
//
// The container must have been created with `-t -i` (allocate TTY + keep
// stdin open) for the attach to deliver a usable terminal; this is handled
// automatically when ContainerSpec.Terminal != nil && Enabled by the docker
// and podman orchestrators' applyCreateContainerOptions helper.
//
Comment on lines +689 to +692
// Optional terminal/PTY configuration. When set, the container's primary
// process is started under a host pseudo-terminal and its
// stdin/stdout/stderr are bridged to the configured UDS via HMP v1,
// instead of the container being run detached with separate log capture.

// startTerminalRun is the PTY-attached counterpart to the regular StartRun
// flow. It allocates a pseudo-terminal, starts the executable inside it, and
// stands up an HMP v1 listener at exe.Spec.Terminal.UDSPath.
Comment on lines +86 to +88
// for CreateProcessW from a path + argv. Each token is wrapped in quotes
// and embedded quotes are escaped per the documented Windows argv parsing
// rules.
Phase 3 of Aspire WithTerminal end-to-end. Adds DCP-side terminal support for Executable resources on Windows via ConPTY. The PTY-attached process is reachable from the Aspire terminal host through a Hex1b Multiplex Protocol v1 server on a per-replica Unix domain socket.
API:
- api/v1/terminal_types.go: TerminalSpec (Enabled, UDSPath, Cols, Rows)
- api/v1/executable_types.go: ExecutableSpec.Terminal *TerminalSpec
Implementation:
- internal/hmp1/: HMP v1 server (Hello/StateSync/Output/Input/Resize/Exit)
- internal/exerunners/pty_windows.go: ConPTY wrapper via UserExistsError/conpty
- internal/exerunners/pty_other.go: non-Windows stub returning ErrTerminalNotSupported
- internal/exerunners/terminal_session.go: per-executable UDS listener + accept loop, single-viewer model with graceful shutdown that flushes the Exit frame
- internal/exerunners/process_executable_runner.go: branches on Terminal.Enabled in StartRun and tears the session down in StopRun
Tests:
- internal/hmp1/server_test.go: 6 unit tests covering Hello/StateSync, output forwarding, input/resize delivery, Exit on PTY exit, oversize-frame rejection
- internal/exerunners/terminal_session_windows_test.go: end-to-end smoke test that spawns cmd.exe under ConPTY, dials the UDS as an HMP v1 client, and asserts the full Hello -> StateSync -> Output -> Exit frame sequence with the literal echoed text and exit code 0
Addresses #133 review:
- A.1/A.2: drop TerminalSpec.Enabled; presence of the field activates the
terminal path. UDSPath is now unconditionally required. Doc rewritten,
named-pipe-on-Windows aside removed (impl is UDS only on all platforms).
- A.3: TerminalSpec.ValidateUpdate forbids any post-creation change to
the terminal (including add/remove); the runtime cannot tear down and
re-establish a PTY session mid-flight.
- A.5: Container/Executable Validate + ValidateUpdate now flow into the
embedded TerminalSpec, and Container.GetLifecycleKey gob-encodes the
TerminalSpec so terminal changes force a rollover. TerminalSpec is
registered with initializeHashEncoder for deterministic gob type IDs.
- C.14: drop redundant <-session.Done() after Session.Close() in the
ctx-cancel branch of the executable terminal runner. Close() already
blocks on doneCh.
- D.15: replace the cancelWatcher goroutine in hmp1.Server with
context.AfterFunc.
- D.16: introduce frameReader with a reusable scratch buffer so the
per-frame allocation in the input pump is amortised; the package-level
readFrame helper is preserved as a copying wrapper for back-compat.
- E.21: replace the hand-rolled retry loop in dialUDSWithRetry with
resiliency.RetryGet + backoff.WithMaxRetries(ConstantBackOff).
Addresses #133 review E.19/E.20: replace the embedded
exec.Cmd in termpty.CommandSpec with explicit Cmd []string,
Env map[string]string, and Dir string. The platform-specific PTY
backend now decides how to materialise the command:
- pty_other.go uses exec.Command(name, args...). Because exec.Command
sets cmd.lookPathErr internally, cmd.Start() now resolves the program
via PATH for free. This eliminates the previous manual exec.LookPath
workaround that the embedded-exec.Cmd shape required.
- pty_windows.go drops the commandLineFromCmd helper and builds the
CreateProcessW command line directly from spec.Cmd[0] + spec.Cmd[1:];
spec.Env is converted to the conpty []string form via envMapToSlice.
Callers updated accordingly:
- executableTerminalCommandSpec builds the argv slice from
exe.Spec.ExecutablePath + exe.Status.EffectiveArgs and the env map
from exe.Status.EffectiveEnv.
- container_terminal.go converts the *exec.Cmd produced by
runner.MakeCommand into the argv slice via cmd.Args (which is already
[program, arg1, ...] thanks to exec.Command).
Also adds an end-to-end Unix PTY smoke test (pty_other_test.go) to cover
the new shape against /dev/ptmx.
@karolz-ms

Copy link
Copy Markdown
Collaborator

#173 will do what this PR aimed for.

Mitch Denny (mitchdenny) pushed a commit to microsoft/aspire that referenced this pull request Jun 2, 2026
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>
Mitch Denny (mitchdenny) added a commit to microsoft/aspire that referenced this pull request Jun 3, 2026
…leCreator
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: #16317 + microsoft/dcp#6 + DCP PR microsoft/dcp#133.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mitch Denny (mitchdenny) added a commit to microsoft/aspire that referenced this pull request Jun 3, 2026
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>
Mitch Denny (mitchdenny) pushed a commit to microsoft/aspire that referenced this pull request Jun 3, 2026
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>
Mitch Denny (mitchdenny) added a commit to microsoft/aspire that referenced this pull request Jun 4, 2026
…leCreator
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: #16317 + microsoft/dcp#6 + DCP PR microsoft/dcp#133.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mitch Denny (mitchdenny) added a commit to microsoft/aspire that referenced this pull request Jun 4, 2026
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>
Mitch Denny (mitchdenny) pushed a commit to microsoft/aspire that referenced this pull request Jun 4, 2026
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>
Mitch Denny (mitchdenny) added a commit to microsoft/aspire that referenced this pull request Jun 10, 2026
…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…
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@mitchdenny@karolz-ms