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:
3 files changed
+78
-3
No files matched your search
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user