feat(streaming): cap StreamingConfig initial buffer per memory profile (#331)
StreamingConfig::from_speed_mbps inflates the initial buffer to 2 MB on slow connections (< 1 MB/s) to absorb network jitter. On a healthy desktop that's fine, but on a memory-constrained host the slow link is itself a symptom: when QBZ on a Pi 3B starts allocating ~300 MB of Hi-Res prefetch the kernel swaps, networking stalls, the speed detector reports < 1 MB/s, and the buffer code reacts by allocating ANOTHER 2 MB — exactly the wrong direction. Add a process-wide AtomicUsize cap (defaults to usize::MAX, i.e. no-op) plus a small public set_max_initial_buffer_bytes setter. Refactor from_speed_mbps to clamp through the cap; extract the speed-to-bytes ladder into a pure raw_initial_buffer_for_speed helper plus a from_speed_mbps_with_cap variant that exposes the clamping for unit tests without touching global state. src-tauri/lib.rs's run() wires the cap into qbz-player from qbz-core's MemoryProfile right after logging the profile. Effective on Pi 3B (issue #331): all StreamingConfig::from_speed_mbps results pin to 256 KB regardless of detected speed. Re-export max_initial_buffer_bytes / set_max_initial_buffer_bytes from qbz_player::player so external callers don't need to reach into the private streaming_source module. Adds 5 unit tests: ladder behavior, pass-through when cap is loose, clamp at the LowMemory cap (the issue #331 case), no-op at the Normal cap, and that max_buffer_bytes stays unchanged. cc
This commit is contained in:
3 files changed
+137
-18
No files matched your search
@@ -14,7 +14,8 @@ mod playback_engine;
|
||||
mod streaming_source;
|
||||
|
||||
pub use streaming_source::{
|
||||
BufferWriter, BufferedMediaSource, InMemorySource, IncrementalStreamingSource, StreamingConfig,
|
||||
max_initial_buffer_bytes, set_max_initial_buffer_bytes, BufferWriter, BufferedMediaSource,
|
||||
InMemorySource, IncrementalStreamingSource, StreamingConfig,
|
||||
};
|
||||
|
||||
use rodio::buffer::SamplesBuffer;
|
||||
|
||||
@@ -90,32 +90,85 @@ impl StreamingConfig {
|
||||
/// - Normal (2-5 MB/s): 512KB
|
||||
/// - Slow (1-2 MB/s): 1MB (more buffer to prevent stutter)
|
||||
/// - Very slow (<1 MB/s): 2MB
|
||||
///
|
||||
/// Result is clamped to the process-wide cap configured via
|
||||
/// [`set_max_initial_buffer_bytes`] (typically derived from the host's
|
||||
/// memory profile — see qbz-core's system_capabilities). On
|
||||
/// memory-constrained hosts the slow-connection branches would
|
||||
/// otherwise inflate to 2 MB, which is exactly the wrong direction
|
||||
/// when "slow connection" is itself a symptom of swap thrash
|
||||
/// (issue #331, Pi 3B).
|
||||
pub fn from_speed_mbps(speed_mbps: f64) -> Self {
|
||||
let initial_buffer = if speed_mbps >= 10.0 {
|
||||
256 * 1024 // 256KB - instant start for very fast connections
|
||||
} else if speed_mbps >= 5.0 {
|
||||
384 * 1024 // 384KB
|
||||
} else if speed_mbps >= 2.0 {
|
||||
512 * 1024 // 512KB - default
|
||||
} else if speed_mbps >= 1.0 {
|
||||
1024 * 1024 // 1MB - more buffer for slower connections
|
||||
let cap = MAX_INITIAL_BUFFER_BYTES.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let cfg = Self::from_speed_mbps_with_cap(speed_mbps, cap);
|
||||
|
||||
if cfg.initial_buffer_bytes < raw_initial_buffer_for_speed(speed_mbps) {
|
||||
log::info!(
|
||||
"Dynamic buffer: {:.1} MB/s detected → {}KB (capped from {}KB by host memory profile)",
|
||||
speed_mbps,
|
||||
cfg.initial_buffer_bytes / 1024,
|
||||
raw_initial_buffer_for_speed(speed_mbps) / 1024
|
||||
);
|
||||
} else {
|
||||
2 * 1024 * 1024 // 2MB - maximum buffer for very slow connections
|
||||
};
|
||||
log::info!(
|
||||
"Dynamic buffer: {:.1} MB/s detected → {}KB initial buffer",
|
||||
speed_mbps,
|
||||
cfg.initial_buffer_bytes / 1024
|
||||
);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Dynamic buffer: {:.1} MB/s detected → {}KB initial buffer",
|
||||
speed_mbps,
|
||||
initial_buffer / 1024
|
||||
);
|
||||
cfg
|
||||
}
|
||||
|
||||
/// Pure variant of [`from_speed_mbps`] — derives the speed-based
|
||||
/// initial buffer and clamps to `cap` without touching global state
|
||||
/// or logging. Exposed for unit tests; production callers should use
|
||||
/// `from_speed_mbps`, which reads the process-wide cap.
|
||||
pub fn from_speed_mbps_with_cap(speed_mbps: f64, cap: usize) -> Self {
|
||||
let raw_initial_buffer = raw_initial_buffer_for_speed(speed_mbps);
|
||||
Self {
|
||||
initial_buffer_bytes: initial_buffer,
|
||||
initial_buffer_bytes: raw_initial_buffer.min(cap),
|
||||
max_buffer_bytes: 100 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Speed-driven initial buffer size, before any cap is applied.
|
||||
/// Pure function — used by both `from_speed_mbps` and
|
||||
/// `from_speed_mbps_with_cap` so they share the same ladder.
|
||||
fn raw_initial_buffer_for_speed(speed_mbps: f64) -> usize {
|
||||
if speed_mbps >= 10.0 {
|
||||
256 * 1024 // 256KB - instant start for very fast connections
|
||||
} else if speed_mbps >= 5.0 {
|
||||
384 * 1024 // 384KB
|
||||
} else if speed_mbps >= 2.0 {
|
||||
512 * 1024 // 512KB - default
|
||||
} else if speed_mbps >= 1.0 {
|
||||
1024 * 1024 // 1MB - more buffer for slower connections
|
||||
} else {
|
||||
2 * 1024 * 1024 // 2MB - maximum buffer for very slow connections
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-wide cap for dynamically-derived initial buffer sizes.
|
||||
/// Defaults to `usize::MAX` (no cap) so behavior is unchanged unless the
|
||||
/// host explicitly configures it via [`set_max_initial_buffer_bytes`] —
|
||||
/// typically once at process start, derived from the detected memory
|
||||
/// profile.
|
||||
static MAX_INITIAL_BUFFER_BYTES: std::sync::atomic::AtomicUsize =
|
||||
std::sync::atomic::AtomicUsize::new(usize::MAX);
|
||||
|
||||
/// Set the process-wide cap for `StreamingConfig::from_speed_mbps`.
|
||||
/// Subsequent calls to that constructor clamp their result to this cap.
|
||||
pub fn set_max_initial_buffer_bytes(bytes: usize) {
|
||||
MAX_INITIAL_BUFFER_BYTES.store(bytes, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Read the current cap. Mainly useful for tests.
|
||||
pub fn max_initial_buffer_bytes() -> usize {
|
||||
MAX_INITIAL_BUFFER_BYTES.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Internal state shared between reader and writer
|
||||
struct BufferState {
|
||||
/// Accumulated data from HTTP response
|
||||
@@ -929,6 +982,64 @@ mod tests {
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn raw_initial_buffer_for_speed_follows_documented_ladder() {
|
||||
// Each band of the documented speed ladder produces its own size.
|
||||
assert_eq!(raw_initial_buffer_for_speed(20.0), 256 * 1024);
|
||||
assert_eq!(raw_initial_buffer_for_speed(10.0), 256 * 1024);
|
||||
assert_eq!(raw_initial_buffer_for_speed(7.0), 384 * 1024);
|
||||
assert_eq!(raw_initial_buffer_for_speed(5.0), 384 * 1024);
|
||||
assert_eq!(raw_initial_buffer_for_speed(3.0), 512 * 1024);
|
||||
assert_eq!(raw_initial_buffer_for_speed(2.0), 512 * 1024);
|
||||
assert_eq!(raw_initial_buffer_for_speed(1.5), 1024 * 1024);
|
||||
assert_eq!(raw_initial_buffer_for_speed(1.0), 1024 * 1024);
|
||||
assert_eq!(raw_initial_buffer_for_speed(0.5), 2 * 1024 * 1024);
|
||||
assert_eq!(raw_initial_buffer_for_speed(0.0), 2 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_speed_mbps_with_cap_passes_through_when_under_cap() {
|
||||
// Cap above the raw value: result equals the raw ladder.
|
||||
let cfg = StreamingConfig::from_speed_mbps_with_cap(0.0, 4 * 1024 * 1024);
|
||||
assert_eq!(cfg.initial_buffer_bytes, 2 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_speed_mbps_with_cap_clamps_slow_connection_to_low_memory_cap() {
|
||||
// The case from issue #331: Pi 3B, slow connection because of swap
|
||||
// thrash, would otherwise inflate the buffer to 2 MB. With the
|
||||
// LowMemory profile's 256KB cap applied, we stay at 256KB.
|
||||
let cfg = StreamingConfig::from_speed_mbps_with_cap(0.0, 256 * 1024);
|
||||
assert_eq!(cfg.initial_buffer_bytes, 256 * 1024);
|
||||
|
||||
let cfg = StreamingConfig::from_speed_mbps_with_cap(1.5, 256 * 1024);
|
||||
assert_eq!(cfg.initial_buffer_bytes, 256 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_speed_mbps_with_cap_no_op_for_normal_profile() {
|
||||
// Normal profile cap is 2 MB — equal to the slowest raw band, so
|
||||
// any raw value passes through unchanged.
|
||||
let cap = 2 * 1024 * 1024;
|
||||
for speed in [0.0, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0] {
|
||||
let cfg = StreamingConfig::from_speed_mbps_with_cap(speed, cap);
|
||||
assert_eq!(
|
||||
cfg.initial_buffer_bytes,
|
||||
raw_initial_buffer_for_speed(speed),
|
||||
"cap should not bind for speed={}",
|
||||
speed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_speed_mbps_with_cap_max_buffer_unchanged() {
|
||||
// Whatever the cap, the secondary max_buffer_bytes stays at its
|
||||
// module default; we are only clamping the initial fill target.
|
||||
let cfg = StreamingConfig::from_speed_mbps_with_cap(0.5, 64 * 1024);
|
||||
assert_eq!(cfg.max_buffer_bytes, 100 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_read_write() {
|
||||
let config = StreamingConfig {
|
||||
|
||||
Reference in new issue
Block a user