fix: Agent 执行与显示层解耦,修复锁屏冻结根因
浏览器锁屏导致 WebSocket 半死,ws_sender.send().await 永久阻塞,
级联阻塞 dispatcher → MessageBus → Agent Loop,后端停止执行直到解锁。
基于第一性原理建立"执行-显示解耦"原则:agent 执行只依赖 SQLite
持久化,实时广播是可丢弃的最佳努力通道。
核心改动:
- MessageBus::publish_outbound 由 send().await 改为 try_send(),
bus 满时丢弃消息并告警,agent 不再被显示层阻塞
- WebSocket writer task 包裹 30s 超时,使用每连接独立的
CancellationToken(非共享 CliChannel 级 token),避免一个连接
超时关闭所有连接;writer 退出时 cancel 通知主 loop 退出
- CliChannel::send 由 send().await 改为 try_send(),避免 dispatcher
单线程被卡住的 writer 阻塞 37s
- 全仓 13 处 publish_outbound 调用统一区分 Dropped(warn)/Closed(error)
- scheduler 3 处 ? 改为 warn,避免 Dropped 触发 misfire 重试风暴
- 前端 WebSocket 添加 25s ping + 指数退避重连(3s→60s封顶)
- 前端 session_list 区分重连恢复/首次连接,重连时保留 messages
并刷新 topic 列表
经五轮对抗性审查验证,修复了共享 cancel token、dispatcher 阻塞、
load_chat_messages 跨 topic 污染、原 session 删除后状态不一致等
回归问题。
同时升级版本号至 0.3.0 并更新 CHANGELOG。
验证:cargo clippy --all-targets --all-features ✓
npm run build ✓ | useChat.test.ts 13 passed ✓
This commit is contained in:
parent
1e2d64e28b
commit
14d903e067
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -1635,7 +1635,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
|
|||||||
@ -2,6 +2,70 @@
|
|||||||
|
|
||||||
本文件记录 Picobot 各版本的显著变更,遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 风格。
|
本文件记录 Picobot 各版本的显著变更,遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 风格。
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-08-04
|
||||||
|
|
||||||
|
较 [0.2.0] 的 14 个已提交 commit + 本次版本一并提交的锁屏冻结架构修复(12 文件),聚焦 **Agent 执行与显示层解耦**、**工程化基线**、**并发持久化稳定性** 与 **安全加固** 四大方向。架构修复经五轮对抗性审查验证。
|
||||||
|
|
||||||
|
### 新增功能
|
||||||
|
|
||||||
|
#### 话题重命名
|
||||||
|
- 新增 `RenameTopic` 命令与 `TopicRenamed` 协议消息,复用存储层已有的 `update_topic_title` 方法。后端响应携带刷新后的完整 topic 列表,前端零额外往返同步侧边栏。
|
||||||
|
- 前端 `TopicList` 侧边栏增加内联编辑入口:悬停显示铅笔图标,Enter 提交 / Esc 取消 / blur 取消,通过 `onMouseDown preventDefault` 防止按钮点击时 input 提前失焦。
|
||||||
|
|
||||||
|
#### 前端自动构建集成
|
||||||
|
- 新增 `build.rs`,`cargo build` 时自动执行 `npm install + npm run build`,消除前后端构建割裂。支持 `SKIP_FRONTEND_BUILD` 环境变量跳过。
|
||||||
|
|
||||||
|
#### 工程化基线
|
||||||
|
- `rustfmt.toml` 固化 `max_width=100` / 4 空格缩进;`Cargo.toml` 配置 `[lints.rust]` 与 `[lints.clippy]` 渐进式规则。
|
||||||
|
- `.github/workflows/ci.yml`:Rust(fmt + clippy + test)+ 前端(eslint + tsc + test + prettier format:check)双平台 CI。
|
||||||
|
- `Makefile` 新增 `check` / `fmt` / `fix` 目标,clippy 对齐 `--all-targets --all-features`。
|
||||||
|
- 前端 eslint flat config + prettier 配置,对 47 个前端文件统一格式化并在 CI 强制 `format:check`。
|
||||||
|
|
||||||
|
#### 默认迭代上限调整
|
||||||
|
- `max_tool_iterations` 默认值调整为 1000,匹配长任务子代理的实际需求。
|
||||||
|
|
||||||
|
### 架构修复
|
||||||
|
|
||||||
|
#### Agent 执行与显示层解耦(锁屏冻结根因修复)
|
||||||
|
根因:浏览器锁屏导致 WebSocket 半死,`ws_sender.send().await` 永久阻塞,级联阻塞 dispatcher → MessageBus → Agent Loop,后端停止执行直到解锁。
|
||||||
|
|
||||||
|
基于第一性原理建立"执行-显示解耦"原则:agent 执行只依赖 SQLite 持久化,实时广播是可丢弃的最佳努力通道。
|
||||||
|
|
||||||
|
- `MessageBus::publish_outbound` 由 `send().await` 改为 `try_send()`:bus 满时丢弃消息并告警,agent 不再被显示层阻塞。新增 `BusError::Dropped` 变体。
|
||||||
|
- WebSocket writer task 包裹 `tokio::time::timeout(30s)`:半死连接超时即关闭。使用每连接独立的 `CancellationToken`(非共享的 CliChannel 级 token),避免一个连接超时关闭所有连接。writer 退出时 cancel 通知主 loop 退出,确保 `unregister_connection` 执行。
|
||||||
|
- `CliChannel::send` 由 `send().await` 改为 `try_send()`:dispatcher 单线程顺序处理,原阻塞式发送在 writer 卡住时会阻塞所有连接 37s,改后立即返回。
|
||||||
|
- 全仓 13 处 `publish_outbound` 调用统一区分 `Dropped`(warn,预期背压)/ `Closed`(error,异常)日志级别,消除监控噪音。scheduler 的 3 处 `?` 改为 warn,避免 `Dropped` 触发 misfire 重试风暴。
|
||||||
|
- 前端 WebSocket 添加 25s 客户端 ping + 指数退避重连(3s→6s→12s→24s→60s 封顶,上限 999 次)。重连后区分"重连恢复"与"首次/切换通道":前者保留断连前 messages 并刷新 topic 列表,后者清空数据避免污染。
|
||||||
|
|
||||||
|
### 安全加固
|
||||||
|
|
||||||
|
#### SSRF 重定向绕过修复
|
||||||
|
- `web_fetch` 工具禁用 HTTP 重定向(`redirect::Policy::none()`):原 `validate_url` 只校验初始 URL 的 host,跟随 302 跳转可重定向到 `169.254.169.254`(云元数据)或 `127.0.0.1` 等内网地址,绕过 `is_private_host` 的 SSRF 防护。与 `http_request` 工具保持一致。
|
||||||
|
|
||||||
|
#### Safety Guard 正则收紧
|
||||||
|
- `format` 正则收紧为 `\bformat\s+.*[a-z]:`,要求出现盘符才拦截,避免误伤 `dart format`、`buf format`、`pytest --format` 等合法命令。
|
||||||
|
- 按平台分组注入规则,Unix 不再注入 Windows 专用规则;`Remove-Item` 正则改为小写,与 `guard_command` 的大小写处理一致。
|
||||||
|
|
||||||
|
### 稳定性修复
|
||||||
|
|
||||||
|
#### SQLite 并发写入死锁
|
||||||
|
- 7 个写事务从 `BEGIN DEFERRED` 改为 `BEGIN IMMEDIATE`:在事务开始即获取写锁,消除多 sub-agent 并发写入时的死锁路径。`busy_timeout` 从 5s 提升至 30s。
|
||||||
|
- 根因:`BEGIN DEFERRED` 下多个事务可同时读 `MAX(seq)` 不持写锁,提交时互相阻塞,5s timeout 耗尽后返回 `SQLITE_BUSY`。`BEGIN IMMEDIATE` 强制写者串行排队,顺带消除 `MAX(seq)+1` 竞态导致的 UNIQUE 约束冲突。
|
||||||
|
|
||||||
|
#### 测试与代码质量
|
||||||
|
- 修复 `anthropic` 错误链嵌套测试为真正的 `#[source]` 链(原测试是无效的,inner 变量被 `let _` 抑制)。
|
||||||
|
- 补充 `anthropic` provider 15 个纯函数单测(原 396 行零测试),覆盖 data URL 解析、图片过滤、字段过滤、响应反序列化、错误链格式化。
|
||||||
|
- 修复 3 个失败测试:`agent_md_template` 写入模板内容 / `StreamingAccumulator` BTreeMap 保序 / `source_order` 断言补全。
|
||||||
|
- 清理未使用的 dead code 函数与方法。
|
||||||
|
- 新增 `ARCHITECTURE.md` 架构文档,聚焦数据流与 7 个关键设计决策。
|
||||||
|
|
||||||
|
#### CI 覆盖范围扩大
|
||||||
|
- `cargo build --lib` 改为 `cargo build`,确保 `main.rs` 二进制入口被编译验证。
|
||||||
|
- `cargo test --lib` 改为 `cargo test`,纳入 `tests/` 目录集成测试。`test_request_format.rs` 的 8 个序列化测试此前从未在 CI 中运行。
|
||||||
|
|
||||||
|
#### 跨平台构建修复
|
||||||
|
- 修正依赖分类错误:`rmcp` / `schemars` / `http` / `tower-http` / `rust-embed` 均为跨平台 crate,错放在 `[target.'cfg(windows)'.dependencies]` 下导致 Linux 构建失败,移出 target 段。
|
||||||
|
|
||||||
## [0.2.0] - 2026-07-31
|
## [0.2.0] - 2026-07-31
|
||||||
|
|
||||||
较 [0.1.2] 的 47 个 commit 迭代,聚焦 **能力策略**、**模型独立配置**、**话题级并发隔离** 与 **Agent Loop 性能优化** 四大方向。
|
较 [0.1.2] 的 47 个 commit 迭代,聚焦 **能力策略**、**模型独立配置**、**话题级并发隔离** 与 **Agent Loop 性能优化** 四大方向。
|
||||||
|
|||||||
@ -52,14 +52,26 @@ impl MessageBus {
|
|||||||
Some(msg)
|
Some(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a message to the outbound queue
|
/// Publish a message to the outbound queue.
|
||||||
|
///
|
||||||
|
/// Uses `try_send` (non-blocking): if the queue is full, the message is
|
||||||
|
/// dropped immediately with a warning. This ensures the agent loop is never
|
||||||
|
/// blocked by slow or disconnected display consumers. Persistent state is
|
||||||
|
/// unaffected — messages are stored in SQLite independently.
|
||||||
pub async fn publish_outbound(&self, msg: OutboundMessage) -> Result<(), BusError> {
|
pub async fn publish_outbound(&self, msg: OutboundMessage) -> Result<(), BusError> {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::debug!(channel = %msg.channel, chat_id = %msg.chat_id, content_len = %msg.content.len(), "Bus: publishing outbound message");
|
tracing::debug!(channel = %msg.channel, chat_id = %msg.chat_id, content_len = %msg.content.len(), "Bus: publishing outbound message");
|
||||||
self.outbound_tx
|
match self.outbound_tx.try_send(msg) {
|
||||||
.send(msg)
|
Ok(()) => Ok(()),
|
||||||
.await
|
Err(tokio::sync::mpsc::error::TrySendError::Full(msg)) => {
|
||||||
.map_err(|_| BusError::Closed)
|
tracing::warn!(
|
||||||
|
channel = %msg.channel,
|
||||||
|
"Outbound bus full, dropping message"
|
||||||
|
);
|
||||||
|
Err(BusError::Dropped)
|
||||||
|
}
|
||||||
|
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => Err(BusError::Closed),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consume an outbound message from the outbound queue.
|
/// Consume an outbound message from the outbound queue.
|
||||||
@ -76,12 +88,14 @@ impl MessageBus {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BusError {
|
pub enum BusError {
|
||||||
Closed,
|
Closed,
|
||||||
|
Dropped,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for BusError {
|
impl std::fmt::Display for BusError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
BusError::Closed => write!(f, "Bus channel closed"),
|
BusError::Closed => write!(f, "Bus channel closed"),
|
||||||
|
BusError::Dropped => write!(f, "Bus full, message dropped"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,12 +100,15 @@ impl Channel for CliChannel {
|
|||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 使用 try_send 避免阻塞 dispatcher——dispatcher 是单线程顺序处理,
|
||||||
|
// 若 writer task 卡在 ws_sender.send() 上,send().await 会阻塞,
|
||||||
|
// 导致所有连接的实时消息被阻塞。try_send 满时立即返回错误,
|
||||||
|
// dispatcher 记录后继续处理下一条消息。
|
||||||
for outbound in ws_outbound_from_outbound_message(&msg) {
|
for outbound in ws_outbound_from_outbound_message(&msg) {
|
||||||
connection
|
connection
|
||||||
.sender
|
.sender
|
||||||
.send(outbound)
|
.try_send(outbound)
|
||||||
.await
|
.map_err(|_| ChannelError::SendError("CLI websocket sender closed or full".to_string()))?;
|
||||||
.map_err(|_| ChannelError::SendError("CLI websocket sender closed".to_string()))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -229,7 +229,14 @@ impl InboundProcessor {
|
|||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!(error = %error, "Failed to publish command response");
|
match error {
|
||||||
|
crate::bus::BusError::Dropped => {
|
||||||
|
tracing::warn!(error = %error, "Outbound dropped (bus full)");
|
||||||
|
}
|
||||||
|
crate::bus::BusError::Closed => {
|
||||||
|
tracing::error!(error = %error, "Failed to publish command response");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(error) = response.error {
|
} else if let Some(error) = response.error {
|
||||||
@ -245,7 +252,14 @@ impl InboundProcessor {
|
|||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!(error = %e, "Failed to publish error response");
|
match e {
|
||||||
|
crate::bus::BusError::Dropped => {
|
||||||
|
tracing::warn!(error = %e, "Outbound dropped (bus full)");
|
||||||
|
}
|
||||||
|
crate::bus::BusError::Closed => {
|
||||||
|
tracing::error!(error = %e, "Failed to publish error response");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@ -305,7 +319,14 @@ impl InboundProcessor {
|
|||||||
.insert("topic_id".to_string(), topic_id.clone());
|
.insert("topic_id".to_string(), topic_id.clone());
|
||||||
}
|
}
|
||||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||||
tracing::error!(error = %error, "Failed to publish outbound");
|
match error {
|
||||||
|
crate::bus::BusError::Dropped => {
|
||||||
|
tracing::warn!(error = %error, "Outbound dropped (bus full)");
|
||||||
|
}
|
||||||
|
crate::bus::BusError::Closed => {
|
||||||
|
tracing::error!(error = %error, "Failed to publish outbound");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -407,7 +428,14 @@ impl InboundProcessor {
|
|||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!(error = %publish_error, "Failed to publish execution error outbound");
|
match publish_error {
|
||||||
|
crate::bus::BusError::Dropped => {
|
||||||
|
tracing::warn!(error = %publish_error, "Outbound dropped (bus full)");
|
||||||
|
}
|
||||||
|
crate::bus::BusError::Closed => {
|
||||||
|
tracing::error!(error = %publish_error, "Failed to publish execution error outbound");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -433,7 +461,14 @@ impl InboundProcessor {
|
|||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!(error = %error, "Failed to publish execution_completed");
|
match error {
|
||||||
|
crate::bus::BusError::Dropped => {
|
||||||
|
tracing::warn!(error = %error, "Outbound dropped (bus full)");
|
||||||
|
}
|
||||||
|
crate::bus::BusError::Closed => {
|
||||||
|
tracing::error!(error = %error, "Failed to publish execution_completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -94,7 +94,14 @@ impl EmittedMessageHandler for BusToolCallEmitter {
|
|||||||
&message,
|
&message,
|
||||||
) {
|
) {
|
||||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||||
tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live outbound tool call");
|
match error {
|
||||||
|
crate::bus::BusError::Dropped => {
|
||||||
|
tracing::warn!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Outbound dropped (bus full)");
|
||||||
|
}
|
||||||
|
crate::bus::BusError::Closed => {
|
||||||
|
tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live outbound tool call");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -113,7 +120,14 @@ impl EmittedMessageHandler for BusToolCallEmitter {
|
|||||||
&message,
|
&message,
|
||||||
) {
|
) {
|
||||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||||
tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live outbound tool call");
|
match error {
|
||||||
|
crate::bus::BusError::Dropped => {
|
||||||
|
tracing::warn!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Outbound dropped (bus full)");
|
||||||
|
}
|
||||||
|
crate::bus::BusError::Closed => {
|
||||||
|
tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live outbound tool call");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,7 +168,14 @@ impl EmittedMessageHandler for BusToolCallEmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||||
tracing::error!(error = %error, channel = %self.channel_name, "Failed to publish stream delta");
|
match error {
|
||||||
|
crate::bus::BusError::Dropped => {
|
||||||
|
tracing::warn!(error = %error, channel = %self.channel_name, "Outbound dropped (bus full)");
|
||||||
|
}
|
||||||
|
crate::bus::BusError::Closed => {
|
||||||
|
tracing::error!(error = %error, channel = %self.channel_name, "Failed to publish stream delta");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use crate::bus::{MessageBus, OutboundMessage};
|
use crate::bus::{BusError, MessageBus, OutboundMessage};
|
||||||
use crate::tools::{SessionMessageSender, SessionSendOutcome, SessionSendRequest, ToolContext};
|
use crate::tools::{SessionMessageSender, SessionSendOutcome, SessionSendRequest, ToolContext};
|
||||||
|
|
||||||
pub(crate) struct BusSessionMessageSender {
|
pub(crate) struct BusSessionMessageSender {
|
||||||
@ -55,15 +55,28 @@ impl SessionMessageSender for BusSessionMessageSender {
|
|||||||
if attachment_count > 0 {
|
if attachment_count > 0 {
|
||||||
outbound.media = request.attachments.clone();
|
outbound.media = request.attachments.clone();
|
||||||
}
|
}
|
||||||
self.bus.publish_outbound(outbound).await?;
|
match self.bus.publish_outbound(outbound).await {
|
||||||
published_messages += 1;
|
Ok(()) => {
|
||||||
tracing::info!(
|
published_messages += 1;
|
||||||
channel = %channel_name,
|
tracing::info!(
|
||||||
chat_id = %chat_id,
|
channel = %channel_name,
|
||||||
content_len = content_len,
|
chat_id = %chat_id,
|
||||||
attachment_count = attachment_count,
|
content_len = content_len,
|
||||||
"Published session text message to outbound bus"
|
attachment_count = attachment_count,
|
||||||
);
|
"Published session text message to outbound bus"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(BusError::Dropped) => {
|
||||||
|
tracing::warn!(
|
||||||
|
channel = %channel_name,
|
||||||
|
chat_id = %chat_id,
|
||||||
|
"Outbound bus full, dropping session text message"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(BusError::Closed) => {
|
||||||
|
return Err(anyhow::anyhow!("Outbound bus closed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for attachment in request.attachments {
|
for attachment in request.attachments {
|
||||||
let media_path = attachment.path.clone();
|
let media_path = attachment.path.clone();
|
||||||
@ -77,15 +90,28 @@ impl SessionMessageSender for BusSessionMessageSender {
|
|||||||
metadata.clone(),
|
metadata.clone(),
|
||||||
);
|
);
|
||||||
outbound.media = vec![attachment];
|
outbound.media = vec![attachment];
|
||||||
self.bus.publish_outbound(outbound).await?;
|
match self.bus.publish_outbound(outbound).await {
|
||||||
published_messages += 1;
|
Ok(()) => {
|
||||||
tracing::info!(
|
published_messages += 1;
|
||||||
channel = %channel_name,
|
tracing::info!(
|
||||||
chat_id = %chat_id,
|
channel = %channel_name,
|
||||||
media_type = %media_type,
|
chat_id = %chat_id,
|
||||||
media_path = %media_path,
|
media_type = %media_type,
|
||||||
"Published session attachment to outbound bus"
|
media_path = %media_path,
|
||||||
);
|
"Published session attachment to outbound bus"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(BusError::Dropped) => {
|
||||||
|
tracing::warn!(
|
||||||
|
channel = %channel_name,
|
||||||
|
chat_id = %chat_id,
|
||||||
|
"Outbound bus full, dropping session attachment"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(BusError::Closed) => {
|
||||||
|
return Err(anyhow::anyhow!("Outbound bus closed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -40,6 +40,7 @@ use std::collections::HashMap;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
const WS_CHANNEL_NAME: &str = "websocket";
|
const WS_CHANNEL_NAME: &str = "websocket";
|
||||||
|
|
||||||
@ -221,13 +222,34 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
|
|||||||
|
|
||||||
let mut receiver = receiver;
|
let mut receiver = receiver;
|
||||||
let session_id_for_sender = runtime_session_id.clone();
|
let session_id_for_sender = runtime_session_id.clone();
|
||||||
|
// 每连接独立的关闭信号:writer 超时/错误时 cancel,通知主 loop 退出
|
||||||
|
// 不能用共享的 shutdown_token——那是 CliChannel 级别的,cancel 会关闭所有连接
|
||||||
|
let writer_closed = CancellationToken::new();
|
||||||
|
let writer_closed_clone = writer_closed.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(msg) = receiver.recv().await {
|
while let Some(msg) = receiver.recv().await {
|
||||||
if let Ok(text) = serialize_outbound(&msg) {
|
if let Ok(text) = serialize_outbound(&msg) {
|
||||||
if ws_sender.send(WsMessage::Text(text.into())).await.is_err() {
|
let send_result = tokio::time::timeout(
|
||||||
#[cfg(debug_assertions)]
|
std::time::Duration::from_secs(30),
|
||||||
tracing::debug!(session_id = %session_id_for_sender, "WebSocket send error");
|
ws_sender.send(WsMessage::Text(text.into())),
|
||||||
break;
|
)
|
||||||
|
.await;
|
||||||
|
match send_result {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(_)) => {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::debug!(session_id = %session_id_for_sender, "WebSocket send error");
|
||||||
|
writer_closed_clone.cancel();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
session_id = %session_id_for_sender,
|
||||||
|
"WebSocket send timed out after 30s, closing connection"
|
||||||
|
);
|
||||||
|
writer_closed_clone.cancel();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -235,11 +257,16 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
// 监听 shutdown 信号(来自 CliChannel::stop())
|
// 监听全局 shutdown 信号(来自 CliChannel::stop())
|
||||||
_ = shutdown_token.cancelled() => {
|
_ = shutdown_token.cancelled() => {
|
||||||
tracing::info!(session_id = %current_session_id, "WebSocket shutdown signal received, closing connection");
|
tracing::info!(session_id = %current_session_id, "WebSocket shutdown signal received, closing connection");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// 监听 writer 退出信号(writer 超时或错误,仅关闭当前连接)
|
||||||
|
_ = writer_closed.cancelled() => {
|
||||||
|
tracing::info!(session_id = %current_session_id, "WebSocket writer closed, shutting down connection");
|
||||||
|
break;
|
||||||
|
}
|
||||||
// 监听 WebSocket 消息
|
// 监听 WebSocket 消息
|
||||||
msg = ws_receiver.next() => {
|
msg = ws_receiver.next() => {
|
||||||
let Some(msg) = msg else {
|
let Some(msg) = msg else {
|
||||||
|
|||||||
@ -298,7 +298,11 @@ impl Scheduler {
|
|||||||
match job.kind {
|
match job.kind {
|
||||||
SchedulerJobKind::OutboundMessage => {
|
SchedulerJobKind::OutboundMessage => {
|
||||||
let message = build_outbound_message(job)?;
|
let message = build_outbound_message(job)?;
|
||||||
self.bus.publish_outbound(message).await?;
|
// publish_outbound 失败(bus 满或关闭)不视为 job 失败:
|
||||||
|
// 通知丢弃是预期的背压行为,标记 job 失败会触发 misfire 重试风暴
|
||||||
|
if let Err(e) = self.bus.publish_outbound(message).await {
|
||||||
|
tracing::warn!(error = %e, job_id = %job.id, "Dropping outbound for scheduler job");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SchedulerJobKind::InternalEvent => {
|
SchedulerJobKind::InternalEvent => {
|
||||||
execute_internal_event(self.maintenance_executor.as_ref(), job).await?;
|
execute_internal_event(self.maintenance_executor.as_ref(), job).await?;
|
||||||
@ -311,7 +315,9 @@ impl Scheduler {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
for message in outbound_messages {
|
for message in outbound_messages {
|
||||||
self.bus.publish_outbound(message).await?;
|
if let Err(e) = self.bus.publish_outbound(message).await {
|
||||||
|
tracing::warn!(error = %e, job_id = %job.id, "Dropping outbound for scheduler agent task");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SchedulerJobKind::SilentAgentTask => {
|
SchedulerJobKind::SilentAgentTask => {
|
||||||
@ -407,7 +413,8 @@ impl Scheduler {
|
|||||||
"silent_agent_task".to_string(),
|
"silent_agent_task".to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
self.bus
|
if let Err(e) = self
|
||||||
|
.bus
|
||||||
.publish_outbound(OutboundMessage::error_notification(
|
.publish_outbound(OutboundMessage::error_notification(
|
||||||
channel,
|
channel,
|
||||||
chat_id,
|
chat_id,
|
||||||
@ -421,7 +428,10 @@ impl Scheduler {
|
|||||||
metadata,
|
metadata,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
.map_err(|error| anyhow::anyhow!(error.to_string()))
|
{
|
||||||
|
tracing::warn!(error = %e, job_id = %job.id, "Dropping silent agent task failure notification");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -140,20 +140,17 @@ impl ShellSessionManager {
|
|||||||
|
|
||||||
// Wait for new output or process exit
|
// Wait for new output or process exit
|
||||||
let deadline = Instant::now() + Duration::from_millis(OUTPUT_WAIT_MS);
|
let deadline = Instant::now() + Duration::from_millis(OUTPUT_WAIT_MS);
|
||||||
loop {
|
tokio::select! {
|
||||||
tokio::select! {
|
status = session.child.wait() => {
|
||||||
status = session.child.wait() => {
|
// Process exited — collect final output
|
||||||
// Process exited — collect final output
|
let stdout = session.stdout_buf.lock().await.clone();
|
||||||
let stdout = session.stdout_buf.lock().await.clone();
|
let stderr = session.stderr_buf.lock().await.clone();
|
||||||
let stderr = session.stderr_buf.lock().await.clone();
|
let code = status.ok().and_then(|s| s.code());
|
||||||
let code = status.ok().and_then(|s| s.code());
|
drop(sessions);
|
||||||
drop(sessions);
|
return Ok(Self::format_output(&stdout, &stderr, code));
|
||||||
return Ok(Self::format_output(&stdout, &stderr, code));
|
}
|
||||||
}
|
_ = tokio::time::sleep_until(deadline) => {
|
||||||
_ = tokio::time::sleep_until(deadline) => {
|
// Timeout — fall through to return current output
|
||||||
// Timeout — return current output
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -128,12 +128,14 @@ impl EmittedMessageHandler for SubAgentEmitter {
|
|||||||
&message,
|
&message,
|
||||||
) {
|
) {
|
||||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||||
tracing::error!(
|
match error {
|
||||||
error = %error,
|
crate::bus::BusError::Dropped => {
|
||||||
channel = %self.channel_name,
|
tracing::warn!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Outbound dropped (bus full)");
|
||||||
chat_id = %self.chat_id,
|
}
|
||||||
"Failed to publish live sub-agent tool call"
|
crate::bus::BusError::Closed => {
|
||||||
);
|
tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live sub-agent tool call");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -152,12 +154,14 @@ impl EmittedMessageHandler for SubAgentEmitter {
|
|||||||
&message,
|
&message,
|
||||||
) {
|
) {
|
||||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||||
tracing::error!(
|
match error {
|
||||||
error = %error,
|
crate::bus::BusError::Dropped => {
|
||||||
channel = %self.channel_name,
|
tracing::warn!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Outbound dropped (bus full)");
|
||||||
chat_id = %self.chat_id,
|
}
|
||||||
"Failed to publish live sub-agent tool call"
|
crate::bus::BusError::Closed => {
|
||||||
);
|
tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live sub-agent tool call");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,7 +200,14 @@ impl EmittedMessageHandler for SubAgentEmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||||
tracing::error!(error = %error, channel = %self.channel_name, "Failed to publish sub-agent stream delta");
|
match error {
|
||||||
|
crate::bus::BusError::Dropped => {
|
||||||
|
tracing::warn!(error = %error, channel = %self.channel_name, "Outbound dropped (bus full)");
|
||||||
|
}
|
||||||
|
crate::bus::BusError::Closed => {
|
||||||
|
tracing::error!(error = %error, channel = %self.channel_name, "Failed to publish sub-agent stream delta");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react';
|
import { useState, useCallback, useMemo, useEffect, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react';
|
||||||
import type { SessionSummary, Command } from '../../types/protocol';
|
import type { SessionSummary, Command } from '../../types/protocol';
|
||||||
|
|
||||||
export interface UseSessionsReturn {
|
export interface UseSessionsReturn {
|
||||||
@ -6,6 +6,7 @@ export interface UseSessionsReturn {
|
|||||||
setSessions: Dispatch<SetStateAction<SessionSummary[]>>;
|
setSessions: Dispatch<SetStateAction<SessionSummary[]>>;
|
||||||
selectedSessionId: string | null;
|
selectedSessionId: string | null;
|
||||||
setSelectedSessionId: Dispatch<SetStateAction<string | null>>;
|
setSelectedSessionId: Dispatch<SetStateAction<string | null>>;
|
||||||
|
selectedSessionIdRef: MutableRefObject<string | null>;
|
||||||
session: SessionSummary | null;
|
session: SessionSummary | null;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
@ -22,6 +23,11 @@ export function useSessions(options?: UseSessionsOptions): UseSessionsReturn {
|
|||||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selectedSessionIdRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
selectedSessionIdRef.current = selectedSessionId;
|
||||||
|
}, [selectedSessionId]);
|
||||||
|
|
||||||
const selectedSession = useMemo(
|
const selectedSession = useMemo(
|
||||||
() => sessions.find((s) => s.session_id === selectedSessionId) ?? null,
|
() => sessions.find((s) => s.session_id === selectedSessionId) ?? null,
|
||||||
[sessions, selectedSessionId],
|
[sessions, selectedSessionId],
|
||||||
@ -50,6 +56,7 @@ export function useSessions(options?: UseSessionsOptions): UseSessionsReturn {
|
|||||||
setSessions,
|
setSessions,
|
||||||
selectedSessionId,
|
selectedSessionId,
|
||||||
setSelectedSessionId,
|
setSelectedSessionId,
|
||||||
|
selectedSessionIdRef,
|
||||||
session: selectedSession,
|
session: selectedSession,
|
||||||
sessionId,
|
sessionId,
|
||||||
chatId,
|
chatId,
|
||||||
|
|||||||
@ -159,22 +159,42 @@ export function useChat(): UseChatReturn {
|
|||||||
conn.setConnectionId(message.session_id);
|
conn.setConnectionId(message.session_id);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case 'session_list':
|
case 'session_list': {
|
||||||
// 清空旧数据(切换通道时避免数据污染)
|
// 重连恢复:重连前已有选中 topic,且原 session 仍存在于新列表中
|
||||||
topics.setTopics([]);
|
// 保留断连前的 messages(用户仍可查看之前的对话),仅刷新 session/topic 列表
|
||||||
topics.setSelectedTopic(null);
|
// 注意:不发 load_chat_messages——历史消息不带 topic_id(ChatMessage 结构无此字段),
|
||||||
messages.setMessages([]);
|
// 前端无法按 topic 过滤,会导致跨 topic 消息污染 + 与残留消息重复
|
||||||
|
// 断连期间的新消息在 SQLite 中,需后端新增按 topic 加载的命令才能恢复(既有限制)
|
||||||
|
const prevSid = sessions.selectedSessionIdRef.current;
|
||||||
|
const prevSessionExists =
|
||||||
|
prevSid !== null && message.sessions.some((s) => s.session_id === prevSid);
|
||||||
|
const isReconnect =
|
||||||
|
topics.selectedTopicRef.current !== null && prevSessionExists;
|
||||||
sessions.setSessions(message.sessions);
|
sessions.setSessions(message.sessions);
|
||||||
// 自动选中:优先保持当前选中,否则选第一个
|
if (isReconnect) {
|
||||||
sessions.setSelectedSessionId((prev) =>
|
// 原 session 仍在,保持选中
|
||||||
prev && message.sessions.some((s) => s.session_id === prev)
|
sessions.setSelectedSessionId(prevSid);
|
||||||
? prev
|
// 刷新 topic 列表(断连期间可能新建了 topic)
|
||||||
: message.sessions.length > 0
|
const topicCmd = topics.requestTopicList(prevSid!);
|
||||||
? message.sessions[0].session_id
|
if (topicCmd) conn.sendCommand(topicCmd);
|
||||||
: null,
|
// 重置 loading 状态(断连时可能卡在 loading)
|
||||||
);
|
messages.setIsLoading(false);
|
||||||
messages.setIsLoading(false);
|
} else {
|
||||||
|
// 首次连接、切换通道、或原 session 已被删除:清空旧数据避免污染
|
||||||
|
topics.setTopics([]);
|
||||||
|
topics.setSelectedTopic(null);
|
||||||
|
messages.setMessages([]);
|
||||||
|
sessions.setSelectedSessionId((prev) =>
|
||||||
|
prev && message.sessions.some((s) => s.session_id === prev)
|
||||||
|
? prev
|
||||||
|
: message.sessions.length > 0
|
||||||
|
? message.sessions[0].session_id
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
messages.setIsLoading(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
case 'session_created':
|
case 'session_created':
|
||||||
case 'session_loaded':
|
case 'session_loaded':
|
||||||
|
|||||||
@ -18,6 +18,11 @@ interface UseWebSocketReturn {
|
|||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 客户端 ping 间隔(ms):小于典型代理/NAT 60s 空闲超时
|
||||||
|
const PING_INTERVAL_MS = 25000;
|
||||||
|
// 指数退避重连上限(ms)
|
||||||
|
const MAX_RECONNECT_DELAY_MS = 60000;
|
||||||
|
|
||||||
export function useWebSocket({
|
export function useWebSocket({
|
||||||
url,
|
url,
|
||||||
onMessage,
|
onMessage,
|
||||||
@ -25,13 +30,21 @@ export function useWebSocket({
|
|||||||
onDisconnect,
|
onDisconnect,
|
||||||
onError,
|
onError,
|
||||||
reconnectInterval = 3000,
|
reconnectInterval = 3000,
|
||||||
maxReconnectAttempts = 5,
|
maxReconnectAttempts = 999,
|
||||||
}: UseWebSocketOptions): UseWebSocketReturn {
|
}: UseWebSocketOptions): UseWebSocketReturn {
|
||||||
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectAttemptsRef = useRef(0);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const isManualDisconnectRef = useRef(false);
|
const isManualDisconnectRef = useRef(false);
|
||||||
|
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const clearPing = useCallback(() => {
|
||||||
|
if (pingIntervalRef.current) {
|
||||||
|
clearInterval(pingIntervalRef.current);
|
||||||
|
pingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
@ -48,6 +61,18 @@ export function useWebSocket({
|
|||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
setStatus('connected');
|
setStatus('connected');
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
|
// 启动客户端 ping:定期写数据保持连接活性,写失败会触发 onclose 检测半死连接
|
||||||
|
clearPing();
|
||||||
|
pingIntervalRef.current = setInterval(() => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'ping' }));
|
||||||
|
} catch {
|
||||||
|
// 极端时序下 send 可能抛 InvalidStateError,忽略即可
|
||||||
|
// onclose 会随后触发,进入重连流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, PING_INTERVAL_MS);
|
||||||
onConnect?.();
|
onConnect?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -67,21 +92,24 @@ export function useWebSocket({
|
|||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
setStatus('disconnected');
|
setStatus('disconnected');
|
||||||
|
clearPing();
|
||||||
onDisconnect?.();
|
onDisconnect?.();
|
||||||
|
|
||||||
// Auto reconnect if not manually disconnected
|
// 指数退避自动重连:3s → 6s → 12s → 24s → 60s 封顶
|
||||||
if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||||
reconnectAttemptsRef.current += 1;
|
reconnectAttemptsRef.current += 1;
|
||||||
|
const attempts = reconnectAttemptsRef.current;
|
||||||
|
const delay = Math.min(reconnectInterval * Math.pow(2, attempts - 1), MAX_RECONNECT_DELAY_MS);
|
||||||
reconnectTimerRef.current = setTimeout(() => {
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
connect();
|
connect();
|
||||||
}, reconnectInterval);
|
}, delay);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
console.error('WebSocket connection error:', error);
|
console.error('WebSocket connection error:', error);
|
||||||
}
|
}
|
||||||
}, [url, onMessage, onConnect, onDisconnect, onError, reconnectInterval, maxReconnectAttempts]);
|
}, [url, onMessage, onConnect, onDisconnect, onError, reconnectInterval, maxReconnectAttempts, clearPing]);
|
||||||
|
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback(() => {
|
||||||
isManualDisconnectRef.current = true;
|
isManualDisconnectRef.current = true;
|
||||||
@ -91,13 +119,15 @@ export function useWebSocket({
|
|||||||
reconnectTimerRef.current = null;
|
reconnectTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearPing();
|
||||||
|
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
wsRef.current.close();
|
wsRef.current.close();
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('disconnected');
|
setStatus('disconnected');
|
||||||
}, []);
|
}, [clearPing]);
|
||||||
|
|
||||||
const sendMessage = useCallback((message: WsInbound): boolean => {
|
const sendMessage = useCallback((message: WsInbound): boolean => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user