708 lines
24 KiB
Rust
708 lines
24 KiB
Rust
//! Docker 容器执行封装:在预热池容器中执行命令、构建容器配置、镜像管理。
|
||
//!
|
||
//! 与 executor.rs 共享 ExecOutcome / ExecError 类型。
|
||
//! 调用方(warm_pool)先 acquire 容器句柄,再调用 exec_in_container 执行。
|
||
|
||
use std::collections::HashMap;
|
||
use std::time::{Duration, Instant};
|
||
|
||
use bollard::container::Config;
|
||
use bollard::exec::{CreateExecOptions, StartExecResults};
|
||
use bollard::image::CreateImageOptions;
|
||
use bollard::container::LogOutput;
|
||
use bollard::models::HostConfig;
|
||
use bollard::{Docker, API_DEFAULT_VERSION};
|
||
use futures_util::StreamExt;
|
||
|
||
use crate::config::WarmPoolConfig;
|
||
use crate::executor::{ExecError, ExecOutcome, StreamEvent};
|
||
|
||
/// 在已运行的容器中执行一条命令,返回 ExecOutcome(与进程模式相同的接口)。
|
||
///
|
||
/// # 参数
|
||
/// - `argv`:命令及参数数组
|
||
/// - `stdin`:可选标准输入
|
||
/// - `env`:环境变量键值对
|
||
/// - `timeout`:执行超时
|
||
/// - `max_output`:单流最大捕获字节数
|
||
pub async fn exec_in_container(
|
||
docker: &Docker,
|
||
container_id: &str,
|
||
argv: &[String],
|
||
stdin: Option<String>,
|
||
env: &[(String, String)],
|
||
timeout: Duration,
|
||
max_output: usize,
|
||
) -> Result<ExecOutcome, ExecError> {
|
||
let start = Instant::now();
|
||
|
||
// 1) 创建 exec 实例
|
||
let exec = docker
|
||
.create_exec(
|
||
container_id,
|
||
CreateExecOptions {
|
||
cmd: Some(argv.to_vec()),
|
||
env: Some(
|
||
env.iter()
|
||
.map(|(k, v)| format!("{k}={v}"))
|
||
.collect::<Vec<_>>(),
|
||
),
|
||
attach_stdout: Some(true),
|
||
attach_stderr: Some(true),
|
||
attach_stdin: Some(stdin.is_some()),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.await
|
||
.map_err(|e| ExecError::Internal {
|
||
user_msg: "创建容器 exec 失败",
|
||
detail: format!("create_exec 失败: {e}"),
|
||
})?;
|
||
|
||
// 2) 启动 exec 并捕获输出
|
||
let mut stdout = Vec::new();
|
||
let mut stderr = Vec::new();
|
||
let mut stdout_truncated = false;
|
||
let mut stderr_truncated = false;
|
||
let mut timeout_hit = false;
|
||
let cancelled = false;
|
||
|
||
let exec_result = tokio::time::timeout(timeout, async {
|
||
match docker.start_exec(&exec.id, None).await {
|
||
Ok(StartExecResults::Attached {
|
||
mut output,
|
||
mut input,
|
||
}) => {
|
||
// 先写入 stdin(若有),然后关闭输入管道
|
||
if let Some(data) = &stdin {
|
||
use tokio::io::AsyncWriteExt;
|
||
let _ = input.write_all(data.as_bytes()).await;
|
||
let _ = input.shutdown().await;
|
||
}
|
||
drop(input); // 关闭 stdin 触发命令的 EOF
|
||
|
||
while let Some(chunk) = output.next().await {
|
||
match chunk {
|
||
Ok(LogOutput::StdOut { message }) => {
|
||
let data = message.as_ref();
|
||
if stdout.len() < max_output {
|
||
let room = max_output - stdout.len();
|
||
let take = data.len().min(room);
|
||
stdout.extend_from_slice(&data[..take]);
|
||
if take < data.len() {
|
||
stdout_truncated = true;
|
||
}
|
||
} else {
|
||
stdout_truncated = true;
|
||
}
|
||
}
|
||
Ok(LogOutput::StdErr { message }) => {
|
||
let data = message.as_ref();
|
||
if stderr.len() < max_output {
|
||
let room = max_output - stderr.len();
|
||
let take = data.len().min(room);
|
||
stderr.extend_from_slice(&data[..take]);
|
||
if take < data.len() {
|
||
stderr_truncated = true;
|
||
}
|
||
} else {
|
||
stderr_truncated = true;
|
||
}
|
||
}
|
||
Ok(_) => {}
|
||
Err(e) => {
|
||
tracing::warn!(error = %e, exec_id = %exec.id, "容器 exec 流错误");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Ok(_) => {
|
||
// Detached(无输出者适用),直接检查退出码
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(error = %e, exec_id = %exec.id, "启动容器 exec 失败");
|
||
}
|
||
}
|
||
})
|
||
.await;
|
||
|
||
if exec_result.is_err() {
|
||
timeout_hit = true;
|
||
// Docker exec 无 kill 端点;exec 会在容器中继续运行,
|
||
// 但容器会在 release 时被销毁,无需额外清理。
|
||
}
|
||
|
||
// 4) 获取退出码
|
||
let exit_code = docker
|
||
.inspect_exec(&exec.id)
|
||
.await
|
||
.ok()
|
||
.and_then(|i| i.exit_code)
|
||
.map(|c| c as i32);
|
||
|
||
Ok(ExecOutcome {
|
||
exit_code,
|
||
stdout: String::from_utf8_lossy(&stdout).into_owned(),
|
||
stderr: String::from_utf8_lossy(&stderr).into_owned(),
|
||
duration_ms: start.elapsed().as_millis(),
|
||
timeout: timeout_hit,
|
||
cancelled,
|
||
stdout_truncated,
|
||
stderr_truncated,
|
||
})
|
||
}
|
||
|
||
/// 流式执行:在容器中执行命令并通过 channel 实时推送输出事件。
|
||
pub async fn exec_in_container_stream(
|
||
docker: &Docker,
|
||
container_id: &str,
|
||
argv: &[String],
|
||
stdin: Option<String>,
|
||
env: &[(String, String)],
|
||
timeout: Duration,
|
||
max_output: usize,
|
||
) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, ExecError> {
|
||
let start = Instant::now();
|
||
|
||
let exec = docker
|
||
.create_exec(
|
||
container_id,
|
||
CreateExecOptions {
|
||
cmd: Some(argv.to_vec()),
|
||
env: Some(
|
||
env.iter()
|
||
.map(|(k, v)| format!("{k}={v}"))
|
||
.collect::<Vec<_>>(),
|
||
),
|
||
attach_stdout: Some(true),
|
||
attach_stderr: Some(true),
|
||
attach_stdin: Some(stdin.is_some()),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.await
|
||
.map_err(|e| ExecError::Internal {
|
||
user_msg: "创建容器 exec 失败",
|
||
detail: format!("create_exec 失败: {e}"),
|
||
})?;
|
||
|
||
let (tx, rx) = tokio::sync::mpsc::channel::<StreamEvent>(64);
|
||
|
||
// 消费 exec 输出并转发到 channel
|
||
let docker_inner = docker.clone();
|
||
tokio::spawn(async move {
|
||
let mut out_written = 0usize;
|
||
let mut err_written = 0usize;
|
||
let mut out_trunc = false;
|
||
let mut err_trunc = false;
|
||
|
||
let exec_result = tokio::time::timeout(timeout, async {
|
||
match docker_inner.start_exec(&exec.id, None).await {
|
||
Ok(StartExecResults::Attached {
|
||
mut output,
|
||
mut input,
|
||
}) => {
|
||
// 先写入 stdin(若有),然后关闭输入管道
|
||
if let Some(data) = &stdin {
|
||
use tokio::io::AsyncWriteExt;
|
||
let _ = input.write_all(data.as_bytes()).await;
|
||
let _ = input.shutdown().await;
|
||
}
|
||
drop(input);
|
||
|
||
while let Some(chunk) = output.next().await {
|
||
match chunk {
|
||
Ok(log_output) => {
|
||
match log_output {
|
||
LogOutput::StdOut { message } => {
|
||
let data = message.as_ref();
|
||
if out_written < max_output {
|
||
let room = max_output - out_written;
|
||
let take = data.len().min(room);
|
||
if take > 0 {
|
||
let _ = tx
|
||
.send(StreamEvent::Stdout(data[..take].to_vec()))
|
||
.await;
|
||
}
|
||
out_written += take;
|
||
if take < data.len() {
|
||
out_trunc = true;
|
||
}
|
||
} else {
|
||
out_trunc = true;
|
||
}
|
||
}
|
||
LogOutput::StdErr { message } => {
|
||
let data = message.as_ref();
|
||
if err_written < max_output {
|
||
let room = max_output - err_written;
|
||
let take = data.len().min(room);
|
||
if take > 0 {
|
||
let _ = tx
|
||
.send(StreamEvent::Stderr(data[..take].to_vec()))
|
||
.await;
|
||
}
|
||
err_written += take;
|
||
if take < data.len() {
|
||
err_trunc = true;
|
||
}
|
||
} else {
|
||
err_trunc = true;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(error = %e, "容器 exec 流错误");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Ok(_) => {}
|
||
Err(e) => {
|
||
tracing::warn!(error = %e, "启动容器 exec 失败");
|
||
}
|
||
}
|
||
})
|
||
.await;
|
||
|
||
let timeout_hit = exec_result.is_err();
|
||
if timeout_hit {
|
||
// Docker exec 无 kill 端点;exec 在容器内继续运行,
|
||
// 容器会在 release 时被销毁,无需额外清理。
|
||
}
|
||
|
||
let exit_code = docker_inner
|
||
.inspect_exec(&exec.id)
|
||
.await
|
||
.ok()
|
||
.and_then(|i| i.exit_code)
|
||
.map(|c| c as i32);
|
||
|
||
let _ = tx
|
||
.send(StreamEvent::Done(ExecOutcome {
|
||
exit_code,
|
||
stdout: String::new(),
|
||
stderr: String::new(),
|
||
duration_ms: start.elapsed().as_millis(),
|
||
timeout: timeout_hit,
|
||
cancelled: false,
|
||
stdout_truncated: out_trunc,
|
||
stderr_truncated: err_trunc,
|
||
}))
|
||
.await;
|
||
});
|
||
|
||
Ok(rx)
|
||
}
|
||
|
||
/// 为预热容器构建 Docker Config。
|
||
///
|
||
/// 预热容器使用 `sleep infinity` 待命,实际执行通过 `exec_in_container` 注入命令。
|
||
pub fn build_container_config(
|
||
config: &WarmPoolConfig,
|
||
image: &str,
|
||
base_workdir: &str,
|
||
) -> Config<String> {
|
||
// Docker bind mount 要求绝对路径;相对路径返回 400 错误。
|
||
if !base_workdir.starts_with('/') {
|
||
tracing::warn!(
|
||
base_workdir,
|
||
"base_workdir 不是绝对路径,Docker bind mount 将失败"
|
||
);
|
||
}
|
||
let network_mode = if config.container_network.is_empty() {
|
||
None
|
||
} else {
|
||
Some(config.container_network.clone())
|
||
};
|
||
Config {
|
||
image: Some(image.to_string()),
|
||
cmd: Some(vec!["sleep".into(), "infinity".into()]),
|
||
host_config: Some(HostConfig {
|
||
network_mode,
|
||
readonly_rootfs: Some(true),
|
||
cap_drop: Some(vec!["ALL".into()]),
|
||
security_opt: Some(vec!["no-new-privileges:true".into()]),
|
||
memory: Some(config.memory_per_container),
|
||
memory_swap: Some(config.memory_per_container),
|
||
pids_limit: Some(config.pids_limit),
|
||
nano_cpus: Some(config.nano_cpus_per_container),
|
||
tmpfs: Some(HashMap::from([(
|
||
"/tmp".into(),
|
||
"size=67108864,mode=1777".into(),
|
||
)])),
|
||
binds: Some(vec![format!("{base_workdir}:/app/sandbox-work:rw")]),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// 检查镜像是否存在,不存在则拉取。
|
||
pub async fn ensure_image(docker: &Docker, image: &str) -> Result<(), String> {
|
||
let images = docker
|
||
.list_images::<String>(None)
|
||
.await
|
||
.map_err(|e| format!("列出镜像失败: {e}"))?;
|
||
|
||
if images
|
||
.iter()
|
||
.any(|i| i.repo_tags.contains(&image.to_string()))
|
||
{
|
||
return Ok(());
|
||
}
|
||
|
||
tracing::info!(image, "拉取容器镜像(首次使用)");
|
||
docker
|
||
.create_image(
|
||
Some(CreateImageOptions {
|
||
from_image: image,
|
||
..Default::default()
|
||
}),
|
||
None,
|
||
None,
|
||
)
|
||
.for_each(|_| async {}) // 静默消费拉取流
|
||
.await;
|
||
|
||
tracing::info!(image, "镜像拉取完成");
|
||
Ok(())
|
||
}
|
||
|
||
/// 连接 Docker 守护进程。
|
||
pub fn connect_docker(socket: &str) -> Result<Docker, String> {
|
||
if socket.starts_with("unix://") {
|
||
Docker::connect_with_unix(socket.trim_start_matches("unix://"), 120, API_DEFAULT_VERSION)
|
||
.map_err(|e| format!("连接 Docker Unix socket 失败: {e}"))
|
||
} else if socket.starts_with("tcp://") {
|
||
Docker::connect_with_http(socket, 120, API_DEFAULT_VERSION)
|
||
.map_err(|e| format!("连接 Docker TCP 失败: {e}"))
|
||
} else {
|
||
Docker::connect_with_local_defaults()
|
||
.map_err(|e| format!("连接本地 Docker 失败: {e}"))
|
||
}
|
||
}
|
||
|
||
/// 从 argv 首元素推断语言(用于选择预热池分桶)。
|
||
pub fn detect_language_from_argv(argv: &[String]) -> &str {
|
||
let cmd = argv.first().map(|s| s.as_str()).unwrap_or("");
|
||
if cmd.ends_with("python3") || cmd == "python" || cmd.contains("python") {
|
||
"python"
|
||
} else if cmd.ends_with("node") || cmd.contains("node") {
|
||
"node"
|
||
} else if cmd.ends_with("java") || cmd.contains("java") {
|
||
"java"
|
||
} else {
|
||
"generic"
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn detect_language_from_argv_python() {
|
||
assert_eq!(
|
||
detect_language_from_argv(&["python3".into(), "script.py".into()]),
|
||
"python"
|
||
);
|
||
assert_eq!(
|
||
detect_language_from_argv(&["python".into(), "-c".into(), "print(1)".into()]),
|
||
"python"
|
||
);
|
||
assert_eq!(
|
||
detect_language_from_argv(&["/usr/bin/python3".into(), "script.py".into()]),
|
||
"python"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn detect_language_from_argv_node() {
|
||
assert_eq!(
|
||
detect_language_from_argv(&["node".into(), "index.js".into()]),
|
||
"node"
|
||
);
|
||
assert_eq!(
|
||
detect_language_from_argv(&["/usr/local/bin/node".into(), "app.js".into()]),
|
||
"node"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn detect_language_from_argv_java() {
|
||
assert_eq!(
|
||
detect_language_from_argv(&["java".into(), "-jar".into(), "app.jar".into()]),
|
||
"java"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn detect_language_from_argv_fallback() {
|
||
assert_eq!(
|
||
detect_language_from_argv(&["cat".into(), "/etc/passwd".into()]),
|
||
"generic"
|
||
);
|
||
assert_eq!(
|
||
detect_language_from_argv(&["sh".into(), "-c".into(), "echo hi".into()]),
|
||
"generic"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn detect_language_from_argv_empty() {
|
||
assert_eq!(detect_language_from_argv(&[]), "generic");
|
||
}
|
||
|
||
#[test]
|
||
fn build_container_config_sets_basic_fields() {
|
||
let mut cfg = WarmPoolConfig::default();
|
||
cfg.images.insert(
|
||
"python".into(),
|
||
"python:3.12-slim".into(),
|
||
);
|
||
let image = cfg.images.get("python").unwrap();
|
||
let config = build_container_config(&cfg, image, "/app/sandbox-work");
|
||
assert_eq!(config.image.as_deref(), Some("python:3.12-slim"));
|
||
assert!(config.host_config.is_some());
|
||
let hc = config.host_config.unwrap();
|
||
assert_eq!(hc.network_mode, None, "默认容器网络应为 None(bridge)");
|
||
assert_eq!(hc.readonly_rootfs, Some(true));
|
||
assert_eq!(hc.cap_drop, Some(vec!["ALL".into()]));
|
||
assert_eq!(hc.memory, Some(512 * 1024 * 1024));
|
||
}
|
||
}
|
||
|
||
/// Docker 集成测试(需要在运行中的 Docker 守护进程)。
|
||
/// cargo test --features docker-tests
|
||
#[cfg(feature = "docker-tests")]
|
||
#[cfg(test)]
|
||
mod docker_integration_tests {
|
||
use super::*;
|
||
use crate::warm_pool::WarmPool;
|
||
|
||
fn test_image() -> &'static str {
|
||
"alpine:3.20"
|
||
}
|
||
|
||
async fn setup_docker() -> Option<(Docker, String)> {
|
||
let docker = connect_docker("unix:///var/run/docker.sock").ok()?;
|
||
ensure_image(&docker, test_image()).await.ok()?;
|
||
Some((docker, test_image().to_string()))
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn exec_echo_captures_stdout() {
|
||
let (docker, image) = match setup_docker().await {
|
||
Some(v) => v,
|
||
None => {
|
||
eprintln!("跳过:Docker 不可用");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let mut cfg = WarmPoolConfig::default();
|
||
cfg.images.insert("generic".into(), image);
|
||
let pool = WarmPool::new(cfg, "/tmp".into(), docker.clone());
|
||
|
||
let config = build_container_config(pool.config(), &image, "/tmp");
|
||
let container = docker
|
||
.create_container::<String, String>(None, config)
|
||
.await
|
||
.expect("创建容器");
|
||
docker
|
||
.start_container::<String>(&container.id, None)
|
||
.await
|
||
.expect("启动容器");
|
||
|
||
let outcome = exec_in_container(
|
||
&docker,
|
||
&container.id,
|
||
&["echo".into(), "hello-docker".into()],
|
||
None,
|
||
&[],
|
||
Duration::from_secs(10),
|
||
4096,
|
||
)
|
||
.await
|
||
.expect("exec_in_container");
|
||
|
||
assert_eq!(outcome.exit_code, Some(0));
|
||
assert!(outcome.stdout.contains("hello-docker"));
|
||
assert!(!outcome.timeout);
|
||
assert!(!outcome.cancelled);
|
||
|
||
docker
|
||
.remove_container(
|
||
&container.id,
|
||
Some(RemoveContainerOptions {
|
||
force: true,
|
||
v: true,
|
||
..Default::default()
|
||
}),
|
||
)
|
||
.await
|
||
.ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn exec_timeout_kills() {
|
||
let (docker, image) = match setup_docker().await {
|
||
Some(v) => v,
|
||
None => {
|
||
eprintln!("跳过:Docker 不可用");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let mut cfg = WarmPoolConfig::default();
|
||
cfg.images.insert("generic".into(), image);
|
||
let pool = WarmPool::new(cfg, "/tmp".into(), docker.clone());
|
||
|
||
let config = build_container_config(pool.config(), &image, "/tmp");
|
||
let container = docker
|
||
.create_container::<String, String>(None, config)
|
||
.await
|
||
.expect("创建容器");
|
||
docker
|
||
.start_container::<String>(&container.id, None)
|
||
.await
|
||
.expect("启动容器");
|
||
|
||
let outcome = exec_in_container(
|
||
&docker,
|
||
&container.id,
|
||
&["sleep".into(), "30".into()],
|
||
None,
|
||
&[],
|
||
Duration::from_millis(200),
|
||
4096,
|
||
)
|
||
.await
|
||
.expect("exec_in_container");
|
||
|
||
assert!(outcome.timeout, "应超时返回");
|
||
assert!(outcome.duration_ms < 5000);
|
||
|
||
docker
|
||
.remove_container(
|
||
&container.id,
|
||
Some(RemoveContainerOptions {
|
||
force: true,
|
||
v: true,
|
||
..Default::default()
|
||
}),
|
||
)
|
||
.await
|
||
.ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn exec_in_container_stream_emits_events() {
|
||
let (docker, image) = match setup_docker().await {
|
||
Some(v) => v,
|
||
None => {
|
||
eprintln!("跳过:Docker 不可用");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let mut cfg = WarmPoolConfig::default();
|
||
cfg.images.insert("generic".into(), image);
|
||
let pool = WarmPool::new(cfg, "/tmp".into(), docker.clone());
|
||
|
||
let config = build_container_config(pool.config(), &image, "/tmp");
|
||
let container = docker
|
||
.create_container::<String, String>(None, config)
|
||
.await
|
||
.expect("创建容器");
|
||
docker
|
||
.start_container::<String>(&container.id, None)
|
||
.await
|
||
.expect("启动容器");
|
||
|
||
let mut rx = exec_in_container_stream(
|
||
&docker,
|
||
&container.id,
|
||
&["echo".into(), "stream-test".into()],
|
||
None,
|
||
&[],
|
||
Duration::from_secs(10),
|
||
4096,
|
||
)
|
||
.await
|
||
.expect("exec_in_container_stream");
|
||
|
||
use futures_util::StreamExt;
|
||
let mut found_stdout = false;
|
||
let mut found_done = false;
|
||
while let Some(event) = rx.recv().await {
|
||
match event {
|
||
StreamEvent::Stdout(data) => {
|
||
if String::from_utf8_lossy(&data).contains("stream-test") {
|
||
found_stdout = true;
|
||
}
|
||
}
|
||
StreamEvent::Done(o) => {
|
||
assert_eq!(o.exit_code, Some(0));
|
||
found_done = true;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
assert!(found_stdout, "应收到 stdout 事件");
|
||
assert!(found_done, "应以 Done 结束");
|
||
|
||
docker
|
||
.remove_container(
|
||
&container.id,
|
||
Some(RemoveContainerOptions {
|
||
force: true,
|
||
v: true,
|
||
..Default::default()
|
||
}),
|
||
)
|
||
.await
|
||
.ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn warm_pool_acquire_release_cycle() {
|
||
let (docker, image) = match setup_docker().await {
|
||
Some(v) => v,
|
||
None => {
|
||
eprintln!("跳过:Docker 不可用");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let mut cfg = WarmPoolConfig::default();
|
||
cfg.images.insert("generic".into(), image);
|
||
cfg.max_idle_per_lang = 1;
|
||
cfg.global_max_containers = 5;
|
||
|
||
let pool = WarmPool::new(cfg, "/tmp".into(), docker);
|
||
pool.warmup_all().await;
|
||
|
||
let stats = pool.collect_metrics().await;
|
||
let (idle, ..) = stats.get("generic").copied().unwrap_or((0, 0, 0));
|
||
assert!(idle >= 1, "预热后应有至少 1 个空闲容器");
|
||
|
||
let handle = pool.clone().acquire("generic").await.expect("acquire");
|
||
assert_eq!(handle.language, "generic");
|
||
|
||
pool.release(handle).await;
|
||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||
|
||
let stats2 = pool.collect_metrics().await;
|
||
let (idle2, ..) = stats2.get("generic").copied().unwrap_or((0, 0, 0));
|
||
assert!(idle2 >= 1, "release 后应有替补容器");
|
||
|
||
pool.shutdown().await;
|
||
}
|
||
}
|