feature: upstream cancel (#19524)
Co-authored-by: Kangyan Zhou <zky314343421@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Kangyan Zhou
Claude Opus 4.7
parent
54eb2904a4
commit
e5589843a3
@@ -20,7 +20,7 @@ use axum::{
|
||||
};
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
use serde_json::json;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::{Notify, RwLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Configuration for mock worker behavior
|
||||
@@ -158,6 +158,15 @@ async fn should_fail(config: &MockWorkerConfig) -> bool {
|
||||
rand::random::<f32>() < config.fail_rate
|
||||
}
|
||||
|
||||
/// Pick the HTTP status used when `should_fail` triggers. Defaults to 500
|
||||
/// for backwards compatibility; tests can override via
|
||||
/// [`set_fail_status_code`] to exercise 4xx/non-5xx failure paths.
|
||||
fn fail_status_code(port: u16) -> StatusCode {
|
||||
get_fail_status_code_for_port(port)
|
||||
.and_then(|s| StatusCode::from_u16(s).ok())
|
||||
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
async fn health_handler(State(config): State<Arc<RwLock<MockWorkerConfig>>>) -> Response {
|
||||
let config = config.read().await;
|
||||
|
||||
@@ -303,7 +312,7 @@ async fn generate_handler(
|
||||
|
||||
if should_fail(&config).await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
fail_status_code(config.port),
|
||||
[("x-worker-id", worker_id)],
|
||||
Json(json!({
|
||||
"error": "Random failure for testing"
|
||||
@@ -324,6 +333,71 @@ async fn generate_handler(
|
||||
if is_stream {
|
||||
let stream_delay = config.response_delay_ms;
|
||||
|
||||
if let Some(num_chunks) = get_slow_stream_chunks_for_port(config.port) {
|
||||
let port = config.port;
|
||||
let delay_ms = stream_delay;
|
||||
let error_after = get_stream_error_after_for_port(port);
|
||||
init_stream_tracking(port, num_chunks);
|
||||
|
||||
let (tx, rx) =
|
||||
tokio::sync::mpsc::channel::<Result<Event, std::io::Error>>(MOCK_STREAM_BUFFER);
|
||||
tokio::spawn(async move {
|
||||
let _exit_guard = install_stream_exit_notifier(port);
|
||||
let timestamp_start = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs_f64();
|
||||
for i in 0..num_chunks {
|
||||
if let Some(n) = error_after {
|
||||
if i == n {
|
||||
let _ = tx
|
||||
.send(Err(std::io::Error::other(
|
||||
"simulated upstream worker crash",
|
||||
)))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if delay_ms > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
let data = json!({
|
||||
"text": format!("chunk-{} ", i),
|
||||
"meta_info": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": (i + 1) as u64,
|
||||
"completion_tokens_wo_jump_forward": (i + 1) as u64,
|
||||
"input_token_logprobs": null,
|
||||
"output_token_logprobs": null,
|
||||
"first_token_latency": delay_ms as f64 / 1000.0,
|
||||
"time_to_first_token": delay_ms as f64 / 1000.0,
|
||||
"time_per_output_token": 0.01,
|
||||
"start_time": timestamp_start,
|
||||
"finish_reason": null
|
||||
},
|
||||
"stage": "mid"
|
||||
});
|
||||
if tx
|
||||
.send(Ok(Event::default().data(data.to_string())))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
record_chunk_sent(port);
|
||||
}
|
||||
let _ = tx.send(Ok(Event::default().data("[DONE]"))).await;
|
||||
mark_stream_completed(port);
|
||||
});
|
||||
|
||||
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
return (
|
||||
[("x-worker-id", worker_id)],
|
||||
Sse::new(stream).keep_alive(KeepAlive::default()),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Check if it's a batch request
|
||||
let is_batch = payload.get("text").and_then(|t| t.as_array()).is_some();
|
||||
|
||||
@@ -442,28 +516,99 @@ async fn chat_completions_handler(
|
||||
if is_stream {
|
||||
let request_id = format!("chatcmpl-{}", Uuid::new_v4());
|
||||
|
||||
let stream = stream::once(async move {
|
||||
let chunk = json!({
|
||||
"id": request_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": timestamp,
|
||||
"model": "mock-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"content": "This is a mock chat response."
|
||||
},
|
||||
"finish_reason": null
|
||||
}]
|
||||
// Check for slow streaming mode (used by upstream cancel tests).
|
||||
// Reads from the global SLOW_STREAM_CONFIG (set via set_slow_stream_chunks)
|
||||
// rather than the payload, because the gateway deserializes/re-serializes
|
||||
// the request body and drops unknown fields.
|
||||
let slow_chunks = get_slow_stream_chunks_for_port(config.port);
|
||||
|
||||
if let Some(num_chunks) = slow_chunks {
|
||||
let port = config.port;
|
||||
let delay_ms = config.response_delay_ms;
|
||||
let error_after = get_stream_error_after_for_port(port);
|
||||
|
||||
init_stream_tracking(port, num_chunks);
|
||||
|
||||
// Small bounded capacity gives a bit of slack between the producer
|
||||
// task and the SSE consumer; on receiver drop, send().await
|
||||
// returns Err and the loop exits regardless of capacity.
|
||||
let (tx, rx) =
|
||||
tokio::sync::mpsc::channel::<Result<Event, std::io::Error>>(MOCK_STREAM_BUFFER);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _exit_guard = install_stream_exit_notifier(port);
|
||||
for i in 0..num_chunks {
|
||||
if let Some(n) = error_after {
|
||||
if i == n {
|
||||
// Inject a transport-level error to exercise the
|
||||
// gateway's `Some(Err(_))` arm.
|
||||
let _ = tx
|
||||
.send(Err(std::io::Error::other(
|
||||
"simulated upstream worker crash",
|
||||
)))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if delay_ms > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
let chunk = json!({
|
||||
"id": &request_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": timestamp,
|
||||
"model": "mock-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"content": format!("chunk-{} ", i)
|
||||
},
|
||||
"finish_reason": null
|
||||
}]
|
||||
});
|
||||
if tx
|
||||
.send(Ok(Event::default().data(chunk.to_string())))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
// Client disconnected, stream was cancelled
|
||||
return;
|
||||
}
|
||||
record_chunk_sent(port);
|
||||
}
|
||||
// Send [DONE]
|
||||
let _ = tx.send(Ok(Event::default().data("[DONE]"))).await;
|
||||
mark_stream_completed(port);
|
||||
});
|
||||
|
||||
Ok::<_, Infallible>(Event::default().data(chunk.to_string()))
|
||||
})
|
||||
.chain(stream::once(async { Ok(Event::default().data("[DONE]")) }));
|
||||
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
Sse::new(stream)
|
||||
.keep_alive(KeepAlive::default())
|
||||
.into_response()
|
||||
} else {
|
||||
let stream = stream::once(async move {
|
||||
let chunk = json!({
|
||||
"id": request_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": timestamp,
|
||||
"model": "mock-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"content": "This is a mock chat response."
|
||||
},
|
||||
"finish_reason": null
|
||||
}]
|
||||
});
|
||||
|
||||
Sse::new(stream)
|
||||
.keep_alive(KeepAlive::default())
|
||||
.into_response()
|
||||
Ok::<_, Infallible>(Event::default().data(chunk.to_string()))
|
||||
})
|
||||
.chain(stream::once(async { Ok(Event::default().data("[DONE]")) }));
|
||||
|
||||
Sse::new(stream)
|
||||
.keep_alive(KeepAlive::default())
|
||||
.into_response()
|
||||
}
|
||||
} else {
|
||||
Json(json!({
|
||||
"id": format!("chatcmpl-{}", Uuid::new_v4()),
|
||||
@@ -793,8 +938,13 @@ async fn responses_handler(
|
||||
Sse::new(stream)
|
||||
.keep_alive(KeepAlive::default())
|
||||
.into_response()
|
||||
} else if has_tools && has_function_output {
|
||||
// Second turn: emit streaming text response
|
||||
} else if has_tools
|
||||
&& has_function_output
|
||||
&& get_slow_stream_chunks_for_port(config.port).is_none()
|
||||
{
|
||||
// Second turn: emit streaming text response.
|
||||
// If slow-stream is configured, fall through to the slow-stream
|
||||
// branch below so cancel tests can disconnect mid second-turn.
|
||||
let rid = request_id.clone();
|
||||
let msg_id = format!(
|
||||
"msg_{}",
|
||||
@@ -941,6 +1091,126 @@ async fn responses_handler(
|
||||
Sse::new(stream)
|
||||
.keep_alive(KeepAlive::default())
|
||||
.into_response()
|
||||
} else if let Some(num_chunks) = get_slow_stream_chunks_for_port(config.port) {
|
||||
// Slow-stream mode for /responses cancel tests. Mirrors the
|
||||
// chat-completions slow-stream path so the same set_slow_stream_chunks
|
||||
// helper drives both endpoints.
|
||||
let port = config.port;
|
||||
let delay_ms = config.response_delay_ms;
|
||||
let error_after = get_stream_error_after_for_port(port);
|
||||
let rid = request_id.clone();
|
||||
let msg_id = format!(
|
||||
"msg_{}",
|
||||
Uuid::new_v4().to_string().split('-').next().unwrap()
|
||||
);
|
||||
|
||||
init_stream_tracking(port, num_chunks);
|
||||
|
||||
let (tx, rx) =
|
||||
tokio::sync::mpsc::channel::<Result<Event, std::io::Error>>(MOCK_STREAM_BUFFER);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _exit_guard = install_stream_exit_notifier(port);
|
||||
// Emit response.created and response.in_progress so the
|
||||
// gateway's /responses persistence accumulator has the
|
||||
// structural events it expects.
|
||||
let created = Event::default().event("response.created").data(
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": rid.clone(),
|
||||
"object": "response",
|
||||
"created_at": timestamp,
|
||||
"model": "mock-model",
|
||||
"status": "in_progress"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
if tx.send(Ok(created)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let in_progress = Event::default().event("response.in_progress").data(
|
||||
json!({
|
||||
"type": "response.in_progress",
|
||||
"response": {
|
||||
"id": rid.clone(),
|
||||
"object": "response",
|
||||
"created_at": timestamp,
|
||||
"model": "mock-model",
|
||||
"status": "in_progress"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
if tx.send(Ok(in_progress)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
for i in 0..num_chunks {
|
||||
if let Some(n) = error_after {
|
||||
if i == n {
|
||||
let _ = tx
|
||||
.send(Err(std::io::Error::other(
|
||||
"simulated upstream worker crash",
|
||||
)))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if delay_ms > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
let delta = Event::default().event("response.output_text.delta").data(
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"item_id": msg_id.clone(),
|
||||
"delta": format!("chunk-{} ", i)
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
if tx.send(Ok(delta)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
record_chunk_sent(port);
|
||||
}
|
||||
|
||||
let aggregated_text = (0..num_chunks)
|
||||
.map(|i| format!("chunk-{} ", i))
|
||||
.collect::<String>();
|
||||
let completed = Event::default().event("response.completed").data(
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": rid,
|
||||
"object": "response",
|
||||
"created_at": timestamp,
|
||||
"model": "mock-model",
|
||||
"status": "completed",
|
||||
"output": [{
|
||||
"id": msg_id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": aggregated_text
|
||||
}]
|
||||
}]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
let _ = tx.send(Ok(completed)).await;
|
||||
let _ = tx.send(Ok(Event::default().data("[DONE]"))).await;
|
||||
mark_stream_completed(port);
|
||||
});
|
||||
|
||||
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
Sse::new(stream)
|
||||
.keep_alive(KeepAlive::default())
|
||||
.into_response()
|
||||
} else {
|
||||
// Default streaming response
|
||||
let stream = stream::once(async move {
|
||||
@@ -1195,6 +1465,241 @@ async fn responses_cancel_handler(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Slow-stream configuration (for upstream cancel tests) ---
|
||||
// Configured via a global map keyed by worker port so that tests
|
||||
// can enable slow streaming WITHOUT relying on the request payload
|
||||
// (the gateway deserializes/re-serializes the body, dropping unknown fields).
|
||||
|
||||
static SLOW_STREAM_CONFIG: OnceLock<Mutex<HashMap<u16, usize>>> = OnceLock::new();
|
||||
|
||||
fn get_slow_stream_config() -> &'static Mutex<HashMap<u16, usize>> {
|
||||
SLOW_STREAM_CONFIG.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Configure a worker (by port) to send `num_chunks` chunks with
|
||||
/// `response_delay_ms` between each when handling a streaming request.
|
||||
/// Call this before making the request through the gateway.
|
||||
pub fn set_slow_stream_chunks(port: u16, num_chunks: usize) {
|
||||
let mut map = get_slow_stream_config().lock().unwrap();
|
||||
map.insert(port, num_chunks);
|
||||
}
|
||||
|
||||
/// Clear slow-stream configuration for a worker port.
|
||||
pub fn clear_slow_stream_chunks(port: u16) {
|
||||
let mut map = get_slow_stream_config().lock().unwrap();
|
||||
map.remove(&port);
|
||||
}
|
||||
|
||||
fn get_slow_stream_chunks_for_port(port: u16) -> Option<usize> {
|
||||
let map = get_slow_stream_config().lock().unwrap();
|
||||
map.get(&port).copied()
|
||||
}
|
||||
|
||||
// --- Stream error injection (for upstream cancel + error tests) ---
|
||||
// When set for `port`, the slow-stream producer emits an io::Error to the
|
||||
// SSE stream after the configured number of successfully-sent chunks.
|
||||
// reqwest will surface this as a transport error, which exercises the
|
||||
// gateway's `Some(Err(_))` arm.
|
||||
|
||||
static STREAM_ERROR_AFTER: OnceLock<Mutex<HashMap<u16, usize>>> = OnceLock::new();
|
||||
|
||||
fn get_stream_error_after_config() -> &'static Mutex<HashMap<u16, usize>> {
|
||||
STREAM_ERROR_AFTER.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Configure a worker (by port) to abort its SSE stream with an error
|
||||
/// after sending `n` chunks. Must be combined with
|
||||
/// [`set_slow_stream_chunks`] to take effect.
|
||||
pub fn set_stream_error_after_chunks(port: u16, n: usize) {
|
||||
let mut map = get_stream_error_after_config().lock().unwrap();
|
||||
map.insert(port, n);
|
||||
}
|
||||
|
||||
/// Clear error-injection configuration for a worker port.
|
||||
pub fn clear_stream_error_after_chunks(port: u16) {
|
||||
let mut map = get_stream_error_after_config().lock().unwrap();
|
||||
map.remove(&port);
|
||||
}
|
||||
|
||||
fn get_stream_error_after_for_port(port: u16) -> Option<usize> {
|
||||
let map = get_stream_error_after_config().lock().unwrap();
|
||||
map.get(&port).copied()
|
||||
}
|
||||
|
||||
// --- Failure-status override (for breaker attribution tests) ---
|
||||
// When set for `port`, `should_fail`-triggered failures return this HTTP
|
||||
// status instead of the default 500. Lets a test pin breaker semantics for
|
||||
// the 4xx-from-worker case (the gateway treats 4xx as "not a worker fault")
|
||||
// without having to fabricate a separate mock worker.
|
||||
|
||||
static FAIL_STATUS_CODE: OnceLock<Mutex<HashMap<u16, u16>>> = OnceLock::new();
|
||||
|
||||
fn get_fail_status_code_config() -> &'static Mutex<HashMap<u16, u16>> {
|
||||
FAIL_STATUS_CODE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Configure a worker (by port) to return `status` when `fail_rate`
|
||||
/// triggers a failure response, instead of the default 500.
|
||||
pub fn set_fail_status_code(port: u16, status: u16) {
|
||||
let mut map = get_fail_status_code_config().lock().unwrap();
|
||||
map.insert(port, status);
|
||||
}
|
||||
|
||||
/// Clear failure-status override for a worker port.
|
||||
pub fn clear_fail_status_code(port: u16) {
|
||||
let mut map = get_fail_status_code_config().lock().unwrap();
|
||||
map.remove(&port);
|
||||
}
|
||||
|
||||
fn get_fail_status_code_for_port(port: u16) -> Option<u16> {
|
||||
let map = get_fail_status_code_config().lock().unwrap();
|
||||
map.get(&port).copied()
|
||||
}
|
||||
|
||||
// --- Stream cancellation tracking (for upstream cancel tests) ---
|
||||
|
||||
/// Tracks the state of a streaming response for cancel verification.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StreamTrackingState {
|
||||
pub total_chunks: usize,
|
||||
pub chunks_sent: usize,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
static STREAM_CANCEL_TRACKER: OnceLock<Mutex<HashMap<u16, StreamTrackingState>>> = OnceLock::new();
|
||||
|
||||
fn get_stream_tracker() -> &'static Mutex<HashMap<u16, StreamTrackingState>> {
|
||||
STREAM_CANCEL_TRACKER.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
// Per-port `Notify` fired when the worker's producer task exits (either
|
||||
// because its outbound `send().await` failed — i.e. the gateway dropped
|
||||
// the upstream connection — or because the stream completed naturally).
|
||||
// Tests await this notification instead of polling counters, so cancel
|
||||
// assertions don't depend on timing windows.
|
||||
static STREAM_FINISH_NOTIFIERS: OnceLock<Mutex<HashMap<u16, Arc<Notify>>>> = OnceLock::new();
|
||||
|
||||
fn get_stream_finish_notifier_map() -> &'static Mutex<HashMap<u16, Arc<Notify>>> {
|
||||
STREAM_FINISH_NOTIFIERS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn get_stream_finish_notifier(port: u16) -> Arc<Notify> {
|
||||
let mut map = get_stream_finish_notifier_map().lock().unwrap();
|
||||
map.entry(port)
|
||||
.or_insert_with(|| Arc::new(Notify::new()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Bound on the per-stream mpsc buffer used by every slow-stream producer
|
||||
/// task in this mock worker. Tests assert that `chunks_sent` after a cancel
|
||||
/// grew by at most this many over the pre-drop snapshot, on the theory that
|
||||
/// anything more means the gateway did not propagate the disconnect
|
||||
/// upstream.
|
||||
pub const MOCK_STREAM_BUFFER: usize = 4;
|
||||
|
||||
/// RAII guard that fires the per-port finish notifier on drop, so the
|
||||
/// notification fires whether the producer task exits normally or returns
|
||||
/// early on `tx.send(...).await.is_err()`.
|
||||
#[must_use = "StreamExitNotifier must be bound to a local (typically `_exit_guard`) \
|
||||
and held until the producer task ends — dropping it immediately fires \
|
||||
the notifier early, causing `wait_for_stream_finish` to return before \
|
||||
the producer has actually exited"]
|
||||
pub struct StreamExitNotifier(Arc<Notify>);
|
||||
|
||||
impl Drop for StreamExitNotifier {
|
||||
fn drop(&mut self) {
|
||||
self.0.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the exit notifier inside a producer task. Hold the returned
|
||||
/// guard until the task ends (typically by binding it to `_exit_guard`).
|
||||
#[must_use = "the returned guard fires the exit notifier on drop; bind it to a local \
|
||||
(e.g. `let _exit_guard = install_stream_exit_notifier(port);`) so it lives \
|
||||
for the producer task's lifetime"]
|
||||
pub fn install_stream_exit_notifier(port: u16) -> StreamExitNotifier {
|
||||
StreamExitNotifier(get_stream_finish_notifier(port))
|
||||
}
|
||||
|
||||
/// Reset the stream tracker for a given port before starting a new test.
|
||||
/// Also replaces the finish notifier so any unconsumed permit from a
|
||||
/// previous test doesn't satisfy this test's wait immediately.
|
||||
pub fn reset_stream_tracker(port: u16) {
|
||||
let mut map = get_stream_tracker().lock().unwrap();
|
||||
map.remove(&port);
|
||||
let mut nmap = get_stream_finish_notifier_map().lock().unwrap();
|
||||
nmap.insert(port, Arc::new(Notify::new()));
|
||||
}
|
||||
|
||||
/// Get the stream tracking state for a given port.
|
||||
pub fn get_stream_tracking_state(port: u16) -> Option<StreamTrackingState> {
|
||||
let map = get_stream_tracker().lock().unwrap();
|
||||
map.get(&port).cloned()
|
||||
}
|
||||
|
||||
/// Wait until the worker's producer task for `port` exits — either because
|
||||
/// the gateway dropped the upstream connection (`send().await` failed) or
|
||||
/// because the stream finished naturally. Returns the final tracking state.
|
||||
/// The `timeout` is a safety net for hung tests; a healthy run returns the
|
||||
/// instant the producer task drops its exit guard.
|
||||
///
|
||||
/// **Precondition:** call [`reset_stream_tracker`] before issuing the
|
||||
/// gateway request whose producer you intend to wait on. The reset
|
||||
/// installs a fresh `Notify` so a stale permit left by a previous test
|
||||
/// on the same port can't satisfy this wait immediately.
|
||||
pub async fn wait_for_stream_finish(
|
||||
port: u16,
|
||||
timeout: tokio::time::Duration,
|
||||
) -> Option<StreamTrackingState> {
|
||||
let notifier = get_stream_finish_notifier(port);
|
||||
if tokio::time::timeout(timeout, notifier.notified())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
// A hung producer would silently look like a successful cancel
|
||||
// (chunks_sent < total_chunks, completed=false) if we just
|
||||
// returned what we have. Panic instead so the test fails loudly.
|
||||
panic!(
|
||||
"wait_for_stream_finish timed out after {:?} for port {} — \
|
||||
producer task never fired its exit notifier. Last tracker \
|
||||
state: {:?}",
|
||||
timeout,
|
||||
port,
|
||||
get_stream_tracking_state(port)
|
||||
);
|
||||
}
|
||||
get_stream_tracking_state(port)
|
||||
}
|
||||
|
||||
// Initialize tracking for a new stream. `map.insert` overwrites any prior
|
||||
// entry for this port, so callers don't need to reset first; we still expose
|
||||
// `reset_stream_tracker` so tests can opt into removing the entry entirely.
|
||||
fn init_stream_tracking(port: u16, total_chunks: usize) {
|
||||
let mut map = get_stream_tracker().lock().unwrap();
|
||||
map.insert(
|
||||
port,
|
||||
StreamTrackingState {
|
||||
total_chunks,
|
||||
chunks_sent: 0,
|
||||
completed: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn record_chunk_sent(port: u16) {
|
||||
let mut map = get_stream_tracker().lock().unwrap();
|
||||
if let Some(state) = map.get_mut(&port) {
|
||||
state.chunks_sent += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_stream_completed(port: u16) {
|
||||
let mut map = get_stream_tracker().lock().unwrap();
|
||||
if let Some(state) = map.get_mut(&port) {
|
||||
state.completed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Simple in-memory response store per worker port (for tests) ---
|
||||
static RESP_STORE: OnceLock<Mutex<HashMap<u16, HashSet<String>>>> = OnceLock::new();
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ pub mod circuit_breaker_test;
|
||||
pub mod fault_tolerance_test;
|
||||
pub mod rate_limiting_test;
|
||||
pub mod retries_test;
|
||||
pub mod upstream_cancel_test;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user