Skip to main content

mockforge_bench/
qos_bench.rs

1//! QoS / DSCP traffic-class load generator (#933).
2//!
3//! Srikanth on #79 asked whether `mockforge bench` can emit different network
4//! traffic classes (Voice, Video, Background, Best-Effort) in one run so the
5//! path under test can be exercised against its QoS handling. k6 (the default
6//! bench engine) is an application-layer HTTP client and exposes no L3/L4
7//! knobs, so this is a NATIVE generator built on raw `socket2` sockets.
8//!
9//! DSCP is the top 6 bits of the IPv4 `IP_TOS` byte (`TOS = DSCP << 2`, ECN 0).
10//! [`connect_marked`] creates the TCP socket, sets `IP_TOS` (and optionally
11//! clamps `TCP_MAXSEG`) BEFORE connect, then hands the connected socket to
12//! Tokio. Each request opens its own marked connection and sends a minimal
13//! HTTP/1.1 request, so a single run can mix classes by weight.
14//!
15//! Jumbo frames (a NIC MTU property) and true IP fragmentation (kernel/path
16//! controlled) are NOT socket knobs and are documented as OS-level operations
17//! (`ip link set ... mtu 9000`, `tc`/`netem`) rather than built here. IPv6
18//! traffic-class marking (`IPV6_TCLASS`) is a follow-up; v1 marks IPv4 IP_TOS.
19
20use 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/// A single traffic class: a human name and its DSCP code point (0-63).
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct TrafficClass {
37    /// Display name (`voice`, `video`, `best-effort`, `background`, `dscp46`).
38    pub name: String,
39    /// DSCP code point, 0-63.
40    pub dscp: u8,
41}
42
43impl TrafficClass {
44    /// The IPv4 `IP_TOS` byte for this class: `DSCP << 2` with ECN bits 0.
45    pub fn tos_byte(&self) -> u8 {
46        dscp_to_tos(self.dscp)
47    }
48}
49
50/// Convert a DSCP code point (0-63) into the 8-bit IP_TOS/Traffic-Class byte.
51/// DSCP occupies the high 6 bits; the low 2 (ECN) are left 0.
52pub fn dscp_to_tos(dscp: u8) -> u8 {
53    (dscp & 0x3f) << 2
54}
55
56/// Resolve a preset name to its DSCP code point.
57fn preset_dscp(name: &str) -> Option<u8> {
58    match name.to_ascii_lowercase().as_str() {
59        // Expedited Forwarding — interactive voice.
60        "voice" | "ef" => Some(46),
61        // Assured Forwarding 41 — interactive video.
62        "video" | "af41" => Some(34),
63        // Default / Best Effort.
64        "best-effort" | "be" | "default" => Some(0),
65        // Class Selector 1 — scavenger / background (email, backups).
66        "background" | "cs1" | "scavenger" => Some(8),
67        _ => None,
68    }
69}
70
71/// Parse a `--class` spec: `NAME[:WEIGHT]`, where NAME is a preset
72/// (`voice`/`video`/`best-effort`/`background`) or `dscpNN` (NN = 0-63).
73/// Weight defaults to 1. Returns the class plus its relative weight.
74pub 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/// Configuration for a QoS traffic-class bench run.
117#[derive(Debug, Clone)]
118pub struct QosBenchConfig {
119    /// Target URL (`http://host:port/path`). HTTPS is not supported by this
120    /// native generator (the point is raw socket control, not TLS); use a
121    /// plain-HTTP endpoint or terminate TLS in front.
122    pub target_url: String,
123    /// HTTP method (GET/HEAD/POST/...). GET by default; body is empty.
124    pub method: String,
125    /// Traffic classes to mix, each with a relative weight.
126    pub classes: Vec<(TrafficClass, u32)>,
127    /// Concurrent workers.
128    pub concurrency: u32,
129    /// Total run duration.
130    pub duration: Duration,
131    /// Optional TCP_MAXSEG clamp (bytes) applied to every connection.
132    pub mss: Option<u32>,
133}
134
135/// Per-class outcome from a QoS bench run.
136#[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/// Aggregate result from a QoS bench run.
149#[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    /// Set when the platform silently ignored IP_TOS (so the operator knows
158    /// the marking may not have reached the wire).
159    pub marking_unsupported: bool,
160}
161
162/// Create a fresh TCP socket for `addr` with its IPv4 `IP_TOS` set to `tos`
163/// (and, when `mss` is given, `TCP_MAXSEG` clamped), BEFORE any connect, so
164/// the very first SYN carries the DSCP marking. `tos_applied`, when given, is
165/// set to `true` iff the kernel accepted the IP_TOS setsockopt. Kept separate
166/// from the connect so a test can `getsockopt` the TOS back off the exact
167/// socket the generator uses (see `connect_marked_socket_carries_tos`).
168fn 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    // DSCP marking. IPv4 IP_TOS only in v1; IPv6 IPV6_TCLASS is a follow-up.
182    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                // Non-fatal: still generate load, just unmarked.
191                tracing::debug!("set_tos({tos}) failed: {e}");
192            }
193        }
194    }
195    if let Some(m) = mss {
196        // Best-effort; TCP_MAXSEG isn't settable on every platform.
197        let _ = socket.set_mss(m);
198    }
199    Ok(socket)
200}
201
202/// Create a TCP connection to `addr` with its IPv4 `IP_TOS` set to `tos`
203/// (and, when `mss` is given, `TCP_MAXSEG` clamped) BEFORE connect, so the
204/// very first SYN carries the DSCP marking. Returns the connected Tokio
205/// stream. `tos_applied` reports whether the kernel accepted the IP_TOS
206/// setsockopt (some platforms reject it for non-privileged users).
207async 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    // Non-blocking connect: EINPROGRESS is expected; the socket becomes
216    // writable when connect resolves, and take_error() reports the outcome.
217    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
234/// Send one minimal HTTP/1.1 request over an already-marked connection and
235/// return the response status code. `Connection: close` so the server hangs
236/// up and we can read to EOF without parsing Content-Length.
237async 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    // Read just enough to see the status line.
250    let mut buf = [0u8; 256];
251    let n = stream.read(&mut buf).await?;
252    let head = String::from_utf8_lossy(&buf[..n]);
253    // "HTTP/1.1 200 OK" -> 200
254    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    // Drain the rest so the peer's close is clean (best effort).
260    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
269/// Split a target URL into (host, port, path) for the raw generator.
270/// Only plain `http://` is supported.
271fn 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
297/// Build the weighted selection table: class index repeated `weight` times.
298fn 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
316/// Run the QoS traffic-class bench. Spawns `concurrency` workers that, until
317/// `duration` elapses, pick a class by weight, open a fresh DSCP-marked
318/// connection, and send one request, then aggregates per-class stats.
319pub 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            // Resolve a hostname to its first address.
331            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    // Per-class counters + latency buckets.
343    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        // Deterministic per-worker starting offset so workers don't all pick
371        // the same class first; avoids Math.random-style nondeterminism.
372        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
444/// Render the result as a compact table for the CLI.
445pub 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        // EF=46 -> 0xB8, AF41=34 -> 0x88, CS1=8 -> 0x20, BE=0 -> 0x00.
487        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        // ECN bits (low 2) always clear.
492        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        // Weight parsing.
503        let (c, w) = parse_class("voice:40").unwrap();
504        assert_eq!((c.dscp, w), (46, 40));
505        // Errors.
506        assert!(parse_class("bogus").is_err());
507        assert!(parse_class("dscp99").is_err()); // > 63
508        assert!(parse_class("voice:0").is_err()); // weight 0
509        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    /// The whole point of this feature: prove the socket the generator
550    /// actually uses carries the DSCP marking. `marked_socket` is the exact
551    /// helper `connect_marked` calls, so reading `IP_TOS` back off it with
552    /// getsockopt proves every outbound SYN/segment carries `DSCP << 2`. The
553    /// kernel stamps a socket's IP_TOS into the IP header of its packets, so
554    /// this is the sender-side wire guarantee.
555    #[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        // And `connect_marked` (which uses `marked_socket`) reaches a live peer.
572        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    /// The MSS clamp is accepted by the socket (TCP_MAXSEG) without breaking
584    /// the connect path.
585    #[tokio::test]
586    async fn mss_clamp_is_accepted() {
587        let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
588        // Should not error; getsockopt of MSS varies by platform so we only
589        // assert the socket is still usable for a connect afterwards.
590        let socket = marked_socket(addr, dscp_to_tos(0), Some(536), None).unwrap();
591        drop(socket);
592    }
593}