Skip to content

Commit 50560bf

Browse files
authored
Merge pull request #74 from yllibed/dev/cdb/issue-70-session-scopes
fix(di): open one DI scope per session so Scoped services match the documented lifetime
2 parents bb56e2d + 5b51a25 commit 50560bf

28 files changed

Lines changed: 2273 additions & 201 deletions

‎docs/architecture.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ The toolkit provides two application entry points for different scenarios.
115115
- Wraps `CoreReplApp` with `Microsoft.Extensions.DependencyInjection`
116116
- Provides `UseDefaultInteractive()`, `UseCliProfile()`, and other composition profiles
117117
- Lazily builds a shared `ServiceProvider` for module resolution and handler injection
118+
- Opens one DI scope per session: each `Run*` call is a session, so `Scoped` services resolve per
119+
session and their disposables are released when it ends. `ReplRunOptions.SessionScope` set to
120+
`SessionScopeBehavior.CallerOwned` opts out when the caller's provider already *is* the session scope
121+
(a Blazor circuit, an ASP.NET request scope, a session owner spanning several one-shot runs).
122+
Hosted services start outside that scope, because their lifetime is the application's
118123
- Best for: standalone CLI/REPL applications with service layers
119124

120125
**`InvalidateRouting()`** — call this when module presence conditions may have changed at runtime (e.g., feature flags toggled, dynamic module discovery). Increments the routing cache version so the next execution re-evaluates all module presence predicates.

‎docs/best-practices.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,52 @@ services.AddSingleton<ITenantClient>(sp =>
107107

108108
Note: DI singleton factories are resolved lazily, so the values are available after global option parsing completes. However, singleton factories capture values once — in interactive mode, global options can change between commands. If your service needs to see updated values per command, inject `IGlobalOptionsAccessor` directly and read values at call time instead of capturing them in a factory. See [Commands — Accessing global options](commands.md#accessing-global-options-outside-handlers).
109109

110+
## Pick the lifetime that matches the boundary
111+
112+
| Lifetime | Resolves once per | Use it for |
113+
|---|---|---|
114+
| `Singleton` | application | caches, clients, anything shared by every session |
115+
| `Scoped` | **session** — one `Run*` call: a CLI invocation, an interactive run, a Telnet or WebSocket connection; under MCP, one client connection\* | per-user state: auth context, a cart, a unit of work |
116+
| `Transient` | resolution | cheap stateless helpers |
117+
118+
A `Scoped` disposable is disposed when its session ends, so session resources do not accumulate for the
119+
life of a long-running host.
120+
121+
\* **Except a server built from a reused `BuildMcpServerOptions()` result** — the shape ASP.NET Core and
122+
other custom-transport hosts use. That path has no per-connection context, so it has no per-connection DI
123+
scope either: one `Scoped` instance is shared by every client it serves, disposed only when the process
124+
ends. A `Scoped` auth-context or cart registered there leaks between clients. See the [known
125+
limitation](mcp-conformance.md#deliberate-gaps) and [Isolation boundaries](mcp-transports.md#what-is-isolated-and-at-which-boundary).
126+
127+
Two concurrency notes that follow from the unit being the whole connection rather than one call: several
128+
MCP tool calls can run at once on one connection, so a `Scoped` instance can be entered concurrently —
129+
make it thread-safe, or serialize access to it yourself, the way a `DbContext` (a poor fit for concurrent
130+
entry) would need. And on `mcp serve`, that `Scoped` instance is exactly what carries state from one tool
131+
call to the next within a connection — the earlier calls in this section describing `Scoped` as
132+
resolving "once per session" are what makes that possible.
133+
134+
The trap worth naming: a **singleton** that injects a `Scoped` service captures whichever scope first
135+
resolved it, and keeps it for the life of the application — silently, because the provider is built
136+
without `ValidateScopes`. Inject the scoped service into the handler instead, or register the holder
137+
`Scoped` too.
138+
139+
The same trap catches a **module's own constructor**. `MapModule<TModule>()` resolves `TModule` once,
140+
at mapping time — before any session exists — the same way a singleton would. A module constructor
141+
that takes a `Scoped` auth-context or cart captures it exactly as the singleton case above does, and
142+
every session's handlers then share that one instance. The [module example above](#structure-commands-with-modules)
143+
avoids this because its handlers take `IContactStore` as a **handler parameter**, resolved fresh per
144+
invocation — not as a constructor dependency. Keep any `Scoped` service out of a module's constructor;
145+
inject it into the handler that needs it instead.
146+
147+
When your caller's provider already represents the session — a Blazor circuit, an ASP.NET request scope,
148+
or a session owner running several one-shot calls — set
149+
`ReplRunOptions.SessionScope = SessionScopeBehavior.CallerOwned` so the run resolves from it directly
150+
instead of opening a second scope beside it. `IServiceScopeFactory` is itself a singleton, so that second
151+
scope is not nested inside the caller's — it is a SIBLING rooted at the application root, and it does not
152+
hide the caller's scoped instances so much as duplicate them: a second unit of work running alongside the
153+
live one. `SessionScopeBehavior` governs the `Run*` family only; MCP's connection-scoped DI is unconditional
154+
and has no equivalent opt-out.
155+
110156
## Group related options with `[ReplOptionsGroup]`
111157

112158
When a command has many options, group them into a class instead of listing them all as handler parameters. This keeps handlers clean and makes option sets reusable across commands.

‎docs/comparison.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Repl Toolkit is a command-surface framework — not just a CLI parser. It builds
7373
| WebSocket session hosting | ❌ | ❌ | ✅ `Repl.WebSocket` |
7474
| Telnet session hosting | ❌ | ❌ | ✅ `Repl.Telnet` |
7575
| Terminal metadata negotiation | ❌ | ❌ | ✅ NAWS, TTYPE, DTTERM |
76-
| Per-session DI & state | ❌ | ❌ | ✅ `IReplSessionState` |
76+
| Per-session DI & state | ❌ | ❌ | ✅ DI scope per session + `IReplSessionState` |
7777
| Window size detection | ❌ | ✅ `Console` only | ✅ Local + remote |
7878
| Transport-agnostic host | ❌ | ❌ | ✅ `StreamedReplHost` |
7979

‎docs/glossary.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ Interface for packaging reusable command groups.
5656

5757
### IReplSessionState
5858

59-
Per-session state container for interactive and hosted sessions.
59+
Per-session state container for interactive and hosted sessions. Registered `Scoped`, so each
60+
session gets its own — under MCP, each client connection.
6061

6162
### Literal segment
6263

‎docs/mcp-conformance.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ returned by a creation tool and passed back as an argument, rather than implicit
129129
| --- | --- | --- |
130130
| No per-caller command graph | The one variance `2026-07-28` permits is by the authorization presented on the request. Repl has no request-authorization concept yet, so it advertises one graph to everyone. | [#97](https://github.com/yllibed/repl/issues/97) |
131131
| `*/list_changed` is advertised on the reusable-options path but never fires there | The SDK forces the flag true for any non-null collection, and the pre-built catalog always supplies one. | [#94](https://github.com/yllibed/repl/issues/94) |
132+
| A `Scoped` DI registration is shared by every client on the reusable-options path | That path has one handler-lifetime context serving all connections, so it has no per-connection scope to give `Scoped` — one instance lives for the process, same as the native roots cache and soft roots on the same path. A `Scoped` auth-context or cart registered there leaks between clients. See [best-practices.md](best-practices.md#pick-the-lifetime-that-matches-the-boundary). | — |
132133
| A multi-connection custom transport sees considerations this page does not solve | `mcp serve` is one connection per process; a host that multiplexes connections over one options instance owns the isolation questions that follow. See [Transports](mcp-transports.md). | — |
133134

134135
## Extensions and SEPs

‎docs/mcp-transports.md‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,38 @@ every request rather than once per connection. So the boundaries are not all the
102102
|---|---|
103103
| Request | Client capabilities, the requested log level, the destination for sampling, elicitation and progress, and — on a reused `BuildMcpServerOptions()` result — native roots, resolved once per request and never cached across connections |
104104
| Invocation | I/O capture — each tool call gets its own capture scope, not one per connection |
105-
| Connection (`mcp serve` only) | The MCP session object, the native roots cache, soft roots, and session-aware routing state |
106-
107-
A server created from a reused `BuildMcpServerOptions()` result has everything above except the
108-
connection row; see the known limitation above. That matters especially when using dynamic tools,
109-
roots, or session-specific modules.
105+
| Connection (`mcp serve` only) | The MCP session object, the native roots cache, soft roots, session-aware routing state, `IReplSessionState`, and **the DI scope**: a `Scoped` registration resolves once per connection, and its disposables are released when the connection ends |
106+
107+
The DI scope is the connection's, for the same reason it is the session's on every other transport: a
108+
`Scoped` service is where per-user state lives, and state that resets between two calls of one client
109+
is not per-user state. It is also what keeps discovery and execution honest — both resolve a module
110+
presence predicate's services from the same scope, so a command that was advertised can be called.
111+
112+
The MCP SDK opens a scope of its own per request (`McpServerOptions.ScopeRequests`, default `true`) and
113+
exposes it as `RequestContext.Services`. Repl does not run commands in it: that scope descends from the
114+
application root and is a sibling of the connection's, so adopting it would give the catalog and the
115+
command two different instances of the same service. `BuildDynamicServerOptions` — the `mcp serve` path
116+
that owns a real connection context — sets `ScopeRequests = false`, so the SDK does not open that
117+
sibling scope there to begin with.
118+
119+
A server created from a reused `BuildMcpServerOptions()` result — which is how an ASP.NET Core or other
120+
custom-transport host reaches Repl — has everything above **except the connection row**: see the known
121+
limitation above. There is no per-connection context to give a DI scope to either, so `Scoped` resolves
122+
once from the application root and is shared by every client the process serves, for as long as it runs.
123+
That is true under stateless HTTP hosting too, even though the SDK itself builds one short-lived server
124+
per request there: Repl's own scope is a property of the *context* it built at construction, not of the
125+
SDK's per-request server, so it does not follow the SDK's request boundary.
126+
127+
**Calling `BuildMcpServerOptions()` again for each connection does not isolate `Scoped` either.** The
128+
`ReplApp` overload always passes that app's one `Services` root (`app.Services`, cached for the app's
129+
whole lifetime), so two calls against the same `ReplApp` still share the same root — and therefore the
130+
same `Scoped` instances — no matter how often the method runs. Genuine per-connection isolation on this
131+
path requires the host to supply a **different** `IServiceProvider` per connection, through the
132+
`ICoreReplApp.BuildMcpServerOptions(configure, services)` overload: build a scope yourself (for example
133+
`app.Services.GetRequiredService<IServiceScopeFactory>().CreateAsyncScope()`), pass its
134+
`ServiceProvider` in for that connection, and dispose the scope yourself when the connection ends — Repl
135+
does not manage that scope's lifetime on this path, the same way it does not manage the connection
136+
itself. This is the "multiplexes connections over one options instance" case flagged as a caller
137+
responsibility below, made concrete.
110138

111139
For those higher-level patterns, see [mcp-advanced.md](mcp-advanced.md).

‎src/Repl.Core/Session/InMemoryReplSessionState.cs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
using System.Collections.Concurrent;
2+
13
namespace Repl;
24

35
internal sealed class InMemoryReplSessionState : IReplSessionState
46
{
5-
private readonly Dictionary<string, object?> _values = new(StringComparer.OrdinalIgnoreCase);
7+
// Registered Scoped, so sessions no longer share an instance — but concurrent calls WITHIN one
8+
// session still do: several MCP tool calls run at once on one connection, and a singleton that
9+
// captured a scope hands its instance to every later caller. An unsynchronised Dictionary does
10+
// not merely interleave under that, it corrupts its bucket table.
11+
private readonly ConcurrentDictionary<string, object?> _values = new(StringComparer.OrdinalIgnoreCase);
612

713
public bool TryGet<T>(string key, out T? value)
814
{
@@ -33,7 +39,7 @@ public void Set<T>(string key, T value)
3339
public bool Remove(string key)
3440
{
3541
ArgumentException.ThrowIfNullOrWhiteSpace(key);
36-
return _values.Remove(key);
42+
return _values.TryRemove(key, out _);
3743
}
3844

3945
public void Clear() => _values.Clear();

‎src/Repl.Defaults/DefaultsSessionState.cs‎

Lines changed: 0 additions & 40 deletions
This file was deleted.

0 commit comments

Comments
 (0)