use crate::{
APP_NAME,
core::{
extensions::{
DockLine, DockSpan, Extension, ExtensionCommand, ExtensionHook, ExtensionMode,
ExtensionTool, ExtensionUiRequest, OverlayEvent, OverlaySize, OverlayView,
SubcommandDef, next_ui_id, request_ui,
},
model_resolver, runtime, settings_manager,
},
extensions::{
ACCENT, DIM, ERROR, EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_DOCTOR, SUCCESS,
WARNING, command_arg, util::default_user_agent,
},
modes::interactive::{
app::{App, MsgLevel},
handlers::register_slash_command,
},
utils::{glyphs::DEF_ARROW_UP_DOWN, http, proxy},
};
use futures_util::StreamExt;
use std::{
sync::{Arc, Mutex, MutexGuard, OnceLock},
time::Duration,
};
const EXT: &str = "doctor";
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
const PROBE_CONCURRENCY: usize = 16;
const MAX_PANEL_ROWS: usize = 20;
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static DOCTOR_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_DOCTOR,
make: || -> Arc<dyn Extension> { Arc::new(Doctor) },
};
#[derive(Debug, Clone, PartialEq, Eq)]
struct Target {
provider: String,
api: String,
base_url: String,
host: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Outcome {
Reachable(u16),
Unreachable(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProbeResult {
provider: String,
api: String,
host: String,
outcome: Outcome,
}
impl ProbeResult {
fn new(target: &Target, outcome: Outcome) -> Self {
Self {
provider: target.provider.clone(),
api: target.api.clone(),
host: target.host.clone(),
outcome,
}
}
}
#[derive(Default)]
struct State {
overlay_id: Option<u64>,
results: Option<Vec<ProbeResult>>,
skip_reason: Option<String>,
summary: bool,
probing: bool,
refresh_pending: bool,
target_count: usize,
}
fn state() -> &'static Mutex<State> {
static S: OnceLock<Mutex<State>> = OnceLock::new();
S.get_or_init(|| Mutex::new(State::default()))
}
fn lock() -> MutexGuard<'static, State> {
state().lock().unwrap_or_else(|e| e.into_inner())
}
pub struct Doctor;
impl Extension for Doctor {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
"Probe provider endpoints for network reachability (/doctor, /doctor summary)"
}
fn default_enabled(&self) -> bool {
false
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![ExtensionCommand {
name: "doctor".to_string(),
description: "/doctor <summary> (probe provider endpoints for reachability)"
.to_string(),
busy_safe: true, subcommands: vec![SubcommandDef {
name: "summary",
description: "Only show reachable / failed counts plus the failure list",
}],
}]
}
fn on_registered(&self) {
register_slash_command(EXT, "doctor", command_doctor);
}
fn on_enabled_changed(&self, enabled: bool) {
if !enabled {
clear(None);
}
}
fn hooks(&self) -> Vec<ExtensionHook> {
if lock().overlay_id.is_some() {
vec![ExtensionHook::Overlay]
} else {
Vec::new()
}
}
fn wants_redraw(&self) -> bool {
let st = lock();
st.probing || st.refresh_pending
}
fn overlay_view(&self, id: u64, _cols: u16) -> Option<OverlayView> {
let mut st = lock();
if st.overlay_id != Some(id) {
return None;
}
st.refresh_pending = false;
let lines = current_lines(&st);
Some(OverlayView {
title: if st.summary {
"Doctor · summary".to_string()
} else {
"Doctor".to_string()
},
header: Vec::new(),
lines,
messages: Vec::new(),
footer: vec![
(DEF_ARROW_UP_DOWN.to_string(), "scroll".to_string()),
("Esc".to_string(), "close".to_string()),
],
input: None,
editor: None,
size: OverlaySize::Panel {
max_rows: MAX_PANEL_ROWS as u16,
},
selected: None,
input_focus: false,
})
}
fn on_overlay_event(&self, id: u64, ev: &OverlayEvent) -> bool {
match ev {
OverlayEvent::Closed => clear(Some(id)),
_ => false,
}
}
}
fn clear(id: Option<u64>) -> bool {
let mut st = lock();
if let Some(id) = id
&& st.overlay_id != Some(id)
{
return false;
}
st.overlay_id = None;
st.results = None;
st.skip_reason = None;
st.probing = false;
st.refresh_pending = false;
st.summary = false;
st.target_count = 0;
true
}
fn command_doctor(st: &mut App, raw: &str) -> bool {
match command_arg(raw).trim() {
"" => start_probe(false),
"summary" => start_probe(true),
other => st.push_msg(
format!("Unknown /doctor subcommand: {other}. Use `summary` or no argument."),
MsgLevel::Warning,
),
}
false
}
fn start_probe(summary: bool) {
let targets = probe_targets();
let target_count = targets.len();
let offline = runtime::offline();
let id = next_ui_id();
{
let mut st = lock();
st.overlay_id = Some(id);
st.results = if offline { Some(Vec::new()) } else { None };
st.skip_reason = offline.then(|| "offline mode: network probes skipped".to_string());
st.summary = summary;
st.probing = !offline;
st.refresh_pending = false;
st.target_count = target_count;
}
request_ui(ExtensionUiRequest::ShowOverlay { id });
if offline {
return;
}
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
handle.spawn(async move {
let results = run_probes(targets).await;
finish_probe(id, results, None);
});
}
Err(_) => finish_probe(
id,
Vec::new(),
Some("no async runtime: network probes skipped".to_string()),
),
}
}
fn finish_probe(id: u64, results: Vec<ProbeResult>, skip_reason: Option<String>) {
let mut st = lock();
if st.overlay_id != Some(id) {
return;
}
st.results = Some(results);
st.skip_reason = skip_reason;
st.probing = false;
st.refresh_pending = true;
}
fn probe_targets() -> Vec<Target> {
let mut out = Vec::new();
for provider in model_resolver::catalog_providers() {
let Some((model_id, _)) = model_resolver::list_models(&provider).into_iter().next() else {
continue;
};
let Ok(entry) = model_resolver::find_model(&provider, &model_id) else {
continue;
};
let base_url = entry.base_url.trim().to_string();
if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
continue;
}
out.push(Target {
host: host_of(&base_url),
provider,
api: entry.api,
base_url,
});
}
out
}
fn host_of(url: &str) -> String {
let rest = url.split_once("://").map_or(url, |(_, r)| r);
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
if host_port.is_empty() {
return String::new();
}
let (host, port) = if let Some(after) = host_port.strip_prefix('[') {
match after.split_once(']') {
Some((h, tail)) => (
format!("[{h}]"),
tail.strip_prefix(':').unwrap_or("").to_string(),
),
None => (format!("[{after}"), String::new()),
}
} else if let Some((h, p)) = host_port.rsplit_once(':') {
if !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !h.contains(':') {
(h.to_string(), p.to_string())
} else {
(host_port.to_string(), String::new())
}
} else {
(host_port.to_string(), String::new())
};
if port.is_empty() {
host
} else {
format!("{host}:{port}")
}
}
fn reachable_detail(code: u16) -> String {
match code {
200..=299 | 401 | 403 | 405 => format!("{code} (reachable, auth not checked)"),
other => format!("{other} (reachable, status not OK)"),
}
}
fn classify_error(err: &reqwest::Error) -> String {
if err.is_timeout() {
return "timeout".to_string();
}
if err.is_connect() {
let text = err.to_string().to_ascii_lowercase();
if text.contains("dns") || text.contains("resolve") || text.contains("lookup") {
return "dns error".to_string();
}
return "connect error".to_string();
}
if err.is_request() {
return "request error".to_string();
}
"request failed".to_string()
}
async fn probe_one(client: &reqwest::Client, target: &Target) -> ProbeResult {
probe_one_with_timeout(client, target, PROBE_TIMEOUT).await
}
async fn probe_one_with_timeout(
client: &reqwest::Client,
target: &Target,
timeout: Duration,
) -> ProbeResult {
let request = client.head(&target.base_url).timeout(timeout);
match request.send().await {
Ok(response) => ProbeResult::new(target, Outcome::Reachable(response.status().as_u16())),
Err(err) => ProbeResult::new(target, Outcome::Unreachable(classify_error(&err))),
}
}
async fn run_probes(targets: Vec<Target>) -> Vec<ProbeResult> {
let client = match http::client_builder(None)
.timeout(PROBE_TIMEOUT)
.user_agent(default_user_agent())
.build()
{
Ok(client) => client,
Err(_) => {
return targets
.iter()
.map(|t| ProbeResult::new(t, Outcome::Unreachable("client error".to_string())))
.collect();
}
};
let mut results: Vec<ProbeResult> =
futures_util::stream::iter(targets.into_iter().map(|target| {
let client = client.clone();
async move { probe_one(&client, &target).await }
}))
.buffer_unordered(PROBE_CONCURRENCY)
.collect()
.await;
results.sort_by(|a, b| a.provider.cmp(&b.provider));
results
}
fn environment_lines(target_count: usize, proxy: &[(&str, bool)]) -> Vec<DockLine> {
let proxies = proxy
.iter()
.map(|(name, set)| format!("{name}={}", if *set { "set" } else { "unset" }))
.collect::<Vec<_>>()
.join(" · ");
vec![
vec![DockSpan::new(ACCENT.0, ACCENT.1, "environment")],
vec![DockSpan::plain(format!(
"{} {} · agent dir: {}",
APP_NAME,
env!("CARGO_PKG_VERSION"),
settings_manager::agent_dir().display()
))],
vec![DockSpan::plain(format!("probe targets: {target_count}"))],
vec![DockSpan::new(DIM.0, DIM.1, format!("proxies: {proxies}"))],
]
}
fn title_line(summary: bool, provider_count: usize) -> DockLine {
let label = if summary {
format!("Doctor · summary · {provider_count} providers")
} else {
format!("Doctor · {provider_count} providers")
};
vec![DockSpan::new(ACCENT.0, ACCENT.1, label)]
}
fn current_lines(st: &State) -> Vec<DockLine> {
let title = title_line(st.summary, st.target_count);
if st.probing {
return vec![
title,
Vec::new(),
vec![DockSpan::new(DIM.0, DIM.1, "probing…")],
];
}
let mut lines = vec![title, Vec::new()];
if let Some(reason) = &st.skip_reason {
lines.push(vec![DockSpan::new(WARNING.0, WARNING.1, reason.clone())]);
lines.extend(environment_lines(st.target_count, &proxy::env_flags()));
return lines;
}
match &st.results {
Some(results) if st.summary => lines.extend(summary_lines(results)),
Some(results) => lines.extend(detail_lines(results)),
None => {}
}
lines
}
fn detail_lines(results: &[ProbeResult]) -> Vec<DockLine> {
let mut lines: Vec<DockLine> = Vec::new();
for result in results {
let (marker, key, color, detail) = match &result.outcome {
Outcome::Reachable(code) => ("✓", SUCCESS.0, SUCCESS.1, reachable_detail(*code)),
Outcome::Unreachable(reason) => ("✗", ERROR.0, ERROR.1, reason.clone()),
};
lines.push(vec![
DockSpan::new(key, color, format!("{marker} ")),
DockSpan::plain(format!(
"{} · {} · {} · {}",
result.provider, result.api, result.host, detail
)),
]);
}
if results.is_empty() {
lines.push(vec![DockSpan::new(DIM.0, DIM.1, "no probe targets")]);
}
lines.push(Vec::new());
lines.extend(environment_lines(results.len(), &proxy::env_flags()));
lines
}
fn summary_lines(results: &[ProbeResult]) -> Vec<DockLine> {
let reachable = results
.iter()
.filter(|r| matches!(r.outcome, Outcome::Reachable(_)))
.count();
let failures: Vec<&ProbeResult> = results
.iter()
.filter(|r| matches!(r.outcome, Outcome::Unreachable(_)))
.collect();
let mut lines: Vec<DockLine> = vec![vec![DockSpan::new(
ACCENT.0,
ACCENT.1,
format!("{reachable} reachable · {} failed", failures.len()),
)]];
if failures.is_empty() {
lines.push(vec![DockSpan::new(
SUCCESS.0,
SUCCESS.1,
"all providers reachable",
)]);
} else {
lines.push(Vec::new());
lines.push(vec![DockSpan::new(ACCENT.0, ACCENT.1, "failed")]);
for result in failures {
let Outcome::Unreachable(reason) = &result.outcome else {
continue;
};
lines.push(vec![DockSpan::new(
ERROR.0,
ERROR.1,
format!("✗ {} · {reason}", result.provider),
)]);
}
}
lines.push(Vec::new());
lines.extend(environment_lines(results.len(), &proxy::env_flags()));
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::extensions::OverlayKey;
use crate::test_support::{AUTH_TEST_LOCK, AgentDirGuard, run_with_timeout};
use tokio::{
io::{AsyncReadExt as _, AsyncWriteExt as _},
net::TcpListener,
};
fn lock_global() -> MutexGuard<'static, ()> {
AUTH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn drain_ui() {
while crate::core::extensions::take_pending_ui().is_some() {}
}
fn target(provider: &str, base_url: &str) -> Target {
Target {
provider: provider.to_string(),
api: "anthropic-messages".to_string(),
base_url: base_url.to_string(),
host: host_of(base_url),
}
}
fn reachable(provider: &str, code: u16) -> ProbeResult {
ProbeResult::new(
&target(provider, "https://example.test"),
Outcome::Reachable(code),
)
}
fn unreachable(provider: &str, reason: &str) -> ProbeResult {
ProbeResult::new(
&target(provider, "https://example.test"),
Outcome::Unreachable(reason.to_string()),
)
}
fn text(lines: &[DockLine]) -> String {
lines
.iter()
.map(|line| line.iter().map(|s| s.text.as_str()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn host_of_strips_scheme_userinfo_and_keeps_port() {
assert_eq!(host_of("https://api.anthropic.com"), "api.anthropic.com");
assert_eq!(
host_of("https://api.example.com:8443/v1/messages"),
"api.example.com:8443"
);
assert_eq!(
host_of("https://user:secret@host.example/v1?token=x#frag"),
"host.example"
);
assert_eq!(host_of("http://[::1]:8080/x"), "[::1]:8080");
assert_eq!(host_of("https://[2001:db8::1]"), "[2001:db8::1]");
assert_eq!(host_of("not-a-url"), "not-a-url");
}
#[test]
fn reachable_detail_marks_expected_and_unexpected_statuses() {
assert_eq!(reachable_detail(200), "200 (reachable, auth not checked)");
assert_eq!(reachable_detail(401), "401 (reachable, auth not checked)");
assert_eq!(reachable_detail(403), "403 (reachable, auth not checked)");
assert_eq!(reachable_detail(405), "405 (reachable, auth not checked)");
assert_eq!(reachable_detail(404), "404 (reachable, status not OK)");
assert_eq!(reachable_detail(500), "500 (reachable, status not OK)");
}
#[test]
fn detail_lines_show_markers_status_and_environment() {
let results = vec![
reachable("anthropic", 200),
unreachable("openai", "timeout"),
];
let joined = text(&detail_lines(&results));
assert!(joined.contains(
"✓ anthropic · anthropic-messages · example.test · 200 (reachable, auth not checked)"
));
assert!(joined.contains("✗ openai · anthropic-messages · example.test · timeout"));
assert!(joined.contains("environment"));
assert!(joined.contains(&format!("{APP_NAME} {}", env!("CARGO_PKG_VERSION"))));
assert!(joined.contains("agent dir:"));
assert!(joined.contains("probe targets: 2"));
for name in proxy::ENV_VARS {
assert!(
joined.contains(&format!("{name}=set"))
|| joined.contains(&format!("{name}=unset"))
);
}
}
#[test]
fn summary_lines_count_and_list_failures() {
let results = vec![
reachable("anthropic", 401),
reachable("google", 200),
unreachable("openai", "HTTP 500"),
];
let joined = text(&summary_lines(&results));
assert!(joined.contains("2 reachable · 1 failed"));
assert!(joined.contains("failed"));
assert!(joined.contains("✗ openai · HTTP 500"));
assert!(joined.contains("environment"));
let ok = text(&summary_lines(&[reachable("anthropic", 200)]));
assert!(ok.contains("1 reachable · 0 failed"));
assert!(ok.contains("all providers reachable"));
}
#[test]
fn environment_lines_render_set_unset_without_values() {
let lines = environment_lines(
3,
&[
("http_proxy", true),
("https_proxy", false),
("all_proxy", false),
("no_proxy", false),
],
);
let joined = text(&lines);
assert!(joined.contains("environment"));
assert!(joined.contains("probe targets: 3"));
assert!(joined.contains("http_proxy=set"));
assert!(joined.contains("https_proxy=unset"));
assert!(joined.contains("all_proxy=unset"));
assert!(joined.contains("no_proxy=unset"));
}
#[test]
fn empty_results_still_show_environment() {
let joined = text(&detail_lines(&[]));
assert!(joined.contains("no probe targets"));
assert!(joined.contains("probe targets: 0"));
assert!(joined.contains("environment"));
}
#[test]
fn probe_targets_have_valid_base_urls() {
let targets = probe_targets();
assert!(!targets.is_empty(), "内置目录应有可探测的 provider");
for t in &targets {
assert!(t.base_url.starts_with("http://") || t.base_url.starts_with("https://"));
assert!(!t.host.is_empty(), "{} 的 host 不应为空", t.provider);
assert!(!t.api.is_empty());
}
}
#[test]
fn command_with_unknown_subcommand_prints_usage() {
let _g = lock_global();
drain_ui();
let mut st = App::new();
assert!(!command_doctor(&mut st, "doctor bogus"), "不应退出 TUI");
assert!(!st.system_messages.is_empty(), "未知子命令应提示用法");
assert_eq!(lock().overlay_id, None, "未知子命令不应打开面板");
}
#[test]
fn overlay_claims_own_id_and_leaves_scrolling_to_the_tui() {
let _g = lock_global();
let _ad = AgentDirGuard::temp();
drain_ui();
let id = next_ui_id();
{
let mut st = lock();
st.overlay_id = Some(id);
st.results = Some(vec![reachable("anthropic", 200)]);
st.probing = false;
st.summary = false;
st.target_count = 1;
}
let ext = Doctor;
assert_eq!(ext.hooks(), vec![ExtensionHook::Overlay]);
let view = ext.overlay_view(id, 80).expect("认领自己的 id");
assert_eq!(view.title, "Doctor");
assert!(matches!(view.size, OverlaySize::Panel { .. }));
assert!(ext.overlay_view(id + 1, 80).is_none(), "不认领其它 id");
let total = {
let st = lock();
current_lines(&st).len()
};
assert_eq!(view.lines.len(), total, "内容一次给全,裁剪交由覆盖层");
for key in [
OverlayKey::Up,
OverlayKey::Down,
OverlayKey::PageDown,
OverlayKey::Home,
OverlayKey::End,
] {
assert!(!ext.on_overlay_event(id, &OverlayEvent::Key { key, input: None }));
}
assert!(!ext.on_overlay_event(
id,
&OverlayEvent::Key {
key: OverlayKey::Esc,
input: None
}
));
assert!(ext.on_overlay_event(id, &OverlayEvent::Closed));
assert_eq!(lock().overlay_id, None);
}
#[test]
fn panel_top_line_names_the_view() {
let results = vec![reachable("anthropic", 200)];
let mut st = State {
results: Some(results),
target_count: 3,
..State::default()
};
assert_eq!(text(¤t_lines(&st)[..1]), "Doctor · 3 providers");
st.summary = true;
assert_eq!(
text(¤t_lines(&st)[..1]),
"Doctor · summary · 3 providers",
"summary 视图的顶栏要能区分自己是哪一档"
);
}
#[test]
fn probing_panel_shows_progress_and_wants_redraw() {
let _g = lock_global();
drain_ui();
let id = next_ui_id();
{
let mut st = lock();
st.overlay_id = Some(id);
st.results = None;
st.probing = true;
st.target_count = 5;
}
assert!(Doctor.wants_redraw());
let view = Doctor.overlay_view(id, 80).unwrap();
let joined = text(&view.lines);
assert!(joined.contains("Doctor · 5 providers"));
assert!(joined.contains("probing…"));
clear(None);
assert!(!Doctor.wants_redraw());
}
#[test]
fn finished_probe_still_wants_one_frame() {
let _g = lock_global();
let id = next_ui_id();
{
let mut st = lock();
st.overlay_id = Some(id);
st.results = None;
st.probing = true;
st.refresh_pending = false;
st.target_count = 3;
}
assert!(Doctor.wants_redraw(), "探测中要重绘");
finish_probe(id, Vec::new(), None);
assert!(!lock().probing);
assert!(Doctor.wants_redraw(), "结果就绪后界面还得刷一次");
assert!(Doctor.overlay_view(id, 80).is_some());
assert!(!Doctor.wants_redraw(), "渲染过一帧后不再持续重绘");
assert!(Doctor.on_overlay_event(id, &OverlayEvent::Closed));
finish_probe(id, Vec::new(), None);
assert!(lock().results.is_none());
assert!(!Doctor.wants_redraw());
}
#[test]
fn disabled_extension_clears_state() {
let _g = lock_global();
let id = next_ui_id();
{
let mut st = lock();
st.overlay_id = Some(id);
st.probing = true;
}
Doctor.on_enabled_changed(false);
assert_eq!(lock().overlay_id, None);
assert!(!Doctor.wants_redraw());
assert!(Doctor.hooks().is_empty());
}
#[test]
fn command_declares_summary_surface() {
let cmds = Doctor.commands();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name, "doctor");
assert!(cmds[0].busy_safe);
assert_eq!(cmds[0].subcommands[0].name, "summary");
assert_eq!(Doctor.name(), EXT);
}
async fn spawn_server(status: u16, hang: bool) -> (String, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
if hang {
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
tokio::time::sleep(Duration::from_secs(30)).await;
continue;
}
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let reason = match status {
200 => "OK",
401 => "Unauthorized",
_ => "Error",
};
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.shutdown().await;
}
});
(format!("http://{addr}"), handle)
}
#[test]
fn local_probe_classifies_reachable_timeout_and_connect_error() {
let _g = lock_global();
run_with_timeout(Duration::from_secs(30), || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let client = reqwest::Client::new();
let (url, handle) = spawn_server(200, false).await;
let result = probe_one(&client, &target("local", &url)).await;
assert_eq!(result.outcome, Outcome::Reachable(200));
handle.abort();
let (url, handle) = spawn_server(401, false).await;
let result = probe_one(&client, &target("local", &url)).await;
assert_eq!(
result.outcome,
Outcome::Reachable(401),
"401 说明端点活着,应判可达"
);
handle.abort();
let (url, handle) = spawn_server(404, false).await;
let result = probe_one(&client, &target("local", &url)).await;
assert_eq!(
result.outcome,
Outcome::Reachable(404),
"有 HTTP 响应就说明网络通(状态码由行文本区分)"
);
assert_eq!(reachable_detail(404), "404 (reachable, status not OK)");
handle.abort();
let (url, handle) = spawn_server(200, true).await;
let result = probe_one_with_timeout(
&client,
&target("local", &url),
Duration::from_millis(300),
)
.await;
assert_eq!(result.outcome, Outcome::Unreachable("timeout".to_string()));
handle.abort();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let result = probe_one(&client, &target("local", &format!("http://{addr}"))).await;
assert_eq!(
result.outcome,
Outcome::Unreachable("connect error".to_string())
);
});
});
}
}