NewLife/NewLife.Remoting

重构命令响应总线,能力合并至SessionManager

移除 ICommandResponseBus/CommandResponseBus 及相关测试,命令响应广播能力完全内聚到 SessionManager,支持命令下发、响应等待、响应广播的完整闭环。ISessionManager 新增 PublishResponseAsync,所有调用点切换为 SessionManager,减少依赖,提升易用性。破坏性变更,需同步迁移依赖方。
大石头 authored at 2026-07-05 09:53:16
7bb5c9f
Tree
1 Parent(s) 656bd6c
Summary: 9 changed files with 361 additions and 659 deletions.
Modified +2 -18
Deleted +0 -253
NewLife.Remoting/Services/CommandResponseBus.cs
Deleted +0 -31
NewLife.Remoting/Services/ICommandResponseBus.cs
Modified +7 -1
Modified +348 -100
Modified +4 -5
Modified +0 -4
Deleted +0 -147
XUnitTest/Services/CommandResponseBusTests.cs
Deleted +0 -100
XUnitTest/Services/SessionManagerResponseBusTests.cs
Modified +2 -18
diff --git a/NewLife.Remoting.Extensions/Services/DefaultDeviceService.cs b/NewLife.Remoting.Extensions/Services/DefaultDeviceService.cs
index 8b5fef4..0fbdb50 100644
--- a/NewLife.Remoting.Extensions/Services/DefaultDeviceService.cs
+++ b/NewLife.Remoting.Extensions/Services/DefaultDeviceService.cs
@@ -32,7 +32,6 @@ public abstract class DefaultDeviceService<TDevice, TOnline>(ISessionManager ses
     private static Func<String, TDevice?>? _findDevice;
     private static Func<String, TOnline?>? _findOnline;
     private readonly ITracer? _tracer = serviceProvider.GetService<ITracer>();
-    private readonly ICommandResponseBus? _responseBus = serviceProvider.GetService<ICommandResponseBus>();
     #endregion
 
     #region 静态构造
@@ -500,8 +499,6 @@ public abstract class DefaultDeviceService<TDevice, TOnline>(ISessionManager ses
 
         // 持久化保存:设备重连或心跳时可恢复未完成的指令
         SaveCommand(command);
-
-        command.Status = CommandStatus.已发送;
     }
 
     /// <summary>命令响应</summary>
@@ -516,21 +513,8 @@ public abstract class DefaultDeviceService<TDevice, TOnline>(ISessionManager ses
         model.Code = context.Device?.Code ?? context.Code;
         model.SenderNodeId = Runtime.ClientId;
 
-        if (_responseBus != null)
-        {
-            // 新方案:事件总线广播响应(fire-and-forget,广播不阻塞)
-            _ = _responseBus.PublishResponseAsync(model, default);
-        }
-        else
-        {
-            // 旧方案(兼容):写入 Redis 队列
-            var topic = $"cmdreply:{model.Id}";
-            var q = cacheProvider.GetQueue<CommandReplyModel>(topic);
-            q.Add(model);
-
-            // 设置过期时间,过期自动清理
-            cacheProvider.Cache.SetExpire(topic, TimeSpan.FromSeconds(60));
-        }
+        // 通过会话管理器内置的响应事件总线广播响应(fire-and-forget,跨实例广播不阻塞)
+        _ = sessionManager.PublishResponseAsync(model, default);
 
         WriteHistory(context, "命令响应", true, model.ToJson(false, false, false));
 
Deleted +0 -253
NewLife.Remoting/Services/CommandResponseBus.cs
Deleted +0 -31
NewLife.Remoting/Services/ICommandResponseBus.cs
Modified +7 -1
diff --git a/NewLife.Remoting/Services/ISessionManager.cs b/NewLife.Remoting/Services/ISessionManager.cs
index 2654a9a..f69c5bc 100644
--- a/NewLife.Remoting/Services/ISessionManager.cs
+++ b/NewLife.Remoting/Services/ISessionManager.cs
@@ -33,7 +33,7 @@ public interface ISessionManager
     /// <summary>向目标会话发送命令,支持同步等待响应</summary>
     /// <remarks>
     /// timeout=0 时 fire-and-forget,立即返回 null;timeout>0 时阻塞等待设备 CommandReply。
-    /// 内部优先使用 ICommandResponseBus 事件总线等待,未注册时降级到 Redis 队列。
+    /// 内部通过事件总线注册回调等待响应,未配置事件总线时降级到 Redis 队列。
     /// </remarks>
     /// <param name="code">设备编码</param>
     /// <param name="command">命令模型</param>
@@ -42,4 +42,10 @@ public interface ISessionManager
     /// <param name="cancellationToken">取消令牌</param>
     /// <returns>命令响应,超时或 fire-and-forget 时返回 null</returns>
     Task<CommandReplyModel?> PublishAsync(String code, CommandModel command, String? message, Int32 timeout = 0, CancellationToken cancellationToken = default);
+
+    /// <summary>发布命令响应。由 CommandReply 入口调用,通过事件总线广播到所有实例</summary>
+    /// <param name="reply">命令响应</param>
+    /// <param name="cancellationToken">取消令牌</param>
+    /// <returns></returns>
+    Task<Int32> PublishResponseAsync(CommandReplyModel reply, CancellationToken cancellationToken);
 }
\ No newline at end of file
Modified +348 -100
diff --git a/NewLife.Remoting/Services/SessionManager.cs b/NewLife.Remoting/Services/SessionManager.cs
index 21c681d..96ca208 100644
--- a/NewLife.Remoting/Services/SessionManager.cs
+++ b/NewLife.Remoting/Services/SessionManager.cs
@@ -10,27 +10,48 @@ using NewLife.Threading;
 
 namespace NewLife.Remoting.Services;
 
-/// <summary>会话管理器。管理命令会话的生命周期,支持进程内或跨进程(Redis队列)消息分发</summary>
+/// <summary>会话管理器。管理命令会话生命周期,内置命令下发、响应等待、响应广播的完整闭环</summary>
 /// <remarks>
-/// 主要用于管理 WebSocket 等长连接会话(如 <see cref="CommandSession"/>),实现服务端向客户端下发命令的能力。
+/// 主要用于管理 WebSocket 等长连接会话(如 <see cref="CommandSession"/>),
+/// 实现服务端向客户端下发命令并同步等待设备响应的能力,无需额外依赖独立响应总线服务。
 /// 
-/// <para>核心工作流程:</para>
+/// <para>四大核心流程:</para>
 /// <list type="number">
-/// <item>客户端建立连接时,调用 <see cref="Add"/> 将会话加入管理器,以 Code 为唯一标识</item>
-/// <item>服务端需要下发命令时,调用 <see cref="PublishAsync"/> 发布消息到事件总线</item>
-/// <item>事件总线触发 <see cref="OnMessage"/>,根据 Code 找到对应会话并交由其处理(如通过 WebSocket 发送给客户端)</item>
-/// <item>客户端断开连接时,调用 <see cref="Remove"/> 从管理器移除会话</item>
-/// <item>内置定时器自动清理不活跃的会话</item>
+/// <item>
+/// <b>命令下发管线</b>:<see cref="PublishAsync"/> → <see cref="Init"/>/<see cref="Create"/>(事件总线 <see cref="Topic"/>)→ 广播 code#json →
+/// <see cref="OnMessage"/>(匹配 Code 找到 Session)→ session.HandleAsync(WebSocket 下发)
+/// </item>
+/// <item>
+/// <b>响应等待管线</b>:<see cref="PublishAsync"/> timeout&gt;0 → <see cref="WaitResponseAsync"/>(注册 <see cref="CallbackEntry"/> 到 _callbacks 字典)→
+/// 阻塞等待 TCS → 超时/取消/收到响应后返回
+/// </item>
+/// <item>
+/// <b>响应广播管线</b>:设备 CommandReply → <see cref="PublishResponseAsync"/> → <see cref="InitResponse"/>(事件总线 <see cref="ResponseTopic"/>)→
+/// 广播 JSON → <see cref="OnCommandResponse"/>(按 CommandId 匹配 _callbacks)→ 完成 TCS
+/// </item>
+/// <item>
+/// <b>会话生命周期</b>:<see cref="Add"/> → <see cref="Remove"/> → <see cref="CloseAll"/> → 定时器 <see cref="RemoveNotAlive"/>
+/// </item>
+/// </list>
+/// 
+/// <para>集群多实例工作流(ABC 三实例典型场景):</para>
+/// <list type="bullet">
+/// <item>A 实例调用 <see cref="PublishAsync"/> 发起命令,通过事件总线「广播」命令到全部实例</item>
+/// <item>B 实例的 <see cref="OnMessage"/> 匹配到设备会话,通过 WebSocket 下发给设备</item>
+/// <item>设备执行完毕,HTTP 上报 CommandReply 到 C 实例的接口</item>
+/// <item>C 实例调用 <see cref="PublishResponseAsync"/> 通过事件总线「广播」响应到全部实例</item>
+/// <item>A 实例的 <see cref="OnCommandResponse"/> 按 CommandId 匹配 _callbacks 中的等待回调,完成 TCS</item>
+/// <item>超时或取消时自动清理回调注册(finally + 定时器兜底),防止内存泄漏</item>
 /// </list>
 /// 
 /// <para>事件总线模式:</para>
 /// <list type="bullet">
-/// <item>单机模式:使用内存事件总线 <see cref="EventBus{T}"/>,仅支持进程内消息分发</item>
-/// <item>集群模式:使用 Redis 事件总线,支持跨进程消息分发,适用于多实例部署场景</item>
+/// <item>单机模式:使用内存 <see cref="EventBus{T}"/>,仅支持进程内消息分发</item>
+/// <item>集群模式:通过 <see cref="IEventBusFactory"/> 创建 Redis EventBus,支持跨进程消息分发</item>
 /// </list>
 /// 
 /// <para>消息格式:</para>
-/// 实际发送的消息格式为 "code#message",其中 code 为会话标识,message 为 JSON 序列化的命令内容,因此 code 内不能包含 # 字符。
+/// 命令消息为 "code#json";响应消息为纯 JSON(<see cref="CommandReplyModel"/>),code 内不能包含 #。
 /// </remarks>
 public class SessionManager(IServiceProvider serviceProvider) : DisposeBase, ISessionManager
 {
@@ -55,14 +76,29 @@ public class SessionManager(IServiceProvider serviceProvider) : DisposeBase, ISe
     /// <summary>事件总线工厂</summary>
     public IEventBusFactory? Factory { get; set; }
 
+    /// <summary>响应主题。事件总线的响应频道名称,默认从 Topic 派生</summary>
+    public String ResponseTopic => $"{Topic}Replies";
+
+    /// <summary>命令响应回调注册表。Key=CommandId,Value=TaskCompletionSource</summary>
+    private readonly ConcurrentDictionary<Int64, CallbackEntry> _callbacks = new();
+
+    /// <summary>命令响应事件总线。用于跨进程响应广播</summary>
+    private IEventBus<String>? _responseBus;
+
     /// <summary>清理会话计时器</summary>
     private TimerX? _clearTimer;
 
     private readonly ICacheProvider? _cacheProvider = serviceProvider.GetService<ICacheProvider>();
     private readonly ITracer? _tracer = serviceProvider.GetService<ITracer>();
+
+#if NET45
+    private static readonly Task _completedTask = TaskEx.FromResult(0);
+#else
+    private static readonly Task _completedTask = Task.CompletedTask;
+#endif
     #endregion
 
-    #region 方法
+    #region 构造/销毁
     /// <summary>销毁</summary>
     /// <param name="disposing"></param>
     protected override void Dispose(Boolean disposing)
@@ -71,87 +107,17 @@ public class SessionManager(IServiceProvider serviceProvider) : DisposeBase, ISe
 
         CloseAll(disposing ? "Dispose" : "GC");
     }
+    #endregion
 
-    /// <summary>初始化事件总线。延迟初始化,首次添加会话或发布消息时触发</summary>
-    private void Init()
-    {
-        if (Bus != null) return;
-        lock (this)
-        {
-            if (Bus != null) return;
-
-            Bus = Create();
-
-            // 进程退出(如 Ctrl+C)时,主动销毁会话管理器,尽快打断会话的Receive等待
-            Host.RegisterExit(() => this.TryDispose());
-        }
-    }
-
-    /// <summary>创建事件总线,用于转发会话消息</summary>
-    /// <returns></returns>
-    protected virtual IEventBus<String> Create()
-    {
-        using var span = _tracer?.NewSpan($"cmd:{Topic}:CreateBus", ClientId);
-        try
-        {
-            // 创建事件总线,指定队列消费组
-            var bus = Factory?.CreateEventBus<String>(Topic, ClientId);
-            bus ??= _cacheProvider?.CreateEventBus<String>(Topic, ClientId);
-            bus ??= new EventBus<String>();
-
-            // 订阅总线事件到OnMessage
-            span?.AppendTag($"订阅[{Topic}]的总线事件到OnMessage,再根据消息头的设备号分发给各Session,一般是WebSocket下发指令");
-            bus.Subscribe(OnMessage);
-
-            return bus;
-        }
-        catch (Exception ex)
-        {
-            span?.SetError(ex, null);
-            throw;
-        }
-    }
-
-    /// <summary>添加会话到管理器</summary>
-    /// <param name="session">命令会话</param>
-    public virtual void Add(ICommandSession session)
-    {
-        using var span = _tracer?.NewSpan($"cmd:{Topic}:Add", session.Code);
-
-        Init();
-
-        _dic.AddOrUpdate(session.Code, session, (k, s) => s);
-
-        // 监听会话销毁事件,自动从管理器移除
-        if (session is IDisposable2 ds)
-            ds.OnDisposed += (s, e) => Remove((s as ICommandSession)!);
-
-        // 启动清理定时器
-        var p = ClearPeriod * 1000;
-        if (p > 0)
-            _clearTimer ??= new TimerX(RemoveNotAlive, null, p, p) { Async = true, };
-    }
-
-    /// <summary>从管理器删除会话</summary>
-    /// <param name="session">命令会话</param>
-    public virtual void Remove(ICommandSession session)
-    {
-        if (session == null) return;
-
-        using var span = _tracer?.NewSpan($"cmd:{Topic}:Remove", session.Code);
-
-        if (_dic.TryRemove(session.Code, out _))
-            span?.AppendTag(null!, 1);
-    }
-
-    /// <summary>向目标会话发送事件。进程内转发,或通过Redis队列</summary>
-    /// <remarks>实际发送的消息是 code#message,因此code内不能带有#</remarks>
+    #region 命令下发管线
+    /// <summary>向目标会话发送命令,支持同步等待响应。进程内转发或通过 Redis 事件总线</summary>
+    /// <remarks>实际发送的消息是 code#message,因此 code 内不能带有 #</remarks>
     /// <param name="code">设备编码</param>
     /// <param name="command">命令模型</param>
     /// <param name="message">原始命令消息</param>
     /// <param name="timeout">超时时间(秒),0 表示不超时</param>
     /// <param name="cancellationToken">取消令牌</param>
-    /// <returns></returns>
+    /// <returns>命令响应,超时或 fire-and-forget 时返回 null</returns>
     public virtual async Task<CommandReplyModel?> PublishAsync(String code, CommandModel command, String? message, Int32 timeout = 0, CancellationToken cancellationToken = default)
     {
         if (command == null && message.IsNullOrEmpty()) throw new ArgumentNullException(nameof(command), "命令和消息不能同时为空");
@@ -177,24 +143,51 @@ public class SessionManager(IServiceProvider serviceProvider) : DisposeBase, ISe
 
         if (timeout <= 0) return null;
 
-        // 优先使用响应总线等待设备回复
-        var responseBus = serviceProvider.GetService<ICommandResponseBus>();
-        if (responseBus != null)
-            return await responseBus.WaitResponseAsync(command!.Id, timeout, cancellationToken).ConfigureAwait(false);
+        // 通过内置事件总线回调机制等待设备响应
+        return await WaitResponseAsync(command!.Id, timeout, cancellationToken).ConfigureAwait(false);
+    }
 
-        // 降级到 Redis 队列
-        var cacheProvider = serviceProvider.GetService<ICacheProvider>();
-        if (cacheProvider != null)
+    /// <summary>初始化命令事件总线。延迟初始化,首次添加会话或发布消息时触发</summary>
+    private void Init()
+    {
+        if (Bus != null) return;
+        lock (this)
         {
-            var q = cacheProvider.GetQueue<CommandReplyModel>($"cmdreply:{command!.Id}");
-            return await q.TakeOneAsync(timeout, cancellationToken).ConfigureAwait(false);
+            if (Bus != null) return;
+
+            Bus = Create();
+
+            // 进程退出(如 Ctrl+C)时,主动销毁会话管理器,尽快打断会话的 Receive 等待
+            Host.RegisterExit(() => this.TryDispose());
         }
+    }
 
-        return null;
+    /// <summary>创建命令事件总线,用于转发会话消息</summary>
+    /// <returns></returns>
+    protected virtual IEventBus<String> Create()
+    {
+        using var span = _tracer?.NewSpan($"cmd:{Topic}:CreateBus", ClientId);
+        try
+        {
+            // 创建事件总线,指定队列消费组
+            var bus = Factory?.CreateEventBus<String>(Topic, ClientId);
+            bus ??= _cacheProvider?.CreateEventBus<String>(Topic, ClientId);
+            bus ??= new EventBus<String>();
+
+            // 订阅总线事件到 OnMessage
+            span?.AppendTag($"订阅[{Topic}]的总线事件到OnMessage,再根据消息头的设备号分发给各Session,一般是WebSocket下发指令");
+            bus.Subscribe(OnMessage);
+
+            return bus;
+        }
+        catch (Exception ex)
+        {
+            span?.SetError(ex, null);
+            throw;
+        }
     }
 
-    /// <summary>从事件总线收到事件</summary>
-    /// <remarks>实际发送消息是 code#message,因此需要先解码消息找到code</remarks>
+    /// <summary>从事件总线收到命令消息。解析 code#message 格式,找到对应会话并下发给设备</summary>
     /// <param name="message">原始命令消息</param>
     /// <param name="context">事件上下文。用于在发布者、订阅者及中间处理器之间传递协调数据,如 Handler、ClientId 等</param>
     /// <param name="cancellationToken">取消令牌</param>
@@ -217,8 +210,8 @@ public class SessionManager(IServiceProvider serviceProvider) : DisposeBase, ISe
         try
         {
             var dic = JsonParser.Decode(message)!;
-            msg = JsonHelper.Convert<CommandModel>(dic);
             span?.Detach(dic);
+            msg = JsonHelper.Convert<CommandModel>(dic);
         }
         catch (Exception ex)
         {
@@ -246,6 +239,208 @@ public class SessionManager(IServiceProvider serviceProvider) : DisposeBase, ISe
                 span?.AppendTag($"未找到编号为[{code}]的会话,无法处理消息。集群模式下事件总线广播到所有实例,该消息将由持有该设备会话的实例处理。");
         }
     }
+    #endregion
+
+    #region 响应等待管线
+    /// <summary>等待命令响应。注册 CallbackEntry 并阻塞直到收到响应或超时</summary>
+    /// <remarks>
+    /// 内部通过 ConcurrentDictionary{CommandId, CallbackEntry} 维护回调注册表。
+    /// 每个回调生命周期 ≤ timeout 秒,finally 保证清理,定时器兜底清理过期项,不会内存泄漏。
+    /// </remarks>
+    /// <param name="commandId">命令 Id</param>
+    /// <param name="timeout">超时时间(秒)</param>
+    /// <param name="cancellationToken">取消令牌</param>
+    /// <returns>命令响应,超时返回 null</returns>
+    protected virtual async Task<CommandReplyModel?> WaitResponseAsync(Int64 commandId, Int32 timeout, CancellationToken cancellationToken)
+    {
+        if (timeout <= 0) return null;
+
+        InitResponse();
+
+        using var span = _tracer?.NewSpan($"cmd:{ResponseTopic}:Wait", commandId);
+        try
+        {
+#if NET45
+            var tcs = new TaskCompletionSource<CommandReplyModel>();
+#else
+            var tcs = new TaskCompletionSource<CommandReplyModel>(TaskCreationOptions.RunContinuationsAsynchronously);
+#endif
+            var entry = new CallbackEntry
+            {
+                Tcs = tcs,
+                CreatedAt = Runtime.TickCount64,
+                Timeout = timeout * 1000,
+                Span = span,
+            };
+
+            _callbacks.TryAdd(commandId, entry);
+
+            // 使用取消令牌注册清理
+            using var ctr = cancellationToken.Register(() =>
+            {
+                if (_callbacks.TryRemove(commandId, out var e))
+                {
+#if NET45
+                    e.Tcs.TrySetCanceled();
+#else
+                    e.Tcs.TrySetCanceled(cancellationToken);
+#endif
+                    e.Span?.AppendTag($"cancelled: commandId={commandId}");
+                }
+            });
+
+            // 超时清理
+            var timeoutMs = timeout * 1000;
+            var delayTask = Task.Delay(timeoutMs, cancellationToken);
+            var completedTask = await Task.WhenAny(tcs.Task, delayTask).ConfigureAwait(false);
+
+            if (completedTask == delayTask)
+            {
+                if (cancellationToken.IsCancellationRequested)
+                {
+                    span?.AppendTag($"cancelled: commandId={commandId}");
+                    throw new OperationCanceledException(cancellationToken);
+                }
+
+                if (_callbacks.TryRemove(commandId, out var e))
+                {
+                    e.Tcs.TrySetResult(null!);
+                    span?.AppendTag($"timeout: commandId={commandId}, timeout={timeout}s");
+                    return null;
+                }
+            }
+
+            return await tcs.Task.ConfigureAwait(false);
+        }
+        finally
+        {
+            _callbacks.TryRemove(commandId, out _);
+        }
+    }
+
+    /// <summary>发布命令响应。由 CommandReply 入口调用,通过响应事件总线广播到所有实例</summary>
+    /// <param name="reply">命令响应</param>
+    /// <param name="cancellationToken">取消令牌</param>
+    /// <returns></returns>
+    public virtual async Task<Int32> PublishResponseAsync(CommandReplyModel reply, CancellationToken cancellationToken)
+    {
+        if (reply == null) throw new ArgumentNullException(nameof(reply));
+
+        InitResponse();
+
+        var jsonHost = serviceProvider.GetService<IJsonHost>();
+        var message = jsonHost != null ? jsonHost.Write(reply) : reply.ToJson();
+
+        using var span = _tracer?.NewSpan($"cmd:{ResponseTopic}:Publish", message);
+        try
+        {
+            return await _responseBus!.PublishAsync(message, null, cancellationToken).ConfigureAwait(false);
+        }
+        catch (Exception ex)
+        {
+            span?.SetError(ex, null);
+            throw;
+        }
+    }
+
+    /// <summary>初始化命令响应事件总线。延迟初始化,首次发送带超时的命令或发布响应时触发</summary>
+    private void InitResponse()
+    {
+        if (_responseBus != null) return;
+        lock (this)
+        {
+            if (_responseBus != null) return;
+
+            var topic = ResponseTopic;
+            using var span = _tracer?.NewSpan($"cmd:{topic}:CreateResponseBus", ClientId);
+            try
+            {
+                // 优先使用工厂创建跨进程总线(如 Redis),降级到内存总线
+                _responseBus = Factory?.CreateEventBus<String>(topic, ClientId);
+                _responseBus ??= _cacheProvider?.CreateEventBus<String>(topic, ClientId);
+                _responseBus ??= new EventBus<String>();
+
+                span?.AppendTag($"订阅[{topic}]的总线事件到OnCommandResponse,用于匹配命令响应回调");
+                _responseBus.Subscribe(OnCommandResponse);
+            }
+            catch (Exception ex)
+            {
+                span?.SetError(ex, null);
+                throw;
+            }
+        }
+    }
+
+    /// <summary>收到命令响应广播。按 CommandId 匹配本地 _callbacks 字典中的等待回调并完成 TCS</summary>
+    /// <param name="message">响应 JSON</param>
+    /// <param name="context">事件上下文</param>
+    /// <param name="cancellationToken">取消令牌</param>
+    /// <returns></returns>
+    protected virtual Task OnCommandResponse(String message, IEventContext context, CancellationToken cancellationToken)
+    {
+        using var span = _tracer?.NewSpan($"cmd:{ResponseTopic}:OnResponse", message);
+
+        CommandReplyModel? reply = null;
+        try
+        {
+            var dic = JsonParser.Decode(message)!;
+            span?.Detach(dic);
+            reply = JsonHelper.Convert<CommandReplyModel>(dic);
+        }
+        catch (Exception ex)
+        {
+            span?.SetError(ex);
+            return _completedTask;
+        }
+
+        if (reply == null || reply.Id == 0) return _completedTask;
+
+        // 按 CommandId 匹配本地回调
+        if (_callbacks.TryGetValue(reply.Id, out var entry))
+        {
+            span?.AppendTag($"matched: commandId={reply.Id}, status={reply.Status}");
+            entry.Tcs.TrySetResult(reply);
+            entry.Span?.AppendTag($"resolved: status={reply.Status}");
+        }
+        else
+            span?.AppendTag($"no callback for commandId={reply.Id} on this node");
+
+        return _completedTask;
+    }
+    #endregion
+
+    #region 会话生命周期
+    /// <summary>添加会话到管理器</summary>
+    /// <param name="session">命令会话</param>
+    public virtual void Add(ICommandSession session)
+    {
+        using var span = _tracer?.NewSpan($"cmd:{Topic}:Add", session.Code);
+
+        Init();
+
+        _dic.AddOrUpdate(session.Code, session, (k, s) => s);
+
+        // 监听会话销毁事件,自动从管理器移除
+        if (session is IDisposable2 ds)
+            ds.OnDisposed += (s, e) => Remove((s as ICommandSession)!);
+
+        // 启动清理定时器
+        var p = ClearPeriod * 1000;
+        if (p > 0)
+            _clearTimer ??= new TimerX(RemoveNotAlive, null, p, p) { Async = true, };
+    }
+
+    /// <summary>从管理器删除会话</summary>
+    /// <param name="session">命令会话</param>
+    public virtual void Remove(ICommandSession session)
+    {
+        if (session == null) return;
+
+        using var span = _tracer?.NewSpan($"cmd:{Topic}:Remove", session.Code);
+
+        if (_dic.TryRemove(session.Code, out _))
+            span?.AppendTag(null!, 1);
+    }
 
     /// <summary>根据标识获取会话</summary>
     /// <param name="key">会话标识(Code)</param>
@@ -267,8 +462,20 @@ public class SessionManager(IServiceProvider serviceProvider) : DisposeBase, ISe
         _clearTimer.TryDispose();
         _clearTimer = null;
 
+        // 清理响应回调
+        if (!_callbacks.IsEmpty)
+        {
+            foreach (var kv in _callbacks)
+            {
+                kv.Value.Tcs.TrySetCanceled();
+            }
+            _callbacks.Clear();
+        }
+
         // 释放事件总线
         Bus.TryDispose();
+        _responseBus.TryDispose();
+        _responseBus = null;
 
         if (_dic.IsEmpty) return;
 
@@ -287,7 +494,9 @@ public class SessionManager(IServiceProvider serviceProvider) : DisposeBase, ISe
             }
         }
     }
+    #endregion
 
+    #region 定时清理
     /// <summary>移除不活动的会话</summary>
     private void RemoveNotAlive(Object? state)
     {
@@ -323,6 +532,45 @@ public class SessionManager(IServiceProvider serviceProvider) : DisposeBase, ISe
             if (item.Value is INetSession ss) ss.Close(nameof(RemoveNotAlive));
             item.Value.TryDispose();
         }
+
+        // 清理过期的命令响应回调
+        CleanupExpiredCallbacks();
+    }
+
+    /// <summary>清理过期的命令响应回调。定时器兜底,防止极端情况下 callback 泄漏</summary>
+    private void CleanupExpiredCallbacks()
+    {
+        if (_callbacks.IsEmpty) return;
+
+        var now = Runtime.TickCount64;
+        var count = 0;
+
+        foreach (var kv in _callbacks)
+        {
+            if (now - kv.Value.CreatedAt > kv.Value.Timeout)
+            {
+                if (_callbacks.TryRemove(kv.Key, out var entry))
+                {
+                    entry.Tcs.TrySetResult(null!);
+                    count++;
+                }
+            }
+        }
+
+        if (count > 0)
+        {
+            using var span = _tracer?.NewSpan($"cmd:{Topic}:CleanupCallbacks", count);
+        }
+    }
+    #endregion
+
+    #region 内部类
+    private class CallbackEntry
+    {
+        public TaskCompletionSource<CommandReplyModel> Tcs { get; set; } = null!;
+        public Int64 CreatedAt { get; set; }
+        public Int32 Timeout { get; set; }
+        public ISpan? Span { get; set; }
     }
     #endregion
 }
Modified +4 -5
diff --git a/Samples/ZeroServer/Clients/ClientTest.cs b/Samples/ZeroServer/Clients/ClientTest.cs
index 2e9dd1e..0d4ddc1 100644
--- a/Samples/ZeroServer/Clients/ClientTest.cs
+++ b/Samples/ZeroServer/Clients/ClientTest.cs
@@ -55,23 +55,22 @@ public static class ClientTest
         using var span = _tracer?.NewSpan("SelfTest:ResponseBus");
 
         var deviceService = serviceProvider.GetService<IDeviceService>();
-        var responseBus = serviceProvider.GetService<ICommandResponseBus>();
+        var sessionManager = serviceProvider.GetService<ISessionManager>();
 
         if (deviceService == null)
         {
             XTrace.WriteLine("[SelfTest] ❌ IDeviceService 未注册,跳过响应总线测试");
             return;
         }
-        if (responseBus == null)
+        if (sessionManager == null)
         {
-            XTrace.WriteLine("[SelfTest] ❌ ICommandResponseBus 未注册,响应总线未启用");
+            XTrace.WriteLine("[SelfTest] ❌ ISessionManager 未注册,响应总线未启用");
             return;
         }
 
         XTrace.WriteLine("========== [SelfTest] 开始响应总线端到端测试 ==========");
         XTrace.WriteLine($"[SelfTest] 目标设备: {code}");
-        XTrace.WriteLine($"[SelfTest] 命令总线: SessionManager (Topic=Commands)");
-        XTrace.WriteLine($"[SelfTest] 响应总线: CommandResponseBus (Topic=CommandReplies)");
+        XTrace.WriteLine($"[SelfTest] 会话管理器: SessionManager (Topic=Commands, ResponseTopic=CommandsReplies)");
 
         // 查找设备
         var device = deviceService.QueryDevice(code);
Modified +0 -4
diff --git a/Samples/ZeroServer/Services/IoTExtensions.cs b/Samples/ZeroServer/Services/IoTExtensions.cs
index dc583df..a467325 100644
--- a/Samples/ZeroServer/Services/IoTExtensions.cs
+++ b/Samples/ZeroServer/Services/IoTExtensions.cs
@@ -35,10 +35,6 @@ public static class IoTExtensions
         // 注册Remoting所必须的服务
         services.AddRemoting(setting);
 
-        // 注册命令响应总线,启用命令响应广播机制(替代旧 Redis cmdreply 队列)
-        // 与 SessionManager 共用 IEventBusFactory 基础设施,各自独立的 IEventBus 实例(不同 Topic)
-        services.TryAddSingleton<ICommandResponseBus, CommandResponseBus>();
-
         // 后台服务
         services.AddHostedService<NodeOnlineService>();
 
Deleted +0 -147
XUnitTest/Services/CommandResponseBusTests.cs
Deleted +0 -100
XUnitTest/Services/SessionManagerResponseBusTests.cs