Repository navigation
[Android] Fix the HCPP SurfaceControl transaction-list race and split the transaction entry points - #192606
Merged
auto-submit[bot] merged 17 commits intoSep 16, 2026
Conversation
… 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.
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.
…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.
…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.
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
requested changes
Sep 15, 2026
mboetger
left a comment
Contributor
There was a problem hiding this comment.
I think the arrays need to be cleaned up, but other than that LGTM
gmackall
force-pushed
the
isolate-hcpp-transactions
branch
from
September 15, 2026 18:08
43b159e to
ba6f0b7
Compare
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
force-pushed
the
isolate-hcpp-transactions
branch
from
September 15, 2026 18:31
ba6f0b7 to
9ec8873
Compare
mboetger
approved these changes
Sep 15, 2026
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. |
This was referenced 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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.createTransaction()is the raster/JNI entry and always returns an isolated transaction, while a privateplatformTransaction()consolidates platform-thread mutations (clips, overlay visibility) into one transaction per frame.onEndFrame()still allocates a merge destination, merges, invalidates and applies on every frame, exactly as before.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:
AHBSwapchainImplVK::Presentwrites through the borrowedASurfaceTransaction*after the transaction has been published to Java, so a merge on the platform thread can race those writes.NativeAllocationRegistrycleaner can free the native transaction while native code is still using it. Avoiding an explicitclose()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:
PlatformViewsController2Testtests pass.NullPointerException: Cannot read field "mResizedSurfaces" because "other" is nullin 10/10 runs — the production stack trace, whichFML_CHECK(fml::jni::CheckException(env))turns into anabort()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 --checkpass.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-assistbot 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.