Repository navigation
[Runtime Configuration] Dynamic Interceptors:运行时启用、禁用与原子替换 Client/Server 拦截器 #263
Description
总跟踪:#262
架构背景:#67
分析与实施基线:dev
模块定位:本 issue 是 Runtime Dynamic Configuration 的第一类实现模板,代表 pure immutable snapshot / next-logical-call semantics。
Use case
SharpLink 当前 Client / Server interceptor 在 Builder 阶段配置,Build 后冻结为数组并贯穿实例整个生命周期。这个模型简单、性能好,但不能满足运行时策略切换,例如:
- 临时开启审计 / tracing interceptor;
- 动态开启或关闭鉴权、租户检查、请求标记等 middleware;
- 灰度替换 interceptor pipeline;
- Hosting / 配置中心未来驱动 interceptor 组合变化;
- 运行中关闭一个高成本 interceptor,而不重建 Client / Server。
本 issue 的目标不是把 interceptor list 改成线程安全 List<T>,而是在保持现有 fast path 的前提下,把 interceptor pipeline 从“Build 时永久冻结”升级为“运行时可原子发布的 immutable generation”。
当前实现与问题定位
Builder / BuildPlan
当前 Builder 侧将 interceptor 收集到 list,并在 Compile / BuildPlan 阶段复制为数组:
src/SharpLink.Client/SharpClientBuilder.cssrc/SharpLink.Client/ClientBuildPlan.cssrc/SharpLink.Server/SharpLinkServerBuilder.cssrc/SharpLink.Server/ServerBuildPlan.cs
这一部分作为初始配置来源应继续保留,不要删除 Build/Compile freeze。
Client 热路径
重点文件:
src/SharpLink.Client/SharpLinkClient.cssrc/SharpLink.Client/SharpLinkClient.Invokers.cssrc/SharpLink.Client/SharpLinkClient.Telemetry.cssrc/SharpLink.Client/SharpLinkClient.Interceptors.cs
当前 InvokeUnaryAsync / OneWay / ClientStreaming / ServerStreaming / DuplexStreaming 都有显式 fast path:
if (SharpLinkTelemetry.ClientCallsEnabled)
{
...
}
else if (_clientInterceptors.Length != 0)
{
... intercepted ...
}
else
{
... direct ...
}Telemetry path 内部又会再次检查 _clientInterceptors.Length。
更重要的是,ClientInterceptorState.InvokeNextAsync(...) 当前在每一步链调用时继续读取:
_client._clientInterceptors.Length
_client._clientInterceptors[index]如果只把 _clientInterceptors 从 readonly 数组改成可替换字段,会出现错误 generation 混用:
RPC A capture/进入 interceptor A(old)
↓
运行时替换 pipeline
↓
A 调用 next
↓
读取到 interceptor B(new)
同一个 logical RPC 可能执行 old[0] -> new[1]。这是本 issue 必须消除的首要 correctness bug。
Server 热路径
重点文件:
src/SharpLink.Server/SharpLinkServer.cssrc/SharpLink.Server/SharpLinkServer.Interceptors.cs
当前 InvokeServiceCoreAsync(...):
if (_serverInterceptors.Length == 0)
{
return generated-stub direct invocation;
}
return InvokeInterceptedWithOwnedArgumentsAsync(...);Server 侧的 ServerPipelineFacts 已经把 interceptor array 保存为字段,因此其结构天然接近正确的 generation capture 模式;需要做的是只在调用入口读取一次当前 snapshot,并将该 snapshot 一路传入,不能在 helper 中重新读全局字段。
已有测试资产
优先扩展而不是另建重复 harness:
test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs
现有测试已经覆盖:
- Client / Server interceptor context;
- Client short-circuit;
- Server structured rejection;
- cancellation / mapped failure;
- response / stream failure;
- continuation 必须 join;
- unary / one-way / streaming 多种 shape;
- synchronization-context 等异步边界。
动态化测试应继续复用这个集成测试体系。
目标语义
1. 生效点:next logical RPC
Interceptor 更新成功后:
- 更新之后新开始的 logical RPC 使用新 pipeline;
- 更新之前已经开始的 RPC 从头到尾使用旧 pipeline;
- 一个 RPC 绝不能混用两个 interceptor generation。
这里的“logical RPC”包括:
- Unary;
- OneWay;
- Client Streaming;
- Server Streaming;
- Duplex Streaming。
Client 侧 generation 应在公开 Invoke* 入口处 capture,而不是延迟到第一个 interceptor next(),也不要在 stream MoveNextAsync() 时重新读取。
Server 侧 generation 应在开始 service invocation/interceptor dispatch 时 capture,并一直传递到该 interceptor chain 完成。
2. 顺序保持
若当前发布:
[A, B, C]
一次调用必须完整看到:
A -> B -> C -> terminal -> C -> B -> A
运行时更新不得改变已经开始调用的顺序。
3. Disabled state
零 interceptor 必须继续走 direct fast path:
snapshot = empty
↓
no pipeline allocation
no continuation state
no no-op interceptor call
↓
direct invocation
不要引入 NoopInterceptor 或永远存在的 no-op pipeline。
4. 更新是 control-plane 操作
更新允许:
- 分配新数组 / snapshot;
- 校验 null / ordering / argument;
- 在专用 update gate 下串行化多个 writer;
请求路径不允许:
- 全局 lock;
ReaderWriterLockSlim;- 每调用复制 interceptor list;
- 每调用构造新的 snapshot object;
- 在 interceptor 每一层重新读取动态字段。
推荐内部模型
最小实现优先:immutable array snapshot
Interceptor 本身没有像 Admission 那样必须迁移的 limiter/token state,因此第一版不需要引入复杂 generation registry。
推荐直接使用“数组引用就是 snapshot”:
private ISharpLinkClientInterceptor[] _clientInterceptors;
private ISharpLinkServerInterceptor[] _serverInterceptors;数组发布后必须被视为 immutable;所有输入序列必须复制,绝不能把 caller-owned mutable array/List 直接保存。
读取:
var interceptors = Volatile.Read(ref _clientInterceptors);发布:
Volatile.Write(ref _clientInterceptors, candidate);或使用等价的 Interlocked.Exchange。
如果实现者认为需要附带 generation id / diagnostics,可使用内部 sealed snapshot:
internal sealed class ClientInterceptorPipeline
{
internal readonly ISharpLinkClientInterceptor[] Interceptors;
internal readonly long Generation;
}但不得因为 snapshot class 在每个 RPC 新建对象。Snapshot 只能在 update path 创建。
生命周期 / ownership
本 issue 不改变 interceptor instance 的 ownership contract。
如果当前 Builder 接收 caller-created interceptor instance,则运行时替换后:
- 框架不能擅自
Dispose/DisposeAsync被移除 interceptor; - in-flight call 通过 captured array/reference 自然保持旧 interceptor 可达;
- snapshot 无额外 native/resource ownership 时,不需要 drain manager。
若未来要引入 framework-owned disposable interceptor factory,应另开 lifecycle issue,不在本实现顺带扩张。
具体实施步骤
下面按实现顺序执行。不要跳过 generation consistency 测试后再“补”。
Phase 1 — 保留 Builder 初始语义,允许 runtime field 发布
修改候选文件:
src/SharpLink.Client/SharpLinkClient.cssrc/SharpLink.Server/SharpLinkServer.cs
要求:
- BuildPlan 仍然生成独立 interceptor array。
- Client / Server 构造时用 BuildPlan 的 array 初始化 runtime snapshot。
- runtime 字段允许 atomic replacement。
- 不把 Builder 的
List<T>直接共享给 runtime。 - 空 pipeline 使用统一 empty array,禁止 null element。
Phase 2 — Client 在 logical invocation 入口 capture 一次
修改:
src/SharpLink.Client/SharpLinkClient.Invokers.cs
每个公开 invocation 入口都先 capture:
var interceptors = Volatile.Read(ref _clientInterceptors);然后整个调用只使用这个局部 snapshot。
推荐结构:
var interceptors = Volatile.Read(ref _clientInterceptors);
if (SharpLinkTelemetry.ClientCallsEnabled)
{
invocation = InvokeUnaryWithTelemetryAsync(..., interceptors, ...);
}
else if (interceptors.Length != 0)
{
invocation = InvokeUnaryInterceptedAsync(..., interceptors, ...);
}
else
{
invocation = InvokeUnaryDirect(...);
}必须覆盖全部 RPC shape,不只 Unary。
Phase 3 — Telemetry path 不得二次读取全局 interceptor field
修改:
src/SharpLink.Client/SharpLinkClient.Telemetry.cs
当前 telemetry helper 内又检查 _clientInterceptors.Length。动态化后,这会造成:
Invoke* capture generation N
↓
telemetry enabled
↓
配置更新 N+1
↓
telemetry helper 再读全局字段
↓
调用实际使用 N+1
因此 telemetry helper 必须接收 outer invocation 已 capture 的 snapshot:
InvokeUnaryWithTelemetryAsync(..., ISharpLinkClientInterceptor[] interceptors, ...)并基于该参数决定 intercepted/direct。
Phase 4 — ClientInterceptorState 保存 snapshot,不得读取 client 全局数组
修改:
src/SharpLink.Client/SharpLinkClient.Interceptors.cs
ClientInterceptorState 增加 captured pipeline field:
private readonly ISharpLinkClientInterceptor[] _interceptors;构造时传入当前 snapshot。
把:
_client._clientInterceptors.Length
_client._clientInterceptors[index]全部替换为:
_interceptors.Length
_interceptors[index]ClientContinuationState 继续通过 owner state 执行,因此只要 owner state 持有 snapshot,就能保证 continuation 无论异步多久都不会跨 generation。
Phase 5 — Server capture 一次并一路传入
修改:
src/SharpLink.Server/SharpLinkServer.Interceptors.cs
在 InvokeServiceCoreAsync 进入 interceptor 选择处:
var interceptors = Volatile.Read(ref _serverInterceptors);
if (interceptors.Length == 0)
{
return direct stub invocation;
}然后:
InvokeInterceptedWithOwnedArgumentsAsync(...)接收interceptors参数;ServerPipelineFacts使用传入数组;- helper 内不再次读取
_serverInterceptors。
Server 当前 ServerPipelineFacts 已持有数组,不要改成每层去读 server field。
Phase 6 — 增加 runtime update control path
具体 public API 命名可在实现 PR 中根据现有 surface 决定,但底层必须支持“完整替换”。建议优先 full replacement,而不是先暴露大量并发 Add/Remove:
概念接口:
client.ReplaceInterceptors(...);
server.ReplaceInterceptors(...);或:
client.UpdateInterceptors(builder => ...);
server.UpdateInterceptors(builder => ...);无论采用哪种 API,都必须满足:
- candidate 在 publish 前完整构建;
- null interceptor 在 publish 前失败;
- 输入 sequence 复制;
- 多 writer 更新若基于“读取旧状态后增删”,必须由 control-path gate 串行化,否则会 lost update;
- invalid candidate 不能改变当前已发布 snapshot;
- Dispose/Stop 后 update 的异常行为与项目现有 runtime mutation API 保持一致;不要静默成功。
如果最终只暴露“完整 replacement”,并发 writer 可以明确 last-writer-wins;如果暴露 mutation builder,则必须 serialize read-modify-publish。
Phase 7 — 文档与 diagnostics
至少同步:
- interceptor XML docs;
- runtime configuration 文档(若 [Runtime Configuration] 动态配置 / Hot Reload 长期跟踪 #262 后续建立统一文档,可先在本 issue/PR 记录);
- 明确 Build
AddInterceptor是 initial configuration; - 明确 runtime replacement 的 next-logical-call semantics;
- 明确 interceptor instances 的 ownership 不因动态替换改变。
禁止实现
以下实现即使测试“看起来能跑”也不接受:
禁止 1:直接把 array field 改成可写,但链内继续读取
_client._clientInterceptors[index]这是 generation mixing。
禁止 2:CopyOnWrite 只做一半
_clientInterceptors = newArray;如果 invocation / telemetry / continuation 后面又重新读 field,仍然错误。
禁止 3:Concurrent collection 进入热路径
不要使用:
ConcurrentBag<IInterceptor>
ConcurrentDictionary<int, IInterceptor>
ImmutableList<T>.Builder // per-call
lock (_interceptorGate) // per-call发布单位应该是一个完整 immutable pipeline reference。
禁止 4:关闭状态使用 Noop pipeline
零 interceptor 应保持 direct branch,不要让所有调用都穿过统一 pipeline abstraction。
禁止 5:动态替换时 dispose 旧 interceptor
除非另有明确 framework ownership contract,否则旧 instance 可能仍被 in-flight call 使用,也可能本来就是 caller-owned。
测试实施手册
优先扩展:
test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs
如测试文件过大,可拆:
DynamicInterceptorIntegrationTests.cs
但应复用当前 InterceptorHarness / generated test service。
A. 基础 enable / disable / replace
Client 与 Server 分别覆盖:
- Build 时无 interceptor,runtime enable 后下一次调用执行 interceptor。
- Build 时有 interceptor,runtime disable 后下一次调用走 direct path。
- A 替换为 B 后,新调用只执行 B。
-
[A, B]替换为[C, D]后,顺序完全按新 snapshot。 - 替换为空数组等价 disable。
- invalid/null candidate 被拒绝,旧 snapshot 保持可用。
B. 最关键 generation consistency 测试
必须有“人为阻塞 interceptor”的 deterministic test,不要只靠 stress 猜竞态。
Client 示例:
published = [A, B]
call #1 enters A
A blocks before calling next
runtime replace => [X, Y]
release A
call #1 MUST continue B
call #2 MUST execute X -> Y
必须断言 call #1 没有执行 Y。
Server 同样做一套。
这是本 issue 的核心回归测试。
C. Async continuation generation consistency
现有 interceptor 支持 async before-next / after-next / continuation join。
覆盖:
- interceptor 在
await前后发生 update; -
next()已发起但未完成时发生 update; - continuation 被延迟,恢复后仍使用旧 snapshot;
- exception / cancellation unwind 仍走原 generation。
D. RPC shapes
至少分别覆盖:
- Unary;
- OneWay;
- Client Streaming;
- Server Streaming;
- Duplex Streaming。
Streaming 测试要求:
- 创建/开始一个使用 old pipeline 的 stream;
- stream 尚未完成时更新 pipeline;
- old stream 继续保持 old generation;
- 新建 stream 使用 new generation;
- update 不导致 stream orphan / duplicate completion。
E. Telemetry interaction
因为 Client telemetry path 当前会再次读 _clientInterceptors,必须有专门测试:
- telemetry enabled + interceptor update;
- call 在 outer
Invoke*开始后发生 update,telemetry helper 不得切 generation; - telemetry disabled 仍保持 direct fast path。
如果测试环境难以在精确 outer capture 点阻塞,可通过内部 test hook 或专用 interceptor barrier 构造,不要用不稳定 Task.Delay 竞争。
F. 并发 stress
至少:
N worker threads continuously invoke RPC
1 updater repeatedly cycles:
[] -> [A] -> [A,B] -> [C] -> []
检查:
- 无 IndexOutOfRangeException;
- 无 NullReferenceException;
- 每个 call 只记录一个合法完整 generation;
- Stop/Dispose 与 update 并发不死锁;
- logical invocation accounting 不 underflow / leak。
G. Ownership / GC
若 interceptor 不由框架拥有:
- replacement 不调用 Dispose;
- in-flight old call 完成前 old interceptor 仍可使用;
- call 完成且没有其他引用后,old snapshot 不被 server/client 永久持有。
如涉及 collectible ALC interceptor,增加弱引用测试,确保 current runtime 不因历史 snapshot 永久 root 已不使用 assembly;如果此验证需要额外生命周期设计,应记录并拆 issue,不要用全局 cache 修补。
Benchmark / 性能门禁
本 issue 修改 Client / Server invocation hot path,因此 benchmark 是 merge gate,不是可选项。
建议新增:
test/SharpLink.Benchmarks/InterceptorBenchmarks.cs
或扩展现有 Runtime hot-path benchmarks。
必测场景
End-to-end RPC:
NoInterceptors— baseline;ClientInterceptorDisabledDynamicPath(即支持动态化后的空 snapshot);OneClientNoopInterceptor;OneServerNoopInterceptor;ClientAndServerNoopInterceptor;- steady state after many replacements(确认更新不会让热路径退化/累积结构)。
至少报告:
- Mean / throughput;
- allocation B/op;
- threading/lock diagnostics;
- 如项目 benchmark 支持,关注 p95/p99 或配合 load test。
性能要求
Disabled path:
- 0 新增 per-call allocation;
- 无 request-path lock;
- 不构造 pipeline object;
- 仅允许必要的 snapshot pointer load + branch;
- 相对改造前 baseline 的差异应处于 benchmark 噪声范围;若稳定退化 > 约 3%–5%,必须解释并继续优化。
Enabled path:
- 动态支持不应在每层 interceptor 增加 global volatile read;
- continuation pool 现有优化不得因为动态化失效;
- snapshot capture 只发生一次。
Update path 不需要按 RPC-rate 优化,但不得阻塞正在执行的 RPC。
验证命令
至少执行并在 PR 记录结果:
dotnet build Sharplink.slnx -c Release
dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release
dotnet test --project test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release
dotnet run --project test/SharpLink.IntegrationTests/SharpLink.IntegrationTests.csproj -c Release -- --timeout 120sBenchmark 使用仓库现有 BenchmarkDotNet 项目,至少运行新增 interceptor benchmark 与相关 Runtime hot-path benchmark,并把 before/after 结果贴到 PR。
如果改动触及 dynamic module / ALC 生命周期,补:
- RuntimeAssemblyIntegrationTests / 对应 ALC tests;
- NativeAOT smoke(若 public API/generic rooting 有变化)。
风险清单
R1 — 同一 RPC 混用 generation
最高风险。Client 当前实现天然会发生,必须通过 state 保存 snapshot 解决。
R2 — Telemetry 二次读取
Outer invocation capture 以后,telemetry helper 若再读全局 field,会破坏语义。
R3 — Streaming capture 时机错误
不要在每个 MoveNextAsync() 读 snapshot。Generation 属于 logical RPC,不属于 stream item。
R4 — 调用方传入 mutable collection
必须复制。否则 publish 后 caller 还能修改 array/list,原子 publication 失去意义。
R5 — 更新与 Dispose 竞态
Control API 必须遵守 server/client lifecycle;不得出现 dispose 后把新 snapshot 重新发布到已停止实例。
R6 — 错误 ownership
不要把“从 pipeline 移除”解释为“framework 可以 dispose interceptor”。
R7 — 为统一 API 引入 hot-path abstraction
本 issue 优先保证 runtime model 与性能;不要为了未来统一 RuntimeConfiguration API 给每次 RPC 套多层 interface/virtual dispatch。
Out of scope
- 不在本 issue 动态化 timeout / retry / exception mapper;这些属于 Dynamic Call Policy。
- 不改变 interceptor 的业务语义、context 字段、short-circuit contract。
- 不引入 interceptor dependency injection lifetime / factory ownership 新模型。
- 不把 Telemetry 本身改成动态配置系统。
- 不统一所有 Runtime Dynamic Configuration 的最终 public control surface;仅提供本模块可工作的更新入口。
建议提交拆分
一个聚焦 PR 可以包含多个 commit:
refactor: capture interceptor snapshots per logical callfeat: add runtime interceptor replacement control pathtest: add dynamic interceptor generation testsperf: add interceptor dynamic-path benchmarksdocs: document interceptor hot reload semantics
不要把 Admission 或其他模块混入同一 PR。
Definition of Done
- Builder / BuildPlan 初始 interceptor freeze 仍然有效。
- Client / Server runtime interceptor pipeline 可 enable / disable / replace。
- 更新采用 immutable candidate + atomic publication。
- caller-owned input collection 在 publish 前被复制。
- Client 每个 logical RPC 只读取一次 interceptor snapshot。
- Client telemetry path 使用同一 captured snapshot。
- ClientInterceptorState / continuation 不再读取全局 pipeline field。
- Server 每个 service invocation 只 capture 一次 interceptor snapshot。
- ServerPipelineFacts 始终持有该次调用的固定 snapshot。
- in-flight call 在更新期间不会混用 generation。
- Unary / OneWay / ClientStreaming / ServerStreaming / DuplexStreaming 都有动态更新测试。
- enable / disable / replace / invalid update / concurrent update 都有测试。
- update 与 Stop/Dispose 并发无死锁、无重新发布到 disposed instance。
- disabled path 无新增 per-call allocation、无 lock。
- BenchmarkDotNet before/after 已提交,性能变化符合 [Runtime Configuration] 动态配置 / Hot Reload 长期跟踪 #262 门禁。
- interceptor ownership contract 未被隐式改变。
- 文档明确 next-logical-call semantics。
Checks
- 以
dev为分析与实施基线。 - 已搜索现有 open issues,仅存在总跟踪 [Runtime Configuration] 动态配置 / Hot Reload 长期跟踪 #262,没有重复 Dynamic Interceptors 实施 issue。
- 本 issue 给出具体代码路径、正确性不变量、实施顺序、测试、benchmark、风险和完成定义。
Activity
github-actions commentedon Aug 20, 2026
github-actions commentedon Aug 20, 2026
github-actions commentedon Aug 20, 2026
github-actions commentedon Aug 20, 2026
github-actions commentedon Aug 20, 2026
github-actions commentedon Aug 20, 2026
github-actions commentedon Aug 20, 2026
github-actions commentedon Aug 20, 2026
github-actions commentedon Aug 20, 2026
github-actions commentedon Aug 20, 2026
github-actions commentedon Aug 20, 2026
github-actions commentedon Aug 20, 2026
github-actions commentedon Aug 20, 2026
feat: support runtime interceptor replacement (#266)
SunSi12138 commentedon Aug 21, 2026
Implemented and merged via #266 into
devas squash commit866dd7557b14a4593cde0f4f3df263720ea9f943.Completion notes:
dev. Before merge, its branch was fast-forwarded to GitHub's conflict-free integration merge with currentdev(c488cf5), then PR Quick fix(generator): allocate arrays after generic element ranks (#873) #881 and CodeQL [P3][3.0][Generator] 服务 required 成员未纳入构造校验,生成 activator 才报 CS9035 #878 both passed on that integrated head. Final merge was performed with squash.Closing as completed.