Skip to content

Use source-generated local proxy in ServiceJsonRpcDescriptor.Construc…#514

Draft
olegtk wants to merge 2 commits into
mainfrom
olegtk/local-proxy-sourcegen
Draft

Use source-generated local proxy in ServiceJsonRpcDescriptor.Construc…#514
olegtk wants to merge 2 commits into
mainfrom
olegtk/local-proxy-sourcegen

Conversation

@olegtk

@olegtk olegtk commented Jun 15, 2026

Copy link
Copy Markdown
Member

Startup.SingleFile perf test indicates 20 methods are being jitted in localRpcProxies_8869a195-770f-62ef-3da5-47c5c92f39b3, which are RPC contract interfaces:
image

They do have source generator setup ([JsonRpcContract]), but still cause jitting because ServiceJsonRpcDescriptor.ConstructLocalProxy ignores it.

ServiceJsonRpcDescriptor.ConstructLocalProxy unconditionally went through LocalProxyGeneration, which emits a dynamic 'localRpcProxies_' assembly via Reflection.Emit and JIT's a fresh proxy method per RPC method. The vs-servicehub source generator has been emitting [LocalProxyMappingAttribute]-annotated ProxyBase subclasses for every [JsonRpcContract] interface for a while, but only ServiceJsonRpcPolyTypeDescriptor consulted them; the legacy descriptor never did, so contracts that opted into [JsonRpcContract] (e.g. RpcContracts. in DevDiv/VS) still paid the JIT cost on hot startup paths.

Make the legacy descriptor try ProxyBase.TryCreateProxy first, falling back to LocalProxyGeneration when no compatible source-generated proxy is registered or when the target lacks one of the additional service interfaces. The fallback path preserves the existing ServiceCompositionException-wrapped failure mode.

The Disposed (and the ConstructLocalProxy() null-return) changes only exist because, once you route the legacy descriptor through ProxyBase, the existing ServiceJsonRpcDescriptor_ProxyTests test class fails on three tests that exercise behavior ProxyBase didn't implement correctly:

INotifyDisposable_HandlerAddedAfterDisposal — handler added after Dispose() must fire immediately
INotifyDisposable_DisposalReleasesRef — handlers must be released for GC after Dispose()
TargetImplementsIJsonRpcLocalProxy — ConstructLocalProxy() returns null when target lacks T

Also fix two pre-existing correctness gaps in ProxyBase that this change exposed via the legacy descriptor's test suite: (1) IJsonRpcLocalProxy.ConstructLocalProxy() now returns null when the wrapped target doesn't implement T (matching the IL-emit proxy contract) instead of throwing InvalidCastException; (2) the INotifyDisposable.Disposed event uses an Interlocked sentinel pattern so that handlers added after Dispose() fire immediately and are released for GC.

…tLocalProxy

ServiceJsonRpcDescriptor.ConstructLocalProxy unconditionally went through LocalProxyGeneration, which emits a dynamic 'localRpcProxies_*' assembly via Reflection.Emit and JIT's a fresh proxy method per RPC method. The vs-servicehub source generator has been emitting [LocalProxyMappingAttribute]-annotated ProxyBase subclasses for every [JsonRpcContract] interface for a while, but only ServiceJsonRpcPolyTypeDescriptor consulted them; the legacy descriptor never did, so contracts that opted into [JsonRpcContract] (e.g. RpcContracts.* in DevDiv/VS) still paid the JIT cost on hot startup paths.

Make the legacy descriptor try ProxyBase.TryCreateProxy first, falling back to LocalProxyGeneration when no compatible source-generated proxy is registered or when the target lacks one of the additional service interfaces. The fallback path preserves the existing ServiceCompositionException-wrapped failure mode.

Also fix two pre-existing correctness gaps in ProxyBase that this change exposed via the legacy descriptor's test suite: (1) IJsonRpcLocalProxy.ConstructLocalProxy<T>() now returns null when the wrapped target doesn't implement T (matching the IL-emit proxy contract) instead of throwing InvalidCastException; (2) the INotifyDisposable.Disposed event uses an Interlocked sentinel pattern so that handlers added after Dispose() fire immediately and are released for GC.
Copilot AI review requested due to automatic review settings June 15, 2026 18:22

Copilot AI 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.

Pull request overview

This pull request updates the legacy ServiceJsonRpcDescriptor local-proxy construction path to prefer existing source-generated ProxyBase proxies (registered via LocalProxyMappingAttribute on [JsonRpcContract] interfaces), falling back to the existing Reflection.Emit-based proxy generation when source-gen isn’t applicable.

Changes:

  • Updated ServiceJsonRpcDescriptor.ConstructLocalProxy<T> to try ProxyBase.TryCreateProxy first (with compatibility checks for any additional service interfaces), then fall back to LocalProxyGeneration.
  • Fixed ProxyBase.IJsonRpcLocalProxy.ConstructLocalProxy<T>() to return null (instead of throwing) when the wrapped target doesn’t implement T.
  • Improved ProxyBase disposal notification semantics by using an interlocked sentinel-backed handler store so handlers added after disposal fire immediately and aren’t retained.
Show a summary per file
File Description
test/Microsoft.ServiceHub.Framework.Tests/ServiceJsonRpcDescriptor_ProxyTests.cs Adds tests validating source-generated proxy selection and dynamic-proxy fallback behavior.
src/Microsoft.ServiceHub.Framework/ServiceJsonRpcDescriptor.cs Prefers source-generated local proxies when available, preserving fallback behavior to dynamic proxy generation.
src/Microsoft.ServiceHub.Framework/Reflection/ProxyBase.cs Aligns ConstructLocalProxy<T>() with documented null-return semantics and strengthens Disposed event handler lifetime/ordering behavior.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0

@olegtk olegtk marked this pull request as draft June 15, 2026 18:54

@AArnott AArnott left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This looks good, but it exposes a problem in that source generatored proxies may implement too many interfaces, leading to casts passing that ought to fail.
We have a similar issue in StreamJsonRpc which we've designed to solve, but that solution isn't present in this repo. I'm using Copilot to add to your PR to address this.

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.

3 participants