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, and
197        // socket2 only exposes it on Unix.
198        #[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
206/// Create a TCP connection to `addr` with its IPv4 `IP_TOS` set to `tos`
207/// (and, when `mss` is given, `TCP_MAXSEG` clamped) BEFORE connect, so the
208/// very first SYN carries the DSCP marking. Returns the connected Tokio
209/// stream. `tos_applied` reports whether the kernel accepted the IP_TOS
210/// setsockopt (some platforms reject it for non-privileged users).
211async 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    // Non-blocking connect: EINPROGRESS is expected; the socket becomes
220    // writable when connect resolves, and take_error() reports the outcome.
221    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
238/// Send one minimal HTTP/1.1 request over an already-marked connection and
239/// return the response status code. `Connection: close` so the server hangs
240/// up and we can read to EOF without parsing Content-Length.
241async 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    // Read just enough to see the status line.
254    let mut buf = [0u8; 256];
255    let n = stream.read(&mut buf).await?;
256    let head = String::from_utf8_lossy(&buf[..n]);
257    // "HTTP/1.1 200 OK" -> 200
258    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    // Drain the rest so the peer's close is clean (best effort).
264    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
273/// Split a target URL into (host, port, path) for the raw generator.
274/// Only plain `http://` is supported.
275fn 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
301/// Build the weighted selection table: class index repeated `weight` times.
302fn 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
320/// Run the QoS traffic-class bench. Spawns `concurrency` workers that, until
321/// `duration` elapses, pick a class by weight, open a fresh DSCP-marked
322/// connection, and send one request, then aggregates per-class stats.
323pub 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            // Resolve a hostname to its first address.
335            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    // Per-class counters + latency buckets.
347    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        // Deterministic per-worker starting offset so workers don't all pick
375        // the same class first; avoids Math.random-style nondeterminism.
376        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
448/// Render the result as a compact table for the CLI.
449pub 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        // EF=46 -> 0xB8, AF41=34 -> 0x88, CS1=8 -> 0x20, BE=0 -> 0x00.
491        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        // ECN bits (low 2) always clear.
496        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        // Weight parsing.
507        let (c, w) = parse_class("voice:40").unwrap();
508        assert_eq!((c.dscp, w), (46, 40));
509        // Errors.
510        assert!(parse_class("bogus").is_err());
511        assert!(parse_class("dscp99").is_err()); // > 63
512        assert!(parse_class("voice:0").is_err()); // weight 0
513        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    /// The whole point of this feature: prove the socket the generator
554    /// actually uses carries the DSCP marking. `marked_socket` is the exact
555    /// helper `connect_marked` calls, so reading `IP_TOS` back off it with
556    /// getsockopt proves every outbound SYN/segment carries `DSCP << 2`. The
557    /// kernel stamps a socket's IP_TOS into the IP header of its packets, so
558    /// this is the sender-side wire guarantee.
559    #[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        // And `connect_marked` (which uses `marked_socket`) reaches a live peer.
576        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    /// The MSS clamp is accepted by the socket (TCP_MAXSEG) without breaking
588    /// the connect path.
589    #[tokio::test]
590    async fn mss_clamp_is_accepted() {
591        let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
592        // Should not error; getsockopt of MSS varies by platform so we only
593        // assert the socket is still usable for a connect afterwards.
594        let socket = marked_socket(addr, dscp_to_tos(0), Some(536), None).unwrap();
595        drop(socket);
596    }
597}