Apphost Hot Reload Architecture for `aspire run`
- Dominant language
- C#
- Stars
- 6.3k
- Forks
- 991
- Avg merge
- 2d 15h
- Merged PRs (30d)
- 196
Description
## Problem Statement
Current hot reload experience in `aspire run` is slow because:
1. `dotnet watch` doesn't integrate well with Aspire's orchestration model
2. Full AppHost restart tears down DCP, dashboard, and all containers
3. Each restart regenerates certs, rebuilds, and restarts all resources
4. TypeScript AppHost could be much faster but is limited by the same constraints
## Proposed Solution: CLI-Owned Infrastructure
Move DCP and dashboard ownership from AppHost to CLI, enabling fast hot reload by keeping infrastructure alive across AppHost restarts.
### Current Architecture
```mermaid
flowchart TB
subgraph NOT_DCP["Outside DCP"]
CLI["aspire run"]
AppHost["AppHost
(owns DCP lifecycle)"]
end
subgraph DCP["Managed by DCP (dies when AppHost dies)"]
Dashboard["dashboard"]
Redis["redis"]
Api["api"]
end
CLI -->|"launches"| AppHost
AppHost -->|"--monitor AppHostPID"| DCP
Dashboard -->|"gRPC"| AppHost
style NOT_DCP fill:#dc3545,stroke:#333,color:#fff
style DCP fill:#dc3545,stroke:#333,color:#fff
```
**On code change:** AppHost restarts → DCP dies (--monitor) → everything restarts → **slow**
### Proposed Architecture
```mermaid
flowchart TB
subgraph NOT_DCP["Outside DCP"]
CLI["aspire run
(owns DCP lifecycle)"]
end
subgraph DCP["Managed by DCP (persistent until CLI exits)"]
Dashboard["dashboard (persistent)"]
Watch["dotnet watch → AppHost (restartable)"]
Redis["redis"]
Api["api"]
end
CLI -->|"--monitor CLI PID"| DCP
Dashboard -->|"gRPC"| Watch
style NOT_DCP fill:#198754,stroke:#333,color:#fff
style DCP fill:#198754,stroke:#333,color:#fff
style Watch fill:#0d6efd,stroke:#333,color:#fff
```
**On code change:** watch restarts AppHost → DCP stays alive → **fast**
## Key Design Decisions
### Detection via Environment Variable
```
DCP_KUBECONFIG_PATH present → CLI-owned mode (connect to existing DCP)
DCP_KUBECONFIG_PATH absent → Standalone mode (AppHost owns everything)
```
**Backwards compatible**: `dotnet run`, IDE F5, and testing scenarios continue to work as before - AppHost self-launches DCP when `DCP_KUBECONFIG_PATH` is not set.
### Process Management Abstraction
```csharp
interface IProcessManager
{
Task StartAsync(ProcessSpec spec);
Task StopAsync(ProcessHandle handle);
Task DeleteAsync(ProcessHandle handle);
IAsyncEnumerable GetLogsAsync(ProcessHandle handle);
}
```
Implementation: `DcpProcessManager` - creates processes as DCP Executable resources
### CLI Only Launches DCP Directly
CLI's only direct process launch is DCP itself. Everything else runs as DCP Executables via `IProcessManager`:
```
CLI launches directly:
└── DCP (--monitor {CLI PID})
DCP manages (as Executable resources):
├── dashboard
├── dotnet watch (owns AppHost subprocess)
└── user resources (redis, api, etc.)
```
Benefits:
- Unified process management
- All processes visible in dashboard
- Logs flow through DCP
- Health checks, restart policies handled by DCP
- Clean shutdown when CLI exits (via --monitor)
### Resource Ownership Model
| Owner | Resources | Lifecycle |
|-------|-----------|-----------|
| CLI | DCP, dashboard | Persistent until `aspire run` exits |
| AppHost | User resources (redis, api, etc.) | Deleted on reload, recreated |
### Dashboard Externalization
CLI-owned mode disables `DashboardEventHandlers`:
- Dashboard not added to resource model
- No log watching via `resourceLoggerService`
- Dashboard logs go directly to CLI console
- `DashboardServiceHost` (gRPC) still runs in AppHost
### Graceful Shutdown Change
Current: `DcpExecutor.StopAsync()` → stops DCP
New: `DcpExecutor.DeleteResourcesAsync()` → deletes resources, DCP stays alive
### Hot Reload Flow
```mermaid
sequenceDiagram
participant CLI
participant DCP
participant Watch as dotnet watch
participant AppHost
participant Dashboard
Note over CLI,Dashboard: Startup
CLI->>DCP: Launch (--monitor CLI PID)
CLI->>DCP: Create dashboard executable
CLI->>DCP: Create dotnet watch executable
DCP->>Watch: Start
Watch->>AppHost: Launch (DCP_KUBECONFIG_PATH=...)
AppHost->>DCP: Create user resources
Dashboard->>AppHost: Connect gRPC
Note over CLI,Dashboard: Hot Reload (file change)
Watch->>Watch: Detect .cs change in AppHost
Watch->>AppHost: SIGTERM
AppHost->>DCP: Delete user resources (graceful shutdown)
AppHost->>AppHost: Exit
Watch->>Watch: Rebuild AppHost
Watch->>AppHost: Launch (new instance)
AppHost->>DCP: Create user resources
Dashboard->>AppHost: Reconnect gRPC (auto)
```
### TypeScript AppHost (Even Faster)
```mermaid
flowchart TB
subgraph NOT_DCP["Outside DCP"]
CLI["aspire run"]
end
subgraph DCP["Managed by DCP (persistent)"]
Dashboard["dashboard"]
AppHostServer["apphost-server (.NET)"]
TSWatch["tsx --watch → apphost.ts (restartable)"]
Redis["redis"]
end
CLI -->|"--monitor CLI PID"| DCP
TSWatch -->|"JSON-RPC"| AppHostServer
Dashboard -->|"gRPC"| AppHostServer
style NOT_DCP fill:#198754,stroke:#333,color:#fff
style DCP fill:#198754,stroke:#333,color:#fff
style TSWatch fill:#0d6efd,stroke:#333,color:#fff
```
TypeScript changes: instant restart (no build step)
## Implementation Phases
### Phase 1: DCP Resolution & Launch in CLI
- `DcpLocator` - find DCP binary (msbuild property extraction or cache from first run)
- `DcpLauncher` - launch DCP with `--monitor {CLI PID}`
- Session directory management (kubeconfig path)
- CLI needs Kubernetes client to talk to DCP API
### Phase 2: Dashboard Resolution & Launch via DCP
- `DashboardLocator` - find dashboard binary
- CLI creates dashboard as DCP Executable resource
- Dashboard logs flow through DCP to CLI console
### Phase 3: AppHost Launch via DCP
- CLI creates `dotnet watch` as DCP Executable resource
- Watch launches AppHost with `DCP_KUBECONFIG_PATH` environment variable
- AppHost connects to existing DCP, doesn't launch its own
### Phase 4: AppHost CLI-Owned Mode Changes
- Detect `DCP_KUBECONFIG_PATH` → skip DCP launch
- Skip `DashboardEventHandlers` (dashboard externalized)
- On shutdown: `DeleteResourcesAsync()` not `StopAsync()` (delete user resources, leave DCP running)
### Phase 5: File Watching & Hot Reload
- **Watch is only for AppHost itself** (not user projects like api, frontend)
- User projects run as DCP Executables with their own lifecycle (managed by AppHost)
- Use native language tools for watching AppHost:
- .NET AppHost: `dotnet watch` - watches AppHost.csproj, rebuilds and restarts
- TypeScript AppHost: `tsx --watch` or similar
- No IDE protocol needed - watch runs autonomously
- CLI launches watch tool as DCP Executable, watch manages AppHost subprocess
## Open Questions
### 1. Publish Mode
Keep simple - CLI runs AppHost directly, no DCP needed?
### 2. Port Collisions
New AppHost starts before old one fully dies → gRPC port conflict
```
Watch kills AppHost (iteration 1)
Watch starts AppHost (iteration 2) ← starts before iteration 1 fully exits
AppHost 2 tries to bind gRPC port ← port still held by AppHost 1
```
**Potential solutions**:
- Watch waits for old process to fully exit before starting new one
- Dynamic port allocation for DashboardServiceHost (dashboard discovers via DCP?)
- Retry binding with backoff
### 3. Process Orphaning
What if dotnet watch crashes (not AppHost)?
```
CLI → DCP → watch → AppHost
↓
watch crashes
↓
AppHost orphaned? Or DCP kills it?
```
**Questions**:
- Does DCP track process tree and kill children when parent dies?
- Should watch have a DCP restart policy?
- Does `--monitor` on AppHost tie it to watch PID?
### 4. Dashboard Stale State
Dashboard shows old resource state during AppHost restart
```
AppHost 1 running → redis: healthy, api: healthy
AppHost 1 killed → dashboard still shows healthy (stale)
AppHost 2 starting → dashboard shows... what?
AppHost 2 creates new resources → dashboard catches up
```
**Questions**:
- How does dashboard know AppHost restarted?
- Should dashboard clear state when gRPC disconnects?
- Brief "unknown" state acceptable?
### 5. Process Lifecycle / Graceful Shutdown
How does watch terminate AppHost?
```
watch detects file change
watch sends SIGTERM to AppHost
AppHost runs DeleteResourcesAsync() (how long?)
watch waits N seconds
watch sends SIGKILL if still running
```
**Questions**:
- What's the graceful shutdown timeout?
- Does AppHost signal "ready to die" back to watch?
- What if DeleteResourcesAsync() hangs?
## Resolved Decisions
- **DCP/Dashboard path resolution**: Use msbuild property extraction in CLI, cache the results
- **No process ordering needed**: Components retry connections until ready (dashboard already does this with exponential backoff)
- **Ports/URLs from launch settings**: AppHost launch settings are source of truth, no chicken-egg problem
- **No health checks needed**: Just retry until ready
- **Crash cleanup**: AppHost cleans up on startup - deletes existing resources with matching names before creating new ones (handles orphaned resources from previous crash)
- **IDE protocol not needed**: `dotnet watch` only watches the AppHost project itself. No IDE protocol complexity required - watch runs autonomously.
- **AppHost owns .NET project hot reload**: For .NET user projects (api, frontend), hot reload moves into the AppHost itself. AppHost may use `dotnet watch` internally on a subset of .NET resources (implementation detail). This keeps watching logic in the orchestrator that knows about dependencies.
## Files to Modify
### CLI (new capabilities)
- `src/Aspire.Cli/ProcessManagement/IProcessManager.cs` - abstraction for process management
- `src/Aspire.Cli/ProcessManagement/DcpProcessManager.cs` - implementation using DCP Executables
- `src/Aspire.Cli/Dcp/DcpLocator.cs` - find DCP binary path (via msbuild, cached)
- `src/Aspire.Cli/Dcp/DcpLauncher.cs` - launch DCP process
- `src/Aspire.Cli/Dcp/DcpClient.cs` - Kubernetes API client to create/delete Executables
- `src/Aspire.Cli/Dashboard/DashboardLocator.cs` - find dashboard binary path (via msbuild, cached)
- `src/Aspire.Cli/Commands/RunCommand.cs` - orchestrate new flow
### AppHost (CLI-owned mode support)
- `src/Aspire.Hosting/Dcp/DcpHost.cs` - skip launch if `DCP_KUBECONFIG_PATH` set
- `src/Aspire.Hosting/Dcp/DcpExecutor.cs` - add `DeleteResourcesAsync()` for graceful shutdown without stopping DCP
- `src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs` - skip entirely in CLI-owned mode (dashboard externalized)
Contributor guide
Assessment
This issue has not been assessed yet.