fix(macos): Stop two native-peer use-after-free crashes in CI - #24112
Conversation
|
Claude finished @MartinZikmund's task in 5m 9s —— View job Code Review
Overall verdict: ✅ Approve with minor concernsAll four changes address real, well-evidenced CI reliability defects. The root causes are correctly identified, the fixes are narrowly scoped, and the PR description is unusually thorough. No blocking issues. 1 medium finding, 3 minor findings posted as inline comments:
Cross-cutting notes
Timeout cancellation pattern (
|
|
🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-24112/wasm-skia-net9/index.html |
|
Claude finished @MartinZikmund's task in 4m 49s —— View job Code Review
Overall verdict: ✅ Approve with minor concernsTwo new commits were added since the previous review. Both are correct and clean. Commit Commit One pre-existing issue worth noting (not in this PR's diff, so no inline comment): Outstanding findings (from previous review, still open)The four findings from the previous review pass are not addressed in the two new commits:
|
e3834e1 to
ded5da4
Compare
|
Claude finished @MartinZikmund's task in 6m 20s —— View job Code Review
Overall verdict: ✅ Approve with minor concernsThis review covers the two commits in this synchronize: 4 outstanding findings from prior review passes remain unaddressed:
Cross-cutting notes on the new commits:
The root-cause analysis is correct:
The |
|
🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-24112/wasm-skia-net9/index.html |
|
The build 228603 found UI Test snapshots differences: Details
|
ded5da4 to
6033b01
Compare
|
Claude finished @MartinZikmund's task in 2m 56s —— View job Code Review
Overall verdict:
|
| # | Severity | File | Issue | Status |
|---|---|---|---|---|
| 1 | Medium | MacOSNativeElementHostingExtension.cs:182 |
Size.Empty returns (-∞, -∞) — layout system throws for negative infinity on the disposed-peer path, defeating the "logged no-op" intent. Fix: return new Size(0, 0). |
🆕 New |
| 2 | Medium | MacOSNativeElementHostingExtension.cs:82 |
LogLevel.Error fires on every arrange/attach/measure call after a reparent (e.g. each MediaPlayerElement full-window toggle). The PR describes this as a "known design defect"; use LogLevel.Warning so log aggregation can distinguish it from unexpected errors. |
Prior review |
| 3 | Minor | UnitTestsControl.cs:1364 |
When UNO_TEST_DEFAULT_TIMEOUT_SECONDS is set to an invalid value (e.g. "5m"), the fallback is applied silently — no log, invisible misconfiguration on CI. A warning log at the parse-failure branch would help diagnose bad CI config. |
Prior review |
| 4 | Minor | MacOSNativeElementHostingExtension.cs:43 |
uno_native_dispose(handle) is called even when NativeHandle was never assigned (handle == 0). The ObjC null guard catches it safely; an if (handle != 0) short-circuit clarifies intent. |
Prior review |
What's correct
UNONative.m— null guard only onuno_native_dispose, which is the only entry point reachable with a zero handle from C#. Removal from bothelementsandtransientsis correct and necessary; the comment explains why both are needed.DisposeNativePeerordering — handle captured beforeDisposed = true/NativeHandle = 0, state transition is correct on the UI thread, native call uses the captured non-zero pointer.- Timeout cancellation —
using var timeoutCts+timeoutCts.Cancel()before theWhenAnyresult is inspected correctly prevents lingering per-test timers. CancelledTask.DelayisTaskStatus.Canceled, not faulted — no unobserved-exception risk. TryGetLiveElementguards — correctly gates all five native entry points (arrange, attach, detach, measure, is-attached) through a single, consistent check.
|
🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-24112/wasm-skia-net9/index.html |
|
The build 228879 found UI Test snapshots differences: Details
|
6033b01 to
d2b95ea
Compare
|
Claude finished @MartinZikmund's task in 2m 45s —— View job Code Review
Overall verdict:
|
| # | Severity | File | Issue |
|---|---|---|---|
| 1 | Medium | MacOSNativeElementHostingExtension.cs:182 |
Size.Empty is {-∞, -∞} (confirmed: src/Uno.Foundation/Size.cs:31). When TryGetLiveElement rejects a disposed element, this flows into Layouter.MeasureCore, which throws InvalidOperationException for any infinity (lines 108–116) — turning the "logged no-op" into a managed exception. Fix: return new Size(0, 0); |
| 2 | Medium | MacOSNativeElementHostingExtension.cs:82 |
LogLevel.Error fires on every arrange/attach/detach/measure call after a reparent (e.g. each MediaPlayerElement full-window toggle). The PR description calls this path a "known design defect" and a "logged no-op" — use LogLevel.Warning so log aggregation can distinguish it from unexpected errors. |
| 3 | Minor | UnitTestsControl.cs:1364 |
When UNO_TEST_DEFAULT_TIMEOUT_SECONDS is set to an invalid value (e.g. "5m"), the parse fails silently and the fallback is applied with no log entry — the misconfiguration is invisible on CI. A warning log in the !int.TryParse branch would help diagnose bad CI config. |
| 4 | Minor | MacOSNativeElementHostingExtension.cs:45 |
NativeUno.uno_native_dispose(handle) is called even when NativeHandle was never assigned (handle == 0). The ObjC null guard at the top of uno_native_dispose handles it safely; an if (handle != 0) short-circuit before the P/Invoke would clarify intent. |
What is correct
UNONative.m— null guard onuno_native_disposeonly, which is correct: all other entry points are gated byTryGetLiveElementon the C# side. Removal from bothelementsandtransientsis necessary and the comment explains why.DisposeNativePeerordering — handle captured beforeDisposed = true/NativeHandle = 0; state transition is correct on the UI thread; native call uses the captured non-zero pointer.- Timeout cancellation —
using var timeoutCts+timeoutCts.Cancel()before inspectingWhenAnyresult correctly prevents lingering per-test timers. CancelledTask.DelayisTaskStatus.Canceled, not faulted — no unobserved-exception risk. TryGetLiveElementguards — consistently gates all five native entry points through a single guard, eliminating the per-methodcontent is MacOSNativeElementcast duplication.
|
🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-24112/wasm-skia-net9/index.html |
|
The build 229240 found UI Test snapshots differences: Details
|
|
Claude finished @Xiaoy312's task in 3m 47s —— View job Code Review
Overall verdict:
|
| # | Sev | File | Issue |
|---|---|---|---|
| 1 | Medium | MacOSNativeElementHostingExtension.cs:82 |
LogLevel.Error fires on every layout/arrange/measure call after any reparent. PR calls this path a "known design defect" and a "logged no-op" — use LogLevel.Warning. |
| 2 | Medium | MacOSNativeElementHostingExtension.cs:182 |
Size.Empty is {-∞, -∞}. When TryGetLiveElement rejects a disposed element, negative-infinity propagates into Layouter.MeasureCore and throws, turning the "logged no-op" into a managed layout exception. Fix: return new Size(0, 0); |
| 3 | Minor | UnitTestsControl.cs:1364 |
When UNO_TEST_DEFAULT_TIMEOUT_SECONDS is set to a non-integer (e.g. "5m", a typo), the fallback is applied silently with no log entry — invisible misconfiguration on CI. |
| 4 | Minor | MacOSNativeElementHostingExtension.cs:43 |
uno_native_dispose(handle) called even when NativeHandle was never assigned (handle == 0). ObjC null guard is safe; an if (handle != 0) short-circuit would clarify intent. |
New findings (this pass)
| # | Sev | File | Issue |
|---|---|---|---|
| 5 | Minor | MacOSNativeWebView.cs:81 |
LogLevel.Error in TryGetHandle — same issue as finding #1. Dispose fires on Unloaded which fires on reparent; every subsequent WebView operation logs an error. Should be LogLevel.Warning. Posted inline. |
| 6 | Note | MacOSNativeWebView.cs:200 |
InvokeScriptAsync awaits inside the using (token.Register(...)) block (correct — cancellation stays wired), but ExecuteScriptAsync awaits outside (cancellation unregistered before the result arrives). The inconsistency is pre-existing, not introduced here, but worth aligning as a follow-up. Posted inline. |
Xiaotian Gu's concern — synchronous tests not covered by the timeout
The new 5-minute timeout in UnitTestsControl.cs only applies to Task-returning test methods (line 1017: if (test.Method.ReturnType == typeof(Task))). Synchronous void tests that deadlock or spin are still unguarded and can hold the macOS CI job for the full 6 hours observed. If the hung test on macOS is synchronous, this PR's timeout won't catch it. A follow-up should either add a thread-abort/process-kill safety valve for sync tests, or require all new tests on the macOS leg to be async.
What is correct in the new commit
TryGetHandleread path — readingNativeHandledirectly (which is zeroed byDisposeNativePeer) instead of a stale local copy is the correct fix for the dangling-handle defect._webViewsregistration keyed on_registeredHandle— decoupling the map key from the liveNativeHandlecorrectly handles the race where the peer is already disposed whenOnUnloadedfires.- GCHandle lifetime in
ExecuteScriptAsync— the handle is freed in the native callback; a cancelled task doesn't leak because the native side still firesExecuteScriptCallback.
`TryGetLiveElement` and `TryGetHandle` are reached on every arrange, measure, attach and detach of a reparented element, so `Error` put a per-layout-pass stream into the channel reserved for genuine faults. The disposed peer is a known degraded path, not an unexpected state. Also from review: - `DisposeNativePeer` skips the P/Invoke when the handle is already zero, which happens when the peer was never created. - `ExecuteScriptAsync` awaits inside the `token.Register` scope, matching `InvokeScriptAsync`. It previously disposed the registration before awaiting, so a cancel arriving after the P/Invoke never reached the pending task. - Records why the disposed-peer measure fallback cannot be `Size.Empty`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEzBHbVj75RX9ENGARb6sL
|
Claude finished @MartinZikmund's task in 2m 33s —— View job Code Review
Overall verdict: ✅ ApproveAll six outstanding findings from prior review passes are resolved in the two new commits ( Prior findings — all addressed
Bonus fix (noted in What is correct across all three files
|
🤖 Addressed PR comments up to this point21 review threads (12 distinct points — the review bot re-posted four of them across three runs). Code in d6462d4 (Copilot agent) and f81eec0. Fixed
Pushed back
Moved out of this PR
Filed for follow-up — from @Xiaoy312's review notes
@Xiaoy312's thread on the 6-hour macOS run is answered but left open — the retry-convergence question is his and @MartinZikmund's call. @Xiaoy312 your WebView2 commit stays in this PR with authorship intact; it is the direct cause of the second crash signature, so splitting it out would have separated a fix from its evidence. |
|
🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-24112/wasm-skia-net9/index.html |
|
The build 229613 found UI Test snapshots differences: Details
|
|
|
`MacOSNativeElement` destroyed its NSView from `Unloaded`, but `Unloaded` also fires on a reparent and the framework re-enters the very same element through `AttachNativeElement`. Every operation on the way back in was then refused, so a `WebView2` removed from and re-added to the tree came back dead — which is why `When_WebMessageReceived_After_RemoveAdd` failed the macOS runtime-test job. The X11 and Win32 hosting extensions never destroyed their peer on unload either. Root-cause fix: the peer's lifetime follows the managed wrapper. The finalizer releases it, dispatched to the main thread since AppKit is main-thread only, and `uno_native_attach` drops the strong reference `uno_native_detach` took so `transients` holds only genuinely detached views. The disposed-handle guards stay as hardening — they can no longer be reached through a reparent. `addScriptMessageHandler:name:` raises on a name that is already registered, and the WKWebView now survives the round trip, so registration removes the previous handler first and `OnUnloaded` unregisters symmetrically with `OnLoaded`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014LKUHFiW2J8nNFy7FmuCsd
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-24112/wasm-skia-net9/index.html |
|
The build 229653 found UI Test snapshots differences: Details
|
|
|
`uno_webview_create`, `uno_mediaplayer_create_view` and `uno_native_create_sample` all end in `objc_autoreleaseReturnValue` (confirmed in the shipped dylib), so the handle they hand back is owned by nothing but the current autorelease pool. The native side only took a strong reference at `uno_native_attach`, which adds the view to `elements`. Any element that is never attached, or whose attach lands in a later run-loop turn than its creation, is therefore already deallocated — and both `uno_native_attach` and `uno_native_dispose` begin with an ARC `objc_retain` of their parameter, so the next call takes the process down at `+0x1f`. That is the SIGSEGV the macOS runtime-test job keeps hitting: three CI core dumps from this branch all fault at the same image offset, which symbolizes to `uno_native_dispose` retaining a freed NSView. `uno_native_track` takes the strong reference at creation instead. The peer is now owned continuously — `transients` from creation, `elements` while attached, back to `transients` on detach — until `uno_native_dispose` drops both and deallocates it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014LKUHFiW2J8nNFy7FmuCsd
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-24112/wasm-skia-net9/index.html |
|
The build 229659 found UI Test snapshots differences: Details
|
`drawInMTKView:` returned without calling managed code when `currentDrawable` was nil. The view runs with `enableSetNeedsDisplay`, so AppKit had already cleared `needsDisplay` to make that call, and the managed side latches its own request: `CompositionTarget.RequestNewFrame` sets `RenderRequested` and only clears it from `OnNativePlatformFrameRequested`, which the dropped frame never reaches. Every later `RequestNewFrame` then coalesced into an invalidation that had already been consumed, so a single nil drawable stopped the window rendering permanently. Anything awaiting a frame after that waits forever — `UITestHelper.WaitForRender` has no timeout of its own — which is how one macOS test takes the whole 60-minute job down with it and publishes no results. The nil case now re-arms `needsDisplay` on a later main-queue turn, so the frame request survives. The software path never had the defect: it calls the managed callback before its own early return. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014LKUHFiW2J8nNFy7FmuCsd
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-24112/wasm-skia-net9/index.html |
|
The build 229660 found UI Test snapshots differences: Details
|
2b3cef7
into
feature/breakingchanges
) On macOS-26 the Desktop Skia macOS runtime tests segfault in the WebView2 ExecuteScriptAsync tests every run: native peers were created owned only by the autorelease pool, so a later uno_native_attach/dispose retained a freed NSView. Backports the fix from master unchanged. (cherry picked from commit 2b3cef7) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018riLfyvT4wZYEh8iFjWkKa
) On macOS-26 the Desktop Skia macOS runtime tests segfault in the WebView2 ExecuteScriptAsync tests every run: native peers were created owned only by the autorelease pool, so a later uno_native_attach/dispose retained a freed NSView. Backports the fix from master unchanged. (cherry picked from commit 2b3cef7) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018riLfyvT4wZYEh8iFjWkKa
GitHub Issue: closes #24111
PR Type:
🐞 Bugfix
What changed? 🚀
macOS native-peer lifetime defects that take down the whole
Tests - Desktop Skia macOSjob with aSIGSEGV, losing the results of every other test in the run.Root cause: native peers were created unowned
uno_webview_create,uno_mediaplayer_create_viewanduno_native_create_sampleall end inobjc_autoreleaseReturnValue— confirmed by disassembling the shipped dylib, not by reading the source. The handle they hand back to managed code is owned by nothing but the current autorelease pool. The native side only took a strong reference later, atuno_native_attach, which adds the view to theelementsset.So any element that is never attached, or whose attach lands in a later run-loop turn than its creation, is already deallocated. Both
uno_native_attachanduno_native_disposebegin with an ARCobjc_retainof their parameter, so the next call takes the process down at+0x1f.Three CI core dumps from this branch all fault at the same image offset, which symbolizes to
uno_native_disposeretaining a freedNSView.Fix:
uno_native_tracktakes the strong reference at creation. Ownership is now continuous —transientsfrom birth,elementswhile attached, back totransientson detach — untiluno_native_disposedrops both and deallocates.Peers no longer die on a reparent
MacOSNativeElementdestroyed itsNSViewfromUnloaded, butUnloadedalso fires on a reparent and the framework re-enters the very same element throughAttachNativeElement. AWebView2removed from and re-added to the tree came back dead — which is what failedWhen_WebMessageReceived_After_RemoveAdd. Neither the X11 nor the Win32 hosting extension destroys its peer on unload.The peer's lifetime now follows the managed wrapper: the finalizer releases it, dispatched to the main thread since AppKit is main-thread only. The disposed-handle guards stay as hardening — they can no longer be reached through a reparent.
addScriptMessageHandler:name:raises on a name that is already registered, and theWKWebViewnow survives the round trip, so registration removes the previous handler first andOnUnloadedunregisters symmetrically withOnLoaded. The stale private_webviewshadow copy of the handle is gone; every call resolves throughTryGetHandle.A dropped frame no longer stops rendering permanently
drawInMTKView:returned without calling managed code whencurrentDrawablewas nil. The view runs withenableSetNeedsDisplay, so AppKit had already clearedneedsDisplayto make that call, and the managed side latches its own request:CompositionTarget.RequestNewFramesetsRenderRequestedand only clears it fromOnNativePlatformFrameRequested, which the dropped frame never reaches. Every laterRequestNewFramethen coalesced into an invalidation that had already been consumed, so a single nil drawable stopped that window rendering for good. It now re-armsneedsDisplayon a later main-queue turn. The software path never had the defect — it calls the managed callback before its own early return.Validation
uno_mediaplayer_create_view(no arguments): create → drain the pool →uno_native_disposegivesSegmentation fault: 11, exit 139 — the same exit code CI reported. The fixed dylib survives, exit 0.When_WebMessageReceived_After_RemoveAddpasses on macOS Skia;Given_WebView29/9.Given_WebView2+Given_MediaPlayerElement+Given_ContentPresenter: 278 passed, 0 failed, clean exit.Given_ListViewBaseshows no regression (144/146; the one failure,When_ThemeChange, fails identically without this change).One claim to correct from an earlier revision of this description: the nil-drawable fix was described as the cause of the macOS 60-minute job hangs. That is not established.
UITestHelper.WaitForRenderdoes have a 1000 ms bound, andNativeDispatcher.TryGetRenderActionconsumes a render action one-shot, so a dead render loop makes a test fail in about a second rather than hang. The latch is a genuine defect worth fixing on its own terms; it is not the hang's explanation. The hang reproduces onfeature/breakingchangeswithout any of this PR's code and is tracked separately.PR Checklist ✅
When_WebMessageReceived_After_RemoveAddalready existed and is the fail-before/pass-after case for the reparent fix. The ownership defect is covered by a native repro described above; there is no managed harness that can reach it.Screenshots Compare Test Runresults.🤖 Generated with Claude Code
https://claude.ai/code/session_014LKUHFiW2J8nNFy7FmuCsd