Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::os::fd::RawFd;
|
||||
|
||||
pub const FRAME_BYTES_MAX: usize = 1920 * 1080 * 4;
|
||||
|
||||
pub const MAX_CPU_FRAMES: usize = 8;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub struct IoSurfaceHandle;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[derive(Debug)]
|
||||
pub struct D3D11Handle(pub usize);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
unsafe impl Send for D3D11Handle {}
|
||||
#[cfg(target_os = "windows")]
|
||||
unsafe impl Sync for D3D11Handle {}
|
||||
|
||||
pub enum Frame {
|
||||
Cpu(Box<[u8]>),
|
||||
#[cfg(feature = "wgpu")]
|
||||
WgpuTexture {
|
||||
texture: wgpu::Texture,
|
||||
format: wgpu::TextureFormat,
|
||||
dims: (u32, u32),
|
||||
},
|
||||
#[cfg(target_os = "macos")]
|
||||
IoSurface {
|
||||
surface_ref: IoSurfaceHandle,
|
||||
},
|
||||
#[cfg(target_os = "linux")]
|
||||
Dmabuf {
|
||||
fds: Vec<RawFd>,
|
||||
format_modifier: u64,
|
||||
},
|
||||
#[cfg(target_os = "windows")]
|
||||
D3D11Shared {
|
||||
handle: D3D11Handle,
|
||||
key: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FramePoolError {
|
||||
ZeroCapacity,
|
||||
CapacityOverflow,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FramePoolError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ZeroCapacity => write!(f, "frame pool capacity must be greater than zero"),
|
||||
Self::CapacityOverflow => write!(f, "frame pool capacity exceeds usize bounds"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for FramePoolError {}
|
||||
|
||||
struct PoolInner {
|
||||
slots: Vec<Arc<Frame>>,
|
||||
free: Mutex<Vec<usize>>,
|
||||
capacity: usize,
|
||||
acquired_total: AtomicU64,
|
||||
skipped_total: AtomicU64,
|
||||
currently_in_flight: AtomicU64,
|
||||
}
|
||||
|
||||
pub struct FramePool {
|
||||
inner: Arc<PoolInner>,
|
||||
}
|
||||
|
||||
impl FramePool {
|
||||
pub fn from_slots(slots: Vec<Arc<Frame>>) -> Result<Self, FramePoolError> {
|
||||
let capacity = slots.len();
|
||||
assert!(capacity == slots.len());
|
||||
if capacity == 0 {
|
||||
return Err(FramePoolError::ZeroCapacity);
|
||||
}
|
||||
assert!(capacity > 0);
|
||||
|
||||
let mut free = Vec::with_capacity(capacity);
|
||||
for index in 0..capacity {
|
||||
free.push(index);
|
||||
}
|
||||
assert_eq!(free.len(), capacity);
|
||||
|
||||
let inner = PoolInner {
|
||||
slots,
|
||||
free: Mutex::new(free),
|
||||
capacity,
|
||||
acquired_total: AtomicU64::new(0),
|
||||
skipped_total: AtomicU64::new(0),
|
||||
currently_in_flight: AtomicU64::new(0),
|
||||
};
|
||||
Ok(Self {
|
||||
inner: Arc::new(inner),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn try_acquire(&self) -> Option<PooledFrame> {
|
||||
assert!(self.inner.capacity > 0);
|
||||
|
||||
let mut free = self.inner.free.lock();
|
||||
assert!(free.len() <= self.inner.capacity);
|
||||
let index = match free.pop() {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
drop(free);
|
||||
self.inner.skipped_total.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
assert!(index < self.inner.capacity);
|
||||
self.inner.acquired_total.fetch_add(1, Ordering::Relaxed);
|
||||
let after = self
|
||||
.inner
|
||||
.currently_in_flight
|
||||
.fetch_add(1, Ordering::AcqRel)
|
||||
+ 1;
|
||||
assert!(after as usize <= self.inner.capacity);
|
||||
drop(free);
|
||||
|
||||
let frame = Arc::clone(&self.inner.slots[index]);
|
||||
Some(PooledFrame {
|
||||
frame,
|
||||
slot_index: index,
|
||||
pool: Arc::clone(&self.inner),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> usize {
|
||||
assert!(self.inner.capacity > 0);
|
||||
let cap = self.inner.capacity;
|
||||
assert!(cap == self.inner.slots.len());
|
||||
cap
|
||||
}
|
||||
|
||||
pub fn acquired_total(&self) -> u64 {
|
||||
assert!(self.inner.capacity > 0);
|
||||
let total = self.inner.acquired_total.load(Ordering::Relaxed);
|
||||
assert!(total >= self.inner.currently_in_flight.load(Ordering::Relaxed));
|
||||
total
|
||||
}
|
||||
|
||||
pub fn skipped_total(&self) -> u64 {
|
||||
assert!(self.inner.capacity > 0);
|
||||
let in_flight = self.inner.currently_in_flight.load(Ordering::Relaxed);
|
||||
assert!(in_flight as usize <= self.inner.capacity);
|
||||
self.inner.skipped_total.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn currently_in_flight(&self) -> u64 {
|
||||
let in_flight = self.inner.currently_in_flight.load(Ordering::Acquire);
|
||||
assert!(in_flight as usize <= self.inner.capacity);
|
||||
assert!(in_flight <= self.inner.acquired_total.load(Ordering::Relaxed));
|
||||
in_flight
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PooledFrame {
|
||||
frame: Arc<Frame>,
|
||||
slot_index: usize,
|
||||
pool: Arc<PoolInner>,
|
||||
}
|
||||
|
||||
impl PooledFrame {
|
||||
pub fn frame(&self) -> &Arc<Frame> {
|
||||
assert!(self.slot_index < self.pool.capacity);
|
||||
assert!(Arc::strong_count(&self.frame) >= 2);
|
||||
&self.frame
|
||||
}
|
||||
|
||||
pub fn slot_index(&self) -> usize {
|
||||
assert!(self.slot_index < self.pool.capacity);
|
||||
let idx = self.slot_index;
|
||||
assert!(idx < self.pool.slots.len());
|
||||
idx
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PooledFrame {
|
||||
fn drop(&mut self) {
|
||||
assert!(self.slot_index < self.pool.capacity);
|
||||
|
||||
let mut free = self.pool.free.lock();
|
||||
assert!(free.len() < self.pool.capacity);
|
||||
let before = self.pool.currently_in_flight.load(Ordering::Acquire);
|
||||
assert!(before >= 1);
|
||||
assert!(before as usize <= self.pool.capacity);
|
||||
free.push(self.slot_index);
|
||||
let after = self.pool.currently_in_flight.fetch_sub(1, Ordering::AcqRel) - 1;
|
||||
assert!(after as usize <= self.pool.capacity);
|
||||
drop(free);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CpuFrameBuilder;
|
||||
|
||||
impl CpuFrameBuilder {
|
||||
pub fn build_pool(bytes_per_slot: usize) -> Result<FramePool, FramePoolError> {
|
||||
assert!(bytes_per_slot > 0);
|
||||
assert!(bytes_per_slot <= FRAME_BYTES_MAX);
|
||||
if bytes_per_slot == 0 {
|
||||
return Err(FramePoolError::ZeroCapacity);
|
||||
}
|
||||
|
||||
let mut slots: Vec<Arc<Frame>> = Vec::with_capacity(MAX_CPU_FRAMES);
|
||||
for _ in 0..MAX_CPU_FRAMES {
|
||||
let buf: Box<[u8]> = vec![0u8; bytes_per_slot].into_boxed_slice();
|
||||
assert_eq!(buf.len(), bytes_per_slot);
|
||||
slots.push(Arc::new(Frame::Cpu(buf)));
|
||||
}
|
||||
assert_eq!(slots.len(), MAX_CPU_FRAMES);
|
||||
FramePool::from_slots(slots)
|
||||
}
|
||||
|
||||
pub fn build_pool_with_capacity(
|
||||
capacity: usize,
|
||||
bytes_per_slot: usize,
|
||||
) -> Result<FramePool, FramePoolError> {
|
||||
assert!(bytes_per_slot <= FRAME_BYTES_MAX);
|
||||
if capacity == 0 {
|
||||
return Err(FramePoolError::ZeroCapacity);
|
||||
}
|
||||
assert!(capacity > 0);
|
||||
let mut slots: Vec<Arc<Frame>> = Vec::with_capacity(capacity);
|
||||
for _ in 0..capacity {
|
||||
let buf: Box<[u8]> = vec![0u8; bytes_per_slot].into_boxed_slice();
|
||||
assert_eq!(buf.len(), bytes_per_slot);
|
||||
slots.push(Arc::new(Frame::Cpu(buf)));
|
||||
}
|
||||
assert_eq!(slots.len(), capacity);
|
||||
FramePool::from_slots(slots)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Barrier;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SMALL_SLOT_BYTES: usize = 64;
|
||||
|
||||
fn small_pool(capacity: usize) -> FramePool {
|
||||
CpuFrameBuilder::build_pool_with_capacity(capacity, SMALL_SLOT_BYTES)
|
||||
.expect("non-zero capacity")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquire_up_to_capacity_then_returns_none() {
|
||||
let pool = small_pool(4);
|
||||
let mut held = Vec::new();
|
||||
for expected in 0..4 {
|
||||
let frame = pool.try_acquire().expect("slot must be available");
|
||||
assert_eq!(pool.currently_in_flight() as usize, expected + 1);
|
||||
held.push(frame);
|
||||
}
|
||||
assert_eq!(pool.acquired_total(), 4);
|
||||
assert!(pool.try_acquire().is_none());
|
||||
assert_eq!(pool.skipped_total(), 1);
|
||||
assert_eq!(pool.currently_in_flight(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_releases_slot_back_to_pool() {
|
||||
let pool = small_pool(2);
|
||||
let first = pool.try_acquire().expect("first slot");
|
||||
let second = pool.try_acquire().expect("second slot");
|
||||
assert!(pool.try_acquire().is_none());
|
||||
drop(first);
|
||||
let revived = pool.try_acquire().expect("released slot returns");
|
||||
assert_eq!(pool.currently_in_flight(), 2);
|
||||
drop(second);
|
||||
drop(revived);
|
||||
assert_eq!(pool.currently_in_flight(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_capacity_pool_always_returns_none() {
|
||||
match CpuFrameBuilder::build_pool_with_capacity(0, SMALL_SLOT_BYTES) {
|
||||
Err(err) => assert_eq!(err, FramePoolError::ZeroCapacity),
|
||||
Ok(_) => panic!("zero capacity must be rejected"),
|
||||
}
|
||||
match FramePool::from_slots(Vec::new()) {
|
||||
Err(err) => assert_eq!(err, FramePoolError::ZeroCapacity),
|
||||
Ok(_) => panic!("empty slot vec must be rejected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_frame_has_strong_count_two_then_one() {
|
||||
let pool = small_pool(1);
|
||||
let held = pool.try_acquire().expect("slot must be available");
|
||||
assert_eq!(Arc::strong_count(held.frame()), 2);
|
||||
drop(held);
|
||||
let again = pool.try_acquire().expect("slot returned");
|
||||
assert_eq!(Arc::strong_count(again.frame()), 2);
|
||||
drop(again);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_thread_acquire_release_stress_does_not_deadlock() {
|
||||
const THREADS: usize = 10;
|
||||
const OPS_PER_THREAD: usize = 1000;
|
||||
const POOL_CAPACITY: usize = 4;
|
||||
|
||||
let pool = Arc::new(small_pool(POOL_CAPACITY));
|
||||
let barrier = Arc::new(Barrier::new(THREADS));
|
||||
let acquired_observed = Arc::new(AtomicUsize::new(0));
|
||||
let skipped_observed = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let mut handles = Vec::with_capacity(THREADS);
|
||||
for _ in 0..THREADS {
|
||||
let pool = Arc::clone(&pool);
|
||||
let barrier = Arc::clone(&barrier);
|
||||
let acquired_observed = Arc::clone(&acquired_observed);
|
||||
let skipped_observed = Arc::clone(&skipped_observed);
|
||||
handles.push(thread::spawn(move || {
|
||||
barrier.wait();
|
||||
let mut local_acquired: usize = 0;
|
||||
let mut local_skipped: usize = 0;
|
||||
for _ in 0..OPS_PER_THREAD {
|
||||
match pool.try_acquire() {
|
||||
Some(frame) => {
|
||||
local_acquired += 1;
|
||||
drop(frame);
|
||||
}
|
||||
None => {
|
||||
local_skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
acquired_observed.fetch_add(local_acquired, Ordering::Relaxed);
|
||||
skipped_observed.fetch_add(local_skipped, Ordering::Relaxed);
|
||||
}));
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
for handle in handles {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"stress test exceeded 30s budget — likely deadlock"
|
||||
);
|
||||
handle.join().expect("worker panicked");
|
||||
}
|
||||
|
||||
let total = pool.acquired_total() + pool.skipped_total();
|
||||
assert_eq!(total as usize, THREADS * OPS_PER_THREAD);
|
||||
assert_eq!(
|
||||
pool.acquired_total() as usize,
|
||||
acquired_observed.load(Ordering::Relaxed)
|
||||
);
|
||||
assert_eq!(
|
||||
pool.skipped_total() as usize,
|
||||
skipped_observed.load(Ordering::Relaxed)
|
||||
);
|
||||
assert_eq!(pool.currently_in_flight(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_counter_increments_only_on_empty_pool() {
|
||||
let pool = small_pool(1);
|
||||
let held = pool.try_acquire().expect("first slot");
|
||||
assert!(pool.try_acquire().is_none());
|
||||
assert!(pool.try_acquire().is_none());
|
||||
assert_eq!(pool.skipped_total(), 2);
|
||||
assert_eq!(pool.acquired_total(), 1);
|
||||
drop(held);
|
||||
let _again = pool.try_acquire().expect("slot returned");
|
||||
assert_eq!(pool.acquired_total(), 2);
|
||||
assert_eq!(pool.skipped_total(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_frame_builder_default_capacity_allocates_max_slots() {
|
||||
let pool =
|
||||
CpuFrameBuilder::build_pool(SMALL_SLOT_BYTES).expect("default builder must succeed");
|
||||
assert_eq!(pool.capacity(), MAX_CPU_FRAMES);
|
||||
let mut held = Vec::with_capacity(MAX_CPU_FRAMES);
|
||||
for _ in 0..MAX_CPU_FRAMES {
|
||||
held.push(pool.try_acquire().expect("slot in capacity"));
|
||||
}
|
||||
assert!(pool.try_acquire().is_none());
|
||||
for frame in &held {
|
||||
match frame.frame().as_ref() {
|
||||
Frame::Cpu(buf) => assert_eq!(buf.len(), SMALL_SLOT_BYTES),
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => panic!("CpuFrameBuilder must emit Frame::Cpu variants"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::StagingBackend;
|
||||
use fluxer_gpu_rebuild::{GpuLossCallback, GpuRebuildError};
|
||||
|
||||
pub const MIN_STAGING_BYTES: u64 = 1;
|
||||
pub const MAX_STAGING_BYTES: u64 = 1 << 30;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct WgpuStagingConfig {
|
||||
pub byte_len: u64,
|
||||
}
|
||||
|
||||
impl WgpuStagingConfig {
|
||||
pub fn new(byte_len: u64) -> Self {
|
||||
assert!(byte_len >= MIN_STAGING_BYTES, "byte_len must be positive");
|
||||
assert!(byte_len <= MAX_STAGING_BYTES, "byte_len exceeds sanity cap");
|
||||
Self { byte_len }
|
||||
}
|
||||
}
|
||||
|
||||
struct WgpuStagingResources {
|
||||
buffer: wgpu::Buffer,
|
||||
cpu_mirror: Vec<u8>,
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
pub struct WgpuStagingBackend {
|
||||
config: WgpuStagingConfig,
|
||||
resources: Option<WgpuStagingResources>,
|
||||
}
|
||||
|
||||
impl WgpuStagingBackend {
|
||||
pub fn new(device: &wgpu::Device, config: WgpuStagingConfig) -> Self {
|
||||
assert!(config.byte_len >= MIN_STAGING_BYTES, "config min invariant");
|
||||
assert!(config.byte_len <= MAX_STAGING_BYTES, "config max invariant");
|
||||
let resources = build_resources(device, config);
|
||||
Self {
|
||||
config,
|
||||
resources: Some(resources),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_unbuilt(config: WgpuStagingConfig) -> Self {
|
||||
assert!(config.byte_len >= MIN_STAGING_BYTES, "config min invariant");
|
||||
assert!(config.byte_len <= MAX_STAGING_BYTES, "config max invariant");
|
||||
Self {
|
||||
config,
|
||||
resources: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(&self) -> WgpuStagingConfig {
|
||||
assert!(
|
||||
self.config.byte_len >= MIN_STAGING_BYTES,
|
||||
"config min invariant"
|
||||
);
|
||||
assert!(
|
||||
self.config.byte_len <= MAX_STAGING_BYTES,
|
||||
"config max invariant"
|
||||
);
|
||||
self.config
|
||||
}
|
||||
|
||||
pub fn is_built(&self) -> bool {
|
||||
let built = self.resources.is_some();
|
||||
assert!(
|
||||
self.config.byte_len >= MIN_STAGING_BYTES,
|
||||
"config min while introspecting"
|
||||
);
|
||||
assert!(
|
||||
self.config.byte_len <= MAX_STAGING_BYTES,
|
||||
"config max while introspecting"
|
||||
);
|
||||
built
|
||||
}
|
||||
|
||||
pub fn buffer(&self) -> Option<&wgpu::Buffer> {
|
||||
let buf = self.resources.as_ref().map(|r| &r.buffer);
|
||||
assert_eq!(
|
||||
buf.is_some(),
|
||||
self.is_built(),
|
||||
"buffer presence must align with built state",
|
||||
);
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
impl StagingBackend for WgpuStagingBackend {
|
||||
fn write<F: FnOnce(&mut [u8])>(&mut self, fill: F) {
|
||||
let Some(resources) = self.resources.as_mut() else {
|
||||
return;
|
||||
};
|
||||
fill(&mut resources.cpu_mirror);
|
||||
resources.ready = true;
|
||||
}
|
||||
|
||||
fn read<R, F: FnOnce(&[u8]) -> R>(&self, read: F) -> R {
|
||||
let empty: &[u8] = &[];
|
||||
match self.resources.as_ref() {
|
||||
Some(r) => read(&r.cpu_mirror),
|
||||
None => read(empty),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
match self.resources.as_ref() {
|
||||
Some(r) => r.ready,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_idle(&self) -> bool {
|
||||
match self.resources.as_ref() {
|
||||
Some(r) => !r.ready,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuLossCallback for WgpuStagingBackend {
|
||||
fn release(&mut self) {
|
||||
assert!(
|
||||
self.config.byte_len >= MIN_STAGING_BYTES,
|
||||
"release config min invariant"
|
||||
);
|
||||
assert!(
|
||||
self.config.byte_len <= MAX_STAGING_BYTES,
|
||||
"release config max invariant"
|
||||
);
|
||||
self.resources = None;
|
||||
assert!(!self.is_built(), "release postcondition: must be unbuilt");
|
||||
}
|
||||
|
||||
fn rebuild(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
_queue: &wgpu::Queue,
|
||||
) -> Result<(), GpuRebuildError> {
|
||||
assert!(
|
||||
self.config.byte_len >= MIN_STAGING_BYTES,
|
||||
"rebuild config min invariant"
|
||||
);
|
||||
assert!(
|
||||
self.config.byte_len <= MAX_STAGING_BYTES,
|
||||
"rebuild config max invariant"
|
||||
);
|
||||
if self.resources.is_some() {
|
||||
return Err(GpuRebuildError::OwnerInvariantBroken {
|
||||
reason: "rebuild without prior release",
|
||||
});
|
||||
}
|
||||
let resources = build_resources(device, self.config);
|
||||
self.resources = Some(resources);
|
||||
assert!(self.is_built(), "rebuild postcondition: must be built");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.is_built()
|
||||
}
|
||||
|
||||
fn debug_label(&self) -> &'static str {
|
||||
"screen_frame_bus.wgpu_staging_backend"
|
||||
}
|
||||
}
|
||||
|
||||
fn build_resources(device: &wgpu::Device, config: WgpuStagingConfig) -> WgpuStagingResources {
|
||||
assert!(
|
||||
config.byte_len >= MIN_STAGING_BYTES,
|
||||
"build_resources min invariant"
|
||||
);
|
||||
assert!(
|
||||
config.byte_len <= MAX_STAGING_BYTES,
|
||||
"build_resources max invariant"
|
||||
);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("screen_frame_bus.wgpu_staging_buffer"),
|
||||
size: config.byte_len,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let cpu_mirror = vec![0u8; config.byte_len as usize];
|
||||
assert_eq!(
|
||||
cpu_mirror.len() as u64,
|
||||
config.byte_len,
|
||||
"cpu mirror must match configured byte len",
|
||||
);
|
||||
WgpuStagingResources {
|
||||
buffer,
|
||||
cpu_mirror,
|
||||
ready: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_acquire_device() -> Option<(wgpu::Device, wgpu::Queue, wgpu::Instance)> {
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
descriptor.backends = wgpu::Backends::all() | wgpu::Backends::SECONDARY;
|
||||
let instance = wgpu::Instance::new(descriptor);
|
||||
let adapter = pollster_block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::default(),
|
||||
force_fallback_adapter: false,
|
||||
compatible_surface: None,
|
||||
}))
|
||||
.ok()?;
|
||||
let device_result = pollster_block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("screen_frame_bus.wgpu_staging_backend.test_device"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_limits: wgpu::Limits::default(),
|
||||
memory_hints: wgpu::MemoryHints::default(),
|
||||
trace: wgpu::Trace::Off,
|
||||
experimental_features: wgpu::ExperimentalFeatures::default(),
|
||||
}));
|
||||
match device_result {
|
||||
Ok((device, queue)) => Some((device, queue, instance)),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pollster_block_on<F: core::future::Future>(fut: F) -> F::Output {
|
||||
futures_executor_block_on(fut)
|
||||
}
|
||||
|
||||
fn futures_executor_block_on<F: core::future::Future>(mut fut: F) -> F::Output {
|
||||
use core::pin::Pin;
|
||||
use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
|
||||
fn raw_waker() -> RawWaker {
|
||||
fn no_op(_: *const ()) {}
|
||||
fn clone(_: *const ()) -> RawWaker {
|
||||
raw_waker()
|
||||
}
|
||||
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, no_op, no_op, no_op);
|
||||
RawWaker::new(core::ptr::null(), &VTABLE)
|
||||
}
|
||||
let waker = unsafe { Waker::from_raw(raw_waker()) };
|
||||
let mut cx = Context::from_waker(&waker);
|
||||
let mut fut = unsafe { Pin::new_unchecked(&mut fut) };
|
||||
loop {
|
||||
match fut.as_mut().poll(&mut cx) {
|
||||
Poll::Ready(out) => return out,
|
||||
Poll::Pending => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{CpuStagingBackend, STAGING_PAIR_LEN, StagingSurfacePair};
|
||||
use fluxer_gpu_rebuild::{GpuLossRegistry, RebuildOutcome};
|
||||
|
||||
struct GpuCtx {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
_instance: wgpu::Instance,
|
||||
}
|
||||
|
||||
fn gpu_ctx() -> Option<GpuCtx> {
|
||||
let acquired = std::panic::catch_unwind(std::panic::AssertUnwindSafe(try_acquire_device));
|
||||
let (device, queue, instance) = match acquired {
|
||||
Ok(Some(triple)) => triple,
|
||||
Ok(None) => return None,
|
||||
Err(_) => return None,
|
||||
};
|
||||
Some(GpuCtx {
|
||||
device,
|
||||
queue,
|
||||
_instance: instance,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_backend_is_ready_only_when_built() {
|
||||
let Some(ctx) = gpu_ctx() else {
|
||||
eprintln!("screen-frame-bus gpu_loss: no wgpu adapter, skipping");
|
||||
return;
|
||||
};
|
||||
let cfg = WgpuStagingConfig::new(256);
|
||||
let mut backend = WgpuStagingBackend::new(&ctx.device, cfg);
|
||||
assert!(
|
||||
GpuLossCallback::is_ready(&backend),
|
||||
"freshly built backend must be ready",
|
||||
);
|
||||
backend.release();
|
||||
assert!(
|
||||
!GpuLossCallback::is_ready(&backend),
|
||||
"released backend must not be ready",
|
||||
);
|
||||
let outcome = backend.rebuild(&ctx.device, &ctx.queue);
|
||||
assert!(outcome.is_ok(), "rebuild on fresh device must succeed");
|
||||
assert!(
|
||||
GpuLossCallback::is_ready(&backend),
|
||||
"rebuilt backend must be ready",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_handles_staging_backend_round_trip() {
|
||||
let Some(ctx) = gpu_ctx() else {
|
||||
eprintln!("screen-frame-bus gpu_loss: no wgpu adapter, skipping");
|
||||
return;
|
||||
};
|
||||
let registry = GpuLossRegistry::new();
|
||||
let backend = Box::new(WgpuStagingBackend::new(
|
||||
&ctx.device,
|
||||
WgpuStagingConfig::new(128),
|
||||
));
|
||||
let _guard = registry.register(backend);
|
||||
let report = registry.handle_device_lost(&ctx.device, &ctx.queue);
|
||||
assert_eq!(report.released_count, 1);
|
||||
assert_eq!(report.rebuilt_count, 1);
|
||||
assert_eq!(report.failed_count, 0);
|
||||
assert!(report.is_total_success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_rebuild_without_release_is_owner_invariant_error() {
|
||||
let Some(ctx) = gpu_ctx() else {
|
||||
eprintln!("screen-frame-bus gpu_loss: no wgpu adapter, skipping");
|
||||
return;
|
||||
};
|
||||
let mut backend = WgpuStagingBackend::new(&ctx.device, WgpuStagingConfig::new(64));
|
||||
let outcome = backend.rebuild(&ctx.device, &ctx.queue);
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
Err(GpuRebuildError::OwnerInvariantBroken { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_between_release_and_rebuild_is_a_noop_and_not_ready() {
|
||||
let Some(ctx) = gpu_ctx() else {
|
||||
eprintln!("screen-frame-bus gpu_loss: no wgpu adapter, skipping");
|
||||
return;
|
||||
};
|
||||
let mut backend = WgpuStagingBackend::new(&ctx.device, WgpuStagingConfig::new(64));
|
||||
backend.release();
|
||||
backend.write(|buf| buf.fill(0xAA));
|
||||
assert!(
|
||||
!<WgpuStagingBackend as StagingBackend>::is_ready(&backend),
|
||||
"write before rebuild must not flip ready",
|
||||
);
|
||||
let observed = backend.read(|buf| buf.len());
|
||||
assert_eq!(observed, 0, "read before rebuild must observe empty mirror");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_pair_with_wgpu_backend_round_trips_cpu_mirror() {
|
||||
let Some(ctx) = gpu_ctx() else {
|
||||
eprintln!("screen-frame-bus gpu_loss: no wgpu adapter, skipping");
|
||||
return;
|
||||
};
|
||||
let cfg = WgpuStagingConfig::new(64);
|
||||
let a = WgpuStagingBackend::new(&ctx.device, cfg);
|
||||
let b = WgpuStagingBackend::new(&ctx.device, cfg);
|
||||
assert_eq!(
|
||||
STAGING_PAIR_LEN, 2,
|
||||
"OBS staging pair is exactly two surfaces"
|
||||
);
|
||||
let mut pair: StagingSurfacePair<WgpuStagingBackend> = StagingSurfacePair::new([a, b]);
|
||||
pair.submit(0, |buf| {
|
||||
buf[0] = 0xDE;
|
||||
buf[1] = 0xAD;
|
||||
})
|
||||
.expect("submit zero must succeed");
|
||||
pair.submit(1, |buf| {
|
||||
buf[0] = 0xBE;
|
||||
buf[1] = 0xEF;
|
||||
})
|
||||
.expect("submit one must succeed");
|
||||
let first = pair
|
||||
.try_map(0, |buf| (buf[0], buf[1]))
|
||||
.expect("map zero ready");
|
||||
assert_eq!(first, (0xDE, 0xAD));
|
||||
let second = pair
|
||||
.try_map(1, |buf| (buf[0], buf[1]))
|
||||
.expect("map one ready");
|
||||
assert_eq!(second, (0xBE, 0xEF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_holds_mixed_packers_and_staging_backends() {
|
||||
let Some(ctx) = gpu_ctx() else {
|
||||
eprintln!("screen-frame-bus gpu_loss: no wgpu adapter, skipping");
|
||||
return;
|
||||
};
|
||||
let registry = GpuLossRegistry::new();
|
||||
let mut guards = Vec::new();
|
||||
for i in 0..4u32 {
|
||||
let bytes = 32u64 << (i % 4);
|
||||
let backend = Box::new(WgpuStagingBackend::new(
|
||||
&ctx.device,
|
||||
WgpuStagingConfig::new(bytes),
|
||||
));
|
||||
guards.push(registry.register(backend));
|
||||
}
|
||||
let report = registry.handle_device_lost(&ctx.device, &ctx.queue);
|
||||
assert_eq!(report.released_count, 4);
|
||||
assert_eq!(report.rebuilt_count, 4);
|
||||
assert_eq!(report.failed_count, 0);
|
||||
for outcome in &report.outcomes {
|
||||
assert!(matches!(outcome, RebuildOutcome::Rebuilt { .. }));
|
||||
}
|
||||
drop(guards);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_backend_still_works_alongside_wgpu_backend() {
|
||||
let cpu = CpuStagingBackend::new(64);
|
||||
assert!(<CpuStagingBackend as StagingBackend>::is_idle(&cpu));
|
||||
assert!(<CpuStagingBackend as StagingBackend>::is_ready(&cpu));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_handle_disappears_after_release_and_reappears_after_rebuild() {
|
||||
let Some(ctx) = gpu_ctx() else {
|
||||
eprintln!("screen-frame-bus gpu_loss: no wgpu adapter, skipping");
|
||||
return;
|
||||
};
|
||||
let mut backend = WgpuStagingBackend::new(&ctx.device, WgpuStagingConfig::new(128));
|
||||
assert!(backend.buffer().is_some(), "buffer present after build");
|
||||
backend.release();
|
||||
assert!(backend.buffer().is_none(), "buffer absent after release");
|
||||
backend
|
||||
.rebuild(&ctx.device, &ctx.queue)
|
||||
.expect("rebuild must succeed");
|
||||
assert!(backend.buffer().is_some(), "buffer present after rebuild");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user