Files
DarkRoom/core/dr-gpu/src/readback.rs
T
dtourolleandClaude Opus 5 7d0fb710a6 Bound a readback by time, so a full-resolution export can finish
Exporting a real 20 MP CR2 failed every time with "readback did not
complete", while the copy itself was perfectly healthy.

The bound was 100,000 non-blocking polls. That sounds generous and is not: a
`Poll` that finds nothing returns immediately, so the loop spent its entire
budget in a few milliseconds. Small transfers — the histogram's 4 KB, a
viewport-sized frame — happened to land inside it. An 80 MB frame never could.

It is a deadline now, thirty seconds, which is the only thing the bound was
ever for: catching a lost device that will never deliver the callback. A
one-millisecond pause after the first sixty-four spins stops the loop
saturating a core for the length of the copy, while keeping a small transfer
as immediate as it was.

Found by developing /home/dtourolle/Downloads/_MG_8596.CR2 through the export
example: 5472×3648 renders in 127 ms and writes all five formats.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:13:17 +02:00

95 lines
4.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Waiting for a buffer mapping without parking the interface.
//!
//! Shared by every transfer off the device — the display bridge, an export, and
//! the histogram's 4 KB of bin counts. It was written once inside `AdjustPass`
//! and the reasoning below is the whole of why it is shaped this way; copying
//! thirty lines of that reasoning into a second caller would have left two
//! copies to keep true of each other.
use crate::{GpuContext, GpuError};
/// How many non-blocking polls a readback gets before it is called failed.
///
/// A bound rather than a spin forever: if the device is lost the map callback
/// never arrives, and an unbounded loop would hang the interface rather than
/// surfacing the error. Set far above any plausible completion — the copies
/// this waits on are milliseconds — so it is reached only when something is
/// wrong.
/// How long a readback may take before it is called failed.
///
/// **A deadline, not an iteration count, and the difference was a real bug.**
/// This was 100,000 non-blocking polls, which sounds generous and is not: a
/// `Poll` that finds nothing returns immediately, so the loop burned through
/// the whole budget in a few milliseconds. Small transfers — the histogram's
/// 4 KB, a viewport-sized frame — happened to complete inside it. A
/// full-resolution export did not: 5472×3648 is 80 MB, and every attempt
/// failed with "readback did not complete" while the copy was still perfectly
/// healthy.
///
/// Generous, because the legitimate worst case is a large export on a slow
/// integrated GPU, and the only thing this bound exists to catch is a lost
/// device that will never deliver the callback at all.
const READBACK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
/// How long to wait between polls once the first few have found nothing.
///
/// Without it this is a busy spin that saturates a core for the duration of
/// the copy. A millisecond is far below the transfer times involved and keeps
/// the thread available to the scheduler.
const POLL_PAUSE: std::time::Duration = std::time::Duration::from_millis(1);
/// Drive the device until a `map_async` callback lands.
///
/// **Polled without blocking, then checked.**
///
/// `PollType::Wait` parks the calling thread until the GPU has finished, and
/// these transfers are called from the UI thread — so that park was a frozen
/// interface for the duration of the copy (~7 ms at 4K for a whole frame).
/// `Poll` drives the same callbacks without sleeping, so the loop below stays
/// interruptible and the mapping still completes.
///
/// The bounded spin matters: a lost device would otherwise never deliver the
/// callback and this would hang the app instead of reporting an error.
pub(crate) fn await_mapping(
ctx: &GpuContext,
rx: &std::sync::mpsc::Receiver<Result<(), wgpu::BufferAsyncError>>,
) -> Result<(), GpuError> {
let mut mapped = None;
let deadline = std::time::Instant::now() + READBACK_DEADLINE;
let mut spins = 0u32;
while std::time::Instant::now() < deadline {
// A poll error is a lost device, which is exactly the case the bounded
// spin exists to escape — returning here reports it immediately rather
// than spinning out the full limit first.
ctx.device
.poll(wgpu::PollType::Poll)
.map_err(|e| GpuError::Readback(e.to_string()))?;
match rx.try_recv() {
Ok(r) => {
mapped = Some(r);
break;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
// The first handful of polls run flat out, so a small
// transfer — the histogram's, most of all — still returns
// without ever sleeping. Only a copy that is genuinely going
// to take a while pays the pause.
spins += 1;
if spins > 64 {
std::thread::sleep(POLL_PAUSE);
}
continue;
}
Err(e) => return Err(GpuError::Readback(e.to_string())),
}
}
mapped
.ok_or_else(|| {
GpuError::Readback(format!(
"readback did not complete within {}s",
READBACK_DEADLINE.as_secs()
))
})?
.map_err(|e| GpuError::Readback(e.to_string()))
}