This commit is contained in:
David Osipov 2026-03-23 13:21:19 +04:00 committed by GitHub
commit ddfe1f6462
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 245 additions and 10 deletions

80
.github/workflows/openbsd-build.yml vendored Normal file
View File

@ -0,0 +1,80 @@
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 opt-level=3
- arch: aarch64
artifact: telemt-openbsd-aarch64
rustflags: -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
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 }}
- 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

View File

@ -206,6 +206,67 @@ 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 opt-level=3
- arch: aarch64
asset: telemt-aarch64-openbsd
rustflags: -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
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 }}
- 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 +322,7 @@ jobs:
release:
name: Release
runs-on: ubuntu-latest
needs: [build-gnu, build-musl]
needs: [build-gnu, build-musl, build-openbsd]
permissions:
contents: write

View File

@ -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

View File

@ -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<DashMap<String, u64>> = 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<Pin<Box<Sleep>>>,
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);
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<S: AsyncRead + Unpin> AsyncRead for StatsIo<S> {
&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<S: AsyncRead + Unpin> AsyncRead for StatsIo<S> {
&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<S: AsyncWrite + Unpin> AsyncWrite for StatsIo<S> {
&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<S: AsyncWrite + Unpin> AsyncWrite for StatsIo<S> {
&mut this.quota_write_retry_sleep,
&mut this.quota_write_wake_scheduled,
&mut this.quota_write_retry_attempt,
&this.user,
cx,
);
return Poll::Pending;

View File

@ -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);
}