From 1259e6329501de70c080559e52a0dd568ccd5bf8 Mon Sep 17 00:00:00 2001 From: David Osipov Date: Mon, 23 Mar 2026 12:40:10 +0400 Subject: [PATCH 1/7] Add OpenBSD build job to CI workflows for x86_64 and aarch64 architectures --- .github/workflows/openbsd-build.yml | 69 +++++++++++++++++++++++++++++ .github/workflows/release.yml | 54 +++++++++++++++++++++- 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/openbsd-build.yml diff --git a/.github/workflows/openbsd-build.yml b/.github/workflows/openbsd-build.yml new file mode 100644 index 0000000..90ca265 --- /dev/null +++ b/.github/workflows/openbsd-build.yml @@ -0,0 +1,69 @@ +name: Build telemt for OpenBSD + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: openbsd-build-${{ github.ref }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: OpenBSD ${{ matrix.arch }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + artifact: telemt-openbsd-x86_64 + rustflags: -C target-cpu=x86-64-v2 -C opt-level=3 + - arch: aarch64 + artifact: telemt-openbsd-aarch64 + rustflags: -C target-cpu=cortex-a53 -C target-feature=+aes,+pmull,+sha2,+sha1,+crc -C opt-level=3 + + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 1 + persist-credentials: false + clean: true + submodules: false + + - name: Compile in OpenBSD VM + uses: vmactions/openbsd-vm@3fafb45f2e2e696249c583835939323fe1c3448c # v1 + with: + release: "7.8" + arch: ${{ matrix.arch }} + usesh: true + sync: sshfs + envs: RUSTFLAGS CARGO_TERM_COLOR + prepare: | + pkg_add rust + run: | + set -e + cargo build --release --locked --verbose + env: + RUSTFLAGS: ${{ matrix.rustflags }} + + - name: Verify artifact exists + run: test -f target/release/telemt + + - name: Upload artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ matrix.artifact }} + path: target/release/telemt + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index def299d..086914d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -206,6 +206,58 @@ jobs: dist/${{ matrix.asset }}.tar.gz dist/${{ matrix.asset }}.sha256 +# ========================== +# OpenBSD +# ========================== + build-openbsd: + name: OpenBSD ${{ matrix.arch }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + asset: telemt-x86_64-openbsd + rustflags: -C target-cpu=x86-64-v2 -C opt-level=3 + - arch: aarch64 + asset: telemt-aarch64-openbsd + rustflags: -C target-cpu=cortex-a53 -C target-feature=+aes,+pmull,+sha2,+sha1,+crc -C opt-level=3 + + steps: + - uses: actions/checkout@v4 + + - name: Build in OpenBSD VM + uses: vmactions/openbsd-vm@v1 + with: + release: "7.8" + arch: ${{ matrix.arch }} + usesh: true + sync: sshfs + envs: RUSTFLAGS CARGO_TERM_COLOR + prepare: | + pkg_add rust + run: | + set -e + cargo build --release --locked --verbose + env: + RUSTFLAGS: ${{ matrix.rustflags }} + + - name: Package + run: | + mkdir -p dist + cp target/release/${{ env.BINARY_NAME }} dist/${{ env.BINARY_NAME }}-${{ matrix.arch }}-unknown-openbsd + cd dist + tar -czf ${{ matrix.asset }}.tar.gz ${{ env.BINARY_NAME }}-${{ matrix.arch }}-unknown-openbsd + sha256sum ${{ matrix.asset }}.tar.gz > ${{ matrix.asset }}.sha256 + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset }} + path: | + dist/${{ matrix.asset }}.tar.gz + dist/${{ matrix.asset }}.sha256 + # ========================== # Docker # ========================== @@ -261,7 +313,7 @@ jobs: release: name: Release runs-on: ubuntu-latest - needs: [build-gnu, build-musl] + needs: [build-gnu, build-musl, build-openbsd] permissions: contents: write From 9aae874735213dd09da2368e1db1e7839acdf6c6 Mon Sep 17 00:00:00 2001 From: David Osipov Date: Mon, 23 Mar 2026 12:47:05 +0400 Subject: [PATCH 2/7] Fix cargo check command to remove unnecessary --lib flag --- .github/workflows/rust.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index b245679..d7f67e0 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -40,7 +40,7 @@ jobs: ${{ runner.os }}-cargo- - name: Compile (no tests) - run: cargo check --workspace --all-features --lib --bins --verbose + run: cargo check --workspace --all-features --bins --verbose - name: Run tests (single pass) run: cargo test --workspace --all-features --verbose From cf75a78b93042b7e5e713fed416055fee239ef96 Mon Sep 17 00:00:00 2001 From: David Osipov Date: Mon, 23 Mar 2026 13:06:47 +0400 Subject: [PATCH 3/7] Update .github/workflows/release.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 086914d..436ef43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -219,10 +219,10 @@ jobs: include: - arch: x86_64 asset: telemt-x86_64-openbsd - rustflags: -C target-cpu=x86-64-v2 -C opt-level=3 + rustflags: -C opt-level=3 - arch: aarch64 asset: telemt-aarch64-openbsd - rustflags: -C target-cpu=cortex-a53 -C target-feature=+aes,+pmull,+sha2,+sha1,+crc -C opt-level=3 + rustflags: -C opt-level=3 steps: - uses: actions/checkout@v4 From 8fdc1b1ea07e364444062d54ced1d5aad599d128 Mon Sep 17 00:00:00 2001 From: David Osipov Date: Mon, 23 Mar 2026 13:06:55 +0400 Subject: [PATCH 4/7] Update .github/workflows/openbsd-build.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/openbsd-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/openbsd-build.yml b/.github/workflows/openbsd-build.yml index 90ca265..5b7f3bc 100644 --- a/.github/workflows/openbsd-build.yml +++ b/.github/workflows/openbsd-build.yml @@ -24,10 +24,10 @@ jobs: include: - arch: x86_64 artifact: telemt-openbsd-x86_64 - rustflags: -C target-cpu=x86-64-v2 -C opt-level=3 + rustflags: -C opt-level=3 - arch: aarch64 artifact: telemt-openbsd-aarch64 - rustflags: -C target-cpu=cortex-a53 -C target-feature=+aes,+pmull,+sha2,+sha1,+crc -C opt-level=3 + rustflags: -C opt-level=3 permissions: contents: read From 9a3ddeec01b40024fcce42d01c9a73b03ce58388 Mon Sep 17 00:00:00 2001 From: David Osipov Date: Mon, 23 Mar 2026 13:07:07 +0400 Subject: [PATCH 5/7] Update .github/workflows/release.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 436ef43..1012793 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -239,6 +239,15 @@ jobs: pkg_add rust run: | set -e + RUSTC_VERSION=$(rustc --version | awk '{print $2}') + RUSTC_MAJOR=$(echo "$RUSTC_VERSION" | cut -d. -f1) + RUSTC_MINOR=$(echo "$RUSTC_VERSION" | cut -d. -f2) + REQUIRED_MAJOR=1 + REQUIRED_MINOR=85 + if [ "$RUSTC_MAJOR" -lt "$REQUIRED_MAJOR" ] || { [ "$RUSTC_MAJOR" -eq "$REQUIRED_MAJOR" ] && [ "$RUSTC_MINOR" -lt "$REQUIRED_MINOR" ]; }; then + echo "rustc ${REQUIRED_MAJOR}.${REQUIRED_MINOR}.0 or newer is required for this project (found ${RUSTC_VERSION})." + exit 1 + fi cargo build --release --locked --verbose env: RUSTFLAGS: ${{ matrix.rustflags }} From aa462af2a7ab3be491664768692607a3b0a8b089 Mon Sep 17 00:00:00 2001 From: David Osipov Date: Mon, 23 Mar 2026 13:07:18 +0400 Subject: [PATCH 6/7] Update .github/workflows/openbsd-build.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/openbsd-build.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/openbsd-build.yml b/.github/workflows/openbsd-build.yml index 5b7f3bc..f2fafd7 100644 --- a/.github/workflows/openbsd-build.yml +++ b/.github/workflows/openbsd-build.yml @@ -53,6 +53,17 @@ jobs: pkg_add rust run: | set -e + required_major=1 + required_minor=85 + rustc_version="$(rustc --version | awk '{print $2}')" + rustc_major="${rustc_version%%.*}" + rustc_rest="${rustc_version#*.}" + rustc_minor="${rustc_rest%%.*}" + if [ "$rustc_major" -lt "$required_major" ] || { [ "$rustc_major" -eq "$required_major" ] && [ "$rustc_minor" -lt "$required_minor" ]; }; then + echo "Installed rustc version ($rustc_version) is older than the required minimum ${required_major}.${required_minor}.x for this project (e.g., edition = \"2024\")." + echo "Please update the OpenBSD rust package or adjust the workflow to use a newer toolchain." + exit 1 + fi cargo build --release --locked --verbose env: RUSTFLAGS: ${{ matrix.rustflags }} From c8ba307780c6db99b647c5a0537fa289fb46d9f2 Mon Sep 17 00:00:00 2001 From: David Osipov Date: Mon, 23 Mar 2026 13:21:14 +0400 Subject: [PATCH 7/7] Add user-specific quota retry sleep allocation tracking in tests --- src/proxy/relay.rs | 30 ++++++- ...retry_allocation_latency_security_tests.rs | 80 +++++++++++++++++-- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/proxy/relay.rs b/src/proxy/relay.rs index 55f1385..90b46d9 100644 --- a/src/proxy/relay.rs +++ b/src/proxy/relay.rs @@ -293,6 +293,8 @@ const QUOTA_CONTENTION_RETRY_MAX_INTERVAL: Duration = Duration::from_millis(64); #[cfg(test)] static QUOTA_RETRY_SLEEP_ALLOCS: AtomicU64 = AtomicU64::new(0); #[cfg(test)] +static QUOTA_RETRY_SLEEP_ALLOCS_BY_USER: OnceLock> = OnceLock::new(); +#[cfg(test)] static QUOTA_USER_LOCK_EVICTOR_SPAWN_COUNT: AtomicU64 = AtomicU64::new(0); #[cfg(test)] @@ -300,11 +302,23 @@ pub(crate) fn reset_quota_retry_sleep_allocs_for_tests() { QUOTA_RETRY_SLEEP_ALLOCS.store(0, Ordering::Relaxed); } +#[cfg(test)] +pub(crate) fn reset_quota_retry_sleep_allocs_for_user_for_tests(user: &str) { + let map = QUOTA_RETRY_SLEEP_ALLOCS_BY_USER.get_or_init(DashMap::new); + map.remove(user); +} + #[cfg(test)] pub(crate) fn quota_retry_sleep_allocs_for_tests() -> u64 { QUOTA_RETRY_SLEEP_ALLOCS.load(Ordering::Relaxed) } +#[cfg(test)] +pub(crate) fn quota_retry_sleep_allocs_for_user_for_tests(user: &str) -> u64 { + let map = QUOTA_RETRY_SLEEP_ALLOCS_BY_USER.get_or_init(DashMap::new); + map.get(user).map(|v| *v.value()).unwrap_or(0) +} + #[inline] fn quota_contention_retry_delay(retry_attempt: u8) -> Duration { let shift = u32::from(retry_attempt.min(5)); @@ -329,12 +343,22 @@ fn poll_quota_retry_sleep( sleep_slot: &mut Option>>, wake_scheduled: &mut bool, retry_attempt: &mut u8, + user: &str, cx: &mut Context<'_>, ) { + #[cfg(not(test))] + let _ = user; + if !*wake_scheduled { *wake_scheduled = true; #[cfg(test)] - QUOTA_RETRY_SLEEP_ALLOCS.fetch_add(1, Ordering::Relaxed); + { + QUOTA_RETRY_SLEEP_ALLOCS.fetch_add(1, Ordering::Relaxed); + let map = QUOTA_RETRY_SLEEP_ALLOCS_BY_USER.get_or_init(DashMap::new); + map.entry(user.to_string()) + .and_modify(|count| *count = count.saturating_add(1)) + .or_insert(1); + } *sleep_slot = Some(Box::pin(tokio::time::sleep(quota_contention_retry_delay( *retry_attempt, )))); @@ -465,6 +489,7 @@ impl AsyncRead for StatsIo { &mut this.quota_read_retry_sleep, &mut this.quota_read_wake_scheduled, &mut this.quota_read_retry_attempt, + &this.user, cx, ); return Poll::Pending; @@ -482,6 +507,7 @@ impl AsyncRead for StatsIo { &mut this.quota_read_retry_sleep, &mut this.quota_read_wake_scheduled, &mut this.quota_read_retry_attempt, + &this.user, cx, ); return Poll::Pending; @@ -570,6 +596,7 @@ impl AsyncWrite for StatsIo { &mut this.quota_write_retry_sleep, &mut this.quota_write_wake_scheduled, &mut this.quota_write_retry_attempt, + &this.user, cx, ); return Poll::Pending; @@ -587,6 +614,7 @@ impl AsyncWrite for StatsIo { &mut this.quota_write_retry_sleep, &mut this.quota_write_wake_scheduled, &mut this.quota_write_retry_attempt, + &this.user, cx, ); return Poll::Pending; diff --git a/src/proxy/tests/relay_quota_retry_allocation_latency_security_tests.rs b/src/proxy/tests/relay_quota_retry_allocation_latency_security_tests.rs index 447a090..0cb7348 100644 --- a/src/proxy/tests/relay_quota_retry_allocation_latency_security_tests.rs +++ b/src/proxy/tests/relay_quota_retry_allocation_latency_security_tests.rs @@ -49,7 +49,7 @@ async fn tdd_single_pending_timer_does_not_allocate_on_each_repoll() { .try_lock() .expect("test must hold local lock to force retry scheduling"); - reset_quota_retry_sleep_allocs_for_tests(); + reset_quota_retry_sleep_allocs_for_user_for_tests(&user); let mut io = StatsIo::new( tokio::io::sink(), @@ -65,12 +65,12 @@ async fn tdd_single_pending_timer_does_not_allocate_on_each_repoll() { let first = Pin::new(&mut io).poll_write(&mut cx, &[0xA1]); assert!(first.is_pending()); - let allocs_after_first = quota_retry_sleep_allocs_for_tests(); + let allocs_after_first = quota_retry_sleep_allocs_for_user_for_tests(&io.user); let ptr_after_first = sleep_slot_ptr(&io.quota_write_retry_sleep); let second = Pin::new(&mut io).poll_write(&mut cx, &[0xA2]); assert!(second.is_pending()); - let allocs_after_second = quota_retry_sleep_allocs_for_tests(); + let allocs_after_second = quota_retry_sleep_allocs_for_user_for_tests(&io.user); let ptr_after_second = sleep_slot_ptr(&io.quota_write_retry_sleep); assert_eq!(allocs_after_first, 1, "first pending poll must allocate one timer"); @@ -96,7 +96,7 @@ async fn tdd_retry_cycle_allocates_once_per_fired_timer_cycle_not_per_poll() { .try_lock() .expect("test must hold local lock to keep write path pending"); - reset_quota_retry_sleep_allocs_for_tests(); + reset_quota_retry_sleep_allocs_for_user_for_tests(&user); let mut io = StatsIo::new( tokio::io::sink(), @@ -126,7 +126,7 @@ async fn tdd_retry_cycle_allocates_once_per_fired_timer_cycle_not_per_poll() { tokio::time::sleep(Duration::from_millis(1)).await; } - let allocs = quota_retry_sleep_allocs_for_tests(); + let allocs = quota_retry_sleep_allocs_for_user_for_tests(&io.user); assert!(allocs >= 2, "multiple fired cycles should allocate multiple timers"); assert!( allocs < polls, @@ -146,7 +146,7 @@ async fn adversarial_backoff_latency_envelope_stays_bounded_under_contention() { .try_lock() .expect("test must hold local lock for sustained contention"); - reset_quota_retry_sleep_allocs_for_tests(); + reset_quota_retry_sleep_allocs_for_user_for_tests(&user); let mut io = StatsIo::new( tokio::io::sink(), @@ -191,7 +191,7 @@ async fn adversarial_backoff_latency_envelope_stays_bounded_under_contention() { "retry wake gap must remain bounded in test profile; observed max gap={max_gap:?}" ); assert!( - quota_retry_sleep_allocs_for_tests() <= 16, + quota_retry_sleep_allocs_for_user_for_tests(&io.user) <= 16, "allocation cycles must remain bounded during a short contention window" ); @@ -247,3 +247,69 @@ async fn micro_benchmark_release_to_completion_latency_stays_bounded() { "contention release->completion p95 must stay bounded; p95_ms={p95_ms}, samples={samples_ms:?}" ); } + +#[tokio::test] +async fn adversarial_per_user_retry_allocation_counter_isolation_under_parallel_contention() { + let _guard = quota_test_guard(); + + let user_a = format!("retry-alloc-isolation-a-{}", std::process::id()); + let user_b = format!("retry-alloc-isolation-b-{}", std::process::id()); + + let lock_a = quota_user_lock(&user_a); + let lock_b = quota_user_lock(&user_b); + let held_guard_a = lock_a + .try_lock() + .expect("test must hold lock A to force pending scheduling"); + let held_guard_b = lock_b + .try_lock() + .expect("test must hold lock B to force pending scheduling"); + + reset_quota_retry_sleep_allocs_for_tests(); + reset_quota_retry_sleep_allocs_for_user_for_tests(&user_a); + reset_quota_retry_sleep_allocs_for_user_for_tests(&user_b); + + let mut io_a = StatsIo::new( + tokio::io::sink(), + Arc::new(SharedCounters::new()), + Arc::new(Stats::new()), + user_a.clone(), + Some(2048), + Arc::new(AtomicBool::new(false)), + Instant::now(), + ); + let mut io_b = StatsIo::new( + tokio::io::sink(), + Arc::new(SharedCounters::new()), + Arc::new(Stats::new()), + user_b.clone(), + Some(2048), + Arc::new(AtomicBool::new(false)), + Instant::now(), + ); + + let (_wake_counter_a, mut cx_a) = build_context(); + let (_wake_counter_b, mut cx_b) = build_context(); + + let first_a = Pin::new(&mut io_a).poll_write(&mut cx_a, &[0xE1]); + let first_b = Pin::new(&mut io_b).poll_write(&mut cx_b, &[0xE2]); + assert!(first_a.is_pending()); + assert!(first_b.is_pending()); + + assert_eq!( + quota_retry_sleep_allocs_for_user_for_tests(&user_a), + 1, + "user A scoped counter must reflect only user A allocations" + ); + assert_eq!( + quota_retry_sleep_allocs_for_user_for_tests(&user_b), + 1, + "user B scoped counter must reflect only user B allocations" + ); + assert!( + quota_retry_sleep_allocs_for_tests() >= 2, + "global counter remains aggregate and should include both users" + ); + + drop(held_guard_a); + drop(held_guard_b); +}