Skip to content

[Android] Fix the HCPP SurfaceControl transaction-list race and split the transaction entry points - #192606

Merged
auto-submit[bot] merged 17 commits into
flutter:masterfrom
gmackall:isolate-hcpp-transactions
Sep 16, 2026
Merged

auto-submit[bot] merged 17 commits into
flutter:masterfrom
gmackall:isolate-hcpp-transactions

Conversation

@gmackall

@gmackall gmackall commented Sep 11, 2026 •

Copy link
Copy Markdown
Member

Description

Fix the Android 14+ HCPP transaction-list race. The raster thread added to the pending transaction list while the platform thread swapped it, leaving null entries that made merge(null) throw and abort the engine through JNI.

  • Guard the pending raster transaction list with one lock shared by raster-thread additions and platform-thread draining. The active raster transaction list and both platform transaction slots are confined to the platform thread.
  • Split the entry points by caller rather than inferring the thread at runtime: createTransaction() is the raster/JNI entry and always returns an isolated transaction, while a private platformTransaction() consolidates platform-thread mutations (clips, overlay visibility) into one transaction per frame.
  • Close platform transactions once they are merged or discarded. Raster transactions are not closed here, because their native producer may still be writing through them.
  • Detached-view handling is unchanged: onEndFrame() still allocates a merge destination, merges, invalidates and applies on every frame, exactly as before.
  • Remove the unused applyTransactions() JNI path, which nothing has ever called.

This fixes the Java-side list corruption only. Two pre-existing native ownership hazards remain, and both need the same follow-up:

  1. AHBSwapchainImplVK::Present writes through the borrowed ASurfaceTransaction* after the transaction has been published to Java, so a merge on the platform thread can race those writes.
  2. Once Java drops its last reference, the NativeAllocationRegistry cleaner can free the native transaction while native code is still using it. Avoiding an explicit close() narrows this window but does not close it.

Fixing either one properly requires the native side to retain the transaction and publish it only after the producer has finished writing. That is a separate change.

Tests

Coverage includes platform consolidation, raster isolation, the separate merge destination, transactions still in use by a paused producer, discarded platform transactions, and detached frames. The tests drive platform mutations through their real call sites rather than calling createTransaction() directly.

Verified locally with a Robolectric harness built against these sources:

  • 20/20 PlatformViewsController2Test tests pass.
  • The stress test passes 10 consecutive runs with 0 skips (3.4s total).
  • With the locks removed from a copy of the sources, the same test reports NullPointerException: Cannot read field "mResizedSurfaces" because "other" is null in 10/10 runs — the production stack trace, which FML_CHECK(fml::jni::CheckException(env)) turns into an abort() on device.

Detection is probabilistic and one-sided: the test contains no timing assertions, so scheduling decides whether it catches an unsynchronized implementation, never whether it fails a synchronized one. A barrier timeout skips the run; a producer that fails to terminate fails it. It covers list corruption, not liveness, and does not exercise the native writes described above.

Java formatting and git diff --check pass.


Portions of this change and this description were authored with AI assistance.

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance.

Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the gemini-code-assist bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.

… thread confinement

Consolidates per-frame platform-thread transactions (clips, overlays) into
a single transaction per frame, while isolating raster-thread swapchain buffer
presentations into thread-confined transactions to prevent concurrent mutation crashes.
swapTransactions() discarded both the stale active raster transactions and
the stale active platform transaction by overwriting them, without calling
close(). Those belong to a frame whose onEndFrame() never ran, so they are
never applied, and raster transactions can already have buffers attached.
Relying on the NativeAllocationRegistry cleaner defers releasing the native
transaction and its buffer references to an arbitrary later GC.

Also adds a newTransaction() seam so tests can observe the transactions the
controller actually retains; overriding createTransaction() cannot do this
because the returned instance is not necessarily the stored one.
Nothing has ever called this. The chain spanned eight sites:

  PlatformViewsController2.applyTransactions()   (the implementation)
  FlutterJNI.applyTransactions()                 (Java wrapper)
  FLUTTER_FOR_EACH_JNI_METHOD                    (binds g_apply_transaction_method)
  PlatformViewAndroidJNIImpl::applyTransaction() (native impl + header decl)
  PlatformViewAndroidJNI::applyTransaction()     (pure virtual)
  JNIMock / MockPlatformViewAndroidJNI           (gmock overrides)

HCPP applies its transactions through onEndFrame() via
AttachedSurfaceControl.applyTransactionOnDraw(), which is unaffected.
@github-actions github-actions Bot added platform-android Android applications specifically engine flutter/engine related. See also e: labels. team-android Owned by Android platform team labels Sep 11, 2026
@gmackall gmackall added CICD Run CI/CD and removed platform-android Android applications specifically engine flutter/engine related. See also e: labels. team-android Owned by Android platform team labels Sep 11, 2026
@gmackall gmackall changed the title Isolate hcpp transactions [Android] Fix HCPP SurfaceControl transaction data race and consolidate per-frame transactions Sep 11, 2026
The existing tests cover the structure of the change (consolidation,
raster isolation, closing dropped transactions) but none of them fail if
the locking is removed. This adds one that does.

The test models the real access pattern: four background threads call
createTransaction() the way the raster thread does once per AHB swapchain
present, while the platform thread calls it twice per round (standing in
for maybeApplyClipToSurfaceView(), once per SurfaceView platform view)
and then runs swapTransactions() and onEndFrame(). Rounds are
barrier-synchronized so the collision density is a property of the test
rather than of how two free-running loops happen to balance on a given
machine.

Measured against the parent of the first commit on this branch, it fails
10/10 runs, every time with the production stack trace:

  java.lang.NullPointerException: Cannot read field "mResizedSurfaces"
      because "other" is null
    at android.view.SurfaceControl$Transaction.merge(SurfaceControl.java:4982)
    at io.flutter.plugin.platform.PlatformViewsController2.onEndFrame(
        PlatformViewsController2.java:673)

On device that NPE unwinds into onEndFrame2(), where
FML_CHECK(fml::jni::CheckException(env)) turns it into an abort().

With the fix it passes 10/10 and costs 56ms.

Some notes on why this is safe to run in CI:

- It is a probabilistic detector, but a one-sided one. It contains no
  timing assertions, so scheduling decides whether it catches an
  unsynchronized implementation, never whether it fails a synchronized
  one. It will catch wholesale loss of the locking; it is not guaranteed
  to catch a subtle partial regression.
- It asserts a floor on completed rounds. A concurrency test that
  silently does no work is worse than no test, because it reports green.
- Joins are bounded and the barrier is broken explicitly during cleanup,
  so a failure reports immediately instead of waiting out a timeout, and
  a hang fails legibly instead of wedging the bot.

The presents-per-round constant is load-bearing: it sets how long
swapTransactions() stays exposed to a concurrent add(). At 8 the
detection rate drops to 8/10, at 64 it is 10/10.

Generated with Gemini assistance.
@github-actions github-actions Bot added platform-android Android applications specifically engine flutter/engine related. See also e: labels. team-android Owned by Android platform team labels Sep 11, 2026
…te from empty transaction apply

- In onEndFrame(), remove the zero-transaction early return and make
  flutterView.invalidate() unconditional, while guarding
  rootSurfaceControl.applyTransactionOnDraw(tx) on a non-null tx.
  This ensures any pending transactions held in ViewRootImpl are still
  drawn, and re-activates the flutter#190609 regression tests.
- Add a test verifying onEndFrame() invalidates even when zero
  transactions are produced.
- In swapTransactions(), update close() documentation to clarify that it
  releases the acquire-fence FD and native memory rather than recycling
  buffers.
- In createTransaction(), clarify that while transaction lists are
  synchronized, transaction objects themselves still have a pre-existing
  native-side write-after-publish race.
- In the concurrency stress test, separate product-level assertion
  failures from harness-level timeouts/stalls using Assume.assumeNoException
  to prevent false-positive bug reports on loaded CI bots.
- Remove the dead minRounds floor in the stress test and document coverage
  boundaries.

Generated with Gemini assistance.
Aligns the pendingRasterTransactions field documentation with createTransaction()
to clarify that transactionLock synchronizes Java list access rather than guaranteeing
full native transaction object isolation (cross-referencing the pre-existing native
write-after-publish race).

Also updates PlatformViewsController2Test to document that onEndFrame() walks and
closes unapplied transactions when flutterView is null.

Generated with Gemini assistance.
…hazard

createTransaction() is now the raster/JNI entry point and always allocates an
isolated transaction. A private platformTransaction() consolidates the
platform-thread mutations (clips, overlay visibility). This drops the
Looper-based thread inference, which inferred at runtime what the call sites
already know statically.

Also removes the wrong @UiThread on FlutterJNI.createTransaction(), closes the
platform transaction once it has been merged, and reverts the unrelated
disposed-view guard in surfaceCreated().

createTransaction() now documents both native hazards: the write-after-publish
race, and that dropping the Java reference lets GC free a transaction native
code may still be writing through. Neither is fixed here; both need native
lifetime retention plus publication after the producer finishes.

Tests drive platform mutations through showOverlaySurface()/hideOverlaySurface()
rather than calling createTransaction() directly, so they exercise the real call
sites.

Verified with a local Robolectric harness: 21/21 controller tests pass, and the
stress test passes 10 runs with 0 skips. With the locks removed from a copy of
the sources, the same test reports the merge(null) NPE in 10/10 runs.
@gmackall gmackall changed the title [Android] Fix HCPP SurfaceControl transaction data race and consolidate per-frame transactions [Android] Fix the HCPP SurfaceControl transaction-list race and split the transaction entry points Sep 11, 2026
@mboetger

Copy link
Copy Markdown
Contributor

Looks like #192172, #192170, #192169, #192165 would benefit from this fix.

…ionale

onEndFrame() now always allocates a merge destination, always merges, and
always calls applyTransactionOnDraw(), which is master's control flow. The
only remaining behavioural deltas from master are the lock, the
platform/raster split, and the close() calls.

Skipping the apply on an empty frame saved roughly one allocation on a path
that barely occurs: on API 34+ with a live AHB swapchain there is always at
least one raster transaction, because createTransaction() is called
synchronously inside frame->Submit(). It also weakened the flutter#190609
regression tests by returning before the rootSurfaceControl guard. The
optimization can be argued separately on its own numbers.

Removes onEndFrameInvalidatesEvenWhenTheFrameProducedNoTransactions, whose
never().applyTransactionOnDraw() assertion was that behaviour.

Also corrects the justification for close(). An earlier commit message
claimed it releases buffer references deterministically. It does not: for an
applied transaction the completion callback is registered with libgui's
process-global TransactionCompletedListener, and destroying the Java object
unregisters nothing. What close() does release promptly is the native
transaction and the file descriptors it owns, including acquire fences
merged in from raster buffers. A discarded platform transaction carries only
alpha/crop/visibility, so it has no buffers or fences at all.
pendingRasterTransactions is the only field touched by more than one
thread: the raster thread appends via createTransaction(), the platform
thread drains it in swapTransactions(). Everything else the lock covered
- activeRasterTransactions and the two platform transaction slots - is
written and read exclusively on the platform task runner.

Holding the lock across those fields did not buy safety. Callers of
platformTransaction() mutate the returned transaction after the monitor
is released, and SurfaceControl.Transaction has no internal mutex, so
locking the slot gave the appearance of protection without the
substance. Meanwhile it put real work in the monitor the raster thread
contends on every frame: a transaction allocation in createTransaction(),
an fd-releasing close() syscall in swapTransactions(), and a defensive
copy of the active list in onEndFrame().

The happens-before edge that fixes the crash is unchanged: the raster
thread's add() and the platform thread's addAll() still pair on the same
monitor.

No behavior change. 20/20 PlatformViewsController2Test pass.
@gmackall
gmackall requested a review from mboetger September 14, 2026 20:20
@gmackall
gmackall marked this pull request as ready for review September 14, 2026 20:20
@gmackall
gmackall requested a review from a team as a code owner September 14, 2026 20:20
Matches the dominant convention in the Android embedding, where list
iteration uses for-each roughly 111 times to 3. The indexed loops that
remain are mostly SparseArray, which has no Iterator.

Review feedback on flutter#192606.
Google3's Error Prone [CheckReturnValue] check rejects a bare
tx.merge(...) call, failing the javac build of
//third_party/flutter_engine/shell/platform/android:android and every
target that depends on it.

Assign the result back to tx, matching the pattern this method used
before this PR.

@mboetger mboetger 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.

I think the arrays need to be cleaned up, but other than that LGTM

@gmackall
gmackall force-pushed the isolate-hcpp-transactions branch from 43b159e to ba6f0b7 Compare September 15, 2026 18:08
Platform transactions only touch SurfaceControls that the detach
invalidates, including the overlay that destroyOverlaySurface() just
released, and nothing outside the platform thread references them, so
close them. Raster transactions are dropped without closing: their
native producers may still be writing into them.
@gmackall
gmackall force-pushed the isolate-hcpp-transactions branch from ba6f0b7 to 9ec8873 Compare September 15, 2026 18:31
@gmackall
gmackall requested a review from mboetger September 15, 2026 18:45
@gmackall gmackall added the autosubmit Merge PR when tree becomes green via auto submit App label Sep 15, 2026
@auto-submit auto-submit Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Sep 15, 2026
@auto-submit

auto-submit Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

autosubmit label was removed for flutter/flutter/192606, because - The status or check suite Dashboard Checks has failed. Please fix the issues identified (or deflake) before re-applying this label.

@gmackall gmackall added autosubmit Merge PR when tree becomes green via auto submit App and removed autosubmit Merge PR when tree becomes green via auto submit App labels Sep 15, 2026
@auto-submit
auto-submit Bot added this pull request to the merge queue Sep 16, 2026
Merged via the queue into flutter:master with commit 66afb82 Sep 16, 2026
41 checks passed
@flutter-dashboard flutter-dashboard Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Sep 16, 2026
gmackall added a commit to gmackall/flutter that referenced this pull request Oct 2, 2026
…orm views are active

When HCPP (hybrid composition++) is enabled but no platform views are
present in the frame, the AHB swapchain no longer round-trips its
SurfaceControl transaction through Java. The raster thread creates and
applies an owned ASurfaceTransaction directly, avoiding a platform-thread
hop and the resulting dependency on main-thread availability.

Frames that contain platform views, that follow a frame which had platform
views, or that change the frame size continue to route through Java so
that platform view mutations and the Flutter content are applied in the
same transaction via applyTransactionOnDraw.

This is a squash-rebase of the original flutter#192083 branch onto master after
flutter#192606 (createTransaction publishes directly into
pendingRasterTransactions) landed; the parts of this change that
overlapped with flutter#192606 are dropped.
MylioClaude added a commit to mylollc/flutter that referenced this pull request Oct 6, 2026
…platform race (OLYM-5308) (#4)

With EnableSurfaceControl (HCPP) on Android 14+, the raster thread appends
a SurfaceControl.Transaction to PlatformViewsController2.pendingTransactions
for every presented frame (FlutterJNI.createTransaction), while the platform
thread drains the same unsynchronized ArrayList after every frame that has no
platform view (applyTransaction -> applyTransactions). An append that races
the drain leaves a null slot; merge(null) throws a NullPointerException on
the main thread, and FML_CHECK(CheckException) after the JNI call in
PlatformViewAndroidJNIImpl::applyTransaction (line 2298) aborts the app.
A race on any frame, so an app left on screen dies within minutes.

Every access to pendingTransactions now holds one lock; applyTransactions
drains a snapshot and merges outside the lock. Same idea as upstream
flutter#192606, kept minimal because this branch still calls
applyTransaction from the no-platform-view frame path.

Co-authored-by: Mylio Sysops <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
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. platform-android Android applications specifically team-android Owned by Android platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants