Skip to content

fix(di): open one DI scope per session so Scoped services match the documented lifetime#74

Open
carldebilly wants to merge 2 commits into
mainfrom
dev/cdb/issue-70-session-scopes
Open

fix(di): open one DI scope per session so Scoped services match the documented lifetime#74
carldebilly wants to merge 2 commits into
mainfrom
dev/cdb/issue-70-session-scopes

Conversation

@carldebilly

Copy link
Copy Markdown
Member

Summary

Fixes #70. Repl never created an IServiceScope: Scoped registrations resolved from the shared root provider, so the same instance was reused across commands and across distinct hosted sessions — sharing per-user state (auth context, carts) between concurrent WebSocket/MCP clients, exactly what the documentation told users Scoped would prevent.

Fix

  • One Run* call = one session = one AsyncServiceScope (CLI one-shot, local REPL, hosted WebSocket/Telnet connection). Scoped services resolve per session; scoped disposables are released when the session ends. Hosted services keep starting from the unscoped provider (app-level lifetime). Providers without scope support run unscoped, as before.
  • ISessionScopedServiceProvider (internal marker): a session owner spanning several one-shot Run* calls with one provider (Repl.Testing's session handle runs each command as a separate one-shot run) owns its scope itself; the entry points skip re-scoping so a logical session does not get fresh Scoped instances per command.
  • Repl.Testing sessions now see the app's services: ReplSessionHandle composed an isolated near-empty local provider, so services registered through ReplApp.Create(services => ...) were invisible to session commands (silent NullReferenceException). Sessions now compose over a scope of the app's provider (+ per-session InMemorySessionState), and the scope is disposed with the handle.
  • docs/comparison.md: the per-session DI claim now states the DI-scope reality.

Not in scope: the MCP handler's session overlay (single McpServiceProviderOverlay shared across sessions) — follow-up after PR #71 merges, as noted on the issue.

The website docs (repl-website dependency-injection.mdx) describe the now-correct behavior; they need no change beyond removing any caveats, tracked separately since it is another repository.

Test Plan

  • RED observed: 4 failing regressions in Given_SessionScopedServices — two hosted sessions resolve distinct Scoped instances; two commands of one session share theirs; Transient stays per-resolution; a Scoped IDisposable is disposed when its session ends (also RED as a NullReferenceException before Repl.Testing sessions could see app services at all).
  • Full suite: 1316 passed, 1 known external-toolchain skip, 0 failed.

Fixes #70

…ocumented lifetime

Fixes #70.

Repl never created an IServiceScope: Scoped registrations resolved from the
shared root provider, so the same instance crossed commands AND distinct
hosted sessions — sharing per-user state (auth context, carts) across
concurrent clients, exactly what the documentation told users Scoped would
prevent.

- ReplApp Run* entry points now run each session inside an AsyncServiceScope
  (one Run* call = one session: CLI one-shot, local REPL, hosted WebSocket/
  Telnet connection). Scoped disposables are released when the session ends.
  Hosted services keep starting from the unscoped provider (app-level
  lifetime). Providers without scope support run unscoped, as before.
- ISessionScopedServiceProvider (internal marker): a session owner spanning
  several one-shot Run* calls with one provider carries its own scope and
  the entry points skip re-scoping (no fresh Scoped instances per command).
- Repl.Testing sessions now compose their provider over a scope of the
  APP's services instead of an isolated near-empty local provider: user
  registrations become visible to session commands (previously a silent
  NullReferenceException), Scoped instances are shared across a session's
  commands and released on handle dispose.
- docs/comparison.md: per-session DI claim now states the DI-scope reality.

RED observed: 4 failing regressions in Given_SessionScopedServices (distinct
scoped instances across sessions, shared within a session, transient per
resolution, scoped disposal at session end).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b99e0991b4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return await run(services, cancellationToken).ConfigureAwait(false);
}

var scope = scopeFactory.CreateAsyncScope();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve caller-owned DI scopes

When a caller passes an already-created IServiceScope.ServiceProvider to the externally managed RunAsync overloads (for example, HttpContext.RequestServices), that provider still exposes the root IServiceScopeFactory, so this creates a separate scope rather than using the supplied one. Scoped services resolved by the command will then differ from the caller's request/session-scoped instances, and the caller cannot opt out because ISessionScopedServiceProvider is internal. Keep a supplied scope intact or expose an explicit opt-out mechanism.

Useful? React with 👍 / 👎.

Comment on lines +208 to +212
var root = app.Services;
if (root.GetService(typeof(IServiceScopeFactory)) is IServiceScopeFactory scopeFactory)
{
var scope = scopeFactory.CreateAsyncScope();
return new SessionScopedTestServices(scope, scope.ServiceProvider);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Dispose root providers created for test sessions

When ReplTestHost's appFactory creates a fresh app per OpenSessionAsync call, this materializes a new app root provider for every test session. ReplSessionHandle.DisposeAsync only disposes the child AsyncServiceScope, so disposable singleton services resolved while running the session remain undisposed for the life of the test process; before this change the test harness did not materialize the app provider at all. Track ownership of these per-session app providers and dispose them with the handle, without disposing a shared app provider.

Useful? React with 👍 / 👎.

…(review)

- Replace the internal ISessionScopedServiceProvider marker with a public
  ReplRunOptions.SessionScope option (PerRun default, CallerOwned opt-out):
  a caller whose provider already represents the session scope (Blazor
  circuit, per-request scope, multi-run session owners) is no longer
  double-scoped with its scoped instances hidden — and external session
  owners could not use an internal marker anyway.
- Repl.Testing opts out through the public option instead of the marker.
- New core regression: two Run* calls against ONE shared app resolve
  distinct Scoped instances from the app's own root provider — exercising
  the scope-creation branch itself (RED verified against the pre-fix
  ReplApp: both runs shared one instance). The previous suite only passed
  through Repl.Testing's marked provider and never hit that branch.
- New regression for the CallerOwned contract (same instance across runs).
- ReplSessionHandle takes the command gate before disposing the session
  scope (no race between an orphaned command and scoped disposal), and the
  shared-provider comment states the Scoped nuance instead of promising
  module/handler instance identity for every lifetime.
@carldebilly

Copy link
Copy Markdown
Member Author

Review pass applied (commit dd4ee1b): the internal marker was replaced by a public ReplRunOptions.SessionScope option (PerRun/CallerOwned) so external session owners can opt out of per-run scoping — double-scoping a caller-owned scope would silently hide the caller's scoped instances. Also added the core regression exercising the scope-creation branch on one shared app (RED verified against the pre-fix ReplApp: both runs shared one instance), a CallerOwned contract regression, and gate-protected disposal of the test-session scope. Related to #71's McpSessionContext follow-up per the architecture discussion there.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dd4ee1b257

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (_services is IDisposable disposable)
// Take the command gate before disposing the session scope so an in-flight command
// (e.g. an orphaned timed-out run) cannot race scoped-service disposal.
await _commandGate.WaitAsync().ConfigureAwait(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Release the command gate after disposing

If RunCommandAsync passes ThrowIfDisposed() and is then waiting for the gate when DisposeAsync begins, disposal can acquire the gate first. This method disposes the semaphore without releasing it, so that already-queued command awaits forever (the default command token does not cancel it). Keep the gate acquisition/release in a try/finally, or otherwise wake queued callers so they receive an ObjectDisposedException rather than hanging.

Useful? React with 👍 / 👎.

Comment on lines +226 to +227
public object? GetService(Type serviceType) =>
serviceType == typeof(IReplSessionState) ? _sessionState : _inner.GetService(serviceType);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make injected session state use the test session

When a command resolves an app-registered service whose constructor depends on IReplSessionState, DI constructs that service through _inner, so it receives the app registration rather than this handle's _sessionState; only direct handler lookups are intercepted here. Consequently a ReplTestHost test can have its handler and a dependent service mutate different state objects (and a reused app can share the app-default state between test sessions), making session-state tests of newly visible user services inaccurate.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scoped DI services are shared across hosted sessions despite per-session documentation

1 participant