mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(openai): restore upstream websocket keepalive pings
Send upstream Ping frames every 25 seconds without a Pong deadline. Keep downstream cancellation, business idle timeout and pool eviction independent from heartbeat traffic, with bounded heartbeat writes. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -404,7 +404,10 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
|
||||
reason => CodexWebSocketExchangeError::StreamEndedBeforeTerminal {
|
||||
reason: reason.map_or("stream_eof", PumpExitReason::as_str),
|
||||
timeout: match reason {
|
||||
Some(PumpExitReason::PongWriteTimeout { timeout }) => Some(*timeout),
|
||||
Some(
|
||||
PumpExitReason::PingWriteTimeout { timeout }
|
||||
| PumpExitReason::PongWriteTimeout { timeout },
|
||||
) => Some(*timeout),
|
||||
_ => None,
|
||||
},
|
||||
last_event_type,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 单 owner 驱动上游 WebSocket,被动回复心跳并在终止后有界释放 socket
|
||||
//! 单 owner 驱动上游 WebSocket,定时保活、回复心跳并在终止后有界释放 socket
|
||||
//!
|
||||
//! 业务消息通过有界队列交付,Ping/Pong 由 pump 消费,不刷新生成期间的消息等待超时
|
||||
//! 入站背压暂停读取,仍可接收关闭命令;已观察到的终止先释放 socket,再等待终止消息交付
|
||||
@@ -42,6 +42,8 @@ pub(crate) type RawWsStream = Box<dyn WebSocketIo>;
|
||||
|
||||
const PUMP_COMMAND_BUFFER: usize = 32;
|
||||
const CLOSE_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const PING_INTERVAL: Duration = Duration::from_secs(25);
|
||||
const PING_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const PONG_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// pump 日志的账号级关联上下文;空闲连接被上游重置等场景用于回溯归属
|
||||
@@ -78,6 +80,9 @@ pub(crate) enum PumpExitReason {
|
||||
message: String,
|
||||
metric_reason: &'static str,
|
||||
},
|
||||
PingWriteTimeout {
|
||||
timeout: Duration,
|
||||
},
|
||||
PongWriteTimeout {
|
||||
timeout: Duration,
|
||||
},
|
||||
@@ -97,6 +102,7 @@ impl PumpExitReason {
|
||||
Self::UpstreamCloseFrame { .. } => "upstream_close_frame",
|
||||
Self::UpstreamEof => "upstream_eof",
|
||||
Self::InboundTransportError { .. } => "inbound_transport_error",
|
||||
Self::PingWriteTimeout { .. } => "ping_write_timeout",
|
||||
Self::PongWriteTimeout { .. } => "pong_write_timeout",
|
||||
Self::KeepaliveTransportError { .. } => "keepalive_transport_error",
|
||||
Self::MessageReceiverClosed => "message_receiver_closed",
|
||||
@@ -109,7 +115,9 @@ impl PumpExitReason {
|
||||
| Self::InboundTransportError { message, .. }
|
||||
| Self::KeepaliveTransportError { message, .. } => Some(message.clone()),
|
||||
Self::UpstreamCloseFrame { close } => close.as_ref().map(ToString::to_string),
|
||||
Self::PongWriteTimeout { timeout } => Some(format!("{timeout:?}")),
|
||||
Self::PingWriteTimeout { timeout } | Self::PongWriteTimeout { timeout } => {
|
||||
Some(format!("{timeout:?}"))
|
||||
}
|
||||
Self::CommandChannelClosed
|
||||
| Self::LocalClose
|
||||
| Self::UpstreamEof
|
||||
@@ -132,6 +140,7 @@ impl PumpExitReason {
|
||||
}
|
||||
}
|
||||
Self::UpstreamEof => "eof",
|
||||
Self::PingWriteTimeout { .. } => "ping_write_timeout",
|
||||
Self::PongWriteTimeout { .. } => "pong_write_timeout",
|
||||
Self::MessageReceiverClosed => "message_receiver_closed",
|
||||
}
|
||||
@@ -150,6 +159,7 @@ impl PumpExitReason {
|
||||
Self::OutboundTransportError { .. }
|
||||
| Self::UpstreamEof
|
||||
| Self::InboundTransportError { .. }
|
||||
| Self::PingWriteTimeout { .. }
|
||||
| Self::PongWriteTimeout { .. }
|
||||
| Self::KeepaliveTransportError { .. }
|
||||
)
|
||||
@@ -400,6 +410,9 @@ async fn pump_loop(
|
||||
) {
|
||||
let mut pending_inbound = None;
|
||||
let mut backpressure_events = 0_u64;
|
||||
// 首次保活留出完整间隔,背压恢复后跳过错过的 tick,避免集中补发
|
||||
let mut heartbeat = tokio::time::interval_at(Instant::now() + PING_INTERVAL, PING_INTERVAL);
|
||||
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
let reason = 'pump: loop {
|
||||
if pending_inbound.is_some() {
|
||||
@@ -434,6 +447,22 @@ async fn pump_loop(
|
||||
break 'pump reason;
|
||||
}
|
||||
}
|
||||
_ = heartbeat.tick() => {
|
||||
// Ping 只保活链路,不设 Pong deadline,也不刷新业务空闲或连接池回收时间
|
||||
match tokio::time::timeout(
|
||||
PING_WRITE_TIMEOUT,
|
||||
inner.stream.send(Message::Ping(Default::default())),
|
||||
).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => break 'pump PumpExitReason::KeepaliveTransportError {
|
||||
message: error.to_string(),
|
||||
metric_reason: transport_metric_reason(&error),
|
||||
},
|
||||
Err(_) => break 'pump PumpExitReason::PingWriteTimeout {
|
||||
timeout: PING_WRITE_TIMEOUT,
|
||||
},
|
||||
}
|
||||
}
|
||||
message = inner.stream.next() => {
|
||||
match message {
|
||||
None => break 'pump PumpExitReason::UpstreamEof,
|
||||
|
||||
@@ -1493,10 +1493,12 @@ async fn codex_backend_client_should_timeout_when_upstream_is_silent() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_stream_should_allow_silence_below_idle_timeout() {
|
||||
async fn websocket_stream_should_keep_alive_without_requiring_pong_below_idle_timeout() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (created_tx, created_rx) = tokio::sync::oneshot::channel();
|
||||
let (ping_tx, ping_rx) = tokio::sync::oneshot::channel();
|
||||
let (finish_tx, finish_rx) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut websocket = accept_codex_test_websocket(stream).await;
|
||||
@@ -1513,7 +1515,10 @@ async fn websocket_stream_should_allow_silence_below_idle_timeout() {
|
||||
.await
|
||||
.unwrap();
|
||||
created_tx.send(()).unwrap();
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
std::assert_matches!(websocket.next().await.unwrap().unwrap(), Message::Ping(_));
|
||||
ping_tx.send(()).unwrap();
|
||||
// 暂停读取和 flush,避免 tungstenite 自动回复 Pong
|
||||
finish_rx.await.unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_structural", 3, 1).into(),
|
||||
@@ -1529,7 +1534,21 @@ async fn websocket_stream_should_allow_silence_below_idle_timeout() {
|
||||
created_rx.await.unwrap();
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(30)).await;
|
||||
tokio::time::advance(Duration::from_secs(26)).await;
|
||||
tokio::time::resume();
|
||||
timeout(Duration::from_secs(2), ping_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(60)).await;
|
||||
tokio::time::resume();
|
||||
tokio::task::yield_now().await;
|
||||
assert!(
|
||||
!response_task.is_finished(),
|
||||
"missing Pong must not end the response"
|
||||
);
|
||||
finish_tx.send(()).unwrap();
|
||||
let response = response_task
|
||||
.await
|
||||
.expect("websocket task should finish")
|
||||
@@ -1725,6 +1744,12 @@ async fn codex_backend_client_stream_should_keep_socket_after_structural_activit
|
||||
.await
|
||||
.unwrap();
|
||||
first_websocket.close(None).await.unwrap();
|
||||
// 完成关闭握手,避免未读的保活 Ping 导致 TCP reset 丢失已发送的响应
|
||||
while let Some(Ok(message)) = first_websocket.next().await {
|
||||
if matches!(message, Message::Close(_)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
@@ -1744,7 +1769,9 @@ async fn codex_backend_client_stream_should_keep_socket_after_structural_activit
|
||||
let mut stream = response.body;
|
||||
let mut body = String::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.expect("delayed websocket stream chunk should be valid");
|
||||
let chunk = chunk.unwrap_or_else(|error| {
|
||||
panic!("delayed websocket stream chunk should be valid: {error}")
|
||||
});
|
||||
body.push_str(std::str::from_utf8(&chunk).unwrap());
|
||||
}
|
||||
server.await.unwrap();
|
||||
@@ -2087,10 +2114,11 @@ async fn codex_backend_client_stream_should_wait_for_terminal_after_active_webso
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn codex_backend_client_stream_should_timeout_when_active_websocket_stalls() {
|
||||
#[tokio::test]
|
||||
async fn codex_backend_client_stream_should_timeout_when_active_websocket_stalls_despite_pong() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel(1);
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut websocket = accept_codex_test_websocket(stream).await;
|
||||
@@ -2106,7 +2134,16 @@ async fn codex_backend_client_stream_should_timeout_when_active_websocket_stalls
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
futures::future::pending::<()>().await;
|
||||
while let Some(Ok(message)) = websocket.next().await {
|
||||
match message {
|
||||
Message::Ping(_) => {
|
||||
websocket.flush().await.unwrap();
|
||||
pong_tx.send(()).await.unwrap();
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
other => panic!("unexpected frame during stalled response: {other:?}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut request = codex_request("gpt-5.5", "be brief", Vec::new());
|
||||
request.set_previous_response_id(Some("resp_previous".to_string()));
|
||||
@@ -2132,7 +2169,18 @@ async fn codex_backend_client_stream_should_timeout_when_active_websocket_stalls
|
||||
.expect("stream should yield partial frame")
|
||||
.expect("partial frame should be valid");
|
||||
assert!(std::str::from_utf8(&first).unwrap().contains("partial"));
|
||||
tokio::time::advance(Duration::from_secs(5 * 60)).await;
|
||||
for _ in 0..11 {
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(25)).await;
|
||||
tokio::time::resume();
|
||||
timeout(Duration::from_secs(2), pong_rx.recv())
|
||||
.await
|
||||
.expect("gateway should send keepalive Ping")
|
||||
.expect("upstream should reply with Pong");
|
||||
}
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(25)).await;
|
||||
tokio::time::resume();
|
||||
tokio::task::yield_now().await;
|
||||
let error = response
|
||||
.body
|
||||
|
||||
@@ -365,7 +365,7 @@ async fn codex_backend_client_should_open_fresh_socket_when_idle_pooled_websocke
|
||||
.unwrap();
|
||||
second_websocket.close(None).await.unwrap();
|
||||
});
|
||||
// 无 maintenance、无主动 ping:完全依赖 pump 后台读取感知连接死亡
|
||||
// 不启动维护,验证 pump 读取到上游关闭后即可阻止复用
|
||||
let pool = Arc::new(CodexWebSocketPool::with_config(
|
||||
websocket_pool_config_for_tests(None),
|
||||
));
|
||||
@@ -1210,11 +1210,10 @@ async fn concurrent_pool_transport_counts(max_connecting: usize) -> (usize, usiz
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_pool_should_reject_expired_idle_continuation_despite_upstream_ping() {
|
||||
async fn websocket_pool_should_reject_expired_idle_continuation_despite_keepalive() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (send_ping_tx, send_ping_rx) = tokio::sync::oneshot::channel();
|
||||
let (pong_seen_tx, pong_seen_rx) = tokio::sync::oneshot::channel();
|
||||
let (ping_seen_tx, ping_seen_rx) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut first = accept_codex_test_websocket(stream).await;
|
||||
@@ -1225,16 +1224,16 @@ async fn websocket_pool_should_reject_expired_idle_continuation_despite_upstream
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
send_ping_rx.await.unwrap();
|
||||
let payload = b"upstream-idle-probe".to_vec();
|
||||
first
|
||||
.send(Message::Ping(payload.clone().into()))
|
||||
.await
|
||||
.unwrap();
|
||||
let pong = first.next().await.unwrap().unwrap();
|
||||
assert_eq!(pong, Message::Pong(payload.into()));
|
||||
pong_seen_tx.send(()).unwrap();
|
||||
std::assert_matches!(first.next().await.unwrap().unwrap(), Message::Close(_));
|
||||
std::assert_matches!(first.next().await.unwrap().unwrap(), Message::Ping(_));
|
||||
first.flush().await.unwrap();
|
||||
ping_seen_tx.send(()).unwrap();
|
||||
loop {
|
||||
match first.next().await.unwrap().unwrap() {
|
||||
Message::Ping(_) => first.flush().await.unwrap(),
|
||||
Message::Close(_) => break,
|
||||
other => panic!("unexpected idle frame: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut second = accept_codex_test_websocket(stream).await;
|
||||
@@ -1269,8 +1268,7 @@ async fn websocket_pool_should_reject_expired_idle_continuation_despite_upstream
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(299)).await;
|
||||
tokio::time::resume();
|
||||
send_ping_tx.send(()).unwrap();
|
||||
timeout(Duration::from_secs(2), pong_seen_rx)
|
||||
timeout(Duration::from_secs(2), ping_seen_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
@@ -1332,12 +1330,17 @@ async fn websocket_pool_should_gc_expired_idle_connections() {
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let close = timeout(Duration::from_secs(60), first_websocket.next())
|
||||
.await
|
||||
.expect("gc sweep should close the expired idle websocket")
|
||||
.expect("gc sweep should send a close frame")
|
||||
.expect("close frame should be valid");
|
||||
std::assert_matches!(close, Message::Close(_));
|
||||
timeout(Duration::from_secs(60), async {
|
||||
loop {
|
||||
match first_websocket.next().await.unwrap().unwrap() {
|
||||
Message::Ping(_) => first_websocket.flush().await.unwrap(),
|
||||
Message::Close(_) => break,
|
||||
other => panic!("unexpected idle frame: {other:?}"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("gc sweep should close the expired idle websocket");
|
||||
|
||||
let (second_stream, _) = listener.accept().await.unwrap();
|
||||
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
|
||||
@@ -1791,8 +1794,13 @@ async fn websocket_pool_should_reclaim_idle_connections_on_maintenance_ticks() {
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let message = websocket.next().await.unwrap().unwrap();
|
||||
std::assert_matches!(message, Message::Close(_));
|
||||
loop {
|
||||
match websocket.next().await.unwrap().unwrap() {
|
||||
Message::Ping(_) => websocket.flush().await.unwrap(),
|
||||
Message::Close(_) => break,
|
||||
other => panic!("unexpected idle frame: {other:?}"),
|
||||
}
|
||||
}
|
||||
closed_tx.send(()).unwrap();
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::default());
|
||||
|
||||
+2
-1
@@ -305,7 +305,8 @@ Responses 流式请求完成准入后,每 15 秒无输出会发送一次 SSE
|
||||
`api.request_timeout_seconds` 默认 `null`,只控制 HTTP 路由返回响应前的等待,不是流式正文或 WebSocket 每轮执行的总时限。
|
||||
排队、插件显式执行期限与客户端断开的边界见 [请求期限](../docs/api.md#请求期限)
|
||||
|
||||
OpenAI 上游 WebSocket 被动回应 Ping,不主动发送心跳;Pong 写入最多等待 5 秒。
|
||||
OpenAI Responses 上游 WebSocket 每 25 秒发送 Ping 保活,并回应上游 Ping;Ping/Pong 写入最多等待 5 秒,
|
||||
不因等待 Pong 超时主动断开。入站背压期间暂停主动 Ping,恢复后不集中补发。
|
||||
下游 Responses WebSocket 每 25 秒发送 Ping,第一条未响应的 Ping 发出后 30 秒没有入站活动即断开;
|
||||
接收中的消息字节、Pong 和其他有效入站消息均算活动,慢速上传不必等整帧收齐才刷新探活状态。
|
||||
后续 Ping 不延长这次截止,心跳入队及写入最多等待 5 秒。两段心跳各自处理,不跨 WS 转发或转换为 SSE 事件。
|
||||
|
||||
+1
-1
@@ -466,7 +466,7 @@ Responses WebSocket 接受文本消息,`response.create` 在同一连接串行
|
||||
留在有界接收队列中,待当前响应完成终结和写出后再逐条校验、准入与执行,不因请求提前到达而断开。
|
||||
接收队列容量为 32 个事件,超载仍关闭连接;Ping/Pong、客户端关闭和服务关闭不等待队列中的请求执行
|
||||
|
||||
网关分别维护两段链路的心跳:上游 WS 被动回复 Pong,下游 WS 独立探活;Ping/Pong 不跨段转发,也不转换为 SSE 事件。
|
||||
网关分别维护两段链路的心跳:上游 WS 定时发送 Ping 保活并回复 Pong,下游 WS 独立探活;Ping/Pong 不跨段转发,也不转换为 SSE 事件。
|
||||
下游探活包含消息接收中的字节进度,完整消息收齐后才进入中间件与请求处理。
|
||||
客户端断开时取消当前执行,并释放仍归该客户端所有的上游连接。`response.completed` 只结束本轮响应,保留客户端 WS 等待下一轮请求
|
||||
|
||||
|
||||
Reference in New Issue
Block a user