blob: 54f43cbb281081cc2d2599f8df295f3b8c55d47f [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::provider_stream::{read_request, request};
use maka_model::{ModelEvent, ModelExecutor, ProviderKind, StepBuilder};
use serde_json::json;
use std::time::Duration;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
sync::oneshot,
};
use tokio_util::sync::CancellationToken;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shared_native_requests_progress_through_backpressure_and_cancellation() {
tokio::time::timeout(Duration::from_secs(20), async {
let models = ModelExecutor::new(Duration::from_secs(15)).unwrap();
let waiting = TcpListener::bind("127.0.0.1:0").await.unwrap();
let waiting_base = format!("http://{}/v1", waiting.local_addr().unwrap());
let (received, ready) = oneshot::channel();
let pending_http = tokio::spawn(async move {
let (mut socket, _) = waiting.accept().await.unwrap();
read_request(&mut socket).await;
received.send(()).unwrap();
assert_http_closed(&mut socket).await;
});
let held = models.stream(request(ProviderKind::OpenaiChatCompletions, waiting_base), CancellationToken::new()).await.unwrap();
ready.await.unwrap();
let flooding = TcpListener::bind("127.0.0.1:0").await.unwrap();
let flooding_base = format!("http://{}/v1", flooding.local_addr().unwrap());
let flood_http = tokio::spawn(async move {
let (mut socket, _) = flooding.accept().await.unwrap();
read_request(&mut socket).await;
socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await.unwrap();
let part = json!({"id":"slow","object":"chat.completion.chunk","created":1,"model":"test-model",
"choices":[{"index":0,"delta":{"content":"x".repeat(4096)},"finish_reason":null}]});
let data = format!("data: {part}\n\n").repeat(256);
socket.write_all(format!("{:x}\r\n{data}\r\n", data.len()).as_bytes()).await.unwrap();
assert_http_closed(&mut socket).await;
});
let mut slow = models.stream(request(ProviderKind::OpenaiChatCompletions, flooding_base), CancellationToken::new()).await.unwrap();
loop {
if matches!(slow.next().await.unwrap().unwrap().event, ModelEvent::PartDelta { .. }) { break; }
}
// Leave the stream alive and undrained. More events than its bounded
// output queue can hold must not occupy the other requests indefinitely.
let serving = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}/v1", serving.local_addr().unwrap());
let healthy_http = tokio::spawn(async move {
for _ in 0..3 {
let (mut socket, _) = serving.accept().await.unwrap();
read_request(&mut socket).await;
let part = json!({"id":"healthy","object":"chat.completion.chunk","created":1,"model":"test-model",
"choices":[{"index":0,"delta":{"content":"alive"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}});
let body = format!("data: {part}\n\ndata: [DONE]\n\n");
socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
}
});
async fn complete(models: &ModelExecutor, base: &str) {
let mut stream = models.stream(request(ProviderKind::OpenaiChatCompletions, base.into()), CancellationToken::new()).await.unwrap();
let mut step = StepBuilder::default();
let mut text = String::new();
while let Some(event) = stream.next().await {
let event = event.unwrap().event;
if let ModelEvent::PartDelta { text: delta, .. } = &event { text.push_str(delta); }
step.push(event).unwrap();
}
assert_eq!(text, "alive");
step.finish().unwrap();
stream.cancel_and_wait().await.expect("actual stream cleanup");
}
complete(&models, &base).await;
held.cancel_and_wait().await.expect("actual stream cleanup");
pending_http.await.unwrap();
// An abandoned model consumer also cancels and settles its adapter request.
let abandoned = TcpListener::bind("127.0.0.1:0").await.unwrap();
let abandoned_base = format!("http://{}/v1", abandoned.local_addr().unwrap());
let (arrived, ready) = oneshot::channel();
let abandoned_http = tokio::spawn(async move {
let (mut socket, _) = abandoned.accept().await.unwrap();
read_request(&mut socket).await;
arrived.send(()).unwrap();
assert_http_closed(&mut socket).await;
});
let abandoned_models = models.clone();
let abandoned_call = tokio::spawn(async move {
let _stream = abandoned_models.stream(request(ProviderKind::OpenaiChatCompletions, abandoned_base), CancellationToken::new()).await.unwrap();
std::future::pending::<()>().await;
});
ready.await.unwrap();
abandoned_call.abort();
assert!(abandoned_call.await.unwrap_err().is_cancelled());
abandoned_http.await.unwrap();
complete(&models, &base).await;
slow.cancel_and_wait().await.expect("actual stream cleanup");
flood_http.await.unwrap();
complete(&models, &base).await;
healthy_http.await.unwrap();
}).await.expect("native adapters must make progress across independent lifetimes");
}
async fn assert_http_closed(socket: &mut TcpStream) {
match socket.read(&mut [0]).await {
Ok(0) => {}
// Cancellation may reset a socket with unread response bytes. Winsock
// also reports this as ConnectionAborted, rather than an orderly EOF.
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::ConnectionAborted
) => {}
result => panic!("cancelled HTTP connection did not close: {result:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn native_input_budget_wait_is_not_provider_silence_and_remains_cancellable() {
tokio::time::timeout(Duration::from_secs(60), async {
let large_models = ModelExecutor::new(Duration::from_secs(30)).unwrap();
let binding = large_models.binding(&request(ProviderKind::OpenaiChatCompletions, "http://fixture.invalid".into()).provider).unwrap();
let queued_models = ModelExecutor::new(Duration::from_millis(250)).unwrap()
.with_adapters(std::collections::BTreeMap::from([("chat-completions".into(), binding)]));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}/v1", listener.local_addr().unwrap());
let (received, ready) = oneshot::channel();
let http = tokio::spawn(async move {
let (mut first, _) = listener.accept().await.unwrap();
assert!(read_request(&mut first).await.len() > 32 * 1024 * 1024);
received.send(()).unwrap();
// The complete large request owns the input window until its
// physical transport has closed. Neither queued request may pass it.
tokio::select! {
_ = assert_http_closed(&mut first) => {},
unexpected = listener.accept() => panic!("input reservation released before cleanup: {unexpected:?}"),
}
let (mut second, _) = listener.accept().await.unwrap();
let wire = read_request(&mut second).await;
assert!(wire.contains("queued survivor"));
assert!(!wire.contains("cancelled before transport"));
let part = json!({"id":"queued","object":"chat.completion.chunk","created":1,"model":"test-model",
"choices":[{"index":0,"delta":{"content":"after capacity"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}});
let body = format!("data: {part}\n\ndata: [DONE]\n\n");
second.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
});
let mut large = request(ProviderKind::OpenaiChatCompletions, base.clone());
large.prompt = vec![maka_model::prompt::Message::user("x".repeat(33 * 1024 * 1024))];
let held = large_models.stream(large, CancellationToken::new()).await.unwrap();
ready.await.unwrap();
let mut survivor = request(ProviderKind::OpenaiChatCompletions, base.clone());
survivor.prompt = vec![maka_model::prompt::Message::user("queued survivor")];
let mut waiting = queued_models.stream(survivor, CancellationToken::new()).await.unwrap();
let mut cancelled = request(ProviderKind::OpenaiChatCompletions, base);
cancelled.prompt = vec![maka_model::prompt::Message::user("cancelled before transport")];
queued_models.stream(cancelled, CancellationToken::new()).await.unwrap()
.cancel_and_wait().await.expect("queued cancellation settles without transport");
assert!(tokio::time::timeout(Duration::from_millis(750), waiting.next()).await.is_err(),
"waiting for local capacity must outlive the provider idle budget without failing");
held.cancel_and_wait().await.expect("original HTTP custody closes before input capacity returns");
let mut text = String::new();
let mut step = StepBuilder::default();
while let Some(event) = waiting.next().await {
let event = event.unwrap().event;
if let ModelEvent::PartDelta { text: delta, .. } = &event { text.push_str(delta); }
step.push(event).unwrap();
}
assert_eq!(text, "after capacity");
step.finish().unwrap();
waiting.cancel_and_wait().await.expect("surviving stream cleanup");
http.await.unwrap();
}).await.expect("local input backpressure and cancellation settle");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn native_waiting_for_response_headers_times_out_and_closes_each_protocol() {
tokio::time::timeout(Duration::from_secs(20), async {
let models = ModelExecutor::new(Duration::from_millis(400)).unwrap();
for kind in [
ProviderKind::OpenaiChatCompletions,
ProviderKind::OpenaiCompatible {
name: "fixture".into(),
},
ProviderKind::Anthropic,
] {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}/v1", listener.local_addr().unwrap());
let http = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
read_request(&mut socket).await;
assert_http_closed(&mut socket).await;
});
let mut stream = models
.stream(request(kind, base), CancellationToken::new())
.await
.unwrap();
let error = loop {
match stream.next().await {
Some(Ok(_)) => {}
Some(Err(error)) => break error,
None => panic!("silent HTTP headers must report the actual timeout"),
}
};
assert!(
matches!(error, maka_model::ModelError::TimedOut),
"{error:?}"
);
stream
.cancel_and_wait()
.await
.expect("timed-out HTTP cleanup");
http.await.unwrap();
}
})
.await
.expect("all adapter protocols bound network silence");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn native_output_backpressure_outlives_idle_timeout_without_losing_text() {
tokio::time::timeout(Duration::from_secs(20), async {
for silent_after_resume in [false, true] {
let models = ModelExecutor::new(Duration::from_millis(400)).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}/v1", listener.local_addr().unwrap());
let unit = "retained 字🙂 ";
let (resume, resumed) = oneshot::channel();
let http = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
read_request(&mut socket).await;
let part = json!({"id":"backpressure","object":"chat.completion.chunk","created":1,"model":"test-model",
"choices":[{"index":0,"delta":{"content":unit},"finish_reason":null}]});
let body = format!("data: {part}\n\n").repeat(128);
socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await.unwrap();
socket.write_all(format!("{:x}\r\n{body}\r\n",body.len()).as_bytes()).await.unwrap();
// No further wire bytes arrive while the caller deliberately stops
// draining the bounded event queue beyond its idle interval.
resumed.await.unwrap();
if silent_after_resume {
assert_http_closed(&mut socket).await;
return;
}
let last = json!({"id":"backpressure","object":"chat.completion.chunk","created":1,"model":"test-model",
"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
"usage":{"prompt_tokens":1,"completion_tokens":128,"total_tokens":129}});
let body = format!("data: {last}\n\ndata: [DONE]\n\n");
socket.write_all(format!("{:x}\r\n{body}\r\n0\r\n\r\n",body.len()).as_bytes()).await.unwrap();
});
let mut stream = models.stream(request(ProviderKind::OpenaiChatCompletions, base), CancellationToken::new()).await.unwrap();
let mut step = StepBuilder::default();
let mut text = String::new();
let mut paused = false;
let mut resume = Some(resume);
let mut failure = None;
while let Some(event) = stream.next().await {
let event = match event {
Ok(event) => event.event,
Err(error) => { failure = Some(error); break; },
};
if let ModelEvent::PartDelta { text: delta, .. } = &event {
text.push_str(delta);
if !paused {
paused = true;
tokio::time::sleep(Duration::from_millis(1200)).await;
resume.take().unwrap().send(()).unwrap();
}
}
step.push(event).unwrap();
}
assert!(paused);
assert_eq!(text, unit.repeat(128));
if silent_after_resume {
assert!(matches!(failure, Some(maka_model::ModelError::TimedOut)), "{failure:?}");
assert!(step.finish().is_err());
} else {
assert!(failure.is_none(), "{failure:?}");
step.finish().unwrap();
}
stream.cancel_and_wait().await.expect("backpressured output cleanup");
http.await.unwrap();
}
}).await.expect("local output backpressure preserves the complete stream");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shared_native_admits_more_than_128_independent_models_and_cleans_them_up() {
tokio::time::timeout(Duration::from_secs(30), async {
const CALLS: usize = 256;
let models = ModelExecutor::new(Duration::from_secs(25)).unwrap();
let server = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}/v1", server.local_addr().unwrap());
let (received, ready) = oneshot::channel();
let http = tokio::spawn(async move {
let mut connections = tokio::task::JoinSet::new();
for _ in 0..CALLS {
let (mut socket, _) = server.accept().await.unwrap();
connections.spawn(async move {
read_request(&mut socket).await;
socket
});
}
let mut sockets = Vec::new();
while let Some(result) = connections.join_next().await {
sockets.push(result.unwrap());
}
received.send(()).unwrap();
for mut socket in sockets {
assert_http_closed(&mut socket).await;
}
});
let mut streams = Vec::new();
for _ in 0..CALLS {
streams.push(
models
.stream(
request(ProviderKind::OpenaiChatCompletions, base.clone()),
CancellationToken::new(),
)
.await
.unwrap(),
);
}
ready.await.unwrap();
let mut cleanup = tokio::task::JoinSet::new();
for stream in streams {
cleanup.spawn(async move {
stream
.cancel_and_wait()
.await
.expect("actual stream cleanup");
});
}
while let Some(result) = cleanup.join_next().await {
result.unwrap();
}
http.await.unwrap();
})
.await
.expect("all model requests reach transport before any is released");
}