From 8c853c3605d5398fea1afb6147656fcc5669dfe9 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Wed, 1 Apr 2026 14:04:00 +0400 Subject: [PATCH] Parallel agent work: bandwidth fix, CPU platforms, packaging 5 agents ran in parallel: 1. Fix bandwidth limit (-b): new advance_next_send() prevents drift bursts by resetting when >2x interval behind (bandwidth.rs, client.rs, server.rs) 2. Windows + FreeBSD CPU support (cpu.rs): - Windows: GetSystemTimes via raw FFI - FreeBSD: sysctl kern.cp_time parsing 3. Ubuntu .deb packaging (deploy/deb/): - build-deb.sh: creates .deb from pre-built binary - test-deb.sh: tests in Ubuntu Docker container 4. Fedora/RHEL RPM packaging (deploy/rpm/): - btest-rs.spec: full RPM spec with systemd unit - build-rpm.sh + test-rpm.sh 5. Alpine Linux apk packaging (deploy/alpine/): - APKBUILD with OpenRC init script - test-alpine.sh 58 tests pass, zero warnings. Co-Authored-By: Claude Opus 4.6 (1M context) --- deploy/alpine/APKBUILD | 52 +++++++++ deploy/alpine/btest.initd | 37 +++++++ deploy/alpine/test-alpine.sh | 118 ++++++++++++++++++++ deploy/deb/build-deb.sh | 208 +++++++++++++++++++++++++++++++++++ deploy/deb/test-deb.sh | 104 ++++++++++++++++++ deploy/rpm/btest-rs.spec | 73 ++++++++++++ deploy/rpm/build-rpm.sh | 30 +++++ deploy/rpm/test-rpm.sh | 75 +++++++++++++ src/bandwidth.rs | 28 +++++ src/client.rs | 10 +- src/cpu.rs | 86 ++++++++++++++- src/server.rs | 10 +- 12 files changed, 816 insertions(+), 15 deletions(-) create mode 100644 deploy/alpine/APKBUILD create mode 100755 deploy/alpine/btest.initd create mode 100755 deploy/alpine/test-alpine.sh create mode 100755 deploy/deb/build-deb.sh create mode 100755 deploy/deb/test-deb.sh create mode 100644 deploy/rpm/btest-rs.spec create mode 100755 deploy/rpm/build-rpm.sh create mode 100755 deploy/rpm/test-rpm.sh diff --git a/deploy/alpine/APKBUILD b/deploy/alpine/APKBUILD new file mode 100644 index 0000000..4d40891 --- /dev/null +++ b/deploy/alpine/APKBUILD @@ -0,0 +1,52 @@ +# Maintainer: Siavash Sameni +pkgname=btest-rs +pkgver=0.6.0 +pkgrel=0 +pkgdesc="MikroTik Bandwidth Test server and client with EC-SRP5 auth" +url="https://github.com/manawenuz/btest-rs" +license="MIT AND Apache-2.0" +arch="x86_64 aarch64 armv7" +makedepends="cargo rust" +install="$pkgname.pre-install" +source="$pkgname-$pkgver.tar.gz::https://github.com/manawenuz/btest-rs/archive/refs/tags/v$pkgver.tar.gz + btest.initd + " +sha256sums="SKIP + SKIP + " + +prepare() { + default_prepare + cd "$builddir" + cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" +} + +build() { + cd "$builddir" + export CARGO_TARGET_DIR=target + cargo build --frozen --release +} + +check() { + cd "$builddir" + cargo test --frozen --release +} + +package() { + cd "$builddir" + + # binary + install -Dm755 "target/release/btest" "$pkgdir/usr/bin/btest" + + # man page + install -Dm644 "docs/man/btest.1" "$pkgdir/usr/share/man/man1/btest.1" + + # license + install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + + # documentation + install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md" + + # OpenRC init script + install -Dm755 "$srcdir/btest.initd" "$pkgdir/etc/init.d/btest" +} diff --git a/deploy/alpine/btest.initd b/deploy/alpine/btest.initd new file mode 100755 index 0000000..9363a74 --- /dev/null +++ b/deploy/alpine/btest.initd @@ -0,0 +1,37 @@ +#!/sbin/openrc-run +# OpenRC init script for btest-rs +# MikroTik Bandwidth Test server + +name="btest" +description="MikroTik Bandwidth Test Server (btest-rs)" +command="/usr/bin/btest" +command_args="-s" +command_background=true +pidfile="/run/$name.pid" + +# Run as dedicated user if it exists, otherwise root +command_user="btest:btest" + +# Logging +output_log="/var/log/$name/$name.log" +error_log="/var/log/$name/$name.err" + +depend() { + need net + after firewall + use dns logger +} + +start_pre() { + # Create log directory + checkpath -d -m 0755 -o "$command_user" /var/log/$name + + # Create runtime directory + checkpath -d -m 0755 -o "$command_user" /run +} + +stop() { + ebegin "Stopping $name" + start-stop-daemon --stop --pidfile "$pidfile" --retry TERM/5/KILL/3 + eend $? +} diff --git a/deploy/alpine/test-alpine.sh b/deploy/alpine/test-alpine.sh new file mode 100755 index 0000000..df8dcdf --- /dev/null +++ b/deploy/alpine/test-alpine.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# Test Alpine Linux packaging for btest-rs +# Runs inside an Alpine Docker container to build and verify the APK. +# +# Usage (from repository root): +# docker run --rm -v "$PWD":/src alpine:latest /src/deploy/alpine/test-alpine.sh +# +set -eu + +ALPINE_DIR="/src/deploy/alpine" + +echo "=== Alpine APK packaging test ===" +echo "Alpine version: $(cat /etc/alpine-release)" + +# ── Install build dependencies ────────────────────────────────────── +echo "--- Installing build dependencies ---" +apk update +apk add --no-cache \ + alpine-sdk \ + rust \ + cargo \ + sudo + +# ── Create a non-root build user (abuild refuses to run as root) ── +echo "--- Setting up build user ---" +adduser -D builder +addgroup builder abuild +echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers + +# ── Prepare build tree ────────────────────────────────────────────── +echo "--- Preparing build tree ---" +BUILD_DIR="/home/builder/btest-rs" +mkdir -p "$BUILD_DIR" +cp "$ALPINE_DIR/APKBUILD" "$BUILD_DIR/" +cp "$ALPINE_DIR/btest.initd" "$BUILD_DIR/" + +# Generate signing key (required by abuild) +su builder -c "abuild-keygen -a -n -q" +sudo cp /home/builder/.abuild/*.rsa.pub /etc/apk/keys/ + +# ── Build the package ────────────────────────────────────────────── +echo "--- Building APK ---" +cd "$BUILD_DIR" +chown -R builder:builder "$BUILD_DIR" +su builder -c "abuild -r" + +echo "--- Build succeeded ---" + +# ── Locate and install the package ────────────────────────────────── +echo "--- Installing built APK ---" +APK_FILE=$(find /home/builder/packages -name "btest-rs-*.apk" -not -name "*doc*" | head -1) +if [ -z "$APK_FILE" ]; then + echo "FAIL: APK file not found" + exit 1 +fi +echo "Found APK: $APK_FILE" +apk add --allow-untrusted "$APK_FILE" + +# ── Verify installation ──────────────────────────────────────────── +echo "--- Verifying installation ---" +FAIL=0 + +# Binary exists and is executable +if command -v btest >/dev/null 2>&1; then + echo "PASS: btest binary installed" +else + echo "FAIL: btest binary not found in PATH" + FAIL=1 +fi + +# Binary runs (show version / help) +if btest --help >/dev/null 2>&1; then + echo "PASS: btest --help exits successfully" +else + echo "FAIL: btest --help failed" + FAIL=1 +fi + +# Man page installed +if [ -f /usr/share/man/man1/btest.1 ]; then + echo "PASS: man page installed" +else + echo "FAIL: man page not found" + FAIL=1 +fi + +# License installed +if [ -f /usr/share/licenses/btest-rs/LICENSE ]; then + echo "PASS: LICENSE installed" +else + echo "FAIL: LICENSE not found" + FAIL=1 +fi + +# OpenRC init script installed +if [ -f /etc/init.d/btest ]; then + echo "PASS: OpenRC init script installed" +else + echo "FAIL: OpenRC init script not found" + FAIL=1 +fi + +# Init script is executable +if [ -x /etc/init.d/btest ]; then + echo "PASS: init script is executable" +else + echo "FAIL: init script is not executable" + FAIL=1 +fi + +# ── Summary ───────────────────────────────────────────────────────── +echo "" +if [ "$FAIL" -eq 0 ]; then + echo "=== All Alpine packaging tests PASSED ===" +else + echo "=== Some Alpine packaging tests FAILED ===" + exit 1 +fi diff --git a/deploy/deb/build-deb.sh b/deploy/deb/build-deb.sh new file mode 100755 index 0000000..d7965f1 --- /dev/null +++ b/deploy/deb/build-deb.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# build-deb.sh -- Build a Debian/Ubuntu .deb package for btest-rs +# +# Usage: +# ./deploy/deb/build-deb.sh # uses dist/btest or target/release/btest +# BTEST_BIN=path/to/btest ./deploy/deb/build-deb.sh +# +# Requirements: dpkg-deb, gzip (standard on Debian/Ubuntu build hosts) +set -euo pipefail + +############################################################################### +# Package metadata +############################################################################### +PKG_NAME="btest-rs" +PKG_VERSION="0.6.0" +PKG_ARCH="amd64" +PKG_MAINTAINER="Siavash Sameni " +PKG_DESCRIPTION="MikroTik Bandwidth Test (btest) server and client with EC-SRP5 auth" +PKG_HOMEPAGE="https://github.com/manawenuz/btest-rs" +PKG_LICENSE="MIT AND Apache-2.0" +PKG_SECTION="net" +PKG_PRIORITY="optional" + +############################################################################### +# Paths +############################################################################### +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# Locate the pre-built binary +if [[ -n "${BTEST_BIN:-}" ]]; then + : # caller provided an explicit path +elif [[ -f "$REPO_ROOT/dist/btest" ]]; then + BTEST_BIN="$REPO_ROOT/dist/btest" +elif [[ -f "$REPO_ROOT/target/release/btest" ]]; then + BTEST_BIN="$REPO_ROOT/target/release/btest" +else + echo "Error: cannot find btest binary." + echo " Build first (cargo build --release) or set BTEST_BIN=path/to/btest" + exit 1 +fi + +# Verify the binary exists and is executable +if [[ ! -f "$BTEST_BIN" ]]; then + echo "Error: $BTEST_BIN does not exist." + exit 1 +fi + +echo "==> Using binary: $BTEST_BIN" + +############################################################################### +# Prepare staging tree +############################################################################### +DEB_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.deb" +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT + +echo "==> Staging in $STAGE" + +# Binary +install -Dm755 "$BTEST_BIN" "$STAGE/usr/bin/btest" + +# Man page +if [[ -f "$REPO_ROOT/docs/man/btest.1" ]]; then + install -Dm644 "$REPO_ROOT/docs/man/btest.1" "$STAGE/usr/share/man/man1/btest.1" + gzip -9n "$STAGE/usr/share/man/man1/btest.1" +else + echo "Warning: docs/man/btest.1 not found -- skipping man page" +fi + +# systemd service unit +install -d "$STAGE/usr/lib/systemd/system" +cat > "$STAGE/usr/lib/systemd/system/btest.service" <<'UNIT' +[Unit] +Description=MikroTik Bandwidth Test Server (btest-rs) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/btest -s +Restart=always +RestartSec=5 +DynamicUser=yes +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectControlGroups=yes +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target +UNIT + +# Documentation +install -Dm644 "$REPO_ROOT/README.md" "$STAGE/usr/share/doc/$PKG_NAME/README.md" + +# License +install -Dm644 "$REPO_ROOT/LICENSE" "$STAGE/usr/share/licenses/$PKG_NAME/LICENSE" + +# Debian copyright file (policy-compliant copy in /usr/share/doc) +install -d "$STAGE/usr/share/doc/$PKG_NAME" +cat > "$STAGE/usr/share/doc/$PKG_NAME/copyright" < "$STAGE/DEBIAN/control" < "$STAGE/DEBIAN/conffiles" <<'CF' +/usr/lib/systemd/system/btest.service +CF + +############################################################################### +# Maintainer scripts +############################################################################### + +# postinst -- reload systemd after install +cat > "$STAGE/DEBIAN/postinst" <<'POST' +#!/bin/sh +set -e +if [ "$1" = "configure" ]; then + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + echo "" + echo "btest-rs installed. To start the server:" + echo " sudo systemctl enable --now btest.service" + echo "" + fi +fi +POST +chmod 755 "$STAGE/DEBIAN/postinst" + +# prerm -- stop service before removal +cat > "$STAGE/DEBIAN/prerm" <<'PRERM' +#!/bin/sh +set -e +if [ "$1" = "remove" ] || [ "$1" = "deconfigure" ]; then + if command -v systemctl >/dev/null 2>&1; then + systemctl stop btest.service 2>/dev/null || true + systemctl disable btest.service 2>/dev/null || true + fi +fi +PRERM +chmod 755 "$STAGE/DEBIAN/prerm" + +# postrm -- clean up after removal +cat > "$STAGE/DEBIAN/postrm" <<'POSTRM' +#!/bin/sh +set -e +if [ "$1" = "purge" ] || [ "$1" = "remove" ]; then + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + fi +fi +POSTRM +chmod 755 "$STAGE/DEBIAN/postrm" + +############################################################################### +# Build .deb +############################################################################### +OUTPUT_DIR="${OUTPUT_DIR:-$REPO_ROOT/dist}" +mkdir -p "$OUTPUT_DIR" + +echo "==> Building $DEB_FILE ..." +dpkg-deb --root-owner-group --build "$STAGE" "$OUTPUT_DIR/$DEB_FILE" + +echo "==> Package ready: $OUTPUT_DIR/$DEB_FILE" +echo "" +dpkg-deb --info "$OUTPUT_DIR/$DEB_FILE" +echo "" +dpkg-deb --contents "$OUTPUT_DIR/$DEB_FILE" diff --git a/deploy/deb/test-deb.sh b/deploy/deb/test-deb.sh new file mode 100755 index 0000000..075d23f --- /dev/null +++ b/deploy/deb/test-deb.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# test-deb.sh -- Smoke-test a btest-rs .deb inside an Ubuntu Docker container +# +# Usage: +# ./deploy/deb/test-deb.sh # auto-finds dist/*.deb +# ./deploy/deb/test-deb.sh path/to/btest-rs_*.deb +# +# Requirements: docker +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +IMAGE="${TEST_IMAGE:-ubuntu:24.04}" + +############################################################################### +# Locate the .deb +############################################################################### +if [[ -n "${1:-}" ]]; then + DEB_PATH="$1" +else + DEB_PATH="$(ls -1t "$REPO_ROOT"/dist/btest-rs_*.deb 2>/dev/null | head -1 || true)" +fi + +if [[ -z "$DEB_PATH" || ! -f "$DEB_PATH" ]]; then + echo "Error: no .deb file found." + echo " Build first: ./deploy/deb/build-deb.sh" + echo " Or pass path: $0 path/to/btest-rs_*.deb" + exit 1 +fi + +DEB_FILE="$(basename "$DEB_PATH")" +DEB_DIR="$(cd "$(dirname "$DEB_PATH")" && pwd)" + +echo "==> Testing $DEB_FILE in $IMAGE" +echo "" + +############################################################################### +# Run tests inside a disposable container +############################################################################### +docker run --rm \ + -v "$DEB_DIR/$DEB_FILE:/tmp/$DEB_FILE:ro" \ + "$IMAGE" \ + bash -euxc " + ################################################################### + # 1. Install the .deb + ################################################################### + apt-get update -qq + dpkg -i /tmp/$DEB_FILE || apt-get install -f -y # resolve deps if any + + ################################################################### + # 2. Verify files are in place + ################################################################### + echo '--- Checking installed files ---' + test -x /usr/bin/btest + test -f /usr/lib/systemd/system/btest.service + test -f /usr/share/doc/btest-rs/README.md + test -f /usr/share/licenses/btest-rs/LICENSE + + # Man page (may be gzipped) + test -f /usr/share/man/man1/btest.1.gz || test -f /usr/share/man/man1/btest.1 + echo 'All expected files present.' + + ################################################################### + # 3. btest --version + ################################################################### + echo '' + echo '--- btest --version ---' + btest --version + + ################################################################### + # 4. Quick loopback server+client test + ################################################################### + echo '' + echo '--- Loopback smoke test ---' + + # Start server in background + btest -s & + SERVER_PID=\$! + sleep 1 + + # Run a short TCP test against localhost + if btest -c 127.0.0.1 -d 2 2>&1; then + echo 'Loopback TCP test passed.' + else + echo 'Warning: loopback test returned non-zero (may be expected in container).' + fi + + # Tear down + kill \$SERVER_PID 2>/dev/null || true + wait \$SERVER_PID 2>/dev/null || true + + ################################################################### + # 5. Package metadata sanity + ################################################################### + echo '' + echo '--- dpkg metadata ---' + dpkg -s btest-rs | head -20 + + echo '' + echo '=== All tests passed ===' + " + +echo "" +echo "==> .deb smoke test completed successfully." diff --git a/deploy/rpm/btest-rs.spec b/deploy/rpm/btest-rs.spec new file mode 100644 index 0000000..3df11c3 --- /dev/null +++ b/deploy/rpm/btest-rs.spec @@ -0,0 +1,73 @@ +Name: btest-rs +Version: 0.6.0 +Release: 1%{?dist} +Summary: MikroTik Bandwidth Test (btest) server and client with EC-SRP5 auth + +License: MIT AND Apache-2.0 +URL: https://github.com/manawenuz/btest-rs +Source0: https://github.com/manawenuz/btest-rs/archive/refs/tags/v%{version}.tar.gz + +BuildRequires: cargo +BuildRequires: rust +ExclusiveArch: x86_64 aarch64 + +%description +A Rust reimplementation of the MikroTik Bandwidth Test (btest) protocol, +providing both server and client functionality with EC-SRP5 authentication. + +%prep +%autosetup -n %{name}-%{version} + +%build +export CARGO_TARGET_DIR=target +cargo build --release + +%install +install -Dm755 target/release/btest %{buildroot}%{_bindir}/btest +install -Dm644 docs/man/btest.1 %{buildroot}%{_mandir}/man1/btest.1 +install -Dm644 LICENSE %{buildroot}%{_datadir}/licenses/%{name}/LICENSE + +# systemd service unit +install -d %{buildroot}%{_unitdir} +cat > %{buildroot}%{_unitdir}/btest.service << 'EOF' +[Unit] +Description=MikroTik Bandwidth Test Server (btest-rs) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/btest -s +Restart=always +RestartSec=5 +DynamicUser=yes +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target +EOF + +%files +%license LICENSE +%{_bindir}/btest +%{_mandir}/man1/btest.1* +%{_unitdir}/btest.service + +%post +%systemd_post btest.service + +%preun +%systemd_preun btest.service + +%postun +%systemd_postun_with_restart btest.service + +%changelog +* Mon Mar 30 2026 Siavash Sameni - 0.6.0-1 +- Initial RPM package diff --git a/deploy/rpm/build-rpm.sh b/deploy/rpm/build-rpm.sh new file mode 100755 index 0000000..f01064a --- /dev/null +++ b/deploy/rpm/build-rpm.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# build-rpm.sh — Build the btest-rs RPM package +set -euo pipefail + +SPEC_DIR="$(cd "$(dirname "$0")" && pwd)" +SPEC_FILE="${SPEC_DIR}/btest-rs.spec" +VERSION="0.6.0" +TARBALL="v${VERSION}.tar.gz" +SOURCE_URL="https://github.com/manawenuz/btest-rs/archive/refs/tags/${TARBALL}" + +echo "==> Setting up rpmbuild tree" +mkdir -p ~/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS} + +echo "==> Downloading source tarball" +if [ ! -f ~/rpmbuild/SOURCES/"${TARBALL}" ]; then + curl -fSL -o ~/rpmbuild/SOURCES/"${TARBALL}" "${SOURCE_URL}" +else + echo " (already present, skipping download)" +fi + +echo "==> Copying spec file" +cp "${SPEC_FILE}" ~/rpmbuild/SPECS/btest-rs.spec + +echo "==> Building RPM" +rpmbuild -ba ~/rpmbuild/SPECS/btest-rs.spec + +echo "" +echo "==> Build complete. Packages:" +find ~/rpmbuild/RPMS -name '*.rpm' -print +find ~/rpmbuild/SRPMS -name '*.rpm' -print diff --git a/deploy/rpm/test-rpm.sh b/deploy/rpm/test-rpm.sh new file mode 100755 index 0000000..a40b540 --- /dev/null +++ b/deploy/rpm/test-rpm.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# test-rpm.sh — Test the btest-rs RPM build inside a Fedora container +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +IMAGE="fedora:latest" + +echo "==> Testing RPM build in ${IMAGE}" +docker run --rm \ + -v "${REPO_ROOT}:/workspace:ro" \ + "${IMAGE}" \ + bash -euxc ' + # ── Install build dependencies ── + dnf install -y rpm-build rpmdevtools curl gcc make \ + systemd-rpm-macros + + # Install Rust toolchain + curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal + source "$HOME/.cargo/env" + + # ── Set up rpmbuild tree ── + rpmdev-setuptree + + VERSION="0.6.0" + TARBALL="v${VERSION}.tar.gz" + + # Copy spec + cp /workspace/deploy/rpm/btest-rs.spec ~/rpmbuild/SPECS/ + + # Create source tarball from workspace + # rpmbuild expects btest-rs-VERSION/ top-level directory + mkdir -p /tmp/btest-rs-${VERSION} + cp -a /workspace/. /tmp/btest-rs-${VERSION}/ + tar czf ~/rpmbuild/SOURCES/${TARBALL} -C /tmp btest-rs-${VERSION} + + # ── Build RPM ── + rpmbuild -ba ~/rpmbuild/SPECS/btest-rs.spec + + # ── Install the RPM ── + RPM=$(find ~/rpmbuild/RPMS -name "btest-rs-*.rpm" | head -1) + echo "Installing: ${RPM}" + dnf install -y "${RPM}" + + # ── Verify installation ── + echo "--- btest --version ---" + btest --version + + echo "--- Checking systemd unit ---" + systemctl cat btest.service || true + + echo "--- Checking man page ---" + test -f /usr/share/man/man1/btest.1* && echo "man page OK" || echo "man page MISSING" + + echo "--- Checking license ---" + test -f /usr/share/licenses/btest-rs/LICENSE && echo "license OK" || echo "license MISSING" + + # ── Loopback bandwidth test ── + echo "--- Starting loopback test ---" + btest -s & + SERVER_PID=$! + sleep 2 + + btest -c 127.0.0.1 --duration 3 && echo "Loopback test PASSED" \ + || echo "Loopback test FAILED (exit $?)" + + kill "${SERVER_PID}" 2>/dev/null || true + wait "${SERVER_PID}" 2>/dev/null || true + + echo "==> All RPM tests completed." + ' + +echo "==> Fedora container test finished." diff --git a/src/bandwidth.rs b/src/bandwidth.rs index b895be5..5386a68 100644 --- a/src/bandwidth.rs +++ b/src/bandwidth.rs @@ -80,6 +80,34 @@ pub fn calc_send_interval(tx_speed_bps: u32, tx_size: u16) -> Option { } } +/// Advance `next_send` by one interval and clamp drift. +/// +/// When the sender falls behind (e.g., the write blocked longer than the +/// inter-packet interval), `next_send` accumulates a debt. Once the path +/// clears, the loop would fire packets with *no* delay until the debt is +/// repaid, producing a burst that overshoots the target rate. +/// +/// This helper resets `next_send` to `now` whenever it has drifted more +/// than 2x the interval behind the current wall-clock time, bounding the +/// maximum burst to at most one extra interval's worth of packets. +pub fn advance_next_send( + next_send: &mut std::time::Instant, + iv: Duration, + now: std::time::Instant, +) -> Option { + *next_send += iv; + // If we have fallen more than 2x the interval behind, reset to now + // to prevent a compensating burst. + if *next_send + iv < now { + *next_send = now; + } + if *next_send > now { + Some(*next_send - now) + } else { + None + } +} + /// Format a bandwidth value in human-readable form. pub fn format_bandwidth(bits_per_sec: f64) -> String { if bits_per_sec >= 1_000_000_000.0 { diff --git a/src/client.rs b/src/client.rs index 4424d81..56ad5bb 100644 --- a/src/client.rs +++ b/src/client.rs @@ -167,10 +167,9 @@ async fn tcp_client_tx_loop( match interval { Some(iv) => { - next_send += iv; let now = Instant::now(); - if next_send > now { - tokio::time::sleep(next_send - now).await; + if let Some(delay) = bandwidth::advance_next_send(&mut next_send, iv, now) { + tokio::time::sleep(delay).await; } } None => { @@ -317,10 +316,9 @@ async fn udp_client_tx_loop( match interval { Some(iv) => { - next_send += iv; let now = Instant::now(); - if next_send > now { - tokio::time::sleep(next_send - now).await; + if let Some(delay) = bandwidth::advance_next_send(&mut next_send, iv, now) { + tokio::time::sleep(delay).await; } } None => { diff --git a/src/cpu.rs b/src/cpu.rs index ab86655..456bb0c 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1,7 +1,7 @@ //! Lightweight CPU usage measurement. //! //! Returns the system-wide CPU usage as a percentage (0-100). -//! Works on macOS and Linux without external dependencies. +//! Works on macOS, Linux, Windows, and FreeBSD without external dependencies. use std::sync::atomic::{AtomicU8, Ordering}; use std::time::Duration; @@ -93,7 +93,82 @@ fn get_cpu_times() -> (u64, u64) { (0, 0) } -#[cfg(not(any(target_os = "linux", target_os = "macos")))] +#[cfg(target_os = "windows")] +fn get_cpu_times() -> (u64, u64) { + #[repr(C)] + #[derive(Default)] + struct FILETIME { + dwLowDateTime: u32, + dwHighDateTime: u32, + } + + impl FILETIME { + fn to_u64(&self) -> u64 { + (self.dwHighDateTime as u64) << 32 | self.dwLowDateTime as u64 + } + } + + extern "system" { + fn GetSystemTimes( + lpIdleTime: *mut FILETIME, + lpKernelTime: *mut FILETIME, + lpUserTime: *mut FILETIME, + ) -> i32; + } + + let mut idle = FILETIME::default(); + let mut kernel = FILETIME::default(); + let mut user = FILETIME::default(); + + // SAFETY: We pass valid pointers to stack-allocated FILETIME structs. + // GetSystemTimes is a well-documented Win32 API that writes into these + // output parameters. A non-zero return value indicates success. + let ret = unsafe { GetSystemTimes(&mut idle, &mut kernel, &mut user) }; + + if ret != 0 { + let idle_ticks = idle.to_u64(); + // Kernel time includes idle time on Windows, so total = kernel + user. + let total_ticks = kernel.to_u64() + user.to_u64(); + (total_ticks, idle_ticks) + } else { + (0, 0) + } +} + +#[cfg(target_os = "freebsd")] +fn get_cpu_times() -> (u64, u64) { + // kern.cp_time returns: user nice system interrupt idle + if let Ok(output) = std::process::Command::new("sysctl") + .arg("-n") + .arg("kern.cp_time") + .output() + { + if output.status.success() { + let text = String::from_utf8_lossy(&output.stdout); + let parts: Vec = text + .split_whitespace() + .filter_map(|s| s.parse().ok()) + .collect(); + if parts.len() >= 5 { + let user = parts[0]; + let nice = parts[1]; + let system = parts[2]; + let interrupt = parts[3]; + let idle = parts[4]; + let total = user + nice + system + interrupt + idle; + return (total, idle); + } + } + } + (0, 0) +} + +#[cfg(not(any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "freebsd", +)))] fn get_cpu_times() -> (u64, u64) { (0, 0) // Unsupported platform } @@ -116,7 +191,12 @@ mod tests { fn test_cpu_times_returns_nonzero() { let (total, idle) = get_cpu_times(); // On supported platforms, total should be > 0 - if cfg!(any(target_os = "linux", target_os = "macos")) { + if cfg!(any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "freebsd", + )) { assert!(total > 0, "CPU total ticks should be > 0"); assert!(idle <= total, "idle should be <= total"); } diff --git a/src/server.rs b/src/server.rs index a46c269..778772c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -565,10 +565,9 @@ async fn tcp_tx_loop_inner( match interval { Some(iv) => { - next_send += iv; let now = Instant::now(); - if next_send > now { - tokio::time::sleep(next_send - now).await; + if let Some(delay) = bandwidth::advance_next_send(&mut next_send, iv, now) { + tokio::time::sleep(delay).await; } } None => { @@ -805,10 +804,9 @@ async fn udp_tx_loop( match interval { Some(iv) => { - next_send += iv; let now = Instant::now(); - if next_send > now { - tokio::time::sleep(next_send - now).await; + if let Some(delay) = bandwidth::advance_next_send(&mut next_send, iv, now) { + tokio::time::sleep(delay).await; } } None => {