From 19b624deb366ea049709bb0102fb1f5575bc42d6 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Wed, 30 Sep 2026 13:59:53 -0700 Subject: [PATCH 01/10] docs: add Socket Mode design doc Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/SocketMode-Design.md | 195 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/SocketMode-Design.md diff --git a/docs/SocketMode-Design.md b/docs/SocketMode-Design.md new file mode 100644 index 00000000..9e797cdd --- /dev/null +++ b/docs/SocketMode-Design.md @@ -0,0 +1,195 @@ +# Socket Mode Design + +> **Status note:** This branch contains only the low-level Socket Mode transport +> primitives (`SignalRClientConnection`, `SignalRSocketConnection`, +> `SocketModeConnection`, `SocketModeEnvelope`, `SocketModeJson`, +> `SocketModeNegotiator`, `SocketModeProtocol`, `SocketModeProtocolModels` under +> `src/Microsoft.Teams.Apps/SocketMode/`). The higher-level pieces described +> below (`GeoSocket`, `SocketModeTransport`, `SocketModeOptions`, +> `SocketModeHostedService`, `SocketModeServiceRegistration`, and the +> `UseSocketMode` / `host.UseTeamsBotApplication()` integration) are landing via +> a follow-up PR stack based on +> [#686](https://github.com/microsoft/teams.net/pull/686). This document +> describes the complete, intended design regardless of what has merged so far. + +## 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. + +## 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. From 9f8410a7d016a81f297c1c6948fd317baacbc969 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Wed, 30 Sep 2026 14:25:12 -0700 Subject: [PATCH 02/10] docs: add SocketModeTransport walkthrough Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/SocketMode-Design.md | 3 + docs/SocketModeTransport-Walkthrough.md | 98 +++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 docs/SocketModeTransport-Walkthrough.md diff --git a/docs/SocketMode-Design.md b/docs/SocketMode-Design.md index 9e797cdd..4f10ee63 100644 --- a/docs/SocketMode-Design.md +++ b/docs/SocketMode-Design.md @@ -11,6 +11,9 @@ > a follow-up PR stack based on > [#686](https://github.com/microsoft/teams.net/pull/686). This document > describes the complete, intended design regardless of what has merged so far. +> +> For an implementation-level walkthrough of `SocketModeTransport`, see +> [SocketModeTransport-Walkthrough.md](./SocketModeTransport-Walkthrough.md). ## Overview diff --git a/docs/SocketModeTransport-Walkthrough.md b/docs/SocketModeTransport-Walkthrough.md new file mode 100644 index 00000000..e4dcb988 --- /dev/null +++ b/docs/SocketModeTransport-Walkthrough.md @@ -0,0 +1,98 @@ +# SocketModeTransport Walkthrough + +> This is a supplementary, implementation-level walkthrough of `SocketModeTransport` +> for engineers working on the Socket Mode PR stack. See +> [SocketMode-Design.md](./SocketMode-Design.md) for the user-facing design and +> architecture overview. The class described here lives on the +> `teddyam-socket-mode-app-integration` branch +> ([#686](https://github.com/microsoft/teams.net/pull/686)) and is not yet on +> `main`. + +`SocketModeTransport` is the coordinator that owns one `GeoSocket` per +configured geo, supplies them with retry/backoff policy (as their +`IGeoSocketOwner`), and turns raw inbound envelopes into dispatch calls and +reply frames. Here is how it works, step by step. + +## 1. Construction + +`SocketModeTransport(options, connectionFactory, dispatch, logger, ...)` does +validation and setup only -- no network activity yet: + +- Validates `NegotiateBaseUri` is non-null and `StartupTimeout >= 0`. +- Calls `ResolveGeos()` to turn each geo string (`"amer"`, `"emea"`, `"apac"`) + into a full negotiate `Uri` (base URL + `/{geo}/negotiate`), rejecting + duplicate or empty geo entries. +- Stores the `dispatch` delegate (how an activity reaches the bot pipeline) + and the optional `onError` hook. +- `Status` starts as `Idle`. + +## 2. `StartAsync()` -- bringing geos up + +- Guards against double-start (`_lifecycle != Idle` throws). +- Creates one `GeoSocket` per resolved geo, passing `this` as the + `IGeoSocketOwner` -- this is how each `GeoSocket` gets its retry policy, + timeouts, and dispatch callback. +- Marks every geo `Connecting` in `_geoStatuses`. +- Runs `geoSocket.StartAsync()` for **all geos concurrently** via + `Task.WhenAll`. +- **All-or-nothing startup**: if any geo fails, a shared `startSource` is + cancelled so the other geos abandon their attempt, the *first* failure is + captured via `Interlocked.CompareExchange`, `StopAsync()` tears everything + down, and that first failure is rethrown. This is why a Socket Mode startup + failure surfaces directly from `host.Run()`. + +## 3. Steady state -- acting as `IGeoSocketOwner` + +Once started, `SocketModeTransport` mostly answers callbacks from the running +`GeoSocket`s: + +- `GetBackoffDelay(attempt)` -- indexes into the configured `ReconnectDelays` + schedule (clamped to the last entry) or computes capped exponential backoff + with jitter. +- `GetRetryAfter(error)` -- pulls a server-provided retry hint off a + `SocketModeNegotiateException`, if present. +- `OnGeoReady` / `OnGeoDisconnected` / `OnGeoReconnected` -- update + `_geoStatuses[geo]` under a lock. +- `DispatchAsync(geo, envelope)` -- forwards to `HandleEnvelopeAsync`. + +## 4. `HandleEnvelopeAsync()` -- per-activity processing + +For each inbound envelope: + +1. Rejects unsupported protocol versions with a `400` reply. +2. Extracts the `CoreActivity` payload; drops the envelope silently if there + is none. +3. Determines whether it is an `invoke` activity (needs a reply) versus + fire-and-forget. +4. Calls `_dispatch(activity)` -- this is where + `TeamsBotApplication.ProcessWithInvokeResponseAsync` plugs in. +5. On success: builds an invoke reply or a plain acknowledgement frame with + the result status. +6. On exception: logs it (unless already logged as a `BotHandlerException`), + invokes the optional `_onError` hook, and returns a `500` reply/ack instead + of throwing -- a single bad activity cannot kill the geo connection. + +## 5. Aggregate `Status` property + +Computed on read, not stored directly: + +- `Ready` only if every geo is `Ready`. +- `Disconnected` if any geo is down. +- `Connecting` otherwise. + +This is what a health check or the hosted service polls to decide whether the +transport, as a whole, is serving traffic. + +## 6. `StopAsync()` / `DisposeAsync()` + +Idempotent (memoized via `_stopTask`): flips `_lifecycle` to `Stopped`, marks +every geo `Stopped`, and disposes all `GeoSocket` instances in parallel, +logging (rather than throwing) individual cleanup failures. + +## Summary + +`GeoSocket` handles the low-level "keep one socket alive" mechanics for a +single geo. `SocketModeTransport` is the coordinator: it fans `StartAsync` +out to all geos with all-or-nothing semantics, supplies policy via +`IGeoSocketOwner`, and turns raw envelopes into dispatch calls and reply +frames. From 0c69475649dab08f08299ebed3705abec7a95da7 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Wed, 30 Sep 2026 14:31:09 -0700 Subject: [PATCH 03/10] docs: move Socket Mode docs into docs/SocketMode folder Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/{ => SocketMode}/SocketMode-Design.md | 0 docs/{ => SocketMode}/SocketModeTransport-Walkthrough.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename docs/{ => SocketMode}/SocketMode-Design.md (100%) rename docs/{ => SocketMode}/SocketModeTransport-Walkthrough.md (100%) diff --git a/docs/SocketMode-Design.md b/docs/SocketMode/SocketMode-Design.md similarity index 100% rename from docs/SocketMode-Design.md rename to docs/SocketMode/SocketMode-Design.md diff --git a/docs/SocketModeTransport-Walkthrough.md b/docs/SocketMode/SocketModeTransport-Walkthrough.md similarity index 100% rename from docs/SocketModeTransport-Walkthrough.md rename to docs/SocketMode/SocketModeTransport-Walkthrough.md From f43b7b25348a4a10d2e247f2cf6b950a9fc3934e Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Wed, 30 Sep 2026 14:43:27 -0700 Subject: [PATCH 04/10] docs: add GeoSocket walkthrough Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/SocketMode/GeoSocket-Walkthrough.md | 120 ++++++++++++++++++ docs/SocketMode/SocketMode-Design.md | 5 +- .../SocketModeTransport-Walkthrough.md | 4 +- 3 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 docs/SocketMode/GeoSocket-Walkthrough.md diff --git a/docs/SocketMode/GeoSocket-Walkthrough.md b/docs/SocketMode/GeoSocket-Walkthrough.md new file mode 100644 index 00000000..c78b4191 --- /dev/null +++ b/docs/SocketMode/GeoSocket-Walkthrough.md @@ -0,0 +1,120 @@ +# GeoSocket Walkthrough + +> This is a supplementary, implementation-level walkthrough of `GeoSocket` +> for engineers working on the Socket Mode PR stack. See +> [SocketMode-Design.md](./SocketMode-Design.md) for the user-facing design +> and architecture overview, and +> [SocketModeTransport-Walkthrough.md](./SocketModeTransport-Walkthrough.md) +> for the coordinator that owns one `GeoSocket` per geo. The class described +> here lives on the `teddyam-socket-mode-app-integration` branch +> ([#686](https://github.com/microsoft/teams.net/pull/686)) and is not yet on +> `main`. + +`GeoSocket` manages **one geo's** connection lifecycle: establishing the +initial connection, supervising it in the background, reconnecting after +unexpected closures, and rotating tokens make-before-break. Here is how it +works, step by step. + +## 1. Construction + +`GeoSocket(owner, geo, negotiateUri, connectionFactory, logger, timeProvider)` +just stores its dependencies. `_owner` is the `IGeoSocketOwner` (in practice, +`SocketModeTransport`) that supplies timeouts, backoff policy, and the +dispatch callback. No connection is opened yet. + +## 2. `StartAsync()` -- initial connection + +- Guards against double-start via `Interlocked.Exchange(ref _started, 1)`. +- Calls `ConnectInitialAsync()`, which retries `ConnectAsync()` in a loop + until either it succeeds, the `StartupTimeout` budget runs out (throws + `TimeoutException`), or a non-retryable negotiate failure occurs (HTTP + 401/403 -- thrown immediately, no retry). +- Once a `Generation` is ready, spawns the background `SuperviseAsync(initial)` + loop and returns. This is what `SocketModeTransport.StartAsync` awaits, + per geo. + +## 3. `ConnectAsync()` -- one connection attempt + +- Increments `_generation` and creates a new `Generation` record (an id plus + a `TaskCompletionSource` called `Closed`). +- Creates the actual `ISocketConnection` via `_connectionFactory.Create(...)`, + wiring three callbacks: dispatch, ready, and closed. +- Adds the connection to `_owned` -- the set of every connection this geo + currently owns, including ones being retired. +- Calls `connection.StartAsync()`. The underlying SignalR connection's + `StartAsync` completes once `SocketReady` arrives, but `TryPromote` (next + step) confirms the connection actually became the active generation before + `ConnectAsync` reports success. +- On success, calls `ScheduleRefresh(generation)` to arm the token-rotation + timer. + +## 4. `TryPromote()` -- generation handoff + +This is the core of make-before-break rotation: when a new generation is +ready, `TryPromote` atomically marks the *previous* active generation as +retiring (added to the `_retiring` set) and swaps `_active` to the new one. +At any instant there is at most one active generation, plus zero or more +retiring generations still allowed to dispatch. + +## 5. `DispatchAsync(generation, envelope)` + +Before forwarding to `_owner.DispatchAsync`, this checks that the generation +is still `_active` **or** present in `_retiring`. A fully-replaced, +already-closed generation's stray in-flight messages are dropped (returns +`null`), so a zombie connection cannot process activities after handoff. + +## 6. `ScheduleRefresh()` + `RequestRotation()` -- proactive token rotation + +If the connection reports a `TokenLifetime`, a timer is armed for +`lifetime - TokenRefreshMargin`. When it fires, `RequestRotation` does not +tear down the connection itself -- it simply completes the generation's +`Closed` task-completion-source with `Planned: true`, signaling the +supervisor loop to react. + +## 7. `SuperviseAsync()` -- the main loop + +Waits on `current.Closed.Task`. Two paths: + +- **Planned** (rotation): calls `ReconnectAsync(null, delayFirstAttempt: false)` + to connect a *new* generation immediately (no delay). Only if that succeeds + does it call `StartRetirement(current)` to retire the old one -- new + connection first, old one after, i.e. make-before-break. +- **Unplanned** (real disconnect): releases the dead connection immediately, + then calls `ReconnectAsync(reason.Error, delayFirstAttempt: true)`, which + retries with backoff. + +If `ReconnectAsync` gives up after a non-retryable error, the loop exits -- +`StopAfterNonRetryable` has already stopped the geo permanently and reported +it via `OnGeoDisconnected`. + +## 8. `StartRetirement()` / `RetireAsync()` -- the handoff window + +Runs as a fire-and-forget task (tracked in `_retirements` so `StopAsync` can +wait for it): sleeps for `HandoffWindow`, then removes the generation from +`_retiring` and calls `ReleaseAsync` to stop and dispose it. During that +sleep, the old connection can still dispatch (per step 5's check), giving +in-flight activities time to finish before it is torn down. + +## 9. `HandleClosed()` -- bookkeeping on any close + +Marks the generation closed and removes it from `_retiring`. Only if it was +the `_active` generation, the close was unplanned, and the geo is not +stopping does it flag `_disconnected` and call `_owner.OnGeoDisconnected`. +It always completes the generation's `Closed` task-completion-source so +`SuperviseAsync` wakes up. + +## 10. `StopAsync()` / `DisposeAsync()` + +Idempotent via `_stopTask`. Cancels `_stopSource`, clears `_active` and +`_retiring`, stops and disposes every connection in `_owned` in parallel, +then waits for any in-flight retirement tasks and the supervisor loop to +finish. + +## Summary + +`GeoSocket` treats each connection attempt as a numbered "generation," uses +an `_active` / `_retiring` split to let two generations briefly coexist +during rotation, and drives all state transitions through each generation's +`Closed` signal rather than direct cancellation. That design is what lets +make-before-break handoff and reconnect-on-failure converge into a single +supervisor loop. diff --git a/docs/SocketMode/SocketMode-Design.md b/docs/SocketMode/SocketMode-Design.md index 4f10ee63..6dfd546b 100644 --- a/docs/SocketMode/SocketMode-Design.md +++ b/docs/SocketMode/SocketMode-Design.md @@ -12,8 +12,9 @@ > [#686](https://github.com/microsoft/teams.net/pull/686). This document > describes the complete, intended design regardless of what has merged so far. > -> For an implementation-level walkthrough of `SocketModeTransport`, see -> [SocketModeTransport-Walkthrough.md](./SocketModeTransport-Walkthrough.md). +> For implementation-level walkthroughs, see +> [SocketModeTransport-Walkthrough.md](./SocketModeTransport-Walkthrough.md) and +> [GeoSocket-Walkthrough.md](./GeoSocket-Walkthrough.md). ## Overview diff --git a/docs/SocketMode/SocketModeTransport-Walkthrough.md b/docs/SocketMode/SocketModeTransport-Walkthrough.md index e4dcb988..1ee0de2c 100644 --- a/docs/SocketMode/SocketModeTransport-Walkthrough.md +++ b/docs/SocketMode/SocketModeTransport-Walkthrough.md @@ -6,7 +6,9 @@ > architecture overview. The class described here lives on the > `teddyam-socket-mode-app-integration` branch > ([#686](https://github.com/microsoft/teams.net/pull/686)) and is not yet on -> `main`. +> `main`. See also +> [GeoSocket-Walkthrough.md](./GeoSocket-Walkthrough.md) for the per-geo +> connection supervisor that `SocketModeTransport` owns. `SocketModeTransport` is the coordinator that owns one `GeoSocket` per configured geo, supplies them with retry/backoff policy (as their From de6734cdd98b2ad924abacd2897dd9c63f105c93 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Wed, 30 Sep 2026 14:57:39 -0700 Subject: [PATCH 05/10] docs: add SignalRClientConnection/SocketModeConnection walkthrough Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...on-and-SocketModeConnection-Walkthrough.md | 134 ++++++++++++++++++ docs/SocketMode/SocketMode-Design.md | 5 +- 2 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 docs/SocketMode/SignalRClientConnection-and-SocketModeConnection-Walkthrough.md diff --git a/docs/SocketMode/SignalRClientConnection-and-SocketModeConnection-Walkthrough.md b/docs/SocketMode/SignalRClientConnection-and-SocketModeConnection-Walkthrough.md new file mode 100644 index 00000000..b3e81b8e --- /dev/null +++ b/docs/SocketMode/SignalRClientConnection-and-SocketModeConnection-Walkthrough.md @@ -0,0 +1,134 @@ +# SignalRClientConnection & SocketModeConnection Walkthrough + +> This walkthrough covers the two lowest-level building blocks of the Socket +> Mode transport: `SignalRClientConnection.cs` and `SocketModeConnection.cs`. +> Unlike `GeoSocket` and `SocketModeTransport` (see +> [GeoSocket-Walkthrough.md](./GeoSocket-Walkthrough.md) and +> [SocketModeTransport-Walkthrough.md](./SocketModeTransport-Walkthrough.md)), +> **these two files are already on `main`** today, under +> `src/Microsoft.Teams.Apps/SocketMode/`. See +> [SocketMode-Design.md](./SocketMode-Design.md) for the overall architecture. + +These two files exist to answer one question cleanly: *"how do we open a +SignalR connection, and how do higher layers depend on that without coupling +to the real SignalR client?"* `SignalRClientConnection.cs` answers the first +half; `SocketModeConnection.cs` answers the second half. + +## `SignalRClientConnection.cs` + +### 1. `CreateSignalRClientConnection` delegate + +A factory delegate `(Uri url, string accessToken, TimeSpan keepAliveInterval, +TimeSpan serverTimeout) => ISignalRClientConnection`. Its only purpose is to +let callers swap in a fake for tests instead of a real SignalR client. + +### 2. `ISignalRClientConnection` interface + +The minimal surface Socket Mode needs from a SignalR hub connection: + +- `OnActivity(handler)` -- register the handler for inbound activity + envelopes (returns an optional reply frame). +- `OnReady(handler)` -- register the handler for the `SocketReady` frame. +- `OnClosed(handler)` -- register the handler for terminal closure. +- `StartAsync(cancellationToken)` / `StopAsync(cancellationToken)`. + +Nothing here is Socket-Mode-specific business logic -- it is purely an +adapter surface over a `HubConnection`. + +### 3. `SignalRClientConnection` -- the adapter itself + +This is the **only** place in the whole Socket Mode transport that touches +`Microsoft.AspNetCore.SignalR.Client.HubConnectionBuilder`: + +```csharp +HubConnection connection = new HubConnectionBuilder() + .WithUrl(url, options => + { + options.AccessTokenProvider = () => Task.FromResult(accessToken); + }) + .WithKeepAliveInterval(keepAliveInterval) + .WithServerTimeout(serverTimeout) + .Build(); +``` + +The rest of the class is bookkeeping around that one `HubConnection`: + +- `OnActivity` registers a SignalR hub method handler for `"Activity"` (via + `_connection.On(...)`) and keeps + the returned `IDisposable` subscription so it can be cleaned up later. +- `OnReady` does the same for the `"SocketReady"` hub method. +- `OnClosed` wraps the caller's `Action` in a `Func` and attaches it to `HubConnection.Closed`, keeping a reference so it + can be detached on dispose (SignalR's `Closed` event would otherwise keep + the handler alive past the connection's useful lifetime). +- `StartAsync`/`StopAsync` forward directly to the underlying + `HubConnection`. +- `DisposeAsync` detaches every `Closed` handler, disposes every `On<...>` + subscription, then disposes the `HubConnection` itself -- in that order, so + no stale handler can fire mid-teardown. + +**Takeaway:** this class is intentionally thin. It is not reinventing +SignalR; it is exactly the "use `HubConnectionBuilder` directly" approach, +wrapped only enough to be unit-testable via `ISignalRClientConnection`. + +## `SocketModeConnection.cs` + +This file has **no SignalR code at all**. It defines the seam between +`GeoSocket` (which supervises connections across generations) and whatever +implementation actually opens a socket: + +### 1. `ISocketConnection` + +The contract `GeoSocket` programs against for a single connection +generation: + +- `TokenLifetime` -- the remaining lifetime of the connection's token, when + known, used by `GeoSocket.ScheduleRefresh` to arm the proactive-rotation + timer. +- `StartAsync(cancellationToken)` / `StopAsync(cancellationToken)`. + +### 2. `ISocketConnectionFactory` + +`Create(negotiateUri, handlers) -> ISocketConnection`. `GeoSocket` calls this +once per generation (see `ConnectAsync` in the +[GeoSocket walkthrough](./GeoSocket-Walkthrough.md#3-connectasync----one-connection-attempt)). +The production implementation is `SignalRSocketConnectionFactory`, which +negotiates via `SocketModeNegotiator` and then builds a +`SignalRClientConnection` under the hood. + +### 3. `SocketConnectionHandlers` + +A plain data holder bundling the three callbacks `GeoSocket` passes to a new +connection: + +- `OnActivity` -- dispatches an inbound envelope, returns an optional reply + frame. +- `OnReady` -- reports the `SocketReady` frame. +- `OnClosed` -- reports closure, with a `bool planned` flag so `GeoSocket` + can distinguish proactive token rotation from an unexpected drop. + +**Takeaway:** `SocketModeConnection.cs` is the interface layer that lets +`GeoSocket` be unit-tested against a fake `ISocketConnection` without a real +network socket, and lets the SignalR-specific implementation +(`SignalRSocketConnection`, the next layer up) be swapped or mocked +independently. + +## How the pieces fit together + +``` +GeoSocket + │ calls ISocketConnectionFactory.Create(...) + ▼ +SignalRSocketConnectionFactory / SignalRSocketConnection (one generation's lifecycle: negotiate, readiness gate, TokenLifetime) + │ builds + ▼ +SignalRClientConnection (thin adapter: HubConnectionBuilder, subscription/handler bookkeeping) + │ wraps + ▼ +Microsoft.AspNetCore.SignalR.Client.HubConnection (the actual built-in SignalR client) +``` + +`SocketModeConnection.cs`'s interfaces (`ISocketConnection`, +`ISocketConnectionFactory`, `SocketConnectionHandlers`) sit at the top of +that stack as the contract `GeoSocket` depends on, so it never has to know +SignalR exists at all. diff --git a/docs/SocketMode/SocketMode-Design.md b/docs/SocketMode/SocketMode-Design.md index 6dfd546b..91c54056 100644 --- a/docs/SocketMode/SocketMode-Design.md +++ b/docs/SocketMode/SocketMode-Design.md @@ -13,8 +13,9 @@ > describes the complete, intended design regardless of what has merged so far. > > For implementation-level walkthroughs, see -> [SocketModeTransport-Walkthrough.md](./SocketModeTransport-Walkthrough.md) and -> [GeoSocket-Walkthrough.md](./GeoSocket-Walkthrough.md). +> [SocketModeTransport-Walkthrough.md](./SocketModeTransport-Walkthrough.md), +> [GeoSocket-Walkthrough.md](./GeoSocket-Walkthrough.md), and +> [SignalRClientConnection-and-SocketModeConnection-Walkthrough.md](./SignalRClientConnection-and-SocketModeConnection-Walkthrough.md). ## Overview From f1358b6cf367073c770e6e789317fe1eb64d77fb Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Wed, 30 Sep 2026 15:00:20 -0700 Subject: [PATCH 06/10] docs: add SignalRSocketConnection section to walkthrough --- ...on-and-SocketModeConnection-Walkthrough.md | 133 ++++++++++++++++-- 1 file changed, 120 insertions(+), 13 deletions(-) diff --git a/docs/SocketMode/SignalRClientConnection-and-SocketModeConnection-Walkthrough.md b/docs/SocketMode/SignalRClientConnection-and-SocketModeConnection-Walkthrough.md index b3e81b8e..7a7078d7 100644 --- a/docs/SocketMode/SignalRClientConnection-and-SocketModeConnection-Walkthrough.md +++ b/docs/SocketMode/SignalRClientConnection-and-SocketModeConnection-Walkthrough.md @@ -1,18 +1,20 @@ -# SignalRClientConnection & SocketModeConnection Walkthrough +# SignalRClientConnection, SignalRSocketConnection & SocketModeConnection Walkthrough -> This walkthrough covers the two lowest-level building blocks of the Socket -> Mode transport: `SignalRClientConnection.cs` and `SocketModeConnection.cs`. -> Unlike `GeoSocket` and `SocketModeTransport` (see +> This walkthrough covers the three lowest-level building blocks of the +> Socket Mode transport: `SignalRClientConnection.cs`, +> `SignalRSocketConnection.cs`, and `SocketModeConnection.cs`. Unlike +> `GeoSocket` and `SocketModeTransport` (see > [GeoSocket-Walkthrough.md](./GeoSocket-Walkthrough.md) and > [SocketModeTransport-Walkthrough.md](./SocketModeTransport-Walkthrough.md)), -> **these two files are already on `main`** today, under +> **these three files are already on `main`** today, under > `src/Microsoft.Teams.Apps/SocketMode/`. See > [SocketMode-Design.md](./SocketMode-Design.md) for the overall architecture. -These two files exist to answer one question cleanly: *"how do we open a -SignalR connection, and how do higher layers depend on that without coupling -to the real SignalR client?"* `SignalRClientConnection.cs` answers the first -half; `SocketModeConnection.cs` answers the second half. +These three files exist to answer one question cleanly: *"how do we open a +SignalR connection, manage one connection generation's lifecycle, and let +higher layers depend on that without coupling to the real SignalR client?"* +`SignalRClientConnection.cs` answers the first part, `SignalRSocketConnection.cs` +answers the second, and `SocketModeConnection.cs` answers the third. ## `SignalRClientConnection.cs` @@ -71,6 +73,108 @@ The rest of the class is bookkeeping around that one `HubConnection`: SignalR; it is exactly the "use `HubConnectionBuilder` directly" approach, wrapped only enough to be unit-testable via `ISignalRClientConnection`. +## `SignalRSocketConnection.cs` + +This file sits directly above `SignalRClientConnection.cs` and implements +`ISocketConnection`/`ISocketConnectionFactory` from `SocketModeConnection.cs` +for the **real, SignalR-backed** case. Where `SignalRClientConnection` only +knows how to talk to one already-negotiated `HubConnection`, +`SignalRSocketConnection` is responsible for everything needed to turn a +negotiate URI into one fully ready, then eventually stopped, connection +*generation*. + +### 1. `SignalRSocketConnectionFactory` + +The production `ISocketConnectionFactory`. It is constructed once (by +`GeoSocket`'s owner) with an `ISocketModeNegotiator`, the readiness/keep-alive/ +server timeouts, a logger, and an optional `CreateSignalRClientConnection` +override for tests. `Create(negotiateUri, handlers)` just news up a +`SignalRSocketConnection` with those captured settings -- one instance per +generation, never reused across generations. + +### 2. `SignalRSocketConnection` -- fields and construction + +Holds the negotiate URI, the `SocketConnectionHandlers` bundle it was handed, +the negotiator, the client-connection factory delegate, the three timeouts, +and a logger. Three pieces of internal state matter most: + +- `_lifetimeSource` -- a `CancellationTokenSource` scoped to this one + generation; cancelling it tears down everything this generation started. +- `_readySource` -- a `TaskCompletionSource` that the "is this generation + ready yet" gate (`TokenLifetime`/`StartAsync`) waits on. +- `_started` / `_stopped` / `_disposed` / `_readySettled` / `_closedReported` + -- `Interlocked`-guarded flags so every transition (start-once, stop-once, + ready-once, closed-reported-once) is race-safe even if multiple callers + race to stop/dispose/close concurrently. + +### 3. `StartAsync` -- negotiate, connect, wait for ready + +This is the heart of the class, run once per generation: + +1. Guards against double-start with `Interlocked.Exchange(ref _started, 1)`. +2. Links the caller's `cancellationToken` with `_lifetimeSource.Token` so + either an external cancellation or an internal stop aborts startup. +3. Calls `_negotiator.NegotiateAsync(_negotiateUri, ...)` to get a + `SocketModeNegotiateResponse` (SignalR URL + access token + `ExpiresIn`). + `TokenLifetime` is set from `ExpiresIn` here -- this is what `GeoSocket` + later reads to schedule proactive token-rotation refreshes. +4. Builds the actual client via `_createSignalRClientConnection(...)` -- + i.e. calls into `SignalRClientConnection.Create` (or a test double) with + the negotiated URL/token and the configured keep-alive/server timeouts. +5. Publishes the connection into `_connection` under a lock + (`TryPublishConnection`) -- but only if this generation hasn't already + been stopped/disposed out from under it; otherwise it disposes the + just-built connection immediately rather than leaking it. +6. Wires the three handlers onto the new connection: + - `OnActivity` -- **waits on `_readySource.Task` first**, then forwards to + `_handlers.OnActivity`. This means an activity frame that somehow + arrives before `SocketReady` is held until readiness, not dropped or + dispatched early. + - `OnReady` -- wired to the private `HandleReady`. + - `OnClosed` -- wired to the private `HandleClosed`. +7. Calls `connection.StartAsync(...)`, then awaits `_readySource.Task` with + `WaitAsync(_readinessTimeout, ...)`. A timeout here is rethrown as a + `TimeoutException` naming `_readinessTimeout` explicitly -- this is what + ultimately surfaces as a `GeoSocket`/`SocketModeTransport` startup failure + if a generation never reports ready in time. +8. Any exception anywhere in this sequence routes through a single `catch` + that calls `StopAfterFailedStartAsync()` (best-effort stop + dispose, + with failures only logged, never masking the original exception) before + rethrowing. + +### 4. `HandleReady` and `HandleClosed` + +- `HandleReady` first checks `_lifetimeSource.IsCancellationRequested` and + uses `Interlocked.CompareExchange(ref _readySettled, ...)` so it only ever + fires once. It resolves `_readySource` **before** invoking + `_handlers.OnReady(frame)`, and wraps that callback in a try/catch that + only logs -- a throwing observer must never leave the readiness gate + unsettled. +- `HandleClosed` checks whether the closure was planned (`_stopped != 0`). + If it was *not* planned and readiness was never settled, it fails + `_readySource` with the closure error (or a synthesized `IOException`) so + a caller awaiting startup sees the real failure instead of hanging. It + then reports to `_handlers.OnClosed(error, planned)` exactly once, guarded + by `_closedReported`. + +### 5. `StopAsync` / `DisposeAsync` + +`StopAsync` is idempotent and memoized (`_stopTask ??= StopCoreAsync()` +under `_stopLock`), so concurrent callers all await the same underlying +stop. `StopCoreAsync` cancels `_lifetimeSource` first, then stops the +published `ISignalRClientConnection` if one exists. `DisposeAsync` calls +`StopAsync` first, then disposes the underlying connection, then disposes +`_lifetimeSource` -- all in `finally` blocks so a failure at one stage still +lets later cleanup run. + +**Takeaway:** `SignalRSocketConnection` is the layer that turns "negotiate + +build a client connection" into a single well-behaved, race-safe, +once-only-everything `ISocketConnection` generation -- readiness gating, +token-lifetime reporting, and planned-vs-unplanned closure classification +all live here. This is also the class flagged earlier as a candidate for +being folded directly into `GeoSocket` (removing one layer), since today +`GeoSocket` is the only caller of `ISocketConnectionFactory`. + ## `SocketModeConnection.cs` This file has **no SignalR code at all**. It defines the seam between @@ -92,8 +196,8 @@ generation: `Create(negotiateUri, handlers) -> ISocketConnection`. `GeoSocket` calls this once per generation (see `ConnectAsync` in the [GeoSocket walkthrough](./GeoSocket-Walkthrough.md#3-connectasync----one-connection-attempt)). -The production implementation is `SignalRSocketConnectionFactory`, which -negotiates via `SocketModeNegotiator` and then builds a +The production implementation is `SignalRSocketConnectionFactory` (see +above), which negotiates via `SocketModeNegotiator` and then builds a `SignalRClientConnection` under the hood. ### 3. `SocketConnectionHandlers` @@ -110,7 +214,7 @@ connection: **Takeaway:** `SocketModeConnection.cs` is the interface layer that lets `GeoSocket` be unit-tested against a fake `ISocketConnection` without a real network socket, and lets the SignalR-specific implementation -(`SignalRSocketConnection`, the next layer up) be swapped or mocked +(`SignalRSocketConnection`, covered above) be swapped or mocked independently. ## How the pieces fit together @@ -131,4 +235,7 @@ Microsoft.AspNetCore.SignalR.Client.HubConnection (the actual built-in SignalR `SocketModeConnection.cs`'s interfaces (`ISocketConnection`, `ISocketConnectionFactory`, `SocketConnectionHandlers`) sit at the top of that stack as the contract `GeoSocket` depends on, so it never has to know -SignalR exists at all. +SignalR exists at all. All three concrete classes described in this doc -- +`SignalRClientConnection`, `SignalRSocketConnectionFactory`/ +`SignalRSocketConnection`, and the interfaces in `SocketModeConnection.cs` +-- are already on `main`. From ab65960b7616e56a2c52761be75a6be2897ff83f Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Wed, 30 Sep 2026 15:03:41 -0700 Subject: [PATCH 07/10] docs: consolidate Socket Mode walkthroughs into one sequence-diagram doc --- docs/SocketMode/GeoSocket-Walkthrough.md | 120 --------- ...on-and-SocketModeConnection-Walkthrough.md | 241 ------------------ docs/SocketMode/SocketMode-Design.md | 8 +- .../SocketMode/SocketMode-Flow-Walkthrough.md | 192 ++++++++++++++ .../SocketModeTransport-Walkthrough.md | 100 -------- 5 files changed, 196 insertions(+), 465 deletions(-) delete mode 100644 docs/SocketMode/GeoSocket-Walkthrough.md delete mode 100644 docs/SocketMode/SignalRClientConnection-and-SocketModeConnection-Walkthrough.md create mode 100644 docs/SocketMode/SocketMode-Flow-Walkthrough.md delete mode 100644 docs/SocketMode/SocketModeTransport-Walkthrough.md diff --git a/docs/SocketMode/GeoSocket-Walkthrough.md b/docs/SocketMode/GeoSocket-Walkthrough.md deleted file mode 100644 index c78b4191..00000000 --- a/docs/SocketMode/GeoSocket-Walkthrough.md +++ /dev/null @@ -1,120 +0,0 @@ -# GeoSocket Walkthrough - -> This is a supplementary, implementation-level walkthrough of `GeoSocket` -> for engineers working on the Socket Mode PR stack. See -> [SocketMode-Design.md](./SocketMode-Design.md) for the user-facing design -> and architecture overview, and -> [SocketModeTransport-Walkthrough.md](./SocketModeTransport-Walkthrough.md) -> for the coordinator that owns one `GeoSocket` per geo. The class described -> here lives on the `teddyam-socket-mode-app-integration` branch -> ([#686](https://github.com/microsoft/teams.net/pull/686)) and is not yet on -> `main`. - -`GeoSocket` manages **one geo's** connection lifecycle: establishing the -initial connection, supervising it in the background, reconnecting after -unexpected closures, and rotating tokens make-before-break. Here is how it -works, step by step. - -## 1. Construction - -`GeoSocket(owner, geo, negotiateUri, connectionFactory, logger, timeProvider)` -just stores its dependencies. `_owner` is the `IGeoSocketOwner` (in practice, -`SocketModeTransport`) that supplies timeouts, backoff policy, and the -dispatch callback. No connection is opened yet. - -## 2. `StartAsync()` -- initial connection - -- Guards against double-start via `Interlocked.Exchange(ref _started, 1)`. -- Calls `ConnectInitialAsync()`, which retries `ConnectAsync()` in a loop - until either it succeeds, the `StartupTimeout` budget runs out (throws - `TimeoutException`), or a non-retryable negotiate failure occurs (HTTP - 401/403 -- thrown immediately, no retry). -- Once a `Generation` is ready, spawns the background `SuperviseAsync(initial)` - loop and returns. This is what `SocketModeTransport.StartAsync` awaits, - per geo. - -## 3. `ConnectAsync()` -- one connection attempt - -- Increments `_generation` and creates a new `Generation` record (an id plus - a `TaskCompletionSource` called `Closed`). -- Creates the actual `ISocketConnection` via `_connectionFactory.Create(...)`, - wiring three callbacks: dispatch, ready, and closed. -- Adds the connection to `_owned` -- the set of every connection this geo - currently owns, including ones being retired. -- Calls `connection.StartAsync()`. The underlying SignalR connection's - `StartAsync` completes once `SocketReady` arrives, but `TryPromote` (next - step) confirms the connection actually became the active generation before - `ConnectAsync` reports success. -- On success, calls `ScheduleRefresh(generation)` to arm the token-rotation - timer. - -## 4. `TryPromote()` -- generation handoff - -This is the core of make-before-break rotation: when a new generation is -ready, `TryPromote` atomically marks the *previous* active generation as -retiring (added to the `_retiring` set) and swaps `_active` to the new one. -At any instant there is at most one active generation, plus zero or more -retiring generations still allowed to dispatch. - -## 5. `DispatchAsync(generation, envelope)` - -Before forwarding to `_owner.DispatchAsync`, this checks that the generation -is still `_active` **or** present in `_retiring`. A fully-replaced, -already-closed generation's stray in-flight messages are dropped (returns -`null`), so a zombie connection cannot process activities after handoff. - -## 6. `ScheduleRefresh()` + `RequestRotation()` -- proactive token rotation - -If the connection reports a `TokenLifetime`, a timer is armed for -`lifetime - TokenRefreshMargin`. When it fires, `RequestRotation` does not -tear down the connection itself -- it simply completes the generation's -`Closed` task-completion-source with `Planned: true`, signaling the -supervisor loop to react. - -## 7. `SuperviseAsync()` -- the main loop - -Waits on `current.Closed.Task`. Two paths: - -- **Planned** (rotation): calls `ReconnectAsync(null, delayFirstAttempt: false)` - to connect a *new* generation immediately (no delay). Only if that succeeds - does it call `StartRetirement(current)` to retire the old one -- new - connection first, old one after, i.e. make-before-break. -- **Unplanned** (real disconnect): releases the dead connection immediately, - then calls `ReconnectAsync(reason.Error, delayFirstAttempt: true)`, which - retries with backoff. - -If `ReconnectAsync` gives up after a non-retryable error, the loop exits -- -`StopAfterNonRetryable` has already stopped the geo permanently and reported -it via `OnGeoDisconnected`. - -## 8. `StartRetirement()` / `RetireAsync()` -- the handoff window - -Runs as a fire-and-forget task (tracked in `_retirements` so `StopAsync` can -wait for it): sleeps for `HandoffWindow`, then removes the generation from -`_retiring` and calls `ReleaseAsync` to stop and dispose it. During that -sleep, the old connection can still dispatch (per step 5's check), giving -in-flight activities time to finish before it is torn down. - -## 9. `HandleClosed()` -- bookkeeping on any close - -Marks the generation closed and removes it from `_retiring`. Only if it was -the `_active` generation, the close was unplanned, and the geo is not -stopping does it flag `_disconnected` and call `_owner.OnGeoDisconnected`. -It always completes the generation's `Closed` task-completion-source so -`SuperviseAsync` wakes up. - -## 10. `StopAsync()` / `DisposeAsync()` - -Idempotent via `_stopTask`. Cancels `_stopSource`, clears `_active` and -`_retiring`, stops and disposes every connection in `_owned` in parallel, -then waits for any in-flight retirement tasks and the supervisor loop to -finish. - -## Summary - -`GeoSocket` treats each connection attempt as a numbered "generation," uses -an `_active` / `_retiring` split to let two generations briefly coexist -during rotation, and drives all state transitions through each generation's -`Closed` signal rather than direct cancellation. That design is what lets -make-before-break handoff and reconnect-on-failure converge into a single -supervisor loop. diff --git a/docs/SocketMode/SignalRClientConnection-and-SocketModeConnection-Walkthrough.md b/docs/SocketMode/SignalRClientConnection-and-SocketModeConnection-Walkthrough.md deleted file mode 100644 index 7a7078d7..00000000 --- a/docs/SocketMode/SignalRClientConnection-and-SocketModeConnection-Walkthrough.md +++ /dev/null @@ -1,241 +0,0 @@ -# SignalRClientConnection, SignalRSocketConnection & SocketModeConnection Walkthrough - -> This walkthrough covers the three lowest-level building blocks of the -> Socket Mode transport: `SignalRClientConnection.cs`, -> `SignalRSocketConnection.cs`, and `SocketModeConnection.cs`. Unlike -> `GeoSocket` and `SocketModeTransport` (see -> [GeoSocket-Walkthrough.md](./GeoSocket-Walkthrough.md) and -> [SocketModeTransport-Walkthrough.md](./SocketModeTransport-Walkthrough.md)), -> **these three files are already on `main`** today, under -> `src/Microsoft.Teams.Apps/SocketMode/`. See -> [SocketMode-Design.md](./SocketMode-Design.md) for the overall architecture. - -These three files exist to answer one question cleanly: *"how do we open a -SignalR connection, manage one connection generation's lifecycle, and let -higher layers depend on that without coupling to the real SignalR client?"* -`SignalRClientConnection.cs` answers the first part, `SignalRSocketConnection.cs` -answers the second, and `SocketModeConnection.cs` answers the third. - -## `SignalRClientConnection.cs` - -### 1. `CreateSignalRClientConnection` delegate - -A factory delegate `(Uri url, string accessToken, TimeSpan keepAliveInterval, -TimeSpan serverTimeout) => ISignalRClientConnection`. Its only purpose is to -let callers swap in a fake for tests instead of a real SignalR client. - -### 2. `ISignalRClientConnection` interface - -The minimal surface Socket Mode needs from a SignalR hub connection: - -- `OnActivity(handler)` -- register the handler for inbound activity - envelopes (returns an optional reply frame). -- `OnReady(handler)` -- register the handler for the `SocketReady` frame. -- `OnClosed(handler)` -- register the handler for terminal closure. -- `StartAsync(cancellationToken)` / `StopAsync(cancellationToken)`. - -Nothing here is Socket-Mode-specific business logic -- it is purely an -adapter surface over a `HubConnection`. - -### 3. `SignalRClientConnection` -- the adapter itself - -This is the **only** place in the whole Socket Mode transport that touches -`Microsoft.AspNetCore.SignalR.Client.HubConnectionBuilder`: - -```csharp -HubConnection connection = new HubConnectionBuilder() - .WithUrl(url, options => - { - options.AccessTokenProvider = () => Task.FromResult(accessToken); - }) - .WithKeepAliveInterval(keepAliveInterval) - .WithServerTimeout(serverTimeout) - .Build(); -``` - -The rest of the class is bookkeeping around that one `HubConnection`: - -- `OnActivity` registers a SignalR hub method handler for `"Activity"` (via - `_connection.On(...)`) and keeps - the returned `IDisposable` subscription so it can be cleaned up later. -- `OnReady` does the same for the `"SocketReady"` hub method. -- `OnClosed` wraps the caller's `Action` in a `Func` and attaches it to `HubConnection.Closed`, keeping a reference so it - can be detached on dispose (SignalR's `Closed` event would otherwise keep - the handler alive past the connection's useful lifetime). -- `StartAsync`/`StopAsync` forward directly to the underlying - `HubConnection`. -- `DisposeAsync` detaches every `Closed` handler, disposes every `On<...>` - subscription, then disposes the `HubConnection` itself -- in that order, so - no stale handler can fire mid-teardown. - -**Takeaway:** this class is intentionally thin. It is not reinventing -SignalR; it is exactly the "use `HubConnectionBuilder` directly" approach, -wrapped only enough to be unit-testable via `ISignalRClientConnection`. - -## `SignalRSocketConnection.cs` - -This file sits directly above `SignalRClientConnection.cs` and implements -`ISocketConnection`/`ISocketConnectionFactory` from `SocketModeConnection.cs` -for the **real, SignalR-backed** case. Where `SignalRClientConnection` only -knows how to talk to one already-negotiated `HubConnection`, -`SignalRSocketConnection` is responsible for everything needed to turn a -negotiate URI into one fully ready, then eventually stopped, connection -*generation*. - -### 1. `SignalRSocketConnectionFactory` - -The production `ISocketConnectionFactory`. It is constructed once (by -`GeoSocket`'s owner) with an `ISocketModeNegotiator`, the readiness/keep-alive/ -server timeouts, a logger, and an optional `CreateSignalRClientConnection` -override for tests. `Create(negotiateUri, handlers)` just news up a -`SignalRSocketConnection` with those captured settings -- one instance per -generation, never reused across generations. - -### 2. `SignalRSocketConnection` -- fields and construction - -Holds the negotiate URI, the `SocketConnectionHandlers` bundle it was handed, -the negotiator, the client-connection factory delegate, the three timeouts, -and a logger. Three pieces of internal state matter most: - -- `_lifetimeSource` -- a `CancellationTokenSource` scoped to this one - generation; cancelling it tears down everything this generation started. -- `_readySource` -- a `TaskCompletionSource` that the "is this generation - ready yet" gate (`TokenLifetime`/`StartAsync`) waits on. -- `_started` / `_stopped` / `_disposed` / `_readySettled` / `_closedReported` - -- `Interlocked`-guarded flags so every transition (start-once, stop-once, - ready-once, closed-reported-once) is race-safe even if multiple callers - race to stop/dispose/close concurrently. - -### 3. `StartAsync` -- negotiate, connect, wait for ready - -This is the heart of the class, run once per generation: - -1. Guards against double-start with `Interlocked.Exchange(ref _started, 1)`. -2. Links the caller's `cancellationToken` with `_lifetimeSource.Token` so - either an external cancellation or an internal stop aborts startup. -3. Calls `_negotiator.NegotiateAsync(_negotiateUri, ...)` to get a - `SocketModeNegotiateResponse` (SignalR URL + access token + `ExpiresIn`). - `TokenLifetime` is set from `ExpiresIn` here -- this is what `GeoSocket` - later reads to schedule proactive token-rotation refreshes. -4. Builds the actual client via `_createSignalRClientConnection(...)` -- - i.e. calls into `SignalRClientConnection.Create` (or a test double) with - the negotiated URL/token and the configured keep-alive/server timeouts. -5. Publishes the connection into `_connection` under a lock - (`TryPublishConnection`) -- but only if this generation hasn't already - been stopped/disposed out from under it; otherwise it disposes the - just-built connection immediately rather than leaking it. -6. Wires the three handlers onto the new connection: - - `OnActivity` -- **waits on `_readySource.Task` first**, then forwards to - `_handlers.OnActivity`. This means an activity frame that somehow - arrives before `SocketReady` is held until readiness, not dropped or - dispatched early. - - `OnReady` -- wired to the private `HandleReady`. - - `OnClosed` -- wired to the private `HandleClosed`. -7. Calls `connection.StartAsync(...)`, then awaits `_readySource.Task` with - `WaitAsync(_readinessTimeout, ...)`. A timeout here is rethrown as a - `TimeoutException` naming `_readinessTimeout` explicitly -- this is what - ultimately surfaces as a `GeoSocket`/`SocketModeTransport` startup failure - if a generation never reports ready in time. -8. Any exception anywhere in this sequence routes through a single `catch` - that calls `StopAfterFailedStartAsync()` (best-effort stop + dispose, - with failures only logged, never masking the original exception) before - rethrowing. - -### 4. `HandleReady` and `HandleClosed` - -- `HandleReady` first checks `_lifetimeSource.IsCancellationRequested` and - uses `Interlocked.CompareExchange(ref _readySettled, ...)` so it only ever - fires once. It resolves `_readySource` **before** invoking - `_handlers.OnReady(frame)`, and wraps that callback in a try/catch that - only logs -- a throwing observer must never leave the readiness gate - unsettled. -- `HandleClosed` checks whether the closure was planned (`_stopped != 0`). - If it was *not* planned and readiness was never settled, it fails - `_readySource` with the closure error (or a synthesized `IOException`) so - a caller awaiting startup sees the real failure instead of hanging. It - then reports to `_handlers.OnClosed(error, planned)` exactly once, guarded - by `_closedReported`. - -### 5. `StopAsync` / `DisposeAsync` - -`StopAsync` is idempotent and memoized (`_stopTask ??= StopCoreAsync()` -under `_stopLock`), so concurrent callers all await the same underlying -stop. `StopCoreAsync` cancels `_lifetimeSource` first, then stops the -published `ISignalRClientConnection` if one exists. `DisposeAsync` calls -`StopAsync` first, then disposes the underlying connection, then disposes -`_lifetimeSource` -- all in `finally` blocks so a failure at one stage still -lets later cleanup run. - -**Takeaway:** `SignalRSocketConnection` is the layer that turns "negotiate + -build a client connection" into a single well-behaved, race-safe, -once-only-everything `ISocketConnection` generation -- readiness gating, -token-lifetime reporting, and planned-vs-unplanned closure classification -all live here. This is also the class flagged earlier as a candidate for -being folded directly into `GeoSocket` (removing one layer), since today -`GeoSocket` is the only caller of `ISocketConnectionFactory`. - -## `SocketModeConnection.cs` - -This file has **no SignalR code at all**. It defines the seam between -`GeoSocket` (which supervises connections across generations) and whatever -implementation actually opens a socket: - -### 1. `ISocketConnection` - -The contract `GeoSocket` programs against for a single connection -generation: - -- `TokenLifetime` -- the remaining lifetime of the connection's token, when - known, used by `GeoSocket.ScheduleRefresh` to arm the proactive-rotation - timer. -- `StartAsync(cancellationToken)` / `StopAsync(cancellationToken)`. - -### 2. `ISocketConnectionFactory` - -`Create(negotiateUri, handlers) -> ISocketConnection`. `GeoSocket` calls this -once per generation (see `ConnectAsync` in the -[GeoSocket walkthrough](./GeoSocket-Walkthrough.md#3-connectasync----one-connection-attempt)). -The production implementation is `SignalRSocketConnectionFactory` (see -above), which negotiates via `SocketModeNegotiator` and then builds a -`SignalRClientConnection` under the hood. - -### 3. `SocketConnectionHandlers` - -A plain data holder bundling the three callbacks `GeoSocket` passes to a new -connection: - -- `OnActivity` -- dispatches an inbound envelope, returns an optional reply - frame. -- `OnReady` -- reports the `SocketReady` frame. -- `OnClosed` -- reports closure, with a `bool planned` flag so `GeoSocket` - can distinguish proactive token rotation from an unexpected drop. - -**Takeaway:** `SocketModeConnection.cs` is the interface layer that lets -`GeoSocket` be unit-tested against a fake `ISocketConnection` without a real -network socket, and lets the SignalR-specific implementation -(`SignalRSocketConnection`, covered above) be swapped or mocked -independently. - -## How the pieces fit together - -``` -GeoSocket - │ calls ISocketConnectionFactory.Create(...) - ▼ -SignalRSocketConnectionFactory / SignalRSocketConnection (one generation's lifecycle: negotiate, readiness gate, TokenLifetime) - │ builds - ▼ -SignalRClientConnection (thin adapter: HubConnectionBuilder, subscription/handler bookkeeping) - │ wraps - ▼ -Microsoft.AspNetCore.SignalR.Client.HubConnection (the actual built-in SignalR client) -``` - -`SocketModeConnection.cs`'s interfaces (`ISocketConnection`, -`ISocketConnectionFactory`, `SocketConnectionHandlers`) sit at the top of -that stack as the contract `GeoSocket` depends on, so it never has to know -SignalR exists at all. All three concrete classes described in this doc -- -`SignalRClientConnection`, `SignalRSocketConnectionFactory`/ -`SignalRSocketConnection`, and the interfaces in `SocketModeConnection.cs` --- are already on `main`. diff --git a/docs/SocketMode/SocketMode-Design.md b/docs/SocketMode/SocketMode-Design.md index 91c54056..e4b62f93 100644 --- a/docs/SocketMode/SocketMode-Design.md +++ b/docs/SocketMode/SocketMode-Design.md @@ -12,10 +12,10 @@ > [#686](https://github.com/microsoft/teams.net/pull/686). This document > describes the complete, intended design regardless of what has merged so far. > -> For implementation-level walkthroughs, see -> [SocketModeTransport-Walkthrough.md](./SocketModeTransport-Walkthrough.md), -> [GeoSocket-Walkthrough.md](./GeoSocket-Walkthrough.md), and -> [SignalRClientConnection-and-SocketModeConnection-Walkthrough.md](./SignalRClientConnection-and-SocketModeConnection-Walkthrough.md). +> For an implementation-level walkthrough of the transport, see +> [SocketMode-Flow-Walkthrough.md](./SocketMode-Flow-Walkthrough.md), which +> covers startup, steady-state dispatch, token rotation, and reconnect flows +> as sequence diagrams. ## Overview diff --git a/docs/SocketMode/SocketMode-Flow-Walkthrough.md b/docs/SocketMode/SocketMode-Flow-Walkthrough.md new file mode 100644 index 00000000..4418cfde --- /dev/null +++ b/docs/SocketMode/SocketMode-Flow-Walkthrough.md @@ -0,0 +1,192 @@ +# 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. +> +> `SignalRClientConnection`, `SignalRSocketConnection`/ +> `SignalRSocketConnectionFactory`, and `SocketModeConnection.cs` +> (`ISocketConnection`, `ISocketConnectionFactory`, `SocketConnectionHandlers`) +> are already on `main`. `GeoSocket` and `SocketModeTransport` are still +> landing via [PR #686](https://github.com/microsoft/teams.net/pull/686). + +## 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/SocketModeTransport-Walkthrough.md b/docs/SocketMode/SocketModeTransport-Walkthrough.md deleted file mode 100644 index 1ee0de2c..00000000 --- a/docs/SocketMode/SocketModeTransport-Walkthrough.md +++ /dev/null @@ -1,100 +0,0 @@ -# SocketModeTransport Walkthrough - -> This is a supplementary, implementation-level walkthrough of `SocketModeTransport` -> for engineers working on the Socket Mode PR stack. See -> [SocketMode-Design.md](./SocketMode-Design.md) for the user-facing design and -> architecture overview. The class described here lives on the -> `teddyam-socket-mode-app-integration` branch -> ([#686](https://github.com/microsoft/teams.net/pull/686)) and is not yet on -> `main`. See also -> [GeoSocket-Walkthrough.md](./GeoSocket-Walkthrough.md) for the per-geo -> connection supervisor that `SocketModeTransport` owns. - -`SocketModeTransport` is the coordinator that owns one `GeoSocket` per -configured geo, supplies them with retry/backoff policy (as their -`IGeoSocketOwner`), and turns raw inbound envelopes into dispatch calls and -reply frames. Here is how it works, step by step. - -## 1. Construction - -`SocketModeTransport(options, connectionFactory, dispatch, logger, ...)` does -validation and setup only -- no network activity yet: - -- Validates `NegotiateBaseUri` is non-null and `StartupTimeout >= 0`. -- Calls `ResolveGeos()` to turn each geo string (`"amer"`, `"emea"`, `"apac"`) - into a full negotiate `Uri` (base URL + `/{geo}/negotiate`), rejecting - duplicate or empty geo entries. -- Stores the `dispatch` delegate (how an activity reaches the bot pipeline) - and the optional `onError` hook. -- `Status` starts as `Idle`. - -## 2. `StartAsync()` -- bringing geos up - -- Guards against double-start (`_lifecycle != Idle` throws). -- Creates one `GeoSocket` per resolved geo, passing `this` as the - `IGeoSocketOwner` -- this is how each `GeoSocket` gets its retry policy, - timeouts, and dispatch callback. -- Marks every geo `Connecting` in `_geoStatuses`. -- Runs `geoSocket.StartAsync()` for **all geos concurrently** via - `Task.WhenAll`. -- **All-or-nothing startup**: if any geo fails, a shared `startSource` is - cancelled so the other geos abandon their attempt, the *first* failure is - captured via `Interlocked.CompareExchange`, `StopAsync()` tears everything - down, and that first failure is rethrown. This is why a Socket Mode startup - failure surfaces directly from `host.Run()`. - -## 3. Steady state -- acting as `IGeoSocketOwner` - -Once started, `SocketModeTransport` mostly answers callbacks from the running -`GeoSocket`s: - -- `GetBackoffDelay(attempt)` -- indexes into the configured `ReconnectDelays` - schedule (clamped to the last entry) or computes capped exponential backoff - with jitter. -- `GetRetryAfter(error)` -- pulls a server-provided retry hint off a - `SocketModeNegotiateException`, if present. -- `OnGeoReady` / `OnGeoDisconnected` / `OnGeoReconnected` -- update - `_geoStatuses[geo]` under a lock. -- `DispatchAsync(geo, envelope)` -- forwards to `HandleEnvelopeAsync`. - -## 4. `HandleEnvelopeAsync()` -- per-activity processing - -For each inbound envelope: - -1. Rejects unsupported protocol versions with a `400` reply. -2. Extracts the `CoreActivity` payload; drops the envelope silently if there - is none. -3. Determines whether it is an `invoke` activity (needs a reply) versus - fire-and-forget. -4. Calls `_dispatch(activity)` -- this is where - `TeamsBotApplication.ProcessWithInvokeResponseAsync` plugs in. -5. On success: builds an invoke reply or a plain acknowledgement frame with - the result status. -6. On exception: logs it (unless already logged as a `BotHandlerException`), - invokes the optional `_onError` hook, and returns a `500` reply/ack instead - of throwing -- a single bad activity cannot kill the geo connection. - -## 5. Aggregate `Status` property - -Computed on read, not stored directly: - -- `Ready` only if every geo is `Ready`. -- `Disconnected` if any geo is down. -- `Connecting` otherwise. - -This is what a health check or the hosted service polls to decide whether the -transport, as a whole, is serving traffic. - -## 6. `StopAsync()` / `DisposeAsync()` - -Idempotent (memoized via `_stopTask`): flips `_lifecycle` to `Stopped`, marks -every geo `Stopped`, and disposes all `GeoSocket` instances in parallel, -logging (rather than throwing) individual cleanup failures. - -## Summary - -`GeoSocket` handles the low-level "keep one socket alive" mechanics for a -single geo. `SocketModeTransport` is the coordinator: it fans `StartAsync` -out to all geos with all-or-nothing semantics, supplies policy via -`IGeoSocketOwner`, and turns raw envelopes into dispatch calls and reply -frames. From 7074f9a7b059504dcc32c93a6fe24f710c090e13 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Wed, 30 Sep 2026 15:04:57 -0700 Subject: [PATCH 08/10] docs: remove status notes from Socket Mode docs --- docs/SocketMode/SocketMode-Design.md | 19 +++---------------- .../SocketMode/SocketMode-Flow-Walkthrough.md | 6 ------ 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/docs/SocketMode/SocketMode-Design.md b/docs/SocketMode/SocketMode-Design.md index e4b62f93..54de3afc 100644 --- a/docs/SocketMode/SocketMode-Design.md +++ b/docs/SocketMode/SocketMode-Design.md @@ -1,21 +1,8 @@ # Socket Mode Design -> **Status note:** This branch contains only the low-level Socket Mode transport -> primitives (`SignalRClientConnection`, `SignalRSocketConnection`, -> `SocketModeConnection`, `SocketModeEnvelope`, `SocketModeJson`, -> `SocketModeNegotiator`, `SocketModeProtocol`, `SocketModeProtocolModels` under -> `src/Microsoft.Teams.Apps/SocketMode/`). The higher-level pieces described -> below (`GeoSocket`, `SocketModeTransport`, `SocketModeOptions`, -> `SocketModeHostedService`, `SocketModeServiceRegistration`, and the -> `UseSocketMode` / `host.UseTeamsBotApplication()` integration) are landing via -> a follow-up PR stack based on -> [#686](https://github.com/microsoft/teams.net/pull/686). This document -> describes the complete, intended design regardless of what has merged so far. -> -> For an implementation-level walkthrough of the transport, see -> [SocketMode-Flow-Walkthrough.md](./SocketMode-Flow-Walkthrough.md), which -> covers startup, steady-state dispatch, token rotation, and reconnect flows -> as sequence diagrams. +> 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. ## Overview diff --git a/docs/SocketMode/SocketMode-Flow-Walkthrough.md b/docs/SocketMode/SocketMode-Flow-Walkthrough.md index 4418cfde..921907d3 100644 --- a/docs/SocketMode/SocketMode-Flow-Walkthrough.md +++ b/docs/SocketMode/SocketMode-Flow-Walkthrough.md @@ -7,12 +7,6 @@ > **unexpected disconnect / reconnect**. See > [SocketMode-Design.md](./SocketMode-Design.md) for the user-facing design > and options. -> -> `SignalRClientConnection`, `SignalRSocketConnection`/ -> `SignalRSocketConnectionFactory`, and `SocketModeConnection.cs` -> (`ISocketConnection`, `ISocketConnectionFactory`, `SocketConnectionHandlers`) -> are already on `main`. `GeoSocket` and `SocketModeTransport` are still -> landing via [PR #686](https://github.com/microsoft/teams.net/pull/686). ## Component map From 35ad949500c4c5c16c61c714ec8bcc7af38fdf31 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Wed, 30 Sep 2026 15:27:03 -0700 Subject: [PATCH 09/10] docs: add Socket Mode hosting/dispatch integration doc --- docs/SocketMode/SocketMode-Design.md | 5 +- .../SocketMode-Hosting-Integration.md | 240 ++++++++++++++++++ 2 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 docs/SocketMode/SocketMode-Hosting-Integration.md diff --git a/docs/SocketMode/SocketMode-Design.md b/docs/SocketMode/SocketMode-Design.md index 54de3afc..cdacefc7 100644 --- a/docs/SocketMode/SocketMode-Design.md +++ b/docs/SocketMode/SocketMode-Design.md @@ -2,7 +2,10 @@ > 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. +> 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 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 | From c5ee6040b922a9c8976c4a80ea0a8cf3f05682fe Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Wed, 30 Sep 2026 16:27:17 -0700 Subject: [PATCH 10/10] docs: note why Socket Mode uses AsyncLocal for invoke response capture Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/SocketMode/SocketMode-Design.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/SocketMode/SocketMode-Design.md b/docs/SocketMode/SocketMode-Design.md index cdacefc7..8e9c755b 100644 --- a/docs/SocketMode/SocketMode-Design.md +++ b/docs/SocketMode/SocketMode-Design.md @@ -127,6 +127,17 @@ 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 |