fix(asr): WebSocket 建连补超时——握手挂死不再让热键彻底失灵 (#871)

三个实时 ASR 供应商的 `connect_async` 没有任何超时:握手打到一半断网、公司网关 /
酒店门户静默丢包、服务端黑洞掉连接,这个 await 就永远不返回。

后果不止是「这次听写失败」。`open_session` 是在 hotkey bridge 线程上 `block_on` 等的
(那条 bridge 为修 #468/#475 的 latch 竞态改成了串行,一次只处理一个事件),所以它
一挂,后续的按下 / 松开全都排在 channel 里没人取:

- 按热键开不了录音,也停不了
- 没有任何提示,胶囊不出现,用户只会以为「热键坏了」
- 只能退出 app 重开

真机复现(dogfood 构建,StepFun 实时):9 秒内连续开 6 次会话,第 6 次的建连没返回,
之后热键完全无响应。抓栈确认 `openless-hotkey-bridge` 停在 `__psynch_cvwait`,而
主线程与 tap 线程都正常 —— tap 仍在记录按键,只是没人处理。

四个 WS 供应商里只有 volcengine 当初写对了(CONNECT_TIMEOUT + 有限重试),另外三个
一直是裸 connect_async:

- asr/stepfun_realtime.rs
- asr/qwen_realtime.rs
- asr/bailian.rs

各自补 `CONNECT_TIMEOUT`(5s,与 volcengine 取值一致),超时报
`ConnectionFailed("连接超时(N ms)")`,走既有的错误路径弹胶囊,热键继续可用。

注意与 SESSION_READY_TIMEOUT 的区别:那个管「连上之后等 session.updated 回应」,
本次补的是「连上」本身,之前完全没人管。

只改「连不上」这条已经坏掉的路径,连得上时行为不变。加了一个用例:服务端收下 TCP
但永不完成握手时,open_session 必须有界返回而不是永远挂着。

深层问题(不在本 PR):把 `begin_session` 从串行 hotkey bridge 上挪开,这样任何一个
慢 IO 都不该有能力冻住热键。那处改动风险高(串行正是为修 latch 竞态而来),单独立项。

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-08-01 23:39:51 +08:00
1 parent 1ab2d52484
commit 5fd353de93
3 files changed
+78 -3

No files matched your search

+11 -1
View File
@@ -31,6 +31,10 @@ pub const DEFAULT_MODEL: &str = "fun-asr-realtime";
pub const TARGET_AUDIO_CHUNK_BYTES: usize = 3_200;
const BYTES_PER_MS: u64 = 32;
const FINAL_RESULT_TIMEOUT: Duration = Duration::from_secs(12);
/// WebSocket 建连(TCP + TLS + HTTP upgrade)本身的上限。没有它 `connect_async` 会无限
/// 等,而 `open_session` 是在串行的 hotkey bridge 线程上 `block_on` 等的 —— 卡住就意味着
/// 热键彻底失灵(开不了也停不了,只能退出重开)。详见 stepfun_realtime.rs 同名常量。
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
type WsSink = futures_util::stream::SplitSink<WsStream, Message>;
@@ -149,8 +153,14 @@ impl BailianRealtimeASR {
.map_err(|e| BailianASRError::ConnectionFailed(e.to_string()))?,
);
let (ws, _resp) = connect_async(request)
let (ws, _resp) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request))
.await
.map_err(|_| {
BailianASRError::ConnectionFailed(format!(
"连接超时({} ms)",
CONNECT_TIMEOUT.as_millis()
))
})?
.map_err(|e| BailianASRError::ConnectionFailed(e.to_string()))?;
let (write, read) = ws.split();
*self.writer.lock().await = Some(write);
@@ -43,6 +43,10 @@ const BYTES_PER_MS: u64 = 32;
const FINAL_RESULT_TIMEOUT: Duration = Duration::from_secs(12);
const SESSION_READY_TIMEOUT: Duration = Duration::from_secs(5);
const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
/// WebSocket 建连(TCP + TLS + HTTP upgrade)本身的上限。没有它 `connect_async` 会无限
/// 等,而 `open_session` 是在串行的 hotkey bridge 线程上 `block_on` 等的 —— 卡住就意味着
/// 热键彻底失灵(开不了也停不了,只能退出重开)。详见 stepfun_realtime.rs 同名常量。
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// server_vad 断句静默阈值。官方默认 400ms;取 500ms 降低说话中途换气被切断的概率。
const VAD_SILENCE_DURATION_MS: u32 = 500;
@@ -170,8 +174,14 @@ impl Qwen3RealtimeASR {
.headers_mut()
.insert("OpenAI-Beta", HeaderValue::from_static("realtime=v1"));
let (ws, _resp) = connect_async(request)
let (ws, _resp) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request))
.await
.map_err(|_| {
Qwen3ASRError::ConnectionFailed(format!(
"连接超时({} ms)",
CONNECT_TIMEOUT.as_millis()
))
})?
.map_err(|e| Qwen3ASRError::ConnectionFailed(e.to_string()))?;
let (write, read) = ws.split();
*self.writer.lock().await = Some(write);
@@ -48,6 +48,17 @@ const BYTES_PER_MS: u64 = 32;
const FINAL_RESULT_TIMEOUT: Duration = Duration::from_secs(12);
const SESSION_READY_TIMEOUT: Duration = Duration::from_secs(5);
const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
/// WebSocket 建连(TCP + TLS + HTTP upgrade)本身的上限。
///
/// 没有它,`connect_async` 会无限等:握手打到一半断网、公司网关 / 酒店门户静默丢包、
/// 服务端黑洞掉连接,这个 await 就永远不返回。而 `open_session` 是在 hotkey bridge
/// 线程上 `block_on` 等的(那条 bridge 为修 #468/#475 的 latch 竞态改成了串行),
/// 一旦卡住,按下 / 松开全都排在队里没人处理 —— 用户的表现是「热键突然完全失灵,
/// 录音开不了也停不了,只能退出重开」,而且没有任何提示。
///
/// 与 SESSION_READY_TIMEOUT 的区别:那个管的是「连上之后等 session.updated 回应」,
/// 这个管的是「连上」本身,之前完全没人管。取值与 volcengine 侧一致。
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// server_vad 断句静默阈值,与 qwen_realtime 取齐(500ms 降低换气误切概率)。
const VAD_SILENCE_DURATION_MS: u32 = 500;
/// 收尾补送的静音时长:必须 > VAD_SILENCE_DURATION_MS 并留网络余量,
@@ -206,8 +217,14 @@ impl StepfunRealtimeASR {
.map_err(|e| StepfunASRError::ConnectionFailed(e.to_string()))?,
);
let (ws, _resp) = connect_async(request)
let (ws, _resp) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request))
.await
.map_err(|_| {
StepfunASRError::ConnectionFailed(format!(
"连接超时({} ms)",
CONNECT_TIMEOUT.as_millis()
))
})?
.map_err(|e| StepfunASRError::ConnectionFailed(e.to_string()))?;
let (write, read) = ws.split();
*self.writer.lock().await = Some(write);
@@ -1344,4 +1361,42 @@ mod tests {
"这句话没有收到 completed"
);
}
// 服务端收下 TCP 却永不完成 WebSocket 握手(断网、公司网关 / 酒店门户静默丢包、
// 服务端黑洞)时,open_session 必须超时返回错误,而不是把调用方永远挂住 ——
// 它是在串行的 hotkey bridge 线程上 block_on 等的,一旦挂住,按下 / 松开全都排在
// 队列里没人处理,用户表现为「热键彻底失灵,录音开不了也停不了,只能退出重开」。
#[tokio::test]
async fn open_session_times_out_when_handshake_never_completes() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// 收下连接后什么都不回,连接一直挂着。
let _server = tokio::spawn(async move {
let _accepted = listener.accept().await;
std::future::pending::<()>().await;
});
let asr = Arc::new(StepfunRealtimeASR::new(StepfunRealtimeCredentials {
api_key: "sk-test".to_string(),
endpoint: format!("ws://{addr}"),
model: String::new(),
prompt: None,
}));
let started = Instant::now();
let err = asr
.open_session()
.await
.expect_err("握手不完成时必须超时失败,不能永远挂着");
assert!(
matches!(&err, StepfunASRError::ConnectionFailed(msg) if msg.contains("连接超时")),
"应报连接超时,实际: {err:?}"
);
// 上限给足余量,只为证明它真的有界(没有 CONNECT_TIMEOUT 时这里会永远不返回)。
assert!(
started.elapsed() < CONNECT_TIMEOUT * 3,
"超时应在 CONNECT_TIMEOUT 量级返回,实际耗时 {:?}",
started.elapsed()
);
}
}