Skip to content

Container lifetimes for JSON-RPC services (AUS-981) - #152

Merged
Astn merged 1 commit into
masterfrom
di-lifetimes
Sep 25, 2026
Merged

Astn merged 1 commit into
masterfrom
di-lifetimes

Conversation

@Astn

@Astn Astn commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Implements the five dependency-injection decisions (Linear AUS-981): opt-in scoped and transient services, with the singleton default and its constant-target invoker untouched.

What changes

Core seam (D1, D3). ServiceBinder.BindService(sessionId, serviceType, resolve) binds a type instead of an instance. Discovery reflects over the type; for each instance method the compiled invoker reads the arguments, then calls the resolver once with the RPC context (Handler.RpcContext()) and invokes the method on what it returns. Static methods keep a null receiver. A request the serializer refuses (-32602) never resolves anything. A null or foreign result is an InvalidOperationException naming the service type, answered as -32603 and visible to the error handler. The core still references no container; the resolver is a plain delegate, so a hand-written factory, Autofac or anything else fits. BindInterface stays instance-bound.

AspNetCore (D1, D2, D3, D5). AddJsonRpcService<T>(ServiceLifetime, sessionId) and the matching AddJsonRpcServicesFromAssembly overload. Singletons are resolved at startup as before. Scoped and transient services resolve nothing at startup; each call resolves from the request's provider:

  • HTTP: HttpContext.RequestServices.
  • Raw connections: when a non-singleton service is bound to the handler's session, one scope per document from IServiceScopeFactory, published as IServiceProvidersFeature on the connection and removed and disposed (asynchronously on the async path) once the document is answered, before the flush. Never connection-long. With singletons only, nothing changes on the connection.
  • A batch shares the document's scope; a transient is created per call.

JsonRpcOptions.ServiceProviderSelector (Func<object, IServiceProvider>) locates the provider from a custom context; selectors from per-endpoint MapJsonRpc options are tried before the global one, the built-in HttpContext/raw-feature selection last, and the root provider is never a fallback.

Validation: a lifetime that disagrees with a descriptor already in the collection throws from AddJsonRpcService; a conflicting registration added later fails at startup (the binder compares against the collection the host was built from). A non-singleton JsonRpcService subclass is refused at registration. A ContextFactory without a selector, with a non-singleton service registered, fails at startup for the global options and at MapJsonRpc for per-endpoint options. No startup probe; the docs point at ValidateOnBuild/ValidateScopes.

No parameter injection (D4). Documented: dependencies go in the constructor of a scoped service; a singleton uses IDbContextFactory<T> or captures RequestServices before its first await.

Docs. README (Classes, Kestrel HTTP endpoint), the AspNetCore README ("Services and lifetime" rewritten, ServiceProviderSelector in the options table), the micro-benchmark README and the changelog.

Cost

LifetimeBenchmarks (--job short, on a busy machine so compare within the run):

Row Mean Allocated
AddInt_Instance (singleton, unchanged path) 258 ns 0 B
AddInt_Factory (resolver returning a cached instance) 260 ns 0 B
AddInt_Scoped (container scope per document + scoped resolve) 322 ns 152 B
AddInt_Transient 322 ns 152 B
Scope_Only (the container scope alone) 42 ns 128 B

The resolver indirection itself costs nothing measurable and allocates nothing; the scoped rows pay for the container's scope (128 B) and the service instance. The existing numeric rows are untouched.

Tests

1184 tests pass on net8.0 and net10.0 (28 new). Over HTTP and raw TCP, synchronous and EnableAsyncMethods: a new scope per request/document, the batch sharing one scope with transients per call, disposal before the response is flushed, the feature present only during a document and absent when only singletons are registered, ContextFactory with a global and a per-endpoint selector, a selector finding nothing (-32603, handler sees the exception, static methods still work), and every startup refusal. The core seam alone: the resolver receives the context on all three serializers, runs once per call and never for static methods, unknown methods or binding errors, derived types, async methods resolving on the invoking thread before the first await, and the argument checks.

benchmarks/charts/render.py --check and site/build.py pass.

ServiceBinder.BindService(sessionId, serviceType, resolve) binds a type whose
receiver is resolved per call from the RPC context, after the arguments are
read and before the method runs. The core keeps no container dependency.

AddJsonRpcService<T>(ServiceLifetime, sessionId) resolves scoped and transient
services from HttpContext.RequestServices on HTTP and from a scope the raw
connection handler opens per document, published as IServiceProvidersFeature
and disposed once the document is answered; a batch shares one scope.
JsonRpcOptions.ServiceProviderSelector locates the provider from a custom
context. Lifetime conflicts with the container, a non-singleton JsonRpcService
subclass and a ContextFactory without a selector are refused at registration
or startup. LifetimeBenchmarks measures the opt-in cost.
@Astn
Astn merged commit 94b26c2 into master Sep 25, 2026
3 checks passed
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.

1 participant