Skip to content

Commit 1cdf1b6

Browse files
[Android] Fix Screenshot.CaptureAsync deadlock for synchronous UI-thread callers (#37680)
<!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. ### Root Cause The regression was introduced when Android screenshot capture switched from synchronous Canvas rendering to asynchronous `PixelCopy` to support hardware-rendered content such as WebView. #35384 `PixelCopy` delivered its completion callback on `Looper.MainLooper`, while Sentry synchronously waited for `Screenshot.Default.CaptureAsync().GetAwaiter().GetResult()` on the UI thread. This created a circular dependency: the UI thread blocked waiting for the screenshot task, and the `PixelCopy` callback could not execute because it also required the UI thread, resulting in a deadlock. ### Description of Change The fix retains `PixelCopy` but moves its completion callback from `Looper.MainLooper` to a shared, process-wide `HandlerThread`, allowing the screenshot task to complete even when the UI thread is synchronously blocked. Additionally, the internal await chain now uses `ConfigureAwait(false)` to avoid resuming on the UI thread. The legacy Canvas/DrawingCache fallback is guarded to run only on the main thread; if `PixelCopy` fails asynchronously on a worker thread, screenshot capture safely returns failure instead of risking off-thread Android View access or recreating the deadlock. ### Regressed By #35384 ### Issues Fixed Fixes #37638 ### Platforms Tested - [ ] iOS - [ ] MacCatalyst - [x] Android - [ ] Windows ### Note The regression test is **Android-only** because the deadlock originates in Android’s PixelCopy callback and main-looper implementation. Other platforms use different screenshot implementations and cannot exercise this code path. The test models Sentry’s relevant synchronous behavior directly to avoid adding a third-party dependency to the shared HostApp. ### Screenshots | Before Issue Fix | After Issue Fix | |------------------|-----------------| | <video width="350" alt="withoutfix" src="https://github.com/user-attachments/assets/72e74130-9f1b-4751-8d36-a777f3fc256c" /> | <video width="350" alt="withfix" src="https://github.com/user-attachments/assets/d4e9c8d2-cfa5-4f03-a8cc-f67fd5fa004f" /> |
1 parent 90afade commit 1cdf1b6

3 files changed

Lines changed: 119 additions & 5 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using Microsoft.Maui.Media;
2+
3+
namespace Maui.Controls.Sample.Issues;
4+
5+
[Issue(IssueTracker.Github, 37638, "Screenshot.CaptureAsync deadlocks when awaited synchronously from the UI thread", PlatformAffected.Android)]
6+
public class Issue37638 : ContentPage
7+
{
8+
readonly Label _statusLabel;
9+
10+
public Issue37638()
11+
{
12+
_statusLabel = new Label
13+
{
14+
Text = "Ready",
15+
AutomationId = "StatusLabel",
16+
};
17+
18+
var captureButton = new Button
19+
{
20+
Text = "Generate error",
21+
AutomationId = "GenerateErrorButton",
22+
};
23+
captureButton.Clicked += OnGenerateErrorClicked;
24+
25+
Content = new VerticalStackLayout
26+
{
27+
Padding = 30,
28+
Spacing = 20,
29+
Children =
30+
{
31+
captureButton,
32+
_statusLabel,
33+
new Editor
34+
{
35+
Placeholder = "Check if app still responds",
36+
AutomationId = "ResponsivenessEditor",
37+
},
38+
},
39+
};
40+
}
41+
42+
void OnGenerateErrorClicked(object sender, EventArgs e)
43+
{
44+
CaptureTestException();
45+
}
46+
47+
void CaptureTestException()
48+
{
49+
try
50+
{
51+
throw new InvalidOperationException("Screenshot deadlock test");
52+
}
53+
catch (Exception ex)
54+
{
55+
_statusLabel.Text = $"Capturing error: {ex.Message}";
56+
string eventId = CaptureException(ex);
57+
_statusLabel.Text = $"Error captured: {eventId}";
58+
}
59+
}
60+
61+
static string CaptureException(Exception exception)
62+
{
63+
_ = exception ?? throw new ArgumentNullException(nameof(exception));
64+
65+
var screenshot = Screenshot.Default.CaptureAsync().GetAwaiter().GetResult();
66+
return screenshot is null ? string.Empty : Guid.NewGuid().ToString("N");
67+
}
68+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#if ANDROID
2+
// Reproduces the Android only PixelCopy deadlock triggered by Sentry's synchronous screenshot attachment without adding the third-party SDK to the shared HostApp.
3+
using NUnit.Framework;
4+
using UITest.Appium;
5+
using UITest.Core;
6+
7+
namespace Microsoft.Maui.TestCases.Tests.Issues;
8+
9+
public class Issue37638 : _IssuesUITest
10+
{
11+
public Issue37638(TestDevice device) : base(device)
12+
{
13+
}
14+
15+
public override string Issue =>
16+
"Screenshot.CaptureAsync deadlocks when awaited synchronously from the UI thread";
17+
18+
[Test]
19+
[Category(UITestCategories.Essentials)]
20+
public void GenerateErrorIsCapturedWithoutDeadlockingUIThread()
21+
{
22+
App.WaitForElement("GenerateErrorButton");
23+
App.Tap("GenerateErrorButton");
24+
Assert.That(
25+
App.WaitForTextToBePresentInElement("StatusLabel", "Error captured"),
26+
Is.True);
27+
}
28+
}
29+
#endif

src/Essentials/src/Screenshot/Screenshot.android.cs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System;
44
using System.IO;
55
using System.Runtime.InteropServices;
6+
using System.Threading;
67
using System.Threading.Tasks;
78
using Android.App;
89
using Android.Content;
@@ -16,6 +17,9 @@ namespace Microsoft.Maui.Media
1617
{
1718
partial class ScreenshotImplementation : IPlatformScreenshot, IScreenshot
1819
{
20+
static readonly Lazy<Handler> PixelCopyCallbackHandler =
21+
new(CreatePixelCopyCallbackHandler, LazyThreadSafetyMode.ExecutionAndPublication);
22+
1923
static IWindowManager? WindowManager =>
2024
Application.Context.GetSystemService(Context.WindowService) as IWindowManager;
2125

@@ -60,9 +64,13 @@ public async Task<IScreenshotResult> CaptureAsync(Activity activity)
6064
{
6165
if (OperatingSystem.IsAndroidVersionAtLeast(26))
6266
{
63-
var bitmap = await RenderUsingPixelCopyAsync(view, window);
67+
var bitmap = await RenderUsingPixelCopyAsync(view, window).ConfigureAwait(false);
6468
if (bitmap is not null)
6569
return bitmap;
70+
71+
// PixelCopy may complete off the UI thread, where view-based fallbacks are unsafe.
72+
if (!MainThread.IsMainThread)
73+
return null;
6674
}
6775

6876
return RenderUsingCanvasDrawing(view) ?? RenderUsingDrawingCache(view);
@@ -93,13 +101,11 @@ public async Task<IScreenshotResult> CaptureAsync(Activity activity)
93101
try
94102
{
95103
var listener = new PixelCopyFinishedListener(tcs, bitmap);
96-
PixelCopy.Request(window, rect, bitmap,
97-
listener,
98-
new Handler(Looper.MainLooper!));
104+
PixelCopy.Request(window, rect, bitmap, listener, PixelCopyCallbackHandler.Value);
99105

100106
try
101107
{
102-
return await tcs.Task.ConfigureAwait(true);
108+
return await tcs.Task.ConfigureAwait(false);
103109
}
104110
finally
105111
{
@@ -113,6 +119,17 @@ public async Task<IScreenshotResult> CaptureAsync(Activity activity)
113119
}
114120
}
115121

122+
static Handler CreatePixelCopyCallbackHandler()
123+
{
124+
var thread = new HandlerThread("Microsoft.Maui.Screenshot.PixelCopy");
125+
thread.Start();
126+
127+
var looper = thread.Looper
128+
?? throw new InvalidOperationException("Unable to create the PixelCopy callback looper.");
129+
130+
return new Handler(looper);
131+
}
132+
116133
static Activity? GetActivity(Context? context)
117134
{
118135
while (context is ContextWrapper wrapper)

0 commit comments

Comments
 (0)