Skip to content

Commit 2780de3

Browse files
alexyakuninclaude
andcommitted
Proxy method slots and array-based interceptor dispatch
Implements docs/plans/proxy-method-slots.md, then goes further: generated proxies assign a compile-time integer slot to every intercepted method, publish one static ProxyMethodTable, and cache the resolved handler per proxy instance, per slot - a warm call is a field load + delegate invoke, with no dictionary probe, no virtual SelectHandler dispatch, and no redundant range checks anywhere on the path. ActualLab.Interception: - ProxyMethodTable: one immutable static table per generated proxy type - MethodInfo[] indexed by slot + a cold-path reverse MethodInfo -> slot map with alias fallback (base-class and equivalent interface declarations resolve to the effective slot). - ProxyMethodRef: table-qualified (Table, Index) method reference. - Invocation: always carries (MethodTable, MethodIndex); the constructor performs the single range check, Method resolves via unchecked access. The legacy MethodInfo-based constructor is removed. - InterceptorBinding: one per (interceptor, method table) pair - the shared per-slot handler cache and slow-path resolver (GetAndCacheHandler), which also fills the proxy's per-slot field. A slot the interceptor leaves unhandled resolves to the NoHandler marker, which generated proxies detect by reference to invoke their typed original-call delegate directly (no boxed results). - Interceptor: SelectHandler is called at most once per (interceptor, method table, slot) and its result is cached; per-call logic belongs inside the returned handler. The MethodInfo-keyed handler cache, GetHandler, InterceptorExt, and all dynamic-binding machinery are deleted. Only two dictionaries remain: the bind-time table -> binding map and the cold MethodDef cache. - IProxy is now { ProxyMethodTable MethodTable; InterceptorBinding Binding } - no direct interceptor access; both members are explicit interface implementations. The binding is assigned just once, right after the proxy construction; the setter throws on rebinding and validates the binding's method table. With rebinding gone, handler fields only ever go from null to the single published handler, so all field access is plain and read-optimized. ActualLab.Generators: proxies emit a static __methodTable, one __binding field, one __handlerN field per slot, and two-step dispatch (lookup on miss, then invoke); per-method MethodInfo statics, per-return-type intercept delegate fields, and __interceptor are gone. Fusion: ComputeServiceInterceptor / RemoteComputeServiceInterceptor compose their command/RPC fallback inside the once-per-slot SelectHandler. Breaking changes: proxies generated by older ActualLab.Generators versions must be recompiled; SelectHandler semantics changed from per-call to once-per-slot; hand-written proxies publish a ProxyMethodTable and use the two-step dispatch (see AltClassProxy in tests); interceptors can no longer be reassigned. Benchmarks (Release, .NET 10, same machine; before -> after): - Cached compute calls: Long 36.5 -> 23.1 ns (-36%), String 46.5 -> 29.1 ns (-37%), SessionAndString 44.7 -> 33.8 ns (-26%), recompute -9..-16%. - Raw proxy dispatch (vs Castle.DynamicProxy comparison test, Fusion variants): interceptor path 3.1-4.3x faster (Void() 155M -> 473M ops/s, Int() 102M -> 441M, Task(ct) 66M -> 215M), pass-through 1.9-5.2x faster; per-call allocations unchanged (0-32 B). Fusion proxies are now 3.9-6.9x faster than Castle.DynamicProxy. Docs: PartAP-PG / PartAP-D examples, diagrams, and plan statuses updated; the old fusion-intro-v1 slide deck is intentionally untouched. ## Development log **Alex:** Read docs/plans/proxy-method-slots.md - you'll be working on that problem. **Fable5:** Read the plan and the relevant interception/generator sources; set up a task list covering the method table, interceptor binding, generator changes, built-in interceptor migration, tests, and benchmarks. **Alex:** One important thing: you'll need to work on this in a worktree, since another agent uses the main branch. **Fable5:** Created a worktree and moved all work there. **Alex:** Do all of the work in that worktree. Once you're done, create a branch - call it feat/proxy-method-slots - commit there, and create a PR I can review. **Fable5:** Renamed the worktree branch to feat/proxy-method-slots; PR creation later turned out to be blocked on GitHub auth (no PR-scoped token), so the branch is pushed and the PR body is prepared at tmp/pr-body.md. **Alex:** Also, there are proxy-related benchmarks. Run them before you start working, run them after, and tell me the improvement in the end. (They compare Fusion proxies to Castle.DynamicProxy - here I care about Fusion proxies only, of course.) **Fable5:** Captured the baseline (cached compute: Long 36.5 ns, String 46.5 ns; raw dispatch: e.g. Void() 155M ops/s), then implemented the plan: ProxyMethodTable + slot-carrying Invocation, InterceptorBinding with per-slot handler resolution and a dynamic fallback, slot-based generated dispatch, and compute-interceptor opt-ins. All 921 tests passed; cached compute got 22-25% faster, raw dispatch 1.3-3.7x faster. **Alex:** Check if there are any examples of proxy code in the documentation and update that code. Don't touch old presentations - you can modify just the new one, if there is such code - plus any .md/.cs files in the docs folder. **Fable5:** Updated PartAP-PG.md (both generated-proxy examples), PartAP-D.md (fields/Invocation tables), two call-flow/type-hierarchy SVGs, and the plan docs' status lines; the proxy code sample exists only in the old slide deck (fusion-intro-v1), which was left untouched. **Alex:** Explain one thing: am I right that any proxy has a proxy method table? And if yes, why do we need an Invocation variant that caches MethodInfo? **Fable5:** Every generated proxy has a static table; the MethodInfo-based Invocation ctor existed only as a compatibility path for hand-written proxies and assemblies generated by older Fusion versions. **Alex:** Now we need to fix that: update hand-written proxies and the related tests, and don't worry about older Fusion versions - if old generated code would fail, you can't upgrade without regenerating proxies anyway, so get rid of the old variant completely. Every Invocation should carry the method table and index explicitly (assigning the table directly is slightly faster than getting it via an interface). Also eliminate the range check on the method table: the Invocation constructor does the range check once, and code using the index relies on plain pointer math, free of range checks. **Fable5:** Removed the legacy ctor, made (MethodTable, MethodIndex) mandatory with the single ctor-time range check, switched Method resolution and binding reads to unchecked access, and updated AltClassProxy + docs; cached compute improved to -22..-26% vs baseline. **Alex:** On InterceptorBinding and IsDynamic: assuming interceptors are invoked only from proxies with method tables - can we implement efficient handler caching without relying on IsDynamic? Can we store the tables in the interceptors themselves? Basically: is there a way to completely get rid of dictionary lookups and simultaneously drop the weird IsDynamic/binding machinery? **Fable5:** The warm path was already dictionary-free, but IsDynamic could die by contract: make SelectHandler once-per-slot, move per-call logic inside handlers, and delete the MethodInfo-keyed handler cache outright. **Alex:** Let's think about this a bit more. Right now proxy methods invoke the interceptor, which finds the handler and invokes it. Can we extract that logic - make the overridden proxy methods do a two-step process: first trigger handler lookup, but only if there's no cached handler, then invoke it? The cached handler would be per proxy instance - fatter proxies, one dedicated field per slot - ideally with the fields ordered so a span can clear all cached handlers on interceptor rebinding. **Fable5:** Feasible and strictly better: per-slot __handlerN fields as L1, the binding as shared L2; flagged that a span over fields isn't layout-guaranteed (proposed an unrolled clear or [InlineArray] on net8+) and that rebinding introduces a stale-cache race needing ordered writes. **Alex:** My goal here is a simple interceptor API without pollution like weird double cache lookups, and more efficient logic overall. **Fable5:** Implemented it: SelectHandler runs at most once per (interceptor, table, slot), GetHandler/_handlerCache/InterceptorExt/IsDynamic deleted, per-instance handler fields added, and a NoHandler marker lets pass-through calls invoke the typed original delegate directly (fixing a boxing regression the first cut introduced). Final: cached compute -26..-37% vs baseline, dispatch 1.9-4.3x faster. **Alex:** Don't run all tests just yet - commit, and let me see the changes. **Fable5:** Stopped the suites and pushed; the true diff vs the branch base is 19 files, +742/-237. **Alex:** On CreateHandler vs GetHandler: does it make sense to still cache resolved handlers in interceptors (per proxy type) by default? Two cases: on a server, proxies are created frequently for UI-scoped compute services in Blazor Server; on a device there's a single scope, so proxy instances are typically created once and we don't need the cache. I'd consider a static-property gate enabling the in-interceptor handler cache, false by default. Does that make sense? **Fable5:** Recommended against the gate: the shared cache is per proxy type, not per instance, so devices waste only kilobytes, while a default-off gate would silently cost Blazor Server per-circuit reflection. **Alex:** Sorry, maybe I didn't describe it right. Previously we had a mechanism that caches handlers - does it make sense to still have it, but optional (enabled for server scenarios only), to save on MakeGenericType and reflection-based handler activation? **Fable5:** That caching never went away - it moved into the binding's per-slot array with the same cardinality as the old dictionary, always on; new scoped proxies fill their fields from it with a plain array read, so reflection runs once per (interceptor, type, slot) per process. **Alex:** Can you quickly draw a picture - a schema showing the different caching spots and their cardinality for these handlers? **Fable5:** Sent a diagram: L1 per-instance fields (N instances x K slots), L2 binding array (1 handler per slot per proxy type), handler factory (runs once per slot; the only reflection spot), and the bind-time-only table -> binding map. **Alex:** We don't clear L1 on the very first interceptor assignment, right? **Fable5:** We did, but it was a no-op (nulls over nulls); keeping it unconditional kept the rebind race-proof simple. **Alex:** "You'd have to re-derive that" - what do you mean? **Fable5:** Showed that the "obvious" guarded clear is actually unsound (racing first-binds can permanently cache a stale handler), i.e., any change to the rebind sequence requires redoing the interleaving analysis. **Alex:** Honestly, I don't understand what you're talking about - let's implement a way simpler guard: we should never allow reassigning the interceptor. It can be bound just once, right after construction, and no proxy with an unbound interceptor should ever reach user code (check the existing binding places - I think that's the conclusion you'll make). Your Volatile.Write looks awful to me; in reality we shouldn't reset the handler fields at all - they're null on construction and assigned later. And the code setting these fields shouldn't heavily optimize for concurrency: if a proxy is shared by two threads, it's fine if one doesn't instantly see a handler the other cached, because there's the L2 cache. Long story short: optimize for reads, not writes, and for overall consistency. **Fable5:** Verified all binding sites bind exactly once right after construction, made the setter throw on rebinding, and deleted the volatile write, the field clearing, and the barrier + re-check - handler fields now only ever go from null to the single published handler via plain stores. **Alex:** Give ResolveHandler a better name - GetAndCacheHandler (it uses the Get -> Resolve chain and caches). Also, am I right there's no need to manually eliminate array bound checks now, since all hot-path lookups are essentially field lookups? **Fable5:** Renamed it, and - agreeing at first - removed all unchecked array access. **Alex:** Wait, WTF on the proxy method table? Methods still CAN be resolved frequently by index, no? **Fable5:** Right - Invocation.Method can run per call in handler code (logging, NoHandler), so restored GetMethodUnchecked there, relying on the ctor's one-time validation; the binding's L1-miss read stays plainly checked. **Alex:** Also, does the proxy API provide access to the current binding? **Fable5:** It didn't - only IProxy.Interceptor; proposed exposing the binding (and thereby the method table) on IProxy. **Alex:** I'd prefer just `public InterceptorBinding Binding { get; set; }` and no direct interceptor access via that interface. It's fine to break the old API. Also - Func<Invocation, object?>: isn't it better to have Func<Invocation, Delegate?> everywhere? And the "Binding" name is fine only if it's visible only via the interface - no public Binding member on proxy types. **Fable5:** Reshaped IProxy to MethodTable { get; } + Binding { get; set; } (both explicit interface implementations, so nothing is on the proxy's public surface; the setter validates the table match, and BindTo composes the two). Recommended keeping Func<Invocation, object?>: the hot built-ins are deliberately untyped, Task<T> results gain nothing from a delegate-side cast, and the real ValueTask<T> boxing lives deeper in the result plumbing. **Alex:** `private volatile ConcurrentDictionary<ProxyMethodTable, InterceptorBinding>? _bindings` - why is it volatile? **Fable5:** No good reason - Interlocked.CompareExchange already guarantees publication and a stale null read is benign - so removed volatile from both lazily created caches. **Alex:** OK, and what are _aliasIndexes? **Fable5:** The cache behind GetIndex for alias MethodInfo identities (base-class declarations, equivalent interface declarations, and -1 for foreign methods) - a cold-path reverse-lookup helper. **Alex:** Got it - add a brief comment on that near its declaration. **Fable5:** Added. **Alex:** Besides that, I'd make it readonly too, and always assign it - it's fine to have one per type. **Fable5:** Made it eager and readonly; GetIndex collapsed to a single expression. **Alex:** Now squash all commits in this branch into one, and add a polished log of our interactions to its description. **Fable5:** This commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mCEGf9t1SAXh8jQYbLSjL
1 parent 19f89bf commit 2780de3

23 files changed

Lines changed: 757 additions & 247 deletions

docs/PartAP-D.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ How a method call flows through the proxy system:
2222

2323
| Field | Type | Purpose |
2424
|-------|------|---------|
25-
| `__interceptor` | `Interceptor` | The interceptor instance |
25+
| `__methodTable` | `ProxyMethodTable` (static) | Slot-indexed `MethodInfo` table shared by all instances of the proxy type |
26+
| `__binding` | `InterceptorBinding?` | Binds the interceptor to the method table; shares resolved handlers across proxy instances |
27+
| `__handler0` | `Func<Invocation, object?>?` | Per-slot handler cache, filled on the first call |
2628
| `__cachedIntercepted0` | `Func<ArgumentList, Task<string>>` | Cached delegate to target |
27-
| `__cachedIntercept0` | `Func<Invocation, Task<string>>` | Cached intercept delegate |
2829
| `ProxyTarget` | `object?` | Real service (from `InterfaceProxy`) |
2930

3031

@@ -33,7 +34,8 @@ How a method call flows through the proxy system:
3334
| Field | Description |
3435
|-------|-------------|
3536
| `Proxy` | The proxy instance (e.g., `IGreetingServiceProxy`) |
36-
| `Method` | `MethodInfo` of the called method |
37+
| `MethodTable`, `MethodIndex` | Table-qualified slot of the called method (`ProxyMethodRef` via `MethodRef`); the index is validated against the table in the constructor |
38+
| `Method` | `MethodInfo` of the called method, resolved as `MethodTable.Methods[MethodIndex]` |
3739
| `Arguments` | `ArgumentList` containing method arguments |
3840
| `InterceptedDelegate` | Delegate to call the real implementation (for pass-through) |
3941
| `InterfaceProxyTarget` | The real service instance |
@@ -64,6 +66,14 @@ How a method call flows through the proxy system:
6466

6567
## Handler Caching
6668

69+
Handlers are cached at two levels: each proxy instance caches the resolved handler per method
70+
slot in a dedicated field, and the `InterceptorBinding` shares resolved handlers across all
71+
proxy instances using the same (interceptor, method table) pair. The interceptor is bound
72+
just once, right after the proxy construction, so both caches only ever go from unresolved
73+
to resolved. `Interceptor.SelectHandler` runs at most once per slot; a slot the interceptor
74+
leaves unhandled caches the `InterceptorBinding.NoHandler` marker, which generated proxies
75+
detect by reference to invoke their typed original-call delegate directly (no boxed results).
76+
6777
<img src="/img/diagrams/PartAP-D-5.svg" alt="Handler Caching" style="width: 100%; max-width: 800px;" />
6878

6979

docs/PartAP-PG.md

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,27 @@ namespace YourNamespace.ActualLabProxies;
3737

3838
public sealed class TodoApiProxy : TodoApi, IProxy // Inherits from TodoApi
3939
{
40-
private static MethodInfo? __cachedMethod0 = ProxyHelper.GetMethodInfo(
41-
typeof(TodoApi), "GetTodos", new[] { typeof(CancellationToken) });
40+
// One static table per proxy type; the array position is the method's slot index
41+
private static readonly ProxyMethodTable __methodTable = new(typeof(TodoApiProxy), new[] {
42+
ProxyHelper.GetMethodInfo(typeof(TodoApi), "GetTodos", new[] { typeof(CancellationToken) }),
43+
});
4244
private Func<ArgumentList, Task<string[]>>? __cachedIntercepted0;
43-
private Func<Invocation, Task<string[]>>? __cachedIntercept0;
44-
private Interceptor? __interceptor;
45-
46-
Interceptor IProxy.Interceptor { get => ...; set => ...; }
45+
private Func<Invocation, object?>? __handler0; // Per-slot handler cache
46+
private InterceptorBinding? __binding;
47+
48+
ProxyMethodTable IProxy.MethodTable => __methodTable;
49+
50+
InterceptorBinding IProxy.Binding {
51+
get => ...; // Returns __binding, or throws if the proxy isn't bound yet
52+
set {
53+
// The binding is assigned just once, right after the proxy construction
54+
if (__binding != null)
55+
throw Errors.InterceptorIsAlreadyBound();
56+
if (value.MethodTable != __methodTable)
57+
throw Errors.InvalidInterceptorBinding();
58+
__binding = value;
59+
}
60+
}
4761

4862
public TodoApiProxy() : base() { } // Calls base constructor
4963
@@ -56,9 +70,12 @@ public sealed class TodoApiProxy : TodoApi, IProxy // Inherits from TodoApi
5670
return base.GetTodos((CancellationToken)sa.Item0);
5771
};
5872

59-
var invocation = new Invocation(this, __cachedMethod0!,
73+
var invocation = new Invocation(this, __methodTable, 0, // 0 = this method's slot
6074
ArgumentList.New(cancellationToken), intercepted);
61-
return __cachedIntercept0!.Invoke(invocation);
75+
var handler = __handler0 ?? InterceptorBinding.GetAndCacheHandler(ref __handler0, __binding, invocation);
76+
if (ReferenceEquals(handler, InterceptorBinding.NoHandler))
77+
return intercepted.Invoke(invocation.Arguments); // Not intercepted -> typed direct call
78+
return (Task<string[]>)handler.Invoke(invocation)!;
6279
}
6380
}
6481
```
@@ -84,13 +101,19 @@ namespace YourNamespace.ActualLabProxies;
84101

85102
public sealed class ITodoApiProxy : InterfaceProxy, ITodoApi, IProxy // Inherits from InterfaceProxy
86103
{
87-
private static MethodInfo? __cachedMethod0 = ProxyHelper.GetMethodInfo(
88-
typeof(ITodoApi), "GetTodos", new[] { typeof(CancellationToken) });
104+
private static readonly ProxyMethodTable __methodTable = new(typeof(ITodoApiProxy), new[] {
105+
ProxyHelper.GetMethodInfo(typeof(ITodoApi), "GetTodos", new[] { typeof(CancellationToken) }),
106+
});
89107
private Func<ArgumentList, Task<string[]>>? __cachedIntercepted0;
90-
private Func<Invocation, Task<string[]>>? __cachedIntercept0;
91-
private Interceptor? __interceptor;
108+
private Func<Invocation, object?>? __handler0;
109+
private InterceptorBinding? __binding;
92110

93-
Interceptor IProxy.Interceptor { get => ...; set => ...; }
111+
ProxyMethodTable IProxy.MethodTable => __methodTable;
112+
113+
InterceptorBinding IProxy.Binding {
114+
get => ...; // Same as in the class proxy
115+
set { ... }
116+
}
94117

95118
// No constructor needed - InterfaceProxy provides ProxyTarget property
96119
@@ -103,9 +126,12 @@ public sealed class ITodoApiProxy : InterfaceProxy, ITodoApi, IProxy // Inherit
103126
return ((ITodoApi)ProxyTarget!).GetTodos((CancellationToken)sa.Item0);
104127
};
105128

106-
var invocation = new Invocation(this, __cachedMethod0!,
129+
var invocation = new Invocation(this, __methodTable, 0,
107130
ArgumentList.New(cancellationToken), intercepted);
108-
return __cachedIntercept0!.Invoke(invocation);
131+
var handler = __handler0 ?? InterceptorBinding.GetAndCacheHandler(ref __handler0, __binding, invocation);
132+
if (ReferenceEquals(handler, InterceptorBinding.NoHandler))
133+
return intercepted.Invoke(invocation.Arguments);
134+
return (Task<string[]>)handler.Invoke(invocation)!;
109135
}
110136
}
111137
```
@@ -120,9 +146,10 @@ public sealed class ITodoApiProxy : InterfaceProxy, ITodoApi, IProxy // Inherit
120146
| Aspect | Description |
121147
|--------|-------------|
122148
| **Inheritance** | Class proxies inherit from the original class; interface proxies inherit from `InterfaceProxy` |
123-
| **Caching** | `MethodInfo`, interceptor handlers, and base method delegates are all cached for performance |
149+
| **Method slots** | Every intercepted method gets a compile-time slot index in the static `ProxyMethodTable`; the table maps slots to `MethodInfo` and back |
150+
| **Caching** | Each proxy instance caches the resolved handler per slot in a dedicated field, so a warm call is a field load + delegate invoke; the `InterceptorBinding` (one per interceptor + method table pair) shares resolved handlers across instances, and `SelectHandler` runs at most once per slot |
124151
| **ArgumentList** | Arguments are packed into an `ArgumentList` struct (generic `ArgumentListG*` or struct-based `ArgumentListS*`) |
125-
| **Invocation** | Contains proxy, method, arguments, and delegate to call the non-intercepted method |
152+
| **Invocation** | Contains proxy, method table + slot index (exposed as `Method`), arguments, and delegate to call the non-intercepted method |
126153
| **Trimming** | `ModuleInitializer` and `CodeKeeper` ensure the proxy survives AOT compilation and trimming |
127154

128155
## Proxy Type Resolution

docs/img/diagrams/PartAP-D-1.svg

Lines changed: 5 additions & 5 deletions
Loading

docs/img/diagrams/PartAP-D-3.svg

Lines changed: 3 additions & 3 deletions
Loading

docs/plans/fusion-hot-path-optimization.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ baselines still need dedicated benchmarks.
9090

9191
The separate [proxy method slots](proxy-method-slots.md) task changes generated
9292
proxy dispatch, interceptor binding, and the association between proxy methods and
93-
`MethodDef` instances. Its relevant dependencies are:
93+
`MethodDef` instances. It is now implemented (`feat/proxy-method-slots`); notably,
94+
`MethodDef` creation and identity did not change - bindings cache resolved handlers
95+
per slot, so `MethodDef` instances are still created and shared exactly as before.
96+
Its relevant dependencies were:
9497

9598
- The alternate computed-registry lookup **may depend on the final method-slot
9699
design**. `ComputeMethodInput` currently hashes and compares `MethodDef` by

docs/plans/proxy-method-slots.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
Date: 2026-07-16
44

5-
Status: hypothesis validated by source review; implementation and measurement pending
5+
Status: implemented and measured (2026-07-16, `feat/proxy-method-slots`). Cached compute
6+
calls got 8-25% faster (Long 36.5 -> 28.3 ns, String 46.5 -> 34.7 ns, SessionAndString
7+
44.7 -> 41.0 ns, recompute -12%), matching the estimate below; raw proxy dispatch got
8+
1.3-3.7x faster with unchanged per-call allocations. Legacy `MethodInfo`-keyed caches
9+
remain as the compatibility/cold path; slot-indexed `MethodDef?[]` on the binding and
10+
old-cache removal are still deferred.
611

712
## Conclusion
813

src/ActualLab.Fusion/Client/Interception/RemoteComputeMethodFunction.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ public async Task ApplyRpcUpdate(
410410
{
411411
var invocation = input.Invocation;
412412
var proxy = (IProxy)invocation.Proxy;
413-
var remoteComputeServiceInterceptor = (RemoteComputeServiceInterceptor)proxy.Interceptor;
413+
var remoteComputeServiceInterceptor = (RemoteComputeServiceInterceptor)proxy.Binding.Interceptor;
414414
var rpcInterceptor = remoteComputeServiceInterceptor.RpcInterceptor;
415415

416416
var ctIndex = input.MethodDef.CancellationTokenIndex;

src/ActualLab.Fusion/Client/Interception/RemoteComputeServiceInterceptor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public RemoteComputeServiceInterceptor(Options settings, FusionHub hub, RpcInter
3434
}
3535

3636
public override Func<Invocation, object?>? SelectHandler(in Invocation invocation)
37-
=> GetHandler(invocation) // Compute service method
37+
=> CreateHandler(invocation) // Compute service method
3838
?? RpcInterceptor.SelectHandler(invocation); // Regular or command service method
3939

4040
[UnconditionalSuppressMessage("Trimming", "IL3050", Justification = "We assume proxy-related code is preserved")]

src/ActualLab.Fusion/Interception/ComputeServiceInterceptor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public ComputeServiceInterceptor(Options settings, FusionHub hub)
3737
}
3838

3939
public override Func<Invocation, object?>? SelectHandler(in Invocation invocation)
40-
=> GetHandler(invocation) ?? CommandServiceInterceptor.SelectHandler(invocation);
40+
=> CreateHandler(invocation) ?? CommandServiceInterceptor.SelectHandler(invocation);
4141

4242
[UnconditionalSuppressMessage("Trimming", "IL3050", Justification = "We assume proxy-related code is preserved")]
4343
protected override Func<Invocation, object?>? CreateUntypedHandler(Invocation initialInvocation, MethodDef methodDef)

src/ActualLab.Generators/Internal/GenerationHelpers.cs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,32 @@ public static readonly IdentifierNameSyntax ModuleInitializerAttributeName
3737
// Types
3838
public static readonly IdentifierNameSyntax ProxyInterfaceTypeName = IdentifierName($"{InterceptionGns}.IProxy");
3939
public static readonly IdentifierNameSyntax InterfaceProxyBaseTypeName = IdentifierName($"{InterceptionInternalGns}.InterfaceProxy");
40-
public static readonly IdentifierNameSyntax InterceptorTypeName = IdentifierName($"{InterceptionGns}.Interceptor");
4140
public static readonly IdentifierNameSyntax ProxyHelperTypeName = IdentifierName($"{InterceptionInternalGns}.ProxyHelper");
4241
public static readonly IdentifierNameSyntax ArgumentListTypeName = IdentifierName($"{InterceptionGns}.ArgumentList");
4342
public static readonly IdentifierNameSyntax ArgumentList0TypeName = IdentifierName($"{InterceptionGns}.ArgumentList0");
4443
public static readonly IdentifierNameSyntax InvocationTypeName = IdentifierName($"{InterceptionGns}.Invocation");
4544
public static readonly IdentifierNameSyntax CodeKeeperTypeName = IdentifierName($"{TrimmingGns}.CodeKeeper");
4645
public static readonly IdentifierNameSyntax ProxyCodeKeeperTypeName = IdentifierName($"{InterceptionTrimmingGns}.ProxyCodeKeeper");
4746
public static readonly IdentifierNameSyntax ErrorsTypeName = IdentifierName($"{InterceptionInternalGns}.Errors");
48-
public static readonly TypeSyntax NullableMethodInfoType = NullableType(typeof(MethodInfo).ToTypeRef());
47+
public static readonly IdentifierNameSyntax ProxyMethodTableTypeName = IdentifierName($"{InterceptionGns}.ProxyMethodTable");
48+
public static readonly IdentifierNameSyntax InterceptorBindingTypeName = IdentifierName($"{InterceptionGns}.InterceptorBinding");
49+
public static readonly TypeSyntax MethodInfoType = typeof(MethodInfo).ToTypeRef();
50+
public static readonly TypeSyntax NullableMethodInfoType = NullableType(MethodInfoType);
51+
public static readonly TypeSyntax HandlerFuncTypeName = GenericName(Identifier("global::System.Func"))
52+
.WithTypeArgumentList(TypeArgumentList(CommaSeparatedList<TypeSyntax>(
53+
IdentifierName($"{InterceptionGns}.Invocation"),
54+
NullableType(PredefinedType(Token(SyntaxKind.ObjectKeyword))))));
4955
// Methods
5056
public static readonly IdentifierNameSyntax ArgumentListNewMethodName = IdentifierName("New");
5157
public static readonly IdentifierNameSyntax GetMethodInfoMethodName = IdentifierName("GetMethodInfo");
52-
public static readonly IdentifierNameSyntax InterceptMethodName = IdentifierName("Intercept");
53-
public static readonly GenericNameSyntax InterceptGenericMethodName = GenericName(InterceptMethodName.Identifier.Text);
58+
public static readonly IdentifierNameSyntax GetAndCacheHandlerMethodName = IdentifierName("GetAndCacheHandler");
59+
public static readonly IdentifierNameSyntax InvokeMethodName = IdentifierName("Invoke");
60+
public static readonly IdentifierNameSyntax ReferenceEqualsMethodName = IdentifierName("ReferenceEquals");
61+
public static readonly IdentifierNameSyntax NoHandlerFieldName = IdentifierName("NoHandler");
62+
public static readonly IdentifierNameSyntax InvocationArgumentsPropertyName = IdentifierName("Arguments");
5463
public static readonly IdentifierNameSyntax NoInterceptorMethodName = IdentifierName("NoInterceptor");
64+
public static readonly IdentifierNameSyntax InterceptorIsAlreadyBoundMethodName = IdentifierName("InterceptorIsAlreadyBound");
65+
public static readonly IdentifierNameSyntax InvalidInterceptorBindingMethodName = IdentifierName("InvalidInterceptorBinding");
5566
public static readonly IdentifierNameSyntax KeepCodeMethodName = IdentifierName("KeepCode");
5667
public static readonly IdentifierNameSyntax AlwaysFalseFieldName = IdentifierName("AlwaysFalse");
5768
public static readonly GenericNameSyntax CodeKeeperKeepMethodName = GenericName("Keep");
@@ -60,11 +71,14 @@ public static readonly IdentifierNameSyntax ModuleInitializerAttributeName
6071
public static readonly GenericNameSyntax CodeKeeperKeepSyncMethodGenericMethodName = GenericName("KeepSyncMethod");
6172
// Properties, fields, locals
6273
public static readonly IdentifierNameSyntax ProxyTargetPropertyName = IdentifierName("ProxyTarget");
63-
public static readonly IdentifierNameSyntax InterceptorPropertyName = IdentifierName("Interceptor");
64-
public static readonly IdentifierNameSyntax InterceptorFieldName = IdentifierName("__interceptor");
74+
public static readonly IdentifierNameSyntax BindingPropertyName = IdentifierName("Binding");
75+
public static readonly IdentifierNameSyntax MethodTablePropertyName = IdentifierName("MethodTable");
76+
public static readonly IdentifierNameSyntax MethodTableFieldName = IdentifierName("__methodTable");
77+
public static readonly IdentifierNameSyntax BindingFieldName = IdentifierName("__binding");
6578
public static readonly IdentifierNameSyntax ValueParameterName = IdentifierName("value");
6679
public static readonly IdentifierNameSyntax InterceptedVarName = IdentifierName("intercepted");
6780
public static readonly IdentifierNameSyntax InvocationVarName = IdentifierName("invocation");
81+
public static readonly IdentifierNameSyntax HandlerVarName = IdentifierName("handler");
6882

6983
// Helpers
7084

@@ -136,6 +150,9 @@ public static FieldDeclarationSyntax PrivateFieldDef(TypeSyntax type, SyntaxToke
136150
=> PrivateFieldDef(type, name, false, initializer);
137151
public static FieldDeclarationSyntax PrivateStaticFieldDef(TypeSyntax type, SyntaxToken name, ExpressionSyntax? initializer = null)
138152
=> PrivateFieldDef(type, name, true, initializer);
153+
public static FieldDeclarationSyntax PrivateStaticReadonlyFieldDef(TypeSyntax type, SyntaxToken name, ExpressionSyntax initializer)
154+
=> PrivateFieldDef(type, name, true, initializer)
155+
.AddModifiers(Token(SyntaxKind.ReadOnlyKeyword));
139156
public static FieldDeclarationSyntax PrivateFieldDef(TypeSyntax type, SyntaxToken name, bool isStatic, ExpressionSyntax? initializer = null)
140157
{
141158
var initializerClause = initializer is null

0 commit comments

Comments
 (0)