Skip to content
Open
201 changes: 201 additions & 0 deletions docs/SocketMode/SocketMode-Design.md
Original file line number Diff line number Diff line change
@@ -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"]
Comment thread
MehakBindra marked this conversation as resolved.
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i would mention here that any geo failing during startup fails startup for ALL geos (and retries w/ backoff). the third bullet about not affecting other geos is only true after startup

(`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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i see its mentioned here so maybe u could add the part about it failing the startup for all geos and keep it here

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

not sure if it would go here but we do override this URL depending on the testing ring (we currently override for Canary, and next we'll need a different URL for R0)

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.
Comment thread
MehakBindra marked this conversation as resolved.

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);
// });
});
Comment thread
MehakBindra marked this conversation as resolved.

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.
186 changes: 186 additions & 0 deletions docs/SocketMode/SocketMode-Flow-Walkthrough.md
Original file line number Diff line number Diff line change
@@ -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
Comment thread
MehakBindra marked this conversation as resolved.
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 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can update all of these to main

| `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` |
Loading
Loading