Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions apps/desktop-gpui/src/main_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1224,6 +1224,9 @@ impl MainWindow {
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<()>> {
if target_thumbnails::captures_paused() {
return None;
}
match kind {
TargetType::Display => {
if self.thumbnails.display_inflight() {
Expand Down Expand Up @@ -1266,9 +1269,13 @@ impl MainWindow {
// `setDisplayThumbnailsSignature` runs
// (`new-main/index.tsx:2617-2620`). Committing the
// signature at `Listed` time instead would mark a sweep
// that never landed as up to date.
// that never landed as up to date. Likewise a sweep that a
// capture hang cut short: it stays stale so the picker
// retries it once the pause lifts.
this.update(cx, |this, cx| {
if let Some(signature) = swept {
if let Some(signature) = swept
&& !target_thumbnails::captures_paused()
{
this.thumbnails.set_display_signature(signature);
}
this.thumbnails.set_display_inflight(false);
Expand Down Expand Up @@ -1313,7 +1320,9 @@ impl MainWindow {
}
}
this.update(cx, |this, cx| {
if let Some(signature) = swept {
if let Some(signature) = swept
&& !target_thumbnails::captures_paused()
{
this.thumbnails.set_window_signature(signature);
}
this.thumbnails.set_window_inflight(false);
Expand Down
159 changes: 152 additions & 7 deletions apps/desktop-gpui/src/target_thumbnails.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,69 @@ pub const THUMBNAIL_STALE_TIME: Duration = Duration::from_secs(10);
/// idle timeout is the deadline that branch would have used anyway.
pub const PREWARM_DELAY: Duration = Duration::from_millis(250);

/// A ScreenCaptureKit request that has not answered by now is treated as hung.
/// A healthy 320x180 thumbnail or shareable-content read takes milliseconds.
const CAPTURE_TIMEOUT: Duration = Duration::from_secs(5);

/// How long thumbnail captures stay paused after one hangs. A hung
/// `SCScreenshotManager` request cannot be cancelled and keeps macOS's capture
/// session (and the purple indicator) open in `replayd`, so retrying on every
/// picker poll would only pile up more of them:
/// https://github.com/CapSoftware/Cap/issues/2339
const CAPTURE_HANG_PAUSE: Duration = Duration::from_secs(5 * 60);

static CAPTURE_PAUSE: CapturePause = CapturePause::new();

pub fn captures_paused() -> bool {
CAPTURE_PAUSE.active_at(std::time::Instant::now())
}

struct CapturePause(std::sync::Mutex<Option<std::time::Instant>>);

impl CapturePause {
const fn new() -> Self {
Self(std::sync::Mutex::new(None))
}

fn active_at(&self, now: std::time::Instant) -> bool {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some_and(|until| now < until)
}

fn start_at(&self, now: std::time::Instant) {
*self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(now + CAPTURE_HANG_PAUSE);
}
}

/// `None` when `future` outlives `limit`, which also starts `pause`. The
/// dropped request may still be pending inside ScreenCaptureKit; this only
/// stops the sweep from waiting on it forever.
async fn within_capture_timeout<T>(
what: &'static str,
limit: Duration,
pause: &CapturePause,
future: impl std::future::Future<Output = T>,
) -> Option<T> {
match tokio::time::timeout(limit, future).await {
Ok(value) => Some(value),
Err(_) => {
tracing::warn!(
what,
timeout_ms = limit.as_millis() as u64,
pause_secs = CAPTURE_HANG_PAUSE.as_secs(),
"thumbnail capture did not answer; pausing thumbnail captures"
);
pause.start_at(std::time::Instant::now());
None
}
}
}

/// A card's images. Both halves are optional and independent: a window can have
/// an icon and no thumbnail (capture refused) or the reverse (no bundle icon).
#[derive(Clone, Default)]
Expand Down Expand Up @@ -461,7 +524,10 @@ where
Fut: std::future::Future<Output = ()>,
{
let joined = tokio::task::spawn_blocking(move || {
match tokio::runtime::Builder::new_current_thread().build() {
match tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
{
Ok(runtime) => runtime.block_on(make()),
Err(error) => tracing::error!("could not start the thumbnail runtime: {error}"),
}
Expand Down Expand Up @@ -496,14 +562,34 @@ async fn capture_displays_inner(events: flume::Sender<DisplayEvent>) {
return;
}

let Some(content) = shareable_content().await else {
let Some(Some(content)) = within_capture_timeout(
"shareable content",
CAPTURE_TIMEOUT,
&CAPTURE_PAUSE,
shareable_content(),
)
.await
else {
return;
};

let total = targets.len();
let mut captured = 0usize;
for (option, display) in targets {
let Some(rgba) = capture_display_thumbnail(&display, content.retained()).await else {
if captures_paused() {
return;
}
let Some(capture) = within_capture_timeout(
"display thumbnail",
CAPTURE_TIMEOUT,
&CAPTURE_PAUSE,
capture_display_thumbnail(&display, content.retained()),
)
.await
else {
return;
};
let Some(rgba) = capture else {
tracing::debug!(id = %option.id, "display thumbnail capture missed");
continue;
};
Expand Down Expand Up @@ -534,14 +620,32 @@ async fn capture_windows_inner(events: flume::Sender<WindowEvent>) {
return;
}

let Some(content) = shareable_content().await else {
let Some(Some(content)) = within_capture_timeout(
"shareable content",
CAPTURE_TIMEOUT,
&CAPTURE_PAUSE,
shareable_content(),
)
.await
else {
return;
};

for (option, window) in targets {
let image = capture_window_thumbnail(&window, content.retained())
.await
.map(crate::library::rgba_to_render_image);
if captures_paused() {
return;
}
let Some(capture) = within_capture_timeout(
"window thumbnail",
CAPTURE_TIMEOUT,
&CAPTURE_PAUSE,
capture_window_thumbnail(&window, content.retained()),
)
.await
else {
return;
Comment on lines +645 to +646

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Window thumbnails never retry

A timed-out window sweep still gets marked as complete. It sends WindowEvent::Listed before this early return, and the receiver saves that list’s signature when the channel closes. With an unchanged window list, windows_stale() then stays false, so thumbnails do not retry after the five-minute pause or when the picker reopens.

Keep interrupted sweeps stale so they can retry after the pause.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop-gpui/src/target_thumbnails.rs
Line: 647-648

Comment:
**Window thumbnails never retry**

A timed-out window sweep still gets marked as complete. It sends `WindowEvent::Listed` before this early return, and the receiver saves that list’s signature when the channel closes. With an unchanged window list, `windows_stale()` then stays false, so thumbnails do not retry after the five-minute pause or when the picker reopens.

Keep interrupted sweeps stale so they can retry after the pause.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7d12f22. Both sweep receivers now save the list signature only when captures aren't paused, so a sweep cut short by a hang leaves the list stale and gets retried once the pause ends.

};
let image = capture.map(crate::library::rgba_to_render_image);
let app_icon = window.app_icon().filter(|bytes| !bytes.is_empty());
if image.is_some() {
captured += 1;
Expand Down Expand Up @@ -1427,4 +1531,45 @@ mod tests {
}));
assert_eq!(rx.try_iter().collect::<Vec<_>>(), vec!["captured"]);
}

#[test]
fn a_hung_capture_times_out_inside_a_sweep_and_pauses_captures() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.build()
.unwrap();
let pause = Arc::new(CapturePause::new());
let sweep_pause = pause.clone();
let (tx, rx) = flume::unbounded();
runtime.block_on(run_capture(move || async move {
let hung = within_capture_timeout(
"test capture",
Duration::from_millis(20),
&sweep_pause,
std::future::pending::<()>(),
)
.await;
let answered = within_capture_timeout(
"test capture",
Duration::from_secs(5),
&sweep_pause,
async { 7 },
)
.await;
let _ = tx.send((hung, answered));
}));
assert_eq!(rx.try_iter().collect::<Vec<_>>(), vec![(None, Some(7))]);
assert!(pause.active_at(std::time::Instant::now()));
}

#[test]
fn capture_pause_lasts_for_the_hang_pause() {
let pause = CapturePause::new();
let now = std::time::Instant::now();
assert!(!pause.active_at(now));
pause.start_at(now);
assert!(pause.active_at(now));
assert!(pause.active_at(now + CAPTURE_HANG_PAUSE.saturating_sub(Duration::from_millis(1))));
assert!(!pause.active_at(now + CAPTURE_HANG_PAUSE));
}
}
Loading