[net11.0] Make iOS image scaling deadlock-safe - #37057
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 37057Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 37057" |
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). There may be pipelines that require an authorized user to comment /azp run to run. |
|
Azure Pipelines: Successfully started running 3 pipeline(s). |
There was a problem hiding this comment.
Pull request overview
This PR updates the iOS/MacCatalyst image-scaling implementation in Microsoft.Maui.Graphics to avoid deprecated/unsupported UIKit APIs by switching from UIGraphics.BeginImageContext to UIGraphicsImageRenderer, while keeping the output non-opaque and preserving the source image scale.
Changes:
- Replace
UIGraphics.BeginImageContext/GetImageFromCurrentImageContext/EndImageContextusage withUIGraphicsImageRendererinUIImageExtensions.ScaleImage. - Preserve transparency (
Opaque = false) and source scale (Scale = target.CurrentScale) for scaled images.
|
@PureWeen this fix is now proven at the current head |
PureWeen
left a comment
There was a problem hiding this comment.
Adversarial code review — 3 independent reviewers with adversarial consensus (+ repo domain rules)
One substantive finding, unanimous across all three reviewers. The deprecated-API removal itself is sound; the concern is a silent behavior change that rides along with it.
Findings
❌ Regression / Image Handling — ScaleImage(this UIImage, CGSize, bool) now rasterizes at the source image's scale instead of 1.0 (3/3 reviewers)
UIGraphics.BeginImageContext(size) is documented by Apple as equivalent to UIGraphicsBeginImageContextWithOptions(size, opaque: false, scale: 1.0) — the scale is always 1.0, independent of the source image. The new renderer sets Scale = target.CurrentScale.
Net effect for a source image with CurrentScale != 1:
| before | after | |
|---|---|---|
returned UIImage.Size (points) |
size |
size (unchanged) |
returned UIImage.Scale |
1.0 |
target.CurrentScale |
backing CGImage pixels |
size.W × size.H |
size.W·scale × size.H·scale |
Because the logical Size is unchanged, PlatformImage.Width/Height still report the expected values — so this is invisible to a smoke test, but the raster behind them grows 4× (@2x) or 9× (@3x).
Concrete scenario: a @3x asset-catalog image goes through IImage.Downsize(500, 500) (e.g. PlatformImage.Downsize → ScaleImage). Previously the result was backed by a ~500×500 pixel buffer. Now it is 500×500 points backed by 1500×1500 pixels. AsPNG()/AsJPEG() encode the pixel buffer, so downstream memory and encoded file size grow ~9×, which works against Downsize's stated purpose of bounding image size. This reaches real consumers — Essentials MediaPicker ImageProcessor.ApplyResizing calls Downsize(...) to honor caller-supplied maxWidth/maxHeight before saving.
Worth noting the PR description says the change preserves the source image scale — the previous code did not preserve it, it forced 1.0. So this is a deliberate semantic change, not a preservation.
Mitigating context (reviewers agreed on this): the dominant in-repo path PlatformImage.FromStream → UIImage.LoadFromData yields CurrentScale == 1.0, so that path is byte-identical. The exposure is via the public ScaleImage/Downsize surface receiving a scale > 1 image.
The cited precedent, NormalizeOrientation, is a fair pattern to copy but not a fair semantics to copy: there, preserving CurrentScale is correct because the output is meant to match the source exactly. ScaleImage's entire job is to reduce size.
Suggested resolution: set Scale = 1 for exact parity with the code being replaced (still removes the CA1416-flagged APIs, which is the whole point of the backport) — or, if the scale-aware behavior is intended as a quality improvement, keep it but call it out explicitly as a behavior change rather than a preservation, and land it with a test. For a release/11.0.1xx-preview7 backport whose stated goal is unblocking a build, the minimal-delta option looks like the safer default.
ScaleImage or Downsize on any platform (3/3 reviewers; severity ranged 💡–❌)
A repo-wide search finds zero tests exercising either ScaleImage overload or IImage.Downsize. Since this change alters output raster dimensions in a scale-dependent way, nothing would catch a future flip in either direction. A small device test asserting Size, CurrentScale, and CGImage.Width/Height for both a 1× and a 2×/3× source would lock in whichever semantics are chosen above.
What looks right
Opaque = false faithfully matches the old context's implicit opaque: NO, and disposeOriginal remains correct — CreateImage runs its block synchronously, so target is fully drawn before target.Dispose(), with no use-after-dispose.
Considered and discarded
- Non-disposal of
UIGraphicsImageRenderer/UIGraphicsImageRendererFormat— raised by 1/3, rejected by the other two: it is the identical pattern already used byNormalizeOrientationin the same file, the peers are finalizable rather than permanently leaked, and it is not introduced by this PR. - Null-safety of
target—target.CurrentScalewould NRE on a nulltarget, but so would the previoustarget.Draw(...). No new hazard. - Thread affinity —
UIGraphicsImageRenderer.CreateImageavoids the old mutable per-thread context stack. An improvement, not a regression. - Unverified claim (noted, not scored): the "0 warnings, 0 errors on
net11.0-ios26.5" validation could not be independently reproduced here. It is directionally credible and consistent with Apple's documented deprecation.
Prior review status
One prior automated review (copilot-pull-request-reviewer[bot]) posted a descriptive overview with no findings. No unresolved inline threads. Nothing here duplicates it.
Methodology: 3 independent reviewers with adversarial consensus, plus this repository's own expert-review dimensions (.github/agents/maui-expert-reviewer.md) routed to the changed file. Findings surviving cross-validation only. Code review only — CI status is out of scope for this review.
|
/azp run |
|
Azure Pipelines: Successfully started running 3 pipeline(s). |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Graphics/src/Graphics/Platforms/iOS/UIImageExtensions.cs:61
UIGraphicsImageRendererwraps native resources (NSObject). To avoid delaying native resource release in hot paths, it should be disposed deterministically (consistent with other usages in the repo that useusing var renderer = ...).
var renderer = new UIGraphicsImageRenderer(size, new UIGraphicsImageRendererFormat
src/Graphics/tests/DeviceTests/Tests/ImageTests.cs:62
- Consider disposing
sourceRendereras well to avoid keeping a native renderer alive for the full test duration unnecessarily (it implementsIDisposableviaNSObject).
var sourceRenderer = new UIGraphicsImageRenderer(sourceSize, new UIGraphicsImageRendererFormat
PureWeen
left a comment
There was a problem hiding this comment.
Round 2 — re-review of 38740560 "Preserve one-times image scale when downsizing"
Re-ran the full adversarial review against the new commit. Both round-1 findings are resolved. No blocking issues remain; two non-blocking notes below.
Round-1 findings — status
✅ RESOLVED — ❌ Regression (backing pixel dimensions). Scale = 1 is verifiably the exact semantic of the API being replaced: Apple documents UIGraphicsBeginImageContext(size) as equivalent to UIGraphicsBeginImageContextWithOptions(size, opaque: NO, scale: 1.0). Combined with Opaque = false, the returned UIImage.Size, .Scale, and CGImage pixel dimensions now all match the pre-change behavior. The Downsize → ScaleImage chain that feeds Essentials MediaPicker no longer inflates saved output for retina sources. Reviewers specifically checked that Scale on a new UIGraphicsImageRendererFormat() actually takes effect rather than silently no-opping — it does; it's a plain settable property, and this exact object-initializer pattern already ships in NormalizeOrientation in the same file.
✅ RESOLVED — ScaleImageUsesOneXBackingScale is a genuine regression test, not a placebo. Reviewers worked the arithmetic independently: at sourceScale = 3 the source is 30×20 pt / 90×60 px; scaled to CGSize(10, 5) the old Scale = CurrentScale code produced CurrentScale == 3 and a 30×15 px CGImage, so Assert.Equal(1, …CurrentScale) and Assert.Equal(10, (int)…CGImage.Width) both fail against the regressed implementation and pass now. Mechanics also check out: context.FillRect(CGRect) is a real UIGraphicsRendererContext member; Assert.Equal(1, (double)…) resolves fine (the same int-literal-vs-double pattern already compiles elsewhere in this repo); the values asserted are exactly representable so strict float equality is safe; and #if IOS || MACCATALYST correctly excludes the guarded UIKit code from the android/windows TFMs this project also targets. The absence of main-thread dispatch is correct — UIGraphicsImageRenderer is explicitly designed for off-main-thread offscreen compositing, which is one of the reasons it supersedes the legacy context-stack API. And Graphics.DeviceTests is wired into eng/pipelines/device-tests.yml and eng/helix_xharness.proj for both the ios and catalyst legs, so this test actually runs.
Remaining non-blocking notes
💡 Regression — UIGraphicsImageRendererFormat.PreferredRange is left at its default (2/3 reviewers)
Scale and opacity now match exactly, but pixel format may not. PreferredRange defaults to Automatic, which selects extended/wide-gamut range on devices that support it, whereas BeginImageContext always produced a fixed 8-bit-per-component device-RGB bitmap. One reviewer reported probing this and observing 16-bpc extended-sRGB output where the old path gave 8-bpc — treat that specific measurement as reported-but-not-independently-confirmed here, though the documented Automatic default supports the direction. A third reviewer argued the deprecated parameterless UIGraphicsImageRendererFormat() initializer yields plain sRGB instead; that position lost 2-to-1.
Concrete consequence if it holds: a downsized wide-gamut image carries roughly double the backing memory per pixel and may encode differently through AsPNG()/AsJPEG() — a color-fidelity and memory difference, not a dimension regression.
Why this is not blocking: the identical gap already ships in NormalizeOrientation in this same file, so it is not a new divergence for the file — though it is new for ScaleImage specifically, since that method previously went through BeginImageContext. Given this is a preview7 backport whose purpose is clearing CA1416, PreferredRange = UIGraphicsImageRendererFormatRange.Standard would be the tightest-parity option if you want it, but chasing it here (and in NormalizeOrientation) is reasonable follow-up work rather than a merge gate.
💡 Testing — the [InlineData(1f)] row cannot catch the regression (2/3 reviewers)
At sourceScale = 1 the old and new implementations are identical (CurrentScale == 1 == 1), so every assertion in that row passes against the buggy code too. It is a fine baseline sanity check, but only the 3f row has regression-catching power. Consequence: if someone reintroduces Scale = target.CurrentScale, a green 1f row offers false reassurance. Adding [InlineData(2f)] would broaden the discriminating coverage cheaply.
Considered and discarded
Undisposed sourceRenderer / UIGraphicsImageRendererFormat instances in the test — raised by 1/3, rejected by the others: lightweight NSObject config wrappers, matching the pattern already shipping in NormalizeOrientation, with no held resource in a short-lived test.
Verdict
The fix is correct on its own technical merits, not merely responsive to prior feedback — the reviewers verified Scale = 1 against the replaced API's documented contract rather than accepting it as "different from what was flagged." Nothing blocking from this reviewer.
Methodology: 3 independent reviewers with adversarial consensus (round 2), plus this repository's own expert-review dimensions routed to the changed files. Code review only — CI status out of scope.
|
/azp run |
|
Azure Pipelines: Successfully started running 3 pipeline(s). |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Graphics/tests/DeviceTests/Tests/ImageTests.cs:73
- The UIGraphicsImageRenderer callback parameter is a UIGraphicsImageRendererContext, which doesn’t expose FillRect; this should draw via ctx.CGContext. Also, the renderer itself should be disposed (it’s IDisposable) to avoid native resource pressure in test runs.
var sourceRenderer = new UIGraphicsImageRenderer(sourceSize, new UIGraphicsImageRendererFormat
{
Opaque = false,
Scale = sourceScale,
});
src/Graphics/src/Graphics/Platforms/iOS/UIImageExtensions.cs:66
- UIGraphicsImageRenderer is IDisposable; consider disposing it (using var) to avoid holding native resources longer than necessary, especially since this helper may be called frequently.
var renderer = new UIGraphicsImageRenderer(size, new UIGraphicsImageRendererFormat
{
Opaque = false,
PreferredRange = UIGraphicsImageRendererFormatRange.Standard,
Scale = 1,
});
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 5 findings
See inline comments for details.
| else | ||
| DispatchQueue.MainQueue.DispatchSync(ScaleAndWait); | ||
|
|
||
| Assert.True(completedWhileMainThreadWasBlocked, "ScaleImage must not synchronously depend on main-thread progress."); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Async and Threading Safety — Failure path leaks a live background task over a disposed UIImage. If scaleTask.Wait(TimeSpan.FromSeconds(5)) on line 135 times out (i.e. the deadlock this test exists to detect has actually regressed), completedWhileMainThreadWasBlocked is false, this Assert.True throws, and the method unwinds — which runs the using var source disposal on line 127 while the Task.Run on line 134 is still inside source.ScaleImage(...) calling target.Draw(...) on that same native UIImage. The result is a native crash on a thread-pool thread instead of a clean TrueException, so the very regression this test guards against would be reported as an APP_CRASH with no failing test rather than as "ScaleImage must not synchronously depend on main-thread progress".
Suggested fix — source must not be in a using, because ownership is conditional on the worker actually settling (GC.KeepAlive is not sufficient: it prevents collection, not the deterministic Dispose() the using emits on the exception path):
// requires: using System.Runtime.ExceptionServices;
var source = CreatePatternImage(UIImageOrientation.Up); // deliberately not `using`
Task<UIImage> scaleTask = null;
Exception waitFailure = null;
var completedWhileMainThreadWasBlocked = false;
void ScaleAndWait()
{
try
{
scaleTask = Task.Run(() => source.ScaleImage(new CGSize(10, 5)));
// the ONLY main-queue block, and it is the measurement itself: bounded, never retried
completedWhileMainThreadWasBlocked = scaleTask.Wait(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
waitFailure = ex; // never unwind a managed exception through the native GCD frame
}
}
if (NSThread.IsMain)
ScaleAndWait();
else
DispatchQueue.MainQueue.DispatchSync(ScaleAndWait);
if (waitFailure is not null)
{
source.Dispose(); // Wait threw => task settled => worker done with source
ExceptionDispatchInfo.Capture(waitFailure).Throw();
}
if (!completedWhileMainThreadWasBlocked)
{
// Regression case: hand ownership of `source` to a thread-pool continuation that runs only
// once the worker really settles. Nothing here waits on the main queue.
_ = scaleTask.ContinueWith(static (t, s) =>
{
if (t.Status == TaskStatus.RanToCompletion)
t.Result?.Dispose();
else
_ = t.Exception; // observe, don't escalate
((UIImage)s).Dispose();
}, source, TaskScheduler.Default);
Assert.Fail("ScaleImage must not synchronously depend on main-thread progress.");
}
try
{
using var scaled = await scaleTask; // already RanToCompletion: no further waiting
Assert.Equal(10, (int)scaled.CGImage.Width);
Assert.Equal(5, (int)scaled.CGImage.Height);
}
finally
{
source.Dispose();
}source is disposed only where the task has provably settled; if the worker truly never returns it is intentionally leaked for the remaining process lifetime, which is the right trade in a test host. Task.Wait(TimeSpan) throws on a faulted task rather than returning true, so completedWhileMainThreadWasBlocked == true implies RanToCompletion — that is what makes the await and the final Dispose() non-blocking, including on the NSThread.IsMain branch where any second wait would self-deadlock. The try/catch in ScaleAndWait also covers the secondary hazard: without it, an AggregateException from Wait has to unwind through the native DispatchSync frame.
| UIGraphics.EndImageContext(); | ||
| if (size.Width <= 0 || size.Height <= 0) | ||
| { | ||
| return target; |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[minor] Public API Surface Design — This early return hands back the same instance and silently ignores disposeOriginal: true. That is the right runtime behaviour (you cannot dispose what you are returning), and it matches the pre-existing shape at lines 11-14/29, but it is now frozen as intended behaviour by a new test, so it is worth making explicit.
The ownership hazard is concrete one level up: PlatformImage.Downsize wraps the result in a new PlatformImage (Platforms/iOS/PlatformImage.cs:25,31), so a caller passing disposeOriginal: true ends up with two IImage instances aliasing one undisposed UIImage while believing the original was released — the exact expectation Issue21886 asserts against (original.Width must throw ObjectDisposedException). Unreachable from Downsize today because the float overload guards first, hence minor. An XML <remarks> on the public method stating that disposeOriginal is ignored when the original is returned unchanged would close the contract gap without any behaviour change.
This comment has been minimized.
This comment has been minimized.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4059434-4b71-435a-a741-b4ed694c0116
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/Graphics/tests/DeviceTests/Tests/ImageTests.cs:99
- This test intends to validate
disposeOriginalbehavior for non-positive sizes, but it currently only asserts reference equality. IfScaleImage(..., disposeOriginal: true)accidentally disposed the source before returning it,Assert.Samewould still pass and the regression wouldn’t be detected. Add an assertion that the source is still undisposed after the call (e.g.,source.Handle != IntPtr.Zero).
var scaled = source.ScaleImage(new CGSize(width, height), disposeOriginal: true);
Assert.Same(source, scaled);
}
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 4 findings
See inline comments for details.
AI Review Summary
🗂️ Review Sessions — click to expand🚦 Gate — Test Before & After FixGate Result: ❌ FAILEDPlatform: IOS · Base: net11.0 · Merge base: 🩺 Test does not reproduce the bug — ran the same in both states (PASS without fix, PASS with fix). The repro test is not exercising the issue. Strengthen the test before reviewing the fix.
🔴 Without fix — 📱 ImageTests (ScaleImageReturnsOriginalForNonPositiveSize): PASS ❌ · 144s(no coded error found; showing last 1200 chars) 🟢 With fix — 📱 ImageTests (ScaleImageReturnsOriginalForNonPositiveSize): PASS ✅ · 29s(no coded error found; showing last 1200 chars)
|
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #37057 | Draw into a private CGBitmapContext through UIKit's per-thread graphics stack |
❌ FAILED (Gate) | UIImageExtensions.cs |
With-fix tests pass, but the without-fix native crash was misclassified as PASS; code review also found byte-order and liveness-test gaps. |
🔬 Code Review — Deep Analysis
Code Review — PR #37057
Independent Assessment
What this changes: Replaces deprecated iOS/MacCatalyst image-context APIs with a thread-local 1× CGBitmapContext, adding invalid-size handling and device coverage.
Inferred motivation: Remove CA1416 failures without introducing main-thread dependence or changing rendered output.
Reconciliation with PR Narrative
Author claims: Legacy rendering, scale, transparency, orientation, and thread behavior are preserved.
Agreement/disagreement: Pixel and threading behavior match. However, the returned CGImage byte-order metadata changes, and invalid-size coverage is incomplete.
Prior Review Reconciliation
| Prior ❌ Error Finding | Source | Status | Evidence |
|---|---|---|---|
| Retina sources produced oversized backing buffers | PureWeen | ✅ Fixed | Output is explicitly created at scale 1; scale/device tests cover 1×–3×. |
| No regression coverage | PureWeen | ✅ Fixed | Device tests now cover scale, threading, orientation, and invalid dimensions. |
| Renderer construction required the UI thread | Earlier expert review | 🔄 Obsolete | Current implementation uses CGBitmapContext, not UIGraphicsImageRenderer. |
No unresolved prior ❌ Error findings found.
Blast Radius Assessment
- Runs for all instances: Only iOS/MacCatalyst callers invoking
ScaleImage/Downsize. - Startup impact: No.
- Static/shared state: No.
- Adjacent Image/Graphics risks: Raw pixel consumers, orientation, fractional dimensions, disposal, and background execution were examined.
CI Status
- Required-check result: Undetermined because
gh pr checks --requiredwas unavailable; public data showed 3 failures and 19 pending checks. - Classification: The completed failures are unrelated infrastructure failures—truncated Microsoft OpenJDK downloads (
curl: (18)). - Action taken: Invoked
azdo-build-investigator; confidence capped low while CI remains pending.
External Output Contract
| Consumer token/pattern | Producer location | Producer emission condition | Consumer assumption | Ordinary negative case | Downstream effect |
|---|---|---|---|---|---|
| None | — | — | No external-output classifier changed | — | — |
Findings
⚠️ Warning — Backing pixel format no longer matches legacy output
UIImageExtensions.cs:75 creates the bitmap without CGBitmapFlags.ByteOrder32Little. The previous API and UIGraphicsImageRenderer return BGRA/ByteOrder32Little; this code returns ByteOrderDefault. Raw-data consumers can consequently reject the image or interpret channels incorrectly. The repository’s RawBitmap.cs:94-111 explicitly requires ByteOrder32Little.
Use:
CGBitmapFlags.ByteOrder32Little | CGBitmapFlags.PremultipliedFirst⚠️ Warning — Invalid-size test does not verify liveness
ImageTests.cs:98 proves reference identity but still passes if the implementation disposes and returns the original image. Add Assert.NotNull(scaled.CGImage).
💡 Suggestion — Reject non-finite dimensions
UIImageExtensions.cs:59 permits NaN and infinity, which later produce OverflowException. Consider explicitly rejecting non-finite values.
Failure-Mode Probing
- Background execution: Uses a per-thread graphics stack;
PopContextis protected byfinally. - Fractional sizes/orientations: Empirical comparison matched UIKit across all eight orientations.
- Invalid dimensions: Zero/negative values safely return the live original; non-finite values throw.
- Raw-byte consumers: Observe the byte-order regression described above.
Verdict: NEEDS_CHANGES
Confidence: low
Summary: The rendering and deadlock fix is sound, but the observable backing format should retain legacy ByteOrder32Little, and the disposal test should prove the returned image remains usable. CI failures observed so far are infrastructure-related, but checks remain pending.
🛠️ Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| 1 | try-fix / claude-opus-5 |
Pure Core Graphics resampling with explicit orientation transforms | ❌ FAIL — 43 passed, 7 failed | 1 file | Correct geometry and threading, but non-Up exact-pixel rows differ from UIKit's private sub-pixel sampling. |
| 2 | try-fix / gpt-5.6-sol |
UIGraphicsImageRenderer + native Objective-C format construction |
✅ PASS — Debug 50/50, Release 50/50 | 1 file | Exact fidelity and blocked-main-thread safety, but adds ABI-sensitive native interop and is not clearly better than the PR. |
| PR | PR #37057 | Private CGBitmapContext + UIKit PushContext/PopContext + UIImage.Draw |
❌ FAILED (Gate) | 2 files | With-fix run passes; without-fix APP_CRASH was misclassified as PASS. Code review found byte-order and test-liveness gaps. |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
claude-opus-5 |
1 | Yes | Preserve UIImage.Draw semantics through a supported renderer rather than reimplementing UIKit orientation sampling. |
gpt-5.6-sol |
1 | Yes | Bypass only the managed format constructor's Debug thread guard with narrow native interop; keep the renderer on public APIs. |
claude-opus-5 |
2 | No | Exact UIKit fidelity forces UIImage.Draw; the deprecated context API, the PR's pushed context, and candidate 2's renderer exhaust the viable context providers under blocked-main-thread constraints. |
Exhausted: Yes
Selected Fix: PR's context-stack architecture, after addressing the pre-flight byte-order, invalid-size, and liveness-test findings — candidate 1 failed exact rendering, while candidate 2 passed but adds ABI-sensitive Objective-C interop and is not demonstrably better overall.
📝 Recommended PR Title & Description
Assessment: ✏️ Recommend updating — the current title omits MacCatalyst and the metadata predates the winning fix's byte-order, invalid-size, and expanded coverage changes.
Recommended title
[iOS/MacCatalyst] Graphics: Make .NET 11 image scaling deadlock-safe
Recommended description
## Summary
- Replace the unsupported `UIGraphics.BeginImageContext` image-scaling path with a private `CGBitmapContext` pushed onto UIKit's per-thread graphics stack.
- Preserve the legacy 1x backing scale, transparent output, standard color range, UIKit-native BGRA byte order, image orientation, rendering behavior, and ceil-to-integral output size.
- Return the live original image for nonpositive, non-finite, oversized, or otherwise unrepresentable dimensions, regardless of `disposeOriginal`.
- Keep synchronous `ScaleImage` usable from background threads without changing process-global UIKit diagnostics or synchronously depending on main-queue progress.
- Carry only the isolated Graphics fix from #36581, without the unrelated image-metadata API work.
## Failure addressed
The .NET 11 iOS 26.5 bindings mark `BeginImageContext`, `GetImageFromCurrentImageContext`, and `EndImageContext` unsupported on iOS and MacCatalyst 17 or later. These `CA1416` diagnostics are promoted to errors and stop affected builds before tests run, as demonstrated by [build 1537895](https://dev.azure.com/dnceng-public/public/_build/results?buildId=1537895).
PR #37090 provides the intentionally minimal Preview 7 build unblock by suppressing those diagnostics. This PR is the durable `net11.0`/RC1 implementation that removes the dependency on the unsupported APIs.
## Files changed
- `src/Graphics/src/Graphics/Platforms/iOS/UIImageExtensions.cs` replaces the unsupported context APIs, preserves UIKit drawing semantics and BGRA metadata, and guards invalid bitmap dimensions.
- `src/Graphics/tests/DeviceTests/Tests/ImageTests.cs` covers backing scale, invalid-size liveness, threading, orientation, fractional sizing, byte order, alpha, and transparency.
## Coverage
- `ScaleImageUsesOneXBackingScale` covers 1x, 2x, and 3x source images and verifies returned scale and backing pixel dimensions.
- `ScaleImageReturnsOriginalForInvalidSize` covers zero, negative, NaN, positive-infinity, and oversized width and height values, including `disposeOriginal` behavior and the returned image's liveness.
- `ScaleImageCanRunOnBackgroundThread` verifies ordinary worker-thread use.
- `ScaleImageDoesNotRequireMainThreadProgress` blocks the main thread while scaling on a worker and verifies the synchronous API cannot deadlock waiting for main-queue progress.
- `ScaleImageMatchesUIKitRendering` compares exact pixels against `UIGraphicsImageRenderer` for all eight orientations using fractional dimensions, alpha, and transparency, and pins the ceil-rounded output size and UIKit-native byte order.
- The reviewer-enhanced implementation passed all 56 focused Graphics image tests on iOS 26.5 in both Debug and Release. The underlying implementation previously passed all 50 Graphics device tests on both iOS and MacCatalyst; fresh `net11.0` CI will validate the expanded coverage on both platforms.
## Alternatives evaluated
- Pure Core Graphics orientation normalization and resampling was rejected after 7 of 50 exact-pixel cases diverged from UIKit's private fractional/orientation sampling.
- `UIGraphicsImageRenderer` passed all 50 Debug and Release cases, but required ABI-sensitive Objective-C message declarations and manual native ownership to construct `UIGraphicsImageRendererFormat` off-main in Debug. That complexity was not justified over the private bitmap-context approach.
### Issues Fixed
N/A
🏁 Report — Final Recommendation
Comparative Report - PR #37057
Decision
Winner: pr-plus-reviewer
It is the only candidate that combines exact UIKit rendering behavior, blocked-main-thread safety, the legacy BGRA byte-order contract, robust invalid-size handling, focused Debug and Release passes, and no ABI-sensitive Objective-C interop.
Ranking
| Rank | Candidate | Regression evidence | Assessment |
|---|---|---|---|
| 1 | pr-plus-reviewer |
PASS - iOS Debug 56/56; Release 56/56 | Keeps the PR's minimal context-stack design and fixes all four expert findings with discriminating coverage. |
| 2 | try-fix-2 |
PASS - iOS Debug 50/50; Release 50/50 | Behaviorally correct and deadlock-safe, but requires four ABI-sensitive objc_msgSend declarations and manual native ownership solely to bypass a binding thread check. |
| 3 | pr |
Gate FAILED; with-fix run 50/50 | The gate failure is a baseline-classification artifact rather than a product regression, but the raw fix still changes byte-order metadata and leaves invalid-size, liveness, and fractional-size contracts under-covered. It therefore ranks below candidates with clean regression passes. |
| 4 | try-fix-1 |
FAIL - 43/50 | Pure Core Graphics avoids UIKit drawing state, but all seven non-Up exact-pixel rows diverge from UIKit's private fractional/orientation sampling. It also loses UIImage.Draw behaviors for resizable, template, and CIImage-backed images. |
Comparative analysis
- Correctness and fidelity:
pr-plus-reviewerandtry-fix-2preserveUIImage.Drawbehavior for all eight orientations.try-fix-1is empirically incompatible with the pixel oracle. The raw PR renders correctly but changesCGImage.ByteOrderInfo. - Threading: The winner and
try-fix-2both complete while the main thread is blocked. The winner uses publicCGBitmapContextplus UIKit's per-thread context stack;try-fix-2relies on native construction ofUIGraphicsImageRendererFormatto evade a managed Debug thread guard. - Maintainability: The winner is a surgical extension of the submitted fix.
try-fix-2adds platform ABI and ownership code with a future dependency on binding behavior.try-fix-1adds a large orientation-normalization implementation without matching UIKit. - Coverage: The winner expands the focused suite from 50 to 56 cases and directly pins liveness, invalid numeric inputs, ceil rounding, byte order, background execution, blocked-main-thread execution, and exact pixels.
- Gate handling: The supplied gate remains failed and was not rerun as instructed. Its without-fix native abort is not representable as a normal managed xUnit failure, so the candidate ranking uses the recorded candidate regression runs while still ranking the formally failed raw PR below passing candidates.
pr-plus-reviewer is the strongest fix and should be used as the PR outcome.
🧭 Next Steps — review latest findings
No alternative fix was selected for this run. Review the session findings and CI results before merging.
|
/azp run maui-pr-uitests , maui-pr-devicetests |
|
Azure Pipelines: Successfully started running 2 pipeline(s). |
This comment has been minimized.
This comment has been minimized.
Tests Failure Analysis
Test Failure Review: Not ready - click to expandOverall verdict: Not ready. Deterministically, 60 legs/tests are red on this PR but green across the 5 recent
Coverage: 141 checks · 129 passing · 12 failing · 0 pending · 0 inaccessible · 1 unmapped · 19 unexplained build legs · 0 unaccounted failing checks · 2 aborted failing checks · 0 canceled-build checks · 0 device-test unverified · 5 unattributed · 60 regressed-vs-base. Deterministic ceiling: Not ready — 60 legs regressed vs base, 19 unexplained build legs, 2 aborted checks, 1 unmapped check, and 5 unattributed failures. Builds (this PR): maui-pr 1541641, maui-pr-uitests 1542212, maui-pr-devicetests 1542214 (device tests confirmed clean). Base sampling (net11.0, 5 recent builds per definition): maui-pr 1541716, 1541599; maui-pr-uitests 1541749, 1541455. Recommended actionHave a human open the macOS |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Graphics/tests/DeviceTests/Tests/ImageTests.cs:96
- This test covers NaN and Infinity in addition to nonpositive values, so the method name is misleading. Consider renaming to reflect that it’s asserting the no-op behavior for invalid/non-finite sizes too.
public void ScaleImageReturnsOriginalForNonPositiveSize(double width, double height)
src/Graphics/src/Graphics/Platforms/iOS/UIImageExtensions.cs:63
- The input guard relies on
!(size.Width > 0)to treat NaN as invalid, but that intent isn’t obvious. Making NaN/Infinity checks explicit (and using<= 0for the nonpositive case) improves readability and reduces the chance of future edits accidentally changing the NaN behavior.
if (!(size.Width > 0) || !(size.Height > 0) ||
double.IsInfinity(size.Width) || double.IsInfinity(size.Height))
{
return target;
}
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 from this PR and let us know in a comment if this change resolves your issue. Thank you!
Summary
UIGraphics.BeginImageContextimage-scaling path with a privateCGBitmapContextpushed onto UIKit's per-thread graphics stack.CGSizeoverload's no-op behavior for nonpositive dimensions.ScaleImageusable from background threads without changing process-global UIKit diagnostics or synchronously depending on main-queue progress.Failure addressed
The .NET 11 iOS 26.5 bindings mark
BeginImageContext,GetImageFromCurrentImageContext, andEndImageContextunsupported on iOS and MacCatalyst 17 or later. TheseCA1416diagnostics are promoted to errors and stop affected builds before tests run, as demonstrated by build 1537895.PR #37090 provides the intentionally minimal Preview 7 build unblock by suppressing those diagnostics. This PR is the durable
net11.0/RC1 implementation that removes the dependency on the unsupported APIs.Coverage
ScaleImageUsesOneXBackingScalecovers 1x, 2x, and 3x source images and verifies returned scale and backing pixel dimensions.ScaleImageReturnsOriginalForNonPositiveSizecovers zero and negative width and height values, includingdisposeOriginalbehavior.ScaleImageCanRunOnBackgroundThreadverifies ordinary worker-thread use.ScaleImageDoesNotRequireMainThreadProgressblocks the main thread while scaling on a worker and verifies the synchronous API cannot deadlock waiting for main-queue progress.ScaleImageMatchesUIKitRenderingcompares exact pixels againstUIGraphicsImageRendererfor all eight orientations using fractional dimensions, alpha, and transparency.net11.0CI will validate the retargeted PR.Issues Fixed
N/A