622 lines
21 KiB
Rust
622 lines
21 KiB
Rust
use std::future::Future;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
use std::time::Duration;
|
|
|
|
use dever_runtime::{bytes::Bytes, channel::Channel, http, net, task};
|
|
|
|
fn limits() -> http::Limits {
|
|
http::Limits {
|
|
header_bytes: 8192,
|
|
body_bytes: 1024,
|
|
timeout_ms: 1000,
|
|
connections: 4,
|
|
http2: None,
|
|
}
|
|
}
|
|
|
|
fn run_test(test: impl Future<Output = Result<(), String>>) {
|
|
task::run_entry_with(
|
|
task::RuntimeConfig {
|
|
worker_threads: 1,
|
|
max_blocking_threads: 2,
|
|
task_capacity: 32,
|
|
},
|
|
async {
|
|
tokio::time::timeout(Duration::from_secs(8), test)
|
|
.await
|
|
.map_err(|_| "HTTP test deadline exceeded".to_owned())?
|
|
},
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
fn header(name: &str, value: &str) -> http::Header {
|
|
http::Header {
|
|
name: name.into(),
|
|
value: Bytes::from_text(value),
|
|
}
|
|
}
|
|
|
|
fn request() -> http::Request {
|
|
http::Request {
|
|
method: "GET".into(),
|
|
target: "/".into(),
|
|
headers: vec![header("host", "test")],
|
|
body: Bytes::new(vec![]),
|
|
}
|
|
}
|
|
|
|
fn response(body: Bytes) -> http::Response {
|
|
http::Response {
|
|
status: 200,
|
|
headers: vec![],
|
|
body,
|
|
}
|
|
}
|
|
|
|
async fn server<F, Fut>(
|
|
route: F,
|
|
limits: http::Limits,
|
|
) -> Result<(task::Task<()>, net::Listener, i64), String>
|
|
where
|
|
F: Fn(http::Request) -> Fut + Clone + Send + Sync + 'static,
|
|
Fut: Future<Output = Result<http::Response, String>> + Send + 'static,
|
|
{
|
|
let listener = net::listen("127.0.0.1", 0).await?;
|
|
let port = net::port(&listener)?;
|
|
let owned = listener.clone();
|
|
let task = task::run(async move { http::serve(route, owned, limits).await }).await?;
|
|
Ok((task, listener, port))
|
|
}
|
|
|
|
// 独立、只读的测试线协议观察器:按 Content-Length 保留下一条响应,不调用 Hyper。
|
|
struct Peer {
|
|
socket: net::Socket,
|
|
buffered: Vec<u8>,
|
|
}
|
|
|
|
impl Peer {
|
|
async fn connect(port: i64) -> Result<Self, String> {
|
|
let socket = net::connect_timeout("127.0.0.1", port, 2000).await?;
|
|
net::timeout(&socket, 2000)?;
|
|
Ok(Self {
|
|
socket,
|
|
buffered: vec![],
|
|
})
|
|
}
|
|
|
|
async fn write(&self, wire: &[u8]) -> Result<(), String> {
|
|
net::write(&self.socket, &Bytes::new(wire.to_vec())).await
|
|
}
|
|
|
|
async fn read_more(&mut self) -> Result<(), String> {
|
|
let bytes = net::read(&self.socket, 4096)
|
|
.await?
|
|
.ok_or("unexpected HTTP EOF")?;
|
|
self.buffered.extend_from_slice(bytes.values());
|
|
Ok(())
|
|
}
|
|
|
|
async fn response(&mut self, head_only: bool) -> Result<(String, Vec<u8>), String> {
|
|
let end = loop {
|
|
if let Some(end) = self
|
|
.buffered
|
|
.windows(4)
|
|
.position(|part| part == b"\r\n\r\n")
|
|
{
|
|
break end + 4;
|
|
}
|
|
self.read_more().await?;
|
|
};
|
|
let headers = String::from_utf8(self.buffered[..end].to_vec()).unwrap();
|
|
let length = if head_only {
|
|
0
|
|
} else {
|
|
headers
|
|
.lines()
|
|
.find_map(|line| {
|
|
let (name, value) = line.split_once(':')?;
|
|
name.eq_ignore_ascii_case("content-length")
|
|
.then(|| value.trim().parse::<usize>().unwrap())
|
|
})
|
|
.unwrap_or(0)
|
|
};
|
|
while self.buffered.len() < end + length {
|
|
self.read_more().await?;
|
|
}
|
|
let body = self.buffered[end..end + length].to_vec();
|
|
self.buffered.drain(..end + length);
|
|
Ok((headers.to_ascii_lowercase(), body))
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn persistent_pipeline_preserves_chunked_binary_body_and_duplicate_headers() {
|
|
run_test(async {
|
|
let (server, listener, port) = server(
|
|
|request| async move {
|
|
if request.target == "/first" {
|
|
assert_eq!(request.body.values(), &[0, 255, b'x']);
|
|
let values: Vec<_> = request
|
|
.headers
|
|
.iter()
|
|
.filter(|header| header.name == "x-many")
|
|
.map(|header| header.value.values())
|
|
.collect();
|
|
assert_eq!(values, [b"one".as_slice(), &[0x80]]);
|
|
}
|
|
Ok(http::Response {
|
|
status: 200,
|
|
headers: vec![header("set-cookie", "a=1"), header("set-cookie", "b=2")],
|
|
body: request.body,
|
|
})
|
|
},
|
|
limits(),
|
|
)
|
|
.await?;
|
|
let mut peer = Peer::connect(port).await?;
|
|
peer.write(b"POST /first HTTP/1.1\r\nHost: test\r\nX-Many: one\r\nX-Many: \x80\r\nTransfer-Encoding: chunked\r\n\r\n2\r\n\0\xff\r\n1\r\nx\r\n0\r\n\r\nPOST /next HTTP/1.1\r\nHost: test\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok").await?;
|
|
let (headers, body) = peer.response(false).await?;
|
|
assert!(headers.starts_with("http/1.1 200"));
|
|
assert_eq!(headers.matches("set-cookie:").count(), 2);
|
|
assert_eq!(body, [0, 255, b'x']);
|
|
assert_eq!(peer.response(false).await?.1, b"ok");
|
|
assert_eq!(net::read(&peer.socket, 1).await?, None);
|
|
task::stop(server).await?;
|
|
listener.close()?;
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn head_and_no_content_responses_keep_next_message_aligned() {
|
|
run_test(async {
|
|
let (server, listener, port) = server(
|
|
|request| async move {
|
|
let mut result = response(Bytes::from_text("head representation"));
|
|
if request.target == "/empty" {
|
|
result.status = 204;
|
|
result.body = Bytes::new(vec![]);
|
|
}
|
|
if request.target == "/reset" {
|
|
result.status = 205;
|
|
result.body = Bytes::new(vec![]);
|
|
}
|
|
if request.target == "/cached" {
|
|
result.status = 304;
|
|
result.body = Bytes::new(vec![]);
|
|
}
|
|
Ok(result)
|
|
},
|
|
limits(),
|
|
)
|
|
.await?;
|
|
let mut peer = Peer::connect(port).await?;
|
|
for (wire, status, head) in [
|
|
(
|
|
b"HEAD / HTTP/1.1\r\nHost: test\r\n\r\n".as_slice(),
|
|
"200",
|
|
true,
|
|
),
|
|
(b"GET /empty HTTP/1.1\r\nHost: test\r\n\r\n", "204", false),
|
|
(b"GET /reset HTTP/1.1\r\nHost: test\r\n\r\n", "205", false),
|
|
(b"GET /cached HTTP/1.1\r\nHost: test\r\n\r\n", "304", false),
|
|
] {
|
|
peer.write(wire).await?;
|
|
let (headers, body) = peer.response(head).await?;
|
|
assert!(
|
|
headers.starts_with(&format!("http/1.1 {status}")),
|
|
"{headers}"
|
|
);
|
|
assert!(body.is_empty());
|
|
}
|
|
task::stop(server).await?;
|
|
listener.close()?;
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_peers_and_limits_do_not_reach_the_handler_or_stop_the_server() {
|
|
run_test(async {
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
let observed = calls.clone();
|
|
let mut budget = limits();
|
|
budget.body_bytes = 4;
|
|
let (server, listener, port) = server(
|
|
move |_| {
|
|
observed.fetch_add(1, Ordering::SeqCst);
|
|
async { Ok(response(Bytes::new(vec![]))) }
|
|
},
|
|
budget,
|
|
)
|
|
.await?;
|
|
let oversized = format!(
|
|
"GET / HTTP/1.1\r\nHost: test\r\nX-Large: {}\r\n\r\n",
|
|
"a".repeat(9000)
|
|
);
|
|
for (wire, status) in [
|
|
(b"POST / HTTP/1.1\r\nHost: test\r\nContent-Length: 1\r\nContent-Length: 2\r\n\r\nx".as_slice(), "400"),
|
|
(b"GET / HTTP/1.1\r\nHost: a\r\nHost: b\r\n\r\n", "400"),
|
|
(b"POST / HTTP/1.1\r\nHost: test\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nabcde\r\n0\r\n\r\n", "413"),
|
|
(b"POST / HTTP/1.1\r\nHost: test\r\nContent-Length: 5\r\n\r\n", "413"),
|
|
(b"POST / HTTP/1.1\r\nHost: test\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nX-Trailer: yes\r\n\r\n", "501"),
|
|
(b"CONNECT test:80 HTTP/1.1\r\nHost: test\r\n\r\n", "501"),
|
|
(b"GET / HTTP/1.1\r\nHost: test\r\nConnection: upgrade\r\nUpgrade: websocket\r\n\r\n", "501"),
|
|
(oversized.as_bytes(), "431"),
|
|
] {
|
|
let mut peer = Peer::connect(port).await?;
|
|
peer.write(wire).await?;
|
|
let (headers, _) = peer.response(false).await?;
|
|
assert!(headers.starts_with(&format!("http/1.1 {status}")), "{headers}");
|
|
}
|
|
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
|
assert_eq!(
|
|
http::send("127.0.0.1", port, request(), limits())
|
|
.await?
|
|
.status,
|
|
200
|
|
);
|
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
|
task::stop(server).await?;
|
|
listener.close()?;
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
struct Dropped(Arc<AtomicBool>);
|
|
impl Drop for Dropped {
|
|
fn drop(&mut self) {
|
|
self.0.store(true, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn stop_closes_active_connections_and_drains_handler_descendants() {
|
|
run_test(async {
|
|
let entered = Channel::new(1)?;
|
|
let notify = entered.clone();
|
|
let dropped = Arc::new(AtomicBool::new(false));
|
|
let observed = dropped.clone();
|
|
let (server, listener, port) = server(
|
|
move |_| {
|
|
let notify = notify.clone();
|
|
let marker = Dropped(observed.clone());
|
|
async move {
|
|
let child = task::run(async move {
|
|
let _marker = marker;
|
|
notify.send(()).await?;
|
|
std::future::pending::<()>().await;
|
|
Ok(())
|
|
})
|
|
.await?;
|
|
task::wait(child).await?;
|
|
Ok(response(Bytes::new(vec![])))
|
|
}
|
|
},
|
|
limits(),
|
|
)
|
|
.await?;
|
|
let peer = Peer::connect(port).await?;
|
|
peer.write(b"GET / HTTP/1.1\r\nHost: test\r\n\r\n").await?;
|
|
entered.receive().await?;
|
|
task::stop(server).await?;
|
|
assert!(dropped.load(Ordering::SeqCst));
|
|
assert_eq!(net::read(&peer.socket, 1).await?, None);
|
|
// serve 只释放自己的 listener 别名,调用者仍可决定资源生命周期。
|
|
assert_eq!(net::port(&listener)?, port);
|
|
listener.close()?;
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn handler_fault_returns_500_and_keeps_the_accept_loop_running() {
|
|
run_test(async {
|
|
let (server, listener, port) = server(
|
|
|request| async move {
|
|
if request.target == "/fault" {
|
|
return Err("route fault".into());
|
|
}
|
|
Ok(response(Bytes::from_text("healthy")))
|
|
},
|
|
limits(),
|
|
)
|
|
.await?;
|
|
let mut peer = Peer::connect(port).await?;
|
|
peer.write(b"GET /fault HTTP/1.1\r\nHost: test\r\n\r\n")
|
|
.await?;
|
|
let (headers, body) = peer.response(false).await?;
|
|
assert!(headers.starts_with("http/1.1 500"), "{headers}");
|
|
assert!(body.is_empty());
|
|
let healthy = http::send("127.0.0.1", port, request(), limits()).await?;
|
|
assert_eq!(healthy.status, 200);
|
|
assert_eq!(healthy.body.values(), b"healthy");
|
|
task::stop(server).await?;
|
|
listener.close()?;
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn connection_capacity_backpressures_before_dispatching_another_request() {
|
|
run_test(async {
|
|
let entered = Channel::new(2)?;
|
|
let release = Channel::new(2)?;
|
|
let notify = entered.clone();
|
|
let wait = release.clone();
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
let observed = calls.clone();
|
|
let mut budget = limits();
|
|
budget.connections = 1;
|
|
let (server, listener, port) = server(
|
|
move |_| {
|
|
let notify = notify.clone();
|
|
let wait = wait.clone();
|
|
let observed = observed.clone();
|
|
async move {
|
|
observed.fetch_add(1, Ordering::SeqCst);
|
|
notify.send(()).await?;
|
|
wait.receive().await?;
|
|
Ok(response(Bytes::new(vec![])))
|
|
}
|
|
},
|
|
budget,
|
|
)
|
|
.await?;
|
|
let mut first = Peer::connect(port).await?;
|
|
first
|
|
.write(b"GET / HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n")
|
|
.await?;
|
|
entered.receive().await?;
|
|
let mut second = Peer::connect(port).await?;
|
|
second
|
|
.write(b"GET / HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n")
|
|
.await?;
|
|
tokio::task::yield_now().await;
|
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
|
release.send(()).await?;
|
|
first.response(false).await?;
|
|
entered.receive().await?;
|
|
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
|
release.send(()).await?;
|
|
second.response(false).await?;
|
|
task::stop(server).await?;
|
|
listener.close()?;
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn header_body_and_handler_deadlines_release_the_connection() {
|
|
run_test(async {
|
|
let mut budget = limits();
|
|
budget.timeout_ms = 100;
|
|
let (server, listener, port) = server(
|
|
|_| async {
|
|
std::future::pending::<()>().await;
|
|
Ok(response(Bytes::new(vec![])))
|
|
},
|
|
budget,
|
|
)
|
|
.await?;
|
|
let peer = Peer::connect(port).await?;
|
|
peer.write(b"GET / HTTP/1.1\r\nHost:").await?;
|
|
// Hyper 对未完成的请求头超时直接关闭连接,没有可交给 handler 的请求。
|
|
assert_eq!(net::read(&peer.socket, 1).await?, None);
|
|
for (wire, expected) in [
|
|
(
|
|
b"POST / HTTP/1.1\r\nHost: test\r\nContent-Length: 2\r\n\r\nx".as_slice(),
|
|
"408",
|
|
),
|
|
(b"GET / HTTP/1.1\r\nHost: test\r\n\r\n", "504"),
|
|
] {
|
|
let mut peer = Peer::connect(port).await?;
|
|
peer.write(wire).await?;
|
|
let (headers, _) = peer.response(false).await?;
|
|
assert!(
|
|
headers.starts_with(&format!("http/1.1 {expected}")),
|
|
"{headers}"
|
|
);
|
|
assert_eq!(net::read(&peer.socket, 1).await?, None);
|
|
}
|
|
task::stop(server).await?;
|
|
listener.close()?;
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn client_accepts_chunked_and_eof_bodies_but_rejects_truncation_and_oversize() {
|
|
run_test(async {
|
|
for (wire, expected) in [
|
|
(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nSet-Cookie: a=1\r\nSet-Cookie: b=2\r\n\r\n2\r\nok\r\n0\r\n\r\n".as_slice(), true),
|
|
(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nok", true),
|
|
(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nok", false),
|
|
(b"HTTP/1.1 200 OK\r\nContent-Length: 2048\r\n\r\n", false),
|
|
] {
|
|
let listener = net::listen("127.0.0.1", 0).await?;
|
|
let port = net::port(&listener)?;
|
|
let peer = task::run(async move {
|
|
let socket = net::accept(&listener).await?;
|
|
net::timeout(&socket, 1000)?;
|
|
let sent = net::read(&socket, 4096).await?.unwrap().to_text()?;
|
|
assert!(sent.starts_with("GET / HTTP/1.1\r\n"));
|
|
assert!(sent.contains("host: test\r\n"));
|
|
for piece in wire.chunks(7) { net::write(&socket, &Bytes::new(piece.to_vec())).await?; tokio::task::yield_now().await; }
|
|
Ok(())
|
|
}).await?;
|
|
let received = http::send("127.0.0.1", port, request(), limits()).await;
|
|
if expected { assert_eq!(received?.body.values(), b"ok"); } else { assert!(received.is_err()); }
|
|
task::wait(peer).await?;
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_outgoing_messages_and_configuration_fail_before_connecting() {
|
|
run_test(async {
|
|
for invalid in [
|
|
header("x-test", "ok\r\ninjected: yes"),
|
|
header("content-length", "0"),
|
|
header("transfer-encoding", "chunked"),
|
|
] {
|
|
let mut request = request();
|
|
request.headers.push(invalid);
|
|
let error = http::send("127.0.0.1", 0, request, limits())
|
|
.await
|
|
.unwrap_err();
|
|
assert!(!error.contains("Connection refused"), "{error}");
|
|
}
|
|
let mut excessive = request();
|
|
excessive.headers.extend(vec![header("x", ""); 32769]);
|
|
assert_eq!(
|
|
http::send("127.0.0.1", 0, excessive, limits())
|
|
.await
|
|
.unwrap_err(),
|
|
"HTTP headers exceed header_bytes"
|
|
);
|
|
for budget in [
|
|
http::Limits {
|
|
header_bytes: 1,
|
|
..limits()
|
|
},
|
|
http::Limits {
|
|
body_bytes: -1,
|
|
..limits()
|
|
},
|
|
http::Limits {
|
|
timeout_ms: 0,
|
|
..limits()
|
|
},
|
|
http::Limits {
|
|
connections: 0,
|
|
..limits()
|
|
},
|
|
] {
|
|
assert!(
|
|
http::send("127.0.0.1", 0, request(), budget)
|
|
.await
|
|
.unwrap_err()
|
|
.starts_with("HTTP")
|
|
);
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_handler_responses_return_500_and_keep_the_accept_loop_running() {
|
|
run_test(async {
|
|
for status in [99, 204, 205, 304, 600] {
|
|
let (server, listener, port) = server(
|
|
move |request| async move {
|
|
if request.target != "/invalid" {
|
|
return Ok(response(Bytes::from_text("healthy")));
|
|
}
|
|
Ok(http::Response {
|
|
status,
|
|
headers: vec![],
|
|
body: Bytes::from_text("invalid body"),
|
|
})
|
|
},
|
|
limits(),
|
|
)
|
|
.await?;
|
|
let mut peer = Peer::connect(port).await?;
|
|
peer.write(b"GET /invalid HTTP/1.1\r\nHost: test\r\n\r\n")
|
|
.await?;
|
|
let (headers, body) = peer.response(false).await?;
|
|
assert_eq!(headers.split_whitespace().nth(1), Some("500"), "{headers}");
|
|
assert!(headers.lines().any(|line| line == "connection: close"));
|
|
assert!(body.is_empty());
|
|
assert!(peer.buffered.is_empty());
|
|
assert_eq!(net::read(&peer.socket, 1).await?, None);
|
|
let healthy = http::send("127.0.0.1", port, request(), limits()).await?;
|
|
assert_eq!(healthy.status, 200);
|
|
assert_eq!(healthy.body.values(), b"healthy");
|
|
listener.close()?;
|
|
task::wait(server).await?;
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn client_timeout_and_stop_release_the_owned_connection_driver() {
|
|
run_test(async {
|
|
for stop in [false, true] {
|
|
let listener = net::listen("127.0.0.1", 0).await?;
|
|
let port = net::port(&listener)?;
|
|
let received = Channel::new(1)?;
|
|
let notify = received.clone();
|
|
let peer = task::run(async move {
|
|
let socket = net::accept(&listener).await?;
|
|
net::timeout(&socket, 2000)?;
|
|
assert!(net::read(&socket, 4096).await?.is_some());
|
|
notify.send(()).await?;
|
|
assert_eq!(net::read(&socket, 1).await?, None);
|
|
Ok(())
|
|
})
|
|
.await?;
|
|
let mut budget = limits();
|
|
budget.timeout_ms = if stop { 1000 } else { 100 };
|
|
let client = task::run(async move {
|
|
let result = http::send("127.0.0.1", port, request(), budget).await;
|
|
assert_eq!(result.unwrap_err(), "HTTP request timed out");
|
|
Ok(())
|
|
})
|
|
.await?;
|
|
received.receive().await?;
|
|
if stop {
|
|
task::stop(client).await?;
|
|
} else {
|
|
task::wait(client).await?;
|
|
}
|
|
task::wait(peer).await?;
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn a_stalled_writer_releases_its_connection_slot() {
|
|
run_test(async {
|
|
let mut budget = limits();
|
|
budget.timeout_ms = 100;
|
|
budget.connections = 1;
|
|
budget.body_bytes = 16 * 1024 * 1024;
|
|
let entered = Channel::new(1)?;
|
|
let notify = entered.clone();
|
|
let (server, listener, port) = server(
|
|
move |request| {
|
|
let notify = notify.clone();
|
|
async move {
|
|
let body = if request.target == "/slow" {
|
|
notify.send(()).await?;
|
|
Bytes::new(vec![b'x'; 16 * 1024 * 1024])
|
|
} else {
|
|
Bytes::from_text("next")
|
|
};
|
|
Ok(response(body))
|
|
}
|
|
},
|
|
budget,
|
|
)
|
|
.await?;
|
|
let slow = Peer::connect(port).await?;
|
|
slow.write(b"GET /slow HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n")
|
|
.await?;
|
|
entered.receive().await?;
|
|
let mut next = Peer::connect(port).await?;
|
|
next.write(b"GET /next HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n")
|
|
.await?;
|
|
assert_eq!(next.response(false).await?.1, b"next");
|
|
task::stop(server).await?;
|
|
listener.close()?;
|
|
Ok(())
|
|
});
|
|
}
|