diff --git a/docs/SocketMode/SocketMode-Design.md b/docs/SocketMode/SocketMode-Design.md new file mode 100644 index 00000000..8e9c755b --- /dev/null +++ b/docs/SocketMode/SocketMode-Design.md @@ -0,0 +1,201 @@ +# Socket Mode Design + +> See [SocketMode-Flow-Walkthrough.md](./SocketMode-Flow-Walkthrough.md) for +> an implementation-level walkthrough with sequence diagrams covering +> startup, steady-state dispatch, token rotation, and reconnect flows, and +> [SocketMode-Hosting-Integration.md](./SocketMode-Hosting-Integration.md) +> for how Socket Mode plugs into DI registration, host startup, and the +> shared `TeamsBotApplication` activity pipeline. + +## Overview + +Socket Mode lets a bot receive activities over outbound WebSocket connections +instead of exposing an inbound HTTP endpoint. The bot dials out to the Bot +Framework service (SignalR-based), so no public URL needs to be reachable from +Teams. + +Socket Mode is supported **only in the public cloud**. It is an **experimental +feature**, marked with `[Experimental("ExperimentalTeamsSocketMode")]`, and is +recommended only for developing and testing agents locally or in constrained +network environments -- not for production workloads. + +## Architecture + +Socket Mode maintains **one connection per geo**. The default geos are `amer`, +`emea`, and `apac`. + +```mermaid +graph TB + Host["SocketModeHostedService (IHostedService)"] --> Transport["SocketModeTransport (IGeoSocketOwner)"] + Transport --> GeoA["GeoSocket: amer"] + Transport --> GeoB["GeoSocket: emea"] + Transport --> GeoC["GeoSocket: apac"] + GeoA --> ConnA["SocketModeConnection"] + GeoB --> ConnB["SocketModeConnection"] + GeoC --> ConnC["SocketModeConnection"] + ConnA --> App["TeamsBotApplication.ProcessWithInvokeResponseAsync"] + ConnB --> App + ConnC --> App +``` + +Each geo is managed independently by a `GeoSocket` supervisor, which: + +- Establishes an initial ready connection within a startup budget + (`StartupTimeout`, default 30s). +- Supervises the connection in the background for the lifetime of the host. +- Reconnects after unexpected closures, without affecting other geos. + +A `SocketModeTransport` owns all configured `GeoSocket` instances (one per +geo) and implements `IGeoSocketOwner`. It supplies the shared retry/backoff +policy to each `GeoSocket` and dispatches inbound activities into the app's +pipeline. + +`SocketModeHostedService` is an `IHostedService` that starts the transport. +Host startup does not complete until every configured geo has an initial ready +connection; if a geo fails to become ready within its startup budget, the +failure surfaces out of `host.Run()` instead of failing silently in the +background. + +## Negotiate flow + +Before opening a WebSocket, each `GeoSocket` calls a negotiate HTTP endpoint +(`SocketModeNegotiator`) to obtain SignalR connection details: + +1. The negotiate URL is `NegotiateBaseUrl` (default + `https://botapi.skype.com`) plus a geo-specific path segment. +2. The request is authenticated with the bot's own app token, acquired via the + standard client-credentials flow with scope + `https://api.botframework.com/.default` -- the same credential the bot + already uses for outbound Bot Framework API calls. +3. The response provides the actual SignalR connection info (endpoint URL and + access token) used to open the WebSocket. + +Socket Mode is restricted to the public cloud. This is enforced by checking +the bot's token issuer: it must be `https://api.botframework.com`. If the +configured cloud is not the public cloud, registration throws +`InvalidOperationException` rather than attempting to negotiate. + +## Reconnect & token rotation + +Connections are proactively rotated **make-before-break** ahead of token +expiry (`TokenRefreshMargin`): + +1. A new-generation connection negotiates and connects while the current one + keeps serving traffic. +2. Once the new connection reports ready, the old connection is retired. +3. `HandoffWindow` controls how long the retiring connection keeps dispatching + in-flight activities before it is torn down, so no activity is dropped + during rotation. + +On an **unexpected disconnect** (not a planned rotation), the `GeoSocket` +reconnects using one of two strategies: + +- An explicit `ReconnectDelays` schedule, if configured -- the last delay in + the list repeats for subsequent attempts. +- Capped exponential backoff with jitter, if no explicit schedule is + configured. + +Negotiate failures that return HTTP `401`/`403` are treated as **non-retryable +auth failures** (`SocketModeNegotiateException.IsNonRetryable`). In that case +the geo stops permanently until the app restarts, rather than retrying +forever, and `OnGeoDisconnected` is raised so the host/app can observe and log +the failure. + +Each geo's failure is isolated: a permanently failed or reconnecting geo does +not affect the other configured geos, which keep running independently. + +## Dispatch & invoke responses + +Inbound activities received over a socket are handed to the app's normal +processing pipeline via: + +```csharp +await teamsBotApplication.ProcessWithInvokeResponseAsync( + activity, + user: null, + correlationVector: null, + cancellationToken); +``` + +There is no per-activity principal -- the socket connection itself is already +authenticated with the bot's own app token, unlike the HTTP path where each +request carries its own bearer token. + +Because there is no `HttpContext` in Socket Mode, the invoke response (status +code + body) that handlers normally write to the HTTP response is instead +captured through an `AsyncLocal` capture object scoped to the turn. After the +pipeline completes, the captured status and body are serialized and sent back +over the socket as the reply frame for that activity. + +> **Note:** `AsyncLocal` is used deliberately, not as a default choice. The +> base `BotApplication.OnActivity` delegate is a public, `Task`-returning +> extensibility point shared with the HTTP transport, so its signature can't +> be widened to return a value without a breaking API change. `AsyncLocal` +> gives each concurrent turn an isolated slot without touching that contract. +> The capture is private to `TeamsBotApplication`, has exactly one writer and +> one reader, and is opened/closed within a single `try`/`finally` around one +> `ProcessAsync` call, which avoids the usual debugging pitfalls (stale +> values from lost `ExecutionContext` flow, unclear ownership) as long as +> that scoping discipline is preserved. + +## Options (`SocketModeOptions`) + +| Option | Default | Purpose | +|---|---|---| +| `NegotiateBaseUrl` | `https://botapi.skype.com` | Base URL for the per-geo negotiate call | +| `Geos` | `amer`, `emea`, `apac` | Geos to connect to; one `GeoSocket` per entry | +| `StartupTimeout` | 30s | Budget for a geo's initial connection to become ready during host startup | +| `ReconnectDelays` | none (exponential backoff) | Optional explicit reconnect delay schedule; last value repeats | +| `ReadinessTimeout` | 30s | Time allowed for a new connection (initial or rotated) to report ready | +| `KeepAliveInterval` | 15s | Interval for client-side keep-alive pings on the socket | +| `ServerTimeout` | 30s | Time without any server message before the connection is considered lost | + +Options are **validated when the host starts** (inside +`SocketModeHostedService`), not at registration time -- registration only +records the configuration, so misconfiguration is reported alongside other +startup failures rather than during DI setup. + +## Usage + +Socket Mode requires a host **without** a web server, since it has no inbound +HTTP endpoint to map. Build the host with `Host.CreateApplicationBuilder()` +instead of `WebApplication.CreateBuilder()`: + +```csharp +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.AddTeamsBotApplication(options => +{ + options.UseSocketMode(); + // or with configuration: + // options.UseSocketMode(o => + // { + // o.Geos = ["amer", "emea"]; + // o.StartupTimeout = TimeSpan.FromSeconds(45); + // }); +}); + +var host = builder.Build(); +host.UseTeamsBotApplication(); + +host.Run(); +``` + +`host.UseTeamsBotApplication()` is an `IHost` overload that wires up +`SocketModeHostedService` instead of mapping an HTTP route. + +Mixing Socket Mode with a web server is rejected: calling the +`WebApplication` / `IEndpointRouteBuilder` overloads of +`UseTeamsBotApplication()`, or building with `WebApplication.CreateBuilder()` +while Socket Mode is enabled, throws `InvalidOperationException` with a +message pointing at the correct host type to use instead. + +Calling `options.UseSocketMode(false)` clears Socket Mode configuration and +falls back to the normal HTTP endpoint behavior. + +## Sample + +`samples/SocketModeBot` will contain a working end-to-end example once the +app-integration PR stack lands, showing the `Host.CreateApplicationBuilder()` +setup, `UseSocketMode()` configuration, and `host.UseTeamsBotApplication()` +wiring described above. diff --git a/docs/SocketMode/SocketMode-Flow-Walkthrough.md b/docs/SocketMode/SocketMode-Flow-Walkthrough.md new file mode 100644 index 00000000..921907d3 --- /dev/null +++ b/docs/SocketMode/SocketMode-Flow-Walkthrough.md @@ -0,0 +1,186 @@ +# Socket Mode Flow Walkthrough + +> This is a single, flow-oriented walkthrough of the Socket Mode transport, +> replacing three earlier per-class walkthrough docs. Instead of going +> class-by-class, it follows four concrete sequences end-to-end: **startup**, +> **steady-state dispatch**, **make-before-break token rotation**, and +> **unexpected disconnect / reconnect**. See +> [SocketMode-Design.md](./SocketMode-Design.md) for the user-facing design +> and options. + +## Component map + +``` +SocketModeHostedService (IHostedService; blocks host startup until every geo is ready) + │ + ▼ +SocketModeTransport (implements IGeoSocketOwner; one GeoSocket per configured geo) + │ + ▼ +GeoSocket (per-geo supervisor: generations, rotation, retry/backoff) + │ ISocketConnectionFactory.Create(...) + ▼ +SignalRSocketConnectionFactory / SignalRSocketConnection + │ negotiates via SocketModeNegotiator, then builds + ▼ +SignalRClientConnection (thin adapter over HubConnectionBuilder) + │ + ▼ +Microsoft.AspNetCore.SignalR.Client.HubConnection +``` + +`ISocketConnection` / `ISocketConnectionFactory` / `SocketConnectionHandlers` +(all in `SocketModeConnection.cs`) are the interfaces that let `GeoSocket` +depend on "some connection generation" without knowing SignalR exists. + +## 1. Startup + +`SocketModeHostedService` doesn't finish starting until **every** geo has a +ready connection (within `StartupTimeout`, default 30s). A failure here +surfaces from `host.Run()`. + +```mermaid +sequenceDiagram + participant Host as SocketModeHostedService + participant Transport as SocketModeTransport + participant Geo as GeoSocket (per geo) + participant Factory as SignalRSocketConnectionFactory + participant Conn as SignalRSocketConnection + participant Neg as SocketModeNegotiator + participant Client as SignalRClientConnection + participant Hub as HubConnection + + Host->>Transport: StartAsync() + par for each configured geo + Transport->>Geo: StartAsync() + Geo->>Geo: ConnectAsync() (generation 1) + Geo->>Factory: Create(negotiateUri, handlers) + Factory->>Conn: new SignalRSocketConnection + Geo->>Conn: StartAsync() + Conn->>Neg: NegotiateAsync(negotiateUri) + Neg-->>Conn: url, accessToken, expiresIn + Conn->>Client: CreateSignalRClientConnection(url, token, keepAlive, serverTimeout) + Conn->>Client: OnActivity / OnReady / OnClosed + Conn->>Client: StartAsync() + Client->>Hub: HubConnectionBuilder...Build().StartAsync() + Hub-->>Client: SocketReady frame + Client-->>Conn: OnReady(frame) + Conn-->>Conn: TokenLifetime set, readySource resolved + Conn-->>Geo: StartAsync() returns + Geo->>Geo: TryPromote(generation 1) -> active + Geo-->>Transport: geo ready + end + Transport-->>Host: all geos ready -> StartAsync() returns +``` + +If a geo's `ConnectAsync` fails to reach ready within `StartupTimeout` (e.g. +negotiate 401/403, or `ReadinessTimeout` elapses waiting for `SocketReady`), +that failure propagates up through `Transport.StartAsync()` and +`Host.StartAsync()`, so the whole host fails to start rather than running +with a silently-missing geo. + +## 2. Steady-state dispatch + +Once a generation is active, inbound activity envelopes flow straight +through to the existing `TeamsBotApplication` pipeline -- there is no +parallel activity-processing framework. + +```mermaid +sequenceDiagram + participant Hub as HubConnection + participant Client as SignalRClientConnection + participant Conn as SignalRSocketConnection + participant Geo as GeoSocket + participant Transport as SocketModeTransport + participant App as TeamsBotApplication + + Hub->>Client: "Activity" hub invocation (envelope) + Client->>Conn: OnActivity handler + Conn->>Conn: await readySource.Task (already resolved) + Conn->>Geo: handlers.OnActivity(envelope) + Geo->>Geo: only the active (non-retiring) generation dispatches + Geo->>Transport: DispatchAsync(envelope) + Transport->>App: ProcessWithInvokeResponseAsync(activity, user: null, correlationVector: null) + App-->>Transport: invoke response (status + body) via AsyncLocal capture + Transport-->>Geo: reply frame + Geo-->>Conn: reply frame + Conn-->>Client: return value from OnActivity handler + Client-->>Hub: reply frame returned to server +``` + +No per-activity principal is attached -- the socket connection itself is +already authenticated with the bot's app token. The invoke response is +captured via an `AsyncLocal` object instead of `HttpContext`, since there is +no HTTP context in Socket Mode. + +## 3. Make-before-break token rotation + +Connections are rotated **before** their token expires (`TokenRefreshMargin` +before `TokenLifetime` runs out), so there's no gap where the active +connection is unauthenticated. + +```mermaid +sequenceDiagram + participant Geo as GeoSocket + participant NewConn as Generation N+1 + participant OldConn as Generation N (retiring) + + Note over Geo: ScheduleRefresh timer fires at TokenLifetime - TokenRefreshMargin + Geo->>Geo: RequestRotation() + Geo->>NewConn: ConnectAsync() (same negotiate/connect/ready sequence as startup) + NewConn-->>Geo: ready + Geo->>Geo: TryPromote(N+1) -> N+1 becomes active + Geo->>OldConn: StartRetirement(N) + Note over OldConn: N keeps dispatching for up to HandoffWindow + Geo->>OldConn: RetireAsync(N) once HandoffWindow elapses + OldConn->>OldConn: StopAsync() / DisposeAsync() +``` + +During the handoff window there are briefly **two** live generations for the +same geo: the new one is already active for new dispatch, while the old one +finishes out its window so in-flight requests aren't dropped mid-rotation. + +## 4. Unexpected disconnect & reconnect + +Unplanned closures (network blips, server-initiated drops) are distinguished +from planned ones (rotation, shutdown) via the `planned` flag on +`OnClosed(error, planned)`. + +```mermaid +sequenceDiagram + participant Hub as HubConnection + participant Client as SignalRClientConnection + participant Conn as SignalRSocketConnection + participant Geo as GeoSocket + participant Transport as SocketModeTransport + + Hub--xClient: connection drops unexpectedly + Client-->>Conn: HubConnection.Closed + Conn->>Conn: HandleClosed(error) -- planned = (_stopped != 0) = false + Conn-->>Geo: handlers.OnClosed(error, planned: false) + Geo->>Geo: SuperviseAsync: compute delay (ReconnectDelays schedule, or capped exponential backoff + jitter) + Geo->>Geo: after delay, ConnectAsync() (next generation) + alt negotiate returns 401/403 + Geo->>Geo: SocketModeNegotiateException.IsNonRetryable = true + Geo->>Geo: stop retrying this geo permanently (until app restart) + Geo-->>Transport: OnGeoDisconnected(geo) + else negotiate/connect succeeds + Geo->>Geo: TryPromote(new generation) -> active again + end +``` + +Each geo is isolated: one geo permanently failing (non-retryable auth +failure) does not affect the others, which keep running and reconnecting +independently. + +## Class responsibilities (quick reference) + +| Class | Responsibility | Status | +|---|---|---| +| `SocketModeHostedService` | `IHostedService`; blocks host startup until every geo is ready | PR #686 | +| `SocketModeTransport` | Implements `IGeoSocketOwner`; owns all `GeoSocket`s; dispatches to `TeamsBotApplication` | PR #686 | +| `GeoSocket` | Per-geo supervisor: generations, make-before-break rotation, retry/backoff, non-retryable failure isolation | PR #686 | +| `SignalRSocketConnectionFactory` / `SignalRSocketConnection` | One connection generation's lifecycle: negotiate, readiness gate, `TokenLifetime` | `main` | +| `SignalRClientConnection` | Thin adapter over `HubConnectionBuilder`; the only class that touches it directly | `main` | +| `SocketModeConnection.cs` (`ISocketConnection`, `ISocketConnectionFactory`, `SocketConnectionHandlers`) | Interface seam between `GeoSocket` and the SignalR-backed implementation; no SignalR code | `main` | +| `SocketModeNegotiator` | Negotiates SignalR connection info against the negotiate HTTP endpoint | `main` | diff --git a/docs/SocketMode/SocketMode-Hosting-Integration.md b/docs/SocketMode/SocketMode-Hosting-Integration.md new file mode 100644 index 00000000..bd2abc9a --- /dev/null +++ b/docs/SocketMode/SocketMode-Hosting-Integration.md @@ -0,0 +1,240 @@ +# Socket Mode Hosting & Dispatch Integration + +> This doc covers how Socket Mode plugs into the rest of the SDK: DI +> registration, host startup, and how an inbound activity reaches the same +> `TeamsBotApplication` pipeline as the HTTP transport. See +> [SocketMode-Design.md](./SocketMode-Design.md) for the user-facing design +> and [SocketMode-Flow-Walkthrough.md](./SocketMode-Flow-Walkthrough.md) for +> the transport's own connection/rotation sequences. + +## One pipeline, two transports + +Socket Mode does not duplicate activity processing. Both transports funnel +into the same `BotApplication.ProcessAsync(CoreActivity, ...)` method; only +how the activity is obtained, and how the response is returned, differs. + +``` +HTTP transport Socket Mode transport + │ │ +ProcessAsync(HttpContext) SocketModeTransport.HandleEnvelopeAsync + │ │ + └──────────┬───────────────┘ + ▼ + ProcessAsync(CoreActivity, user, correlationVector, cancellationToken) + │ + middleware pipeline → OnActivity → Router → handlers +``` + +The HTTP overload is now a thin wrapper: it deserializes the request body +into a `CoreActivity`, then delegates to the transport-agnostic overload: + +```csharp +public virtual async Task ProcessAsync(HttpContext httpContext, CancellationToken cancellationToken = default) +{ + CoreActivity activity = await CoreActivity.FromJsonStreamAsync(httpContext.Request.Body, cancellationToken) + ?? throw new InvalidOperationException("Invalid Activity"); + + await ProcessAsync(activity, httpContext.User, httpContext.Request.GetCorrelationVector(), cancellationToken); +} +``` + +Socket Mode calls the transport-agnostic overload directly (via +`TeamsBotApplication.ProcessWithInvokeResponseAsync`, below) since it never +has an `HttpContext` to begin with -- there is no per-activity principal +either, because the socket connection itself is already authenticated with +the bot's own app token (`user: null`). + +## DI registration: `AddTeamsBotApplication` + +`AddTeamsBotApplication` registers `TApp`, its options, and the API +client exactly as it does today. If `TeamsBotApplicationOptions.SocketMode` +is non-null (set via `options.UseSocketMode(...)`), it additionally calls +`SocketModeServiceRegistration.AddSocketMode`, which registers: + +| Service | Lifetime | Purpose | +|---|---|---| +| `ISocketModeNegotiator` | Singleton | Calls the negotiate HTTP endpoint using a dedicated, credential-free `HttpClient` plus the bot's own app token | +| `ISocketConnectionFactory` (`SignalRSocketConnectionFactory`) | Singleton | Creates one `SignalRSocketConnection` per connection generation | +| `SocketModeTransport` | Singleton | Owns one `GeoSocket` per configured geo; built with a dispatch delegate (below) | +| `SocketModeHostedService` | Hosted service | Starts/stops the transport with the host | + +Cloud support is checked here, at registration time: if the bot's token +issuer isn't `https://api.botframework.com`, `AddSocketMode` throws +`InvalidOperationException` immediately rather than waiting for host +startup. Everything else (timeouts, geo list, etc.) is validated later, when +the host actually starts and the transport is constructed -- "invalid +options fail when the host starts, as in the other SDKs." + +The dispatch delegate wired into `SocketModeTransport` is where Socket Mode +re-enters the shared pipeline: + +```csharp +internal static async Task DispatchAsync(TeamsBotApplication app, CoreActivity activity) +{ + // No per-activity principal: the connection itself was authenticated with the bot's own token. + InvokeResponse? response = await app.ProcessWithInvokeResponseAsync(activity, user: null, correlationVector: null); + return response is null + ? new SocketDispatchResult(200) + : new SocketDispatchResult(response.Status, response.Body); +} +``` + +## Host startup: `UseSocketMode()` vs. a web server + +`UseTeamsBotApplication` has four overloads, but they all funnel into one +rule: **Socket Mode needs a host without a web server; HTTP needs one.** + +- `UseTeamsBotApplication(this IEndpointRouteBuilder, routePath)` and + `UseTeamsBotApplication(this WebApplication, routePath)` -- the HTTP + overloads. Both throw `InvalidOperationException` if Socket Mode is + enabled (`TeamsBotApplicationOptions.SocketMode is not null`). +- `UseTeamsBotApplication(this IHost)` -- the Socket Mode overload. If Socket + Mode is *not* enabled, it falls back to requiring `host` to also be an + `IEndpointRouteBuilder` (e.g. a `WebApplication`) and maps the HTTP route + instead. If Socket Mode *is* enabled and `host` also happens to implement + `IEndpointRouteBuilder` (i.e. someone built a `WebApplication` anyway), it + throws the same mismatch error. + +``` + UseSocketMode() called in AddTeamsBotApplication? + │ + ┌────────────┴────────────┐ + no yes + │ │ + host must be IEndpointRouteBuilder host must NOT be IEndpointRouteBuilder + (WebApplication.CreateBuilder()) (Host.CreateApplicationBuilder()) + │ │ + map HTTP route resolve TApp; SocketModeHostedService + (api/messages, default) drives it via IHostedService +``` + +Passing `UseSocketMode(false)` clears `TeamsBotApplicationOptions.SocketMode` +back to `null`, so the host falls back to the HTTP path -- the same check +(`SocketMode is not null`) governs both registration and host wiring. + +## `SocketModeHostedService.StartAsync` + +Once the host starts, `SocketModeHostedService` (an `IHostedService`) does +two things before the transport connects anything: + +1. **Guards against a web server.** It resolves + `IServiceProviderIsService` and asks whether `IServer` is a registered + service. A `WebApplication` always registers `IServer`; a plain + `Host.CreateApplicationBuilder()` host never does. If `IServer` is + present, it throws the same `SocketWithWebServerMessage` used by the + `UseTeamsBotApplication` overloads above -- this is the second, defense- + in-depth place that mismatch is caught (the first being + `UseTeamsBotApplicationCore` when `host.UseTeamsBotApplication()` is + called). +2. **Resolves `SocketModeTransport` from DI and starts it**, rather than + having it injected into the constructor. This is deliberate: constructing + `SocketModeTransport` validates `SocketModeOptions` (geo list, timeouts, + etc.), so any invalid option surfaces from `host.StartAsync()` / + `host.Run()`, not from a background task that might swallow the + exception. + +`StopAsync` mirrors this: if the transport never started, it's a no-op; +otherwise it calls `SocketModeTransport.StopAsync()`. + +## Returning the invoke response without `HttpContext` + +`TeamsBotApplication`'s shared `OnActivity` handler (used by both +transports) currently writes invoke responses straight to `HttpContext`: + +```csharp +InvokeResponse invokeResponse = await Router.DispatchWithReturnAsync(defaultContext, cancellationToken); +HttpContext? httpContext = httpContextAccessor.HttpContext; +if (invokeResponse is not null && TryCaptureInvokeResponse(invokeResponse)) +{ + // Socket Mode (or any non-HTTP transport): captured, not written to HttpContext. +} +else if (httpContext is not null && invokeResponse is not null) +{ + httpContext.Response.StatusCode = invokeResponse.Status; + await httpContext.Response.WriteAsJsonAsync(invokeResponse.Body, cancellationToken); +} +``` + +`TryCaptureInvokeResponse` checks an `AsyncLocal` +that's set up around the call, not an instance field -- a single +`TeamsBotApplication` instance processes many activities concurrently, and +each turn's handler must only see *its own* capture: + +```csharp +internal async Task ProcessWithInvokeResponseAsync( + CoreActivity activity, ClaimsPrincipal? user, string? correlationVector, CancellationToken cancellationToken = default) +{ + InvokeResponseCapture capture = new(this); + s_invokeResponseCapture.Value = capture; + try + { + await ProcessAsync(activity, user, correlationVector, cancellationToken); + } + finally + { + capture.Close(); + } + + return capture.Response; +} +``` + +`InvokeResponseCapture` is a small mutable holder (`Owner`, `Response`, +`TrySet`, `Close`) rather than the response itself, because the value has to +be *set* deep inside the pipeline (inside `OnActivity`) but *read* back out +here, after `s_invokeResponseCapture`'s async-local value has flowed back up +through the `await`. `Close()` makes the capture reject any further +`TrySet` calls once this call is winding down, so a handler that (incorrectly) +keeps running after the turn completes can't race a stale write into a +capture whose `Response` has already been returned. + +`TryCaptureInvokeResponse` further checks +`ReferenceEquals(capture.Owner, this)` -- it only captures for the exact +`TeamsBotApplication` instance that started this turn, so nothing bleeds +across instances in a multi-app host. + +### Why not just detect "no `HttpContext`"? + +Because a Socket Mode activity is processed on threads where +`IHttpContextAccessor.HttpContext` is simply `null` (there was never an +HTTP request), checking `httpContext is null` alone would work for +*detecting* Socket Mode. But the capture-first check exists so the exact +same `OnActivity` code path serves **any** current or future non-HTTP +transport without new branching -- the transport opts in by setting the +`AsyncLocal` before calling `ProcessAsync`, rather than `OnActivity` having +to know which transport it's running under. + +## End-to-end: from socket envelope to reply frame + +```mermaid +sequenceDiagram + participant Hub as HubConnection (SignalR) + participant Transport as SocketModeTransport + participant App as TeamsBotApplication + participant Capture as AsyncLocal capture + participant Pipeline as Middleware → OnActivity → Router + + Hub->>Transport: envelope (CoreActivity) + Transport->>App: ProcessWithInvokeResponseAsync(activity, user: null, cv: null) + App->>Capture: s_invokeResponseCapture.Value = new InvokeResponseCapture(this) + App->>Pipeline: ProcessAsync(activity, user: null, cv: null, ct) + Pipeline->>Pipeline: Router.DispatchWithReturnAsync -> InvokeResponse + Pipeline->>Capture: TryCaptureInvokeResponse(response) (HttpContext is null here) + Pipeline-->>App: ProcessAsync returns + App->>Capture: capture.Close() + App-->>Transport: capture.Response (InvokeResponse?) + Transport->>Transport: SocketDispatchResult(status, body) + Transport-->>Hub: reply frame returned over the socket +``` + +## Summary + +| Concern | HTTP transport | Socket Mode transport | +|---|---|---| +| Entry point | `ProcessAsync(HttpContext, ct)` | `ProcessWithInvokeResponseAsync(activity, user, cv, ct)` | +| Shared pipeline call | `ProcessAsync(CoreActivity, user, cv, ct)` | same | +| Per-activity principal | `httpContext.User` | `null` (connection-level auth) | +| Invoke response delivery | Written to `HttpContext.Response` | Returned via `AsyncLocal` capture, then converted to a reply frame | +| Host wiring | `WebApplication` / `IEndpointRouteBuilder` overloads | `IHost` overload + `SocketModeHostedService` | +| Registration-time validation | N/A | Cloud check (token issuer) | +| Startup-time validation | N/A | `SocketModeOptions` validated when `SocketModeTransport` is constructed by the hosted service |