Skip to content

Do not present the root surface when an external view embedder owns it - #193000

Merged
auto-submit[bot] merged 5 commits into
flutter:masterfrom
jwinarske:pr2/render-to-surface
Oct 8, 2026
Merged

auto-submit[bot] merged 5 commits into
flutter:masterfrom
jwinarske:pr2/render-to-surface

Conversation

@jwinarske

Copy link
Copy Markdown
Contributor

GPUSurfaceVulkan and GPUSurfaceGLImpeller both take render_to_surface and return a no-op SurfaceFrame when it is false. GPUSurfaceVulkanImpeller has no such flag, so with a compositor the engine acquires an embedder image and presents it after every composited frame.

The compositor has already presented that frame's layers. The root present puts an unrendered image on top of them. An embedder holding a single image sees nothing; one cycling a ring of scanout buffers sees its frame replaced by whatever the ring handed back — one good frame, then black, at a flip rate that still reads 60fps. That is what it looked like on hardware before I found this.

Nothing can reach it today: with a compositor and Impeller on Vulkan the embedder cannot build a render target at all, so the frame fails earlier (#187525). It becomes reachable with that fix, so the gate is groundwork for it.

Also moves DisposeThreadLocalCachedResources ahead of the gate. It is per-frame upkeep, not part of presenting, and leaving it after would hand the compositor path a raster thread that holds its command and descriptor pools for the life of the engine.

Tests

Two in gpu_surface_vulkan_impeller_unittests.cc:

  • DoesNotPresentWhenNotRenderingToSurface — no image acquired, none presented, and the thread-local pools still released. Fails without the gate; fails again if the dispose is moved back after it ("thread-local pools survived a frame the compositor presented").
  • PresentsWhenRenderingToSurface — the default still acquires, which is what a compositor-less embedder relies on.

Verified against the failing behavior, not just green: reverting each half of the change fails the matching assertion.

Second of six, from #187525. Independent of the others; conflicts textually with the third (both touch gpu_surface_vulkan_impeller.cc), so whichever lands second wants a rebase.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces a render_to_surface flag to GPUSurfaceVulkanImpeller to prevent acquiring or presenting an image when an external view embedder is responsible for presentation. It also moves the disposal of thread-local cached resources to the beginning of AcquireFrame. The review feedback suggests moving the IsValid() check to the very beginning of AcquireFrame to prevent resource disposal or dummy frame creation on an invalid surface.

Comment thread engine/src/flutter/shell/gpu/gpu_surface_vulkan_impeller.cc Outdated
@github-actions github-actions Bot added the engine flutter/engine related. See also e: labels. label Sep 18, 2026
@jwinarske
jwinarske force-pushed the pr2/render-to-surface branch 3 times, most recently from 2d840f2 to 984e119 Compare September 18, 2026 20:09
@jwinarske

Copy link
Copy Markdown
Contributor Author

Force-pushed a fix. CI was red because this did not build standalone: hoisting DisposeThreadLocalCachedResources above the gate removed the impeller::ContextVK& context_vk declaration along with the call, and a later line in the same branch still used it. Declaration restored.

My mistake was only ever building the whole series together, where the next PR happens to supply what this one dropped. Each PR has to build on its own, which is what CI builds.

GPUSurfaceVulkan and GPUSurfaceGLImpeller both take render_to_surface and
hand back a no-op SurfaceFrame when it is false. GPUSurfaceVulkanImpeller
has no such flag, so with a compositor the engine acquires an embedder
image and presents it after every composited frame.

The compositor has already presented that frame's layers. The root present
puts an unrendered image on top of them. An embedder holding one image sees
nothing; one cycling a ring of scanout buffers sees its frame replaced by
whatever the ring handed back -- one good frame, then black, at a flip rate
that still reads 60fps.

Reachable once the embedder can build an Impeller render target from a
Vulkan backing store (flutter#187525); the gate is the groundwork
for it.

Dispose of the thread-local pools before the gate rather than after. It is
per-frame upkeep, not part of presenting, and skipping it on the compositor
path would leave the raster thread holding its command and descriptor pools
for the life of the engine.
// holding those pools for the life of the engine.
if (impeller_context_) {
impeller::ContextVK::Cast(*impeller_context_)
.DisposeThreadLocalCachedResources();

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.

Several presubmit CI tests (linux_host_engine_test, linux_unopt, and framework_tests_impeller) are failing with what looks like a real crash:

system_error was thrown in -fno-exceptions mode with error 22 and message "mutex lock failed"
TestDeviceException(Shell subprocess crashed with SIGABRT (-6).)

I suspect this failure is triggered by the downcast in GPUSurfaceVulkanImpeller::AcquireFrame:

if (impeller_context_) {
  impeller::ContextVK::Cast(*impeller_context_)
      .DisposeThreadLocalCachedResources();
}

impeller_context_ is often an instance of SurfaceContextVK, which inherits directly from impeller::Context rather than impeller::ContextVK. The ContextVK::Cast unchecked downcast causes undefined behavior. In ContextVK::DisposeThreadLocalCachedResources(), attempting to lock desc_pool_mutex_ at an invalid offset causes pthread_mutex_lock to return EINVAL (error 22) and abort the process.

Because DisposeThreadLocalCachedResources() is already declared as a virtual method on the impeller::Context base class (and implemented by both ContextVK and SurfaceContextVK), you can call it directly on impeller_context_ without casting:

if (impeller_context_) {
  impeller_context_->DisposeThreadLocalCachedResources();
}

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.

Commit 587a346 LGTM for fixing this issue.

impeller_context_ is a shared_ptr<impeller::Context> handed in by the caller, and
it is often a SurfaceContextVK, which derives from Context rather than ContextVK.
ContextVK::Cast on it is an unchecked downcast: DisposeThreadLocalCachedResources
then locks desc_pool_mutex_ at the wrong offset, pthread_mutex_lock returns
EINVAL, and the process aborts. The same member is cast to SurfaceContextVK a few
lines below, so the two casts could not both be right.

DisposeThreadLocalCachedResources, CreateCommandBuffer and GetCommandQueue are
all virtual on Context and SurfaceContextVK overrides or delegates them, so call
through the base and drop the casts.
andywolff
andywolff previously approved these changes Oct 6, 2026

@andywolff andywolff left a comment

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.

dashboard checks shows the previously failing tests are passing now. It had an unrelated flake, so I hit retry. LGTM

// holding those pools for the life of the engine.
if (impeller_context_) {
impeller::ContextVK::Cast(*impeller_context_)
.DisposeThreadLocalCachedResources();

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.

Commit 587a346 LGTM for fixing this issue.

587a346 also changed the submit callback. That was unnecessary: the callback is
built inside the `delegate_ != nullptr` branch, the embedder backing-store path,
where impeller_context_ is a ContextVK -- the same branch as the GetDevice cast
below it. ContextVK::Cast is sound there.

Only DisposeThreadLocalCachedResources runs ahead of that branch, on both paths,
so it is the one place the context may be a SurfaceContextVK and the cast
undefined. That is the line andywolff flagged, and it stays fixed.

Restores the callback to ContextVK::Cast so the change is the single line.

@andywolff andywolff left a comment

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.

LGTM. FYI I pushed a merge to move past a tree breakage so CI can pass for this PR

@andywolff andywolff added the autosubmit Merge PR when tree becomes green via auto submit App label Oct 7, 2026
@auto-submit
auto-submit Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into flutter:master with commit a7cb895 Oct 8, 2026
23 checks passed
@flutter-dashboard flutter-dashboard Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD engine flutter/engine related. See also e: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants