1use std::{
21 net::SocketAddr,
22 sync::{
23 atomic::{AtomicU64, Ordering},
24 Arc,
25 },
26 time::{Duration, Instant},
27};
28
29use socket2::{Domain, Protocol, Socket, Type};
30use tokio::io::{AsyncReadExt, AsyncWriteExt};
31use tokio::net::TcpStream;
32use tokio::sync::Mutex;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct TrafficClass {
37 pub name: String,
39 pub dscp: u8,
41}
42
43impl TrafficClass {
44 pub fn tos_byte(&self) -> u8 {
46 dscp_to_tos(self.dscp)
47 }
48}
49
50pub fn dscp_to_tos(dscp: u8) -> u8 {
53 (dscp & 0x3f) << 2
54}
55
56fn preset_dscp(name: &str) -> Option<u8> {
58 match name.to_ascii_lowercase().as_str() {
59 "voice" | "ef" => Some(46),
61 "video" | "af41" => Some(34),
63 "best-effort" | "be" | "default" => Some(0),
65 "background" | "cs1" | "scavenger" => Some(8),
67 _ => None,
68 }
69}
70
71pub fn parse_class(spec: &str) -> Result<(TrafficClass, u32), String> {
75 let (name_part, weight) = match spec.split_once(':') {
76 Some((n, w)) => {
77 let weight: u32 =
78 w.parse().map_err(|_| format!("invalid weight in '{spec}' (want an integer)"))?;
79 if weight == 0 {
80 return Err(format!("weight must be >= 1 in '{spec}'"));
81 }
82 (n, weight)
83 }
84 None => (spec, 1),
85 };
86 let name_part = name_part.trim();
87 if name_part.is_empty() {
88 return Err("empty traffic class name".to_string());
89 }
90
91 let dscp = if let Some(dscp) = preset_dscp(name_part) {
92 dscp
93 } else if let Some(num) =
94 name_part.strip_prefix("dscp").or_else(|| name_part.strip_prefix("DSCP"))
95 {
96 let n: u8 = num.parse().map_err(|_| format!("invalid DSCP number in '{spec}'"))?;
97 if n > 63 {
98 return Err(format!("DSCP must be 0-63, got {n}"));
99 }
100 n
101 } else {
102 return Err(format!(
103 "unknown traffic class '{name_part}' (presets: voice, video, best-effort, background; or dscpNN)"
104 ));
105 };
106
107 Ok((
108 TrafficClass {
109 name: name_part.to_ascii_lowercase(),
110 dscp,
111 },
112 weight,
113 ))
114}
115
116#[derive(Debug, Clone)]
118pub struct QosBenchConfig {
119 pub target_url: String,
123 pub method: String,
125 pub classes: Vec<(TrafficClass, u32)>,
127 pub concurrency: u32,
129 pub duration: Duration,
131 pub mss: Option<u32>,
133}
134
135#[derive(Debug, Clone)]
137pub struct ClassStats {
138 pub name: String,
139 pub dscp: u8,
140 pub tos_byte: u8,
141 pub requests: u64,
142 pub ok: u64,
143 pub failed: u64,
144 pub p50_ms: u64,
145 pub p95_ms: u64,
146}
147
148#[derive(Debug, Clone)]
150pub struct QosBenchResult {
151 pub total_requests: u64,
152 pub successful: u64,
153 pub failed: u64,
154 pub elapsed: Duration,
155 pub req_per_sec: f64,
156 pub per_class: Vec<ClassStats>,
157 pub marking_unsupported: bool,
160}
161
162fn marked_socket(
169 addr: SocketAddr,
170 tos: u8,
171 mss: Option<u32>,
172 tos_applied: Option<&Arc<std::sync::atomic::AtomicBool>>,
173) -> std::io::Result<Socket> {
174 let domain = if addr.is_ipv4() {
175 Domain::IPV4
176 } else {
177 Domain::IPV6
178 };
179 let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP))?;
180
181 if addr.is_ipv4() {
183 match socket.set_tos(u32::from(tos)) {
184 Ok(()) => {
185 if let Some(flag) = tos_applied {
186 flag.store(true, Ordering::Relaxed);
187 }
188 }
189 Err(e) => {
190 tracing::debug!("set_tos({tos}) failed: {e}");
192 }
193 }
194 }
195 if let Some(m) = mss {
196 #[cfg(unix)]
199 let _ = socket.set_mss(m);
200 #[cfg(not(unix))]
201 tracing::debug!("TCP_MAXSEG clamping is not supported on this platform; ignoring mss={m}");
202 }
203 Ok(socket)
204}
205
206async fn connect_marked(
212 addr: SocketAddr,
213 tos: u8,
214 mss: Option<u32>,
215 tos_applied: &Arc<std::sync::atomic::AtomicBool>,
216) -> std::io::Result<TcpStream> {
217 let socket = marked_socket(addr, tos, mss, Some(tos_applied))?;
218
219 socket.set_nonblocking(true)?;
222 match socket.connect(&addr.into()) {
223 Ok(()) => {}
224 Err(e) if e.raw_os_error() == Some(libc::EINPROGRESS) => {}
225 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
226 Err(e) => return Err(e),
227 }
228
229 let std_stream: std::net::TcpStream = socket.into();
230 let stream = TcpStream::from_std(std_stream)?;
231 stream.writable().await?;
232 if let Some(err) = stream.take_error()? {
233 return Err(err);
234 }
235 Ok(stream)
236}
237
238async fn send_request(
242 mut stream: TcpStream,
243 method: &str,
244 host: &str,
245 path: &str,
246) -> std::io::Result<u16> {
247 let req = format!(
248 "{method} {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: mockforge-bench-qos\r\nConnection: close\r\n\r\n"
249 );
250 stream.write_all(req.as_bytes()).await?;
251 stream.flush().await?;
252
253 let mut buf = [0u8; 256];
255 let n = stream.read(&mut buf).await?;
256 let head = String::from_utf8_lossy(&buf[..n]);
257 let status = head
259 .split_whitespace()
260 .nth(1)
261 .and_then(|s| s.parse::<u16>().ok())
262 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "no status line"))?;
263 let mut sink = [0u8; 4096];
265 while let Ok(r) = stream.read(&mut sink).await {
266 if r == 0 {
267 break;
268 }
269 }
270 Ok(status)
271}
272
273fn parse_http_target(url: &str) -> Result<(String, u16, String), String> {
276 let rest = url
277 .strip_prefix("http://")
278 .ok_or_else(|| format!("QoS bench needs a plain http:// target, got '{url}'"))?;
279 let (authority, path) = match rest.find('/') {
280 Some(i) => (&rest[..i], &rest[i..]),
281 None => (rest, "/"),
282 };
283 let (host, port) = match authority.rsplit_once(':') {
284 Some((h, p)) => {
285 let port: u16 = p.parse().map_err(|_| format!("invalid port in '{url}'"))?;
286 (h.to_string(), port)
287 }
288 None => (authority.to_string(), 80),
289 };
290 if host.is_empty() {
291 return Err(format!("empty host in '{url}'"));
292 }
293 let path = if path.is_empty() {
294 "/".to_string()
295 } else {
296 path.to_string()
297 };
298 Ok((host, port, path))
299}
300
301fn weighted_indices(classes: &[(TrafficClass, u32)]) -> Vec<usize> {
303 let mut table = Vec::new();
304 for (i, (_, w)) in classes.iter().enumerate() {
305 for _ in 0..*w {
306 table.push(i);
307 }
308 }
309 table
310}
311
312fn percentile(sorted: &[u64], p: f64) -> u64 {
313 if sorted.is_empty() {
314 return 0;
315 }
316 let idx = ((sorted.len() as f64 - 1.0) * p).round() as usize;
317 sorted[idx.min(sorted.len() - 1)]
318}
319
320pub async fn run(cfg: QosBenchConfig) -> anyhow::Result<QosBenchResult> {
324 if cfg.concurrency == 0 {
325 anyhow::bail!("concurrency must be >= 1");
326 }
327 if cfg.classes.is_empty() {
328 anyhow::bail!("at least one --class is required");
329 }
330 let (host, port, path) = parse_http_target(&cfg.target_url).map_err(|e| anyhow::anyhow!(e))?;
331 let addr: SocketAddr = format!("{host}:{port}")
332 .parse()
333 .or_else(|_| {
334 use std::net::ToSocketAddrs;
336 (host.as_str(), port)
337 .to_socket_addrs()
338 .ok()
339 .and_then(|mut it| it.next())
340 .ok_or(())
341 })
342 .map_err(|_| anyhow::anyhow!("could not resolve target host '{host}:{port}'"))?;
343
344 let table = Arc::new(weighted_indices(&cfg.classes));
345 let classes = Arc::new(cfg.classes.clone());
346 let n = cfg.classes.len();
348 let requests: Arc<Vec<AtomicU64>> = Arc::new((0..n).map(|_| AtomicU64::new(0)).collect());
349 let oks: Arc<Vec<AtomicU64>> = Arc::new((0..n).map(|_| AtomicU64::new(0)).collect());
350 let fails: Arc<Vec<AtomicU64>> = Arc::new((0..n).map(|_| AtomicU64::new(0)).collect());
351 let lats: Arc<Vec<Mutex<Vec<u64>>>> =
352 Arc::new((0..n).map(|_| Mutex::new(Vec::new())).collect());
353 let tos_applied = Arc::new(std::sync::atomic::AtomicBool::new(false));
354
355 let deadline = Instant::now() + cfg.duration;
356 let started = Instant::now();
357 let host = Arc::new(host);
358 let path = Arc::new(path);
359 let method = Arc::new(cfg.method.to_uppercase());
360
361 let mut workers = Vec::with_capacity(cfg.concurrency as usize);
362 for w in 0..cfg.concurrency {
363 let table = table.clone();
364 let classes = classes.clone();
365 let requests = requests.clone();
366 let oks = oks.clone();
367 let fails = fails.clone();
368 let lats = lats.clone();
369 let tos_applied = tos_applied.clone();
370 let host = host.clone();
371 let path = path.clone();
372 let method = method.clone();
373 let mss = cfg.mss;
374 let mut cursor = w as usize;
377
378 workers.push(tokio::spawn(async move {
379 while Instant::now() < deadline {
380 let idx = table[cursor % table.len()];
381 cursor = cursor.wrapping_add(1);
382 let tos = classes[idx].0.tos_byte();
383 let started_req = Instant::now();
384 requests[idx].fetch_add(1, Ordering::Relaxed);
385 let outcome = async {
386 let stream = connect_marked(addr, tos, mss, &tos_applied).await?;
387 send_request(stream, &method, &host, &path).await
388 }
389 .await;
390 match outcome {
391 Ok(status) if (200..400).contains(&status) => {
392 oks[idx].fetch_add(1, Ordering::Relaxed);
393 lats[idx].lock().await.push(started_req.elapsed().as_millis() as u64);
394 }
395 Ok(_) => {
396 fails[idx].fetch_add(1, Ordering::Relaxed);
397 }
398 Err(_) => {
399 fails[idx].fetch_add(1, Ordering::Relaxed);
400 }
401 }
402 }
403 }));
404 }
405
406 for h in workers {
407 let _ = h.await;
408 }
409 let elapsed = started.elapsed();
410
411 let mut per_class = Vec::with_capacity(n);
412 let mut total = 0u64;
413 let mut total_ok = 0u64;
414 let mut total_fail = 0u64;
415 for (i, (class, _)) in cfg.classes.iter().enumerate() {
416 let req = requests[i].load(Ordering::Relaxed);
417 let ok = oks[i].load(Ordering::Relaxed);
418 let fail = fails[i].load(Ordering::Relaxed);
419 total += req;
420 total_ok += ok;
421 total_fail += fail;
422 let mut l = lats[i].lock().await.clone();
423 l.sort_unstable();
424 per_class.push(ClassStats {
425 name: class.name.clone(),
426 dscp: class.dscp,
427 tos_byte: class.tos_byte(),
428 requests: req,
429 ok,
430 failed: fail,
431 p50_ms: percentile(&l, 0.50),
432 p95_ms: percentile(&l, 0.95),
433 });
434 }
435
436 let secs = elapsed.as_secs_f64().max(f64::MIN_POSITIVE);
437 Ok(QosBenchResult {
438 total_requests: total,
439 successful: total_ok,
440 failed: total_fail,
441 elapsed,
442 req_per_sec: total as f64 / secs,
443 per_class,
444 marking_unsupported: !tos_applied.load(Ordering::Relaxed),
445 })
446}
447
448pub fn render_report(res: &QosBenchResult) -> String {
450 let mut out = String::new();
451 out.push_str(&format!(
452 "QoS bench: {} requests in {:.1}s ({:.0} req/s), {} ok / {} failed\n",
453 res.total_requests,
454 res.elapsed.as_secs_f64(),
455 res.req_per_sec,
456 res.successful,
457 res.failed
458 ));
459 if res.marking_unsupported {
460 out.push_str(
461 " WARNING: IP_TOS/DSCP marking was not accepted by the kernel on this run; \
462 traffic went out unmarked (needs a Unix host and, on some platforms, privileges).\n",
463 );
464 }
465 out.push_str(&format!(
466 " {:<14} {:>5} {:>7} {:>8} {:>7} {:>8} {:>8}\n",
467 "class", "dscp", "tos", "reqs", "ok", "p50(ms)", "p95(ms)"
468 ));
469 for c in &res.per_class {
470 out.push_str(&format!(
471 " {:<14} {:>5} {:>7} {:>8} {:>7} {:>8} {:>8}\n",
472 c.name,
473 c.dscp,
474 format!("0x{:02x}", c.tos_byte),
475 c.requests,
476 c.ok,
477 c.p50_ms,
478 c.p95_ms
479 ));
480 }
481 out
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn dscp_maps_to_tos_high_six_bits() {
490 assert_eq!(dscp_to_tos(46), 0xB8);
492 assert_eq!(dscp_to_tos(34), 0x88);
493 assert_eq!(dscp_to_tos(8), 0x20);
494 assert_eq!(dscp_to_tos(0), 0x00);
495 assert_eq!(dscp_to_tos(63) & 0x03, 0);
497 }
498
499 #[test]
500 fn parse_presets_and_custom_and_weights() {
501 assert_eq!(parse_class("voice").unwrap().0.dscp, 46);
502 assert_eq!(parse_class("video").unwrap().0.dscp, 34);
503 assert_eq!(parse_class("best-effort").unwrap().0.dscp, 0);
504 assert_eq!(parse_class("background").unwrap().0.dscp, 8);
505 assert_eq!(parse_class("dscp46").unwrap().0.dscp, 46);
506 let (c, w) = parse_class("voice:40").unwrap();
508 assert_eq!((c.dscp, w), (46, 40));
509 assert!(parse_class("bogus").is_err());
511 assert!(parse_class("dscp99").is_err()); assert!(parse_class("voice:0").is_err()); assert!(parse_class("voice:x").is_err());
514 }
515
516 #[test]
517 fn weighted_table_repeats_by_weight() {
518 let classes = vec![
519 (
520 TrafficClass {
521 name: "a".into(),
522 dscp: 0,
523 },
524 3,
525 ),
526 (
527 TrafficClass {
528 name: "b".into(),
529 dscp: 8,
530 },
531 1,
532 ),
533 ];
534 let table = weighted_indices(&classes);
535 assert_eq!(table.len(), 4);
536 assert_eq!(table.iter().filter(|&&i| i == 0).count(), 3);
537 assert_eq!(table.iter().filter(|&&i| i == 1).count(), 1);
538 }
539
540 #[test]
541 fn parse_http_target_splits_host_port_path() {
542 assert_eq!(
543 parse_http_target("http://host:3000/up").unwrap(),
544 ("host".to_string(), 3000, "/up".to_string())
545 );
546 assert_eq!(
547 parse_http_target("http://host/").unwrap(),
548 ("host".to_string(), 80, "/".to_string())
549 );
550 assert!(parse_http_target("https://host/").is_err());
551 }
552
553 #[tokio::test]
560 async fn marked_socket_carries_dscp_and_connect_works() {
561 let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
562
563 for dscp in [46u8, 34, 8, 0] {
564 let tos = dscp_to_tos(dscp);
565 let applied = Arc::new(std::sync::atomic::AtomicBool::new(false));
566 let socket = marked_socket(addr, tos, None, Some(&applied)).unwrap();
567 assert!(applied.load(Ordering::Relaxed), "set_tos must succeed for dscp {dscp}");
568 let read_back = socket.tos().unwrap() as u8;
569 assert_eq!(
570 read_back, tos,
571 "getsockopt IP_TOS must equal DSCP<<2 (dscp={dscp}, want {tos:#04x})"
572 );
573 }
574
575 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
577 let laddr = listener.local_addr().unwrap();
578 tokio::spawn(async move {
579 let _ = listener.accept().await;
580 });
581 let applied = Arc::new(std::sync::atomic::AtomicBool::new(false));
582 let stream = connect_marked(laddr, dscp_to_tos(46), None, &applied).await.unwrap();
583 assert!(stream.peer_addr().is_ok());
584 assert!(applied.load(Ordering::Relaxed), "connect_marked applied the TOS");
585 }
586
587 #[tokio::test]
590 async fn mss_clamp_is_accepted() {
591 let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
592 let socket = marked_socket(addr, dscp_to_tos(0), Some(536), None).unwrap();
595 drop(socket);
596 }
597}