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 let _ = socket.set_mss(m);
198 }
199 Ok(socket)
200}
201
202async fn connect_marked(
208 addr: SocketAddr,
209 tos: u8,
210 mss: Option<u32>,
211 tos_applied: &Arc<std::sync::atomic::AtomicBool>,
212) -> std::io::Result<TcpStream> {
213 let socket = marked_socket(addr, tos, mss, Some(tos_applied))?;
214
215 socket.set_nonblocking(true)?;
218 match socket.connect(&addr.into()) {
219 Ok(()) => {}
220 Err(e) if e.raw_os_error() == Some(libc::EINPROGRESS) => {}
221 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
222 Err(e) => return Err(e),
223 }
224
225 let std_stream: std::net::TcpStream = socket.into();
226 let stream = TcpStream::from_std(std_stream)?;
227 stream.writable().await?;
228 if let Some(err) = stream.take_error()? {
229 return Err(err);
230 }
231 Ok(stream)
232}
233
234async fn send_request(
238 mut stream: TcpStream,
239 method: &str,
240 host: &str,
241 path: &str,
242) -> std::io::Result<u16> {
243 let req = format!(
244 "{method} {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: mockforge-bench-qos\r\nConnection: close\r\n\r\n"
245 );
246 stream.write_all(req.as_bytes()).await?;
247 stream.flush().await?;
248
249 let mut buf = [0u8; 256];
251 let n = stream.read(&mut buf).await?;
252 let head = String::from_utf8_lossy(&buf[..n]);
253 let status = head
255 .split_whitespace()
256 .nth(1)
257 .and_then(|s| s.parse::<u16>().ok())
258 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "no status line"))?;
259 let mut sink = [0u8; 4096];
261 while let Ok(r) = stream.read(&mut sink).await {
262 if r == 0 {
263 break;
264 }
265 }
266 Ok(status)
267}
268
269fn parse_http_target(url: &str) -> Result<(String, u16, String), String> {
272 let rest = url
273 .strip_prefix("http://")
274 .ok_or_else(|| format!("QoS bench needs a plain http:// target, got '{url}'"))?;
275 let (authority, path) = match rest.find('/') {
276 Some(i) => (&rest[..i], &rest[i..]),
277 None => (rest, "/"),
278 };
279 let (host, port) = match authority.rsplit_once(':') {
280 Some((h, p)) => {
281 let port: u16 = p.parse().map_err(|_| format!("invalid port in '{url}'"))?;
282 (h.to_string(), port)
283 }
284 None => (authority.to_string(), 80),
285 };
286 if host.is_empty() {
287 return Err(format!("empty host in '{url}'"));
288 }
289 let path = if path.is_empty() {
290 "/".to_string()
291 } else {
292 path.to_string()
293 };
294 Ok((host, port, path))
295}
296
297fn weighted_indices(classes: &[(TrafficClass, u32)]) -> Vec<usize> {
299 let mut table = Vec::new();
300 for (i, (_, w)) in classes.iter().enumerate() {
301 for _ in 0..*w {
302 table.push(i);
303 }
304 }
305 table
306}
307
308fn percentile(sorted: &[u64], p: f64) -> u64 {
309 if sorted.is_empty() {
310 return 0;
311 }
312 let idx = ((sorted.len() as f64 - 1.0) * p).round() as usize;
313 sorted[idx.min(sorted.len() - 1)]
314}
315
316pub async fn run(cfg: QosBenchConfig) -> anyhow::Result<QosBenchResult> {
320 if cfg.concurrency == 0 {
321 anyhow::bail!("concurrency must be >= 1");
322 }
323 if cfg.classes.is_empty() {
324 anyhow::bail!("at least one --class is required");
325 }
326 let (host, port, path) = parse_http_target(&cfg.target_url).map_err(|e| anyhow::anyhow!(e))?;
327 let addr: SocketAddr = format!("{host}:{port}")
328 .parse()
329 .or_else(|_| {
330 use std::net::ToSocketAddrs;
332 (host.as_str(), port)
333 .to_socket_addrs()
334 .ok()
335 .and_then(|mut it| it.next())
336 .ok_or(())
337 })
338 .map_err(|_| anyhow::anyhow!("could not resolve target host '{host}:{port}'"))?;
339
340 let table = Arc::new(weighted_indices(&cfg.classes));
341 let classes = Arc::new(cfg.classes.clone());
342 let n = cfg.classes.len();
344 let requests: Arc<Vec<AtomicU64>> = Arc::new((0..n).map(|_| AtomicU64::new(0)).collect());
345 let oks: Arc<Vec<AtomicU64>> = Arc::new((0..n).map(|_| AtomicU64::new(0)).collect());
346 let fails: Arc<Vec<AtomicU64>> = Arc::new((0..n).map(|_| AtomicU64::new(0)).collect());
347 let lats: Arc<Vec<Mutex<Vec<u64>>>> =
348 Arc::new((0..n).map(|_| Mutex::new(Vec::new())).collect());
349 let tos_applied = Arc::new(std::sync::atomic::AtomicBool::new(false));
350
351 let deadline = Instant::now() + cfg.duration;
352 let started = Instant::now();
353 let host = Arc::new(host);
354 let path = Arc::new(path);
355 let method = Arc::new(cfg.method.to_uppercase());
356
357 let mut workers = Vec::with_capacity(cfg.concurrency as usize);
358 for w in 0..cfg.concurrency {
359 let table = table.clone();
360 let classes = classes.clone();
361 let requests = requests.clone();
362 let oks = oks.clone();
363 let fails = fails.clone();
364 let lats = lats.clone();
365 let tos_applied = tos_applied.clone();
366 let host = host.clone();
367 let path = path.clone();
368 let method = method.clone();
369 let mss = cfg.mss;
370 let mut cursor = w as usize;
373
374 workers.push(tokio::spawn(async move {
375 while Instant::now() < deadline {
376 let idx = table[cursor % table.len()];
377 cursor = cursor.wrapping_add(1);
378 let tos = classes[idx].0.tos_byte();
379 let started_req = Instant::now();
380 requests[idx].fetch_add(1, Ordering::Relaxed);
381 let outcome = async {
382 let stream = connect_marked(addr, tos, mss, &tos_applied).await?;
383 send_request(stream, &method, &host, &path).await
384 }
385 .await;
386 match outcome {
387 Ok(status) if (200..400).contains(&status) => {
388 oks[idx].fetch_add(1, Ordering::Relaxed);
389 lats[idx].lock().await.push(started_req.elapsed().as_millis() as u64);
390 }
391 Ok(_) => {
392 fails[idx].fetch_add(1, Ordering::Relaxed);
393 }
394 Err(_) => {
395 fails[idx].fetch_add(1, Ordering::Relaxed);
396 }
397 }
398 }
399 }));
400 }
401
402 for h in workers {
403 let _ = h.await;
404 }
405 let elapsed = started.elapsed();
406
407 let mut per_class = Vec::with_capacity(n);
408 let mut total = 0u64;
409 let mut total_ok = 0u64;
410 let mut total_fail = 0u64;
411 for (i, (class, _)) in cfg.classes.iter().enumerate() {
412 let req = requests[i].load(Ordering::Relaxed);
413 let ok = oks[i].load(Ordering::Relaxed);
414 let fail = fails[i].load(Ordering::Relaxed);
415 total += req;
416 total_ok += ok;
417 total_fail += fail;
418 let mut l = lats[i].lock().await.clone();
419 l.sort_unstable();
420 per_class.push(ClassStats {
421 name: class.name.clone(),
422 dscp: class.dscp,
423 tos_byte: class.tos_byte(),
424 requests: req,
425 ok,
426 failed: fail,
427 p50_ms: percentile(&l, 0.50),
428 p95_ms: percentile(&l, 0.95),
429 });
430 }
431
432 let secs = elapsed.as_secs_f64().max(f64::MIN_POSITIVE);
433 Ok(QosBenchResult {
434 total_requests: total,
435 successful: total_ok,
436 failed: total_fail,
437 elapsed,
438 req_per_sec: total as f64 / secs,
439 per_class,
440 marking_unsupported: !tos_applied.load(Ordering::Relaxed),
441 })
442}
443
444pub fn render_report(res: &QosBenchResult) -> String {
446 let mut out = String::new();
447 out.push_str(&format!(
448 "QoS bench: {} requests in {:.1}s ({:.0} req/s), {} ok / {} failed\n",
449 res.total_requests,
450 res.elapsed.as_secs_f64(),
451 res.req_per_sec,
452 res.successful,
453 res.failed
454 ));
455 if res.marking_unsupported {
456 out.push_str(
457 " WARNING: IP_TOS/DSCP marking was not accepted by the kernel on this run; \
458 traffic went out unmarked (needs a Unix host and, on some platforms, privileges).\n",
459 );
460 }
461 out.push_str(&format!(
462 " {:<14} {:>5} {:>7} {:>8} {:>7} {:>8} {:>8}\n",
463 "class", "dscp", "tos", "reqs", "ok", "p50(ms)", "p95(ms)"
464 ));
465 for c in &res.per_class {
466 out.push_str(&format!(
467 " {:<14} {:>5} {:>7} {:>8} {:>7} {:>8} {:>8}\n",
468 c.name,
469 c.dscp,
470 format!("0x{:02x}", c.tos_byte),
471 c.requests,
472 c.ok,
473 c.p50_ms,
474 c.p95_ms
475 ));
476 }
477 out
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 #[test]
485 fn dscp_maps_to_tos_high_six_bits() {
486 assert_eq!(dscp_to_tos(46), 0xB8);
488 assert_eq!(dscp_to_tos(34), 0x88);
489 assert_eq!(dscp_to_tos(8), 0x20);
490 assert_eq!(dscp_to_tos(0), 0x00);
491 assert_eq!(dscp_to_tos(63) & 0x03, 0);
493 }
494
495 #[test]
496 fn parse_presets_and_custom_and_weights() {
497 assert_eq!(parse_class("voice").unwrap().0.dscp, 46);
498 assert_eq!(parse_class("video").unwrap().0.dscp, 34);
499 assert_eq!(parse_class("best-effort").unwrap().0.dscp, 0);
500 assert_eq!(parse_class("background").unwrap().0.dscp, 8);
501 assert_eq!(parse_class("dscp46").unwrap().0.dscp, 46);
502 let (c, w) = parse_class("voice:40").unwrap();
504 assert_eq!((c.dscp, w), (46, 40));
505 assert!(parse_class("bogus").is_err());
507 assert!(parse_class("dscp99").is_err()); assert!(parse_class("voice:0").is_err()); assert!(parse_class("voice:x").is_err());
510 }
511
512 #[test]
513 fn weighted_table_repeats_by_weight() {
514 let classes = vec![
515 (
516 TrafficClass {
517 name: "a".into(),
518 dscp: 0,
519 },
520 3,
521 ),
522 (
523 TrafficClass {
524 name: "b".into(),
525 dscp: 8,
526 },
527 1,
528 ),
529 ];
530 let table = weighted_indices(&classes);
531 assert_eq!(table.len(), 4);
532 assert_eq!(table.iter().filter(|&&i| i == 0).count(), 3);
533 assert_eq!(table.iter().filter(|&&i| i == 1).count(), 1);
534 }
535
536 #[test]
537 fn parse_http_target_splits_host_port_path() {
538 assert_eq!(
539 parse_http_target("http://host:3000/up").unwrap(),
540 ("host".to_string(), 3000, "/up".to_string())
541 );
542 assert_eq!(
543 parse_http_target("http://host/").unwrap(),
544 ("host".to_string(), 80, "/".to_string())
545 );
546 assert!(parse_http_target("https://host/").is_err());
547 }
548
549 #[tokio::test]
556 async fn marked_socket_carries_dscp_and_connect_works() {
557 let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
558
559 for dscp in [46u8, 34, 8, 0] {
560 let tos = dscp_to_tos(dscp);
561 let applied = Arc::new(std::sync::atomic::AtomicBool::new(false));
562 let socket = marked_socket(addr, tos, None, Some(&applied)).unwrap();
563 assert!(applied.load(Ordering::Relaxed), "set_tos must succeed for dscp {dscp}");
564 let read_back = socket.tos().unwrap() as u8;
565 assert_eq!(
566 read_back, tos,
567 "getsockopt IP_TOS must equal DSCP<<2 (dscp={dscp}, want {tos:#04x})"
568 );
569 }
570
571 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
573 let laddr = listener.local_addr().unwrap();
574 tokio::spawn(async move {
575 let _ = listener.accept().await;
576 });
577 let applied = Arc::new(std::sync::atomic::AtomicBool::new(false));
578 let stream = connect_marked(laddr, dscp_to_tos(46), None, &applied).await.unwrap();
579 assert!(stream.peer_addr().is_ok());
580 assert!(applied.load(Ordering::Relaxed), "connect_marked applied the TOS");
581 }
582
583 #[tokio::test]
586 async fn mss_clamp_is_accepted() {
587 let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
588 let socket = marked_socket(addr, dscp_to_tos(0), Some(536), None).unwrap();
591 drop(socket);
592 }
593}