Skip to content

Commit eb3571a

Browse files
committed
fix(macos): recover capture after the display sleeps
Capture waited on the session semaphore with DISPATCH_TIME_FOREVER, but that semaphore is only signalled when the frame callback returns false — that is, when capture is deliberately stopped. A sleeping display stops AVCaptureSession delivering sample buffers and the session does not resume when the display wakes, so the capture thread parked for the lifetime of the process. Since capture() could also only ever return capture_e::ok, and video.cpp rebuilds the display only on capture_e::reinit, nothing could bring the stream back and the user had to restart Sunshine. Poll the semaphore instead and watch the display while waiting. A display that slept and then woke returns capture_e::reinit so the caller rebuilds it. Waiting for the wake transition rather than for a frame-arrival timeout keeps a static screen from being mistaken for a stalled one. Abandoning a capture needs the same teardown the frame callback performs when it returns false, so AVVideo grows a stopCapture: for it. Merely stopping the session leaves the output in the capture session and in the map tables, and -[AVVideo dealloc] releases those map tables before it stops the session, so the output is released while the session still holds it and the process dies in objc_msgSend during the next reinitialization. dummy_img() had the same unbounded wait, where the semaphore genuinely does mean "one frame arrived". Encoder probing hung there indefinitely when the display was asleep, so bound it and report failure instead. Tested on macOS 26.6.1 (Apple M1 Max): with a stream running, a hot corner set to Put Display to Sleep, and the display then woken, capture is rebuilt in under 100 ms and a fresh IDR frame follows within 300 ms. Two sleep/wake cycles in a row were survived by a single uninterrupted client session.
1 parent cf52f4b commit eb3571a

3 files changed

Lines changed: 91 additions & 3 deletions

File tree

src/platform/macos/av_video.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,15 @@ typedef bool (^FrameCallbackBlock)(CMSampleBufferRef);
8888
*/
8989
- (dispatch_semaphore_t)capture:(FrameCallbackBlock)frameCallback;
9090

91+
/**
92+
* @brief Abandon a capture that has not ended on its own.
93+
*
94+
* The frame callback normally tears its own capture down by returning false. When the
95+
* caller gives up on a capture that stopped delivering frames, such as after the display
96+
* slept, this performs that teardown on its behalf.
97+
*
98+
* @param signal Semaphore previously returned by capture:.
99+
*/
100+
- (void)stopCapture:(dispatch_semaphore_t)signal;
101+
91102
@end

src/platform/macos/av_video.m

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,32 @@ - (dispatch_semaphore_t)capture:(FrameCallbackBlock)frameCallback {
9393
}
9494
}
9595

96+
- (void)stopCapture:(dispatch_semaphore_t)signal {
97+
@synchronized(self) {
98+
AVCaptureConnection *target = nil;
99+
for (AVCaptureConnection *connection in self.captureSignals) {
100+
if ([self.captureSignals objectForKey:connection] == signal) {
101+
target = connection;
102+
break;
103+
}
104+
}
105+
106+
if (target == nil) {
107+
return;
108+
}
109+
110+
// Same teardown the frame callback performs when it returns false. Leaving the output
111+
// in the session while the map tables release it over-releases it once this object is
112+
// deallocated, so the entries have to go before the caller drops us.
113+
[self.session stopRunning];
114+
[self.captureCallbacks removeObjectForKey:target];
115+
[self.session removeOutput:[self.videoOutputs objectForKey:target]];
116+
[self.videoOutputs removeObjectForKey:target];
117+
[self.captureSignals removeObjectForKey:target];
118+
[self.session startRunning];
119+
}
120+
}
121+
96122
- (void)captureOutput:(AVCaptureOutput *)captureOutput
97123
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
98124
fromConnection:(AVCaptureConnection *)connection {

src/platform/macos/display.mm

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,31 @@ OSType videotoolbox_pixel_format(const video::config_t &config) {
5252
const auto colorspace {video::colorspace_from_client_config(config, false)};
5353
return colorspace.bit_depth == 10 ? kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange : kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange;
5454
}
55+
56+
/**
57+
* @brief How often the capture loop wakes up to check on the display while capturing.
58+
*
59+
* The capture semaphore is only signalled when capture ends, so the loop needs its own
60+
* cadence to notice a display that slept and woke.
61+
*/
62+
constexpr auto capture_poll_interval {250ms};
63+
64+
/**
65+
* @brief How long dummy_img() waits for a single frame before giving up.
66+
*
67+
* Without a bound this blocks forever when the display is asleep during encoder probing.
68+
*/
69+
constexpr auto dummy_img_timeout {5s};
70+
71+
/**
72+
* @brief Convert a duration to an absolute dispatch timeout.
73+
*
74+
* @param duration How far in the future the timeout should fire.
75+
* @return Dispatch time suitable for dispatch_semaphore_wait().
76+
*/
77+
dispatch_time_t dispatch_timeout_from_now(std::chrono::nanoseconds duration) {
78+
return dispatch_time(DISPATCH_TIME_NOW, duration.count());
79+
}
5580
} // namespace
5681

5782
/**
@@ -105,8 +130,25 @@ capture_e capture(const push_captured_image_cb_t &push_captured_image_cb, const
105130
return true;
106131
}];
107132

108-
// FIXME: We should time out if an image isn't returned for a while
109-
dispatch_semaphore_wait(signal, DISPATCH_TIME_FOREVER);
133+
// The semaphore is only signalled once capture ends, so waiting on it forever used to
134+
// park this thread for the lifetime of the process: a sleeping display stops
135+
// AVCaptureSession delivering sample buffers, and the session does not resume when the
136+
// display wakes again. Poll instead, so a display that slept and woke can be reported
137+
// to the caller as a display that needs rebuilding.
138+
bool display_slept {false};
139+
while (dispatch_semaphore_wait(signal, dispatch_timeout_from_now(capture_poll_interval)) != 0) {
140+
if (CGDisplayIsAsleep(display_id)) {
141+
display_slept = true;
142+
} else if (display_slept) {
143+
BOOST_LOG(info) << "Display ["sv << display_id << "] woke from sleep, reinitializing capture"sv;
144+
145+
// Tear the capture down before returning, so that no callback outlives this call
146+
// and the output does not survive into our destructor still owned by the session.
147+
[av_capture stopCapture:signal];
148+
149+
return capture_e::reinit;
150+
}
151+
}
110152

111153
return capture_e::ok;
112154
}
@@ -184,7 +226,16 @@ int dummy_img(img_t *img) override {
184226
return false;
185227
}];
186228

187-
dispatch_semaphore_wait(signal, DISPATCH_TIME_FOREVER);
229+
// Unlike capture(), this callback stops after a single frame, so the semaphore really
230+
// does mean "one image arrived". Bound the wait anyway: with the display asleep no
231+
// frame is ever delivered, and encoder probing would hang here forever.
232+
if (dispatch_semaphore_wait(signal, dispatch_timeout_from_now(dummy_img_timeout)) != 0) {
233+
BOOST_LOG(error) << "Timed out waiting for a frame from display ["sv << display_id << "], is it asleep?"sv;
234+
235+
[av_capture stopCapture:signal];
236+
237+
return 1;
238+
}
188239

189240
return 0;
190241
}

0 commit comments

Comments
 (0)