Medium CVSS 6.5 webkit UAF 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected process crash
ComponentWebKit
Sub-componentThirdParty ANGLE
Bug ClassUAF
Tracker314398
Fix commit9d2cc8c9895d (WebKit/WebKit) +49/-56
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CISA KEVNot listed
CreditedAn anonymous researcher
Disclosed2026-06-29

Background

ANGLE
The translation layer WebKit uses to run WebGL/WebGPU on top of native GPU APIs; its Metal backend (ContextMtl/QueryMtl) runs inside the GPU process.
Occlusion query
A GPU feature that counts whether fragments passed the depth test; WebGL/WebGPU expose it, and the result is written into a Metal visibility-result buffer.
Render pass
A unit of Metal GPU work with a defined begin/end. A query can start in one pass and continue in another, so its lifetime is not naturally tied to a single pass.

Root Cause Analysis

This fixes a use-after-free in the ANGLE Metal backend’s occlusion-query handling (ContextMtl), used by WebGL/WebGPU occlusion queries.

Before the fix, ContextMtl stored a raw pointer to the active query (QueryMtl* mOcclusionQuery) and dereferenced it across render-pass boundaries in begin/end/destroy/restart (e.g. disableActiveOcclusionQueryInRenderPass, restartActiveOcclusionQueryInRenderPass, startOcclusionQueryInRenderPass all read mOcclusionQuery->getAllocatedVisibilityOffsets()). The query object’s lifetime is not tied to the render pass, so with adversarial timing of EndQuery/DeleteQueries versus render-pass start/end, ContextMtl could dereference a QueryMtl that had already been destroyed (or whose visibility offsets were freed), causing a use-after-free / crash in the GPU process.

The fix removes the raw query pointer entirely: onOcclusionQueryBegin now captures the query’s ref-counted visibility result buffer (mtl::BufferRef mOcclusionQueryResultBuffer) and a flag (mOcclusionQueryIsEnabledInRenderPass), and all continue/disable operations work off that buffer plus pool offsets (beginQuery/continueQuery/discardQuery) rather than the QueryMtl object. onOcclusionQueryDestroy takes the query only to discard its buffer via the pool (valid whether or not end was called), QueryMtl::onDestroy guards on mVisibilityResultBuffer, and RenderCommandEncoder checks hasPendingVisibilityResults().

The restored invariant is that ContextMtl never dereferences a QueryMtl that may have been freed — it holds a strong reference to the result buffer for the query’s active duration instead. INFERENCE: this is a broad refactor of the ANGLE Metal occlusion path; the precise freed-pointer dereference is one of the mOcclusionQuery-> uses removed here, and ANGLE-internal details beyond the shown hunks are not all included.

Key insight
ContextMtl kept a raw QueryMtl* whose lifetime was not tied to the render pass that dereferenced it. The fix removes the object pointer entirely and instead holds a strong reference to the query’s ref-counted visibility-result buffer for the duration it is needed — turning a lifetime assumption into an enforced ownership guarantee.

Attack Path

  1. Use occlusion queries from WebGL/WebGPU Web content issues BeginQuery/EndQuery (ANY_SAMPLES(_CONSERVATIVE)) on the Metal-backed ANGLE context.
  2. Span render-pass boundaries The query is kept active while a render pass ends and a new one begins, exercising ContextMtl’s continue/disable paths that read the raw mOcclusionQuery pointer.
  3. Destroy the query with adversarial timing deleteQueries / context teardown frees the QueryMtl (or its visibility offsets) while ContextMtl still holds the raw pointer.
  4. Use-after-free in the GPU backend ContextMtl dereferences the freed QueryMtl when continuing or disabling the query in a render pass, crashing the process (now avoided by holding the result buffer by reference).

Impact Assessment

A use-after-free in the GPU process, reachable from ordinary WebGL/WebGPU content in any tab. The GPU process is a valuable target because it is shared across origins and handles attacker-influenced graphics data; corrupting its heap is a strong primitive toward sandbox-constrained code execution. No special privileges are required — just script that issues occlusion queries with adversarial destroy timing.

Changed Functions

FunctionChangeNotes
DisplayMtl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
modified
FramebufferMtl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
modified
QueryMtl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
modified
VertexArrayMtl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
modified
ProgramMtl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
modified
ProgramExecutableMtl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
modified
ContextMtl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.h
modified
VisibilityBufferOffsetsMtl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.h
modified
QueryMtl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.h
modified
ContextMtl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.h
modified
QueryMtl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.h
modified
VisibilityBufferOffsetsMtl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.h
modified

Files Changed

  • LayoutTests/platform/mac-wk2/TestExpectations
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.h
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.mm
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_command_buffer.mm
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.h
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.mm
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_render_utils.h
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_render_utils.mm
  • Source/ThirdParty/ANGLE/src/tests/gl_tests/OcclusionQueriesTest.cpp

Audit Directions

  • Other raw GL-object pointers across pass boundaries
    Audit the ANGLE Metal backend for other renderer objects cached as raw pointers (not RefPtr/BufferRef) and dereferenced after a render-pass boundary or across begin/end/destroy transitions.
  • Query/encoder lifetime coupling
    Check every place that assumes a query, buffer, or encoder outlives the operation using it; prefer capturing the ref-counted resource over the owning object.
diff --git a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
index 41553c65283a..a85c8ecffa9f 100644
--- a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
+++ b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
@@ -31,6 +31,7 @@ namespace rx
 {
 class DisplayMtl;
 class FramebufferMtl;
+class QueryMtl;
 class VertexArrayMtl;
 class ProgramMtl;
 class ProgramExecutableMtl;
@@ -301,20 +302,22 @@ class ContextMtl : public ContextImpl, public mtl::Context
                                        bool renderPassChanged);
     void onBackbufferResized(const gl::Context *context, WindowSurfaceMtl *backbuffer);
 
-    // Invoke by QueryMtl
-    angle::Result onOcclusionQueryBegin(const gl::Context *context, QueryMtl *query);
-    void onOcclusionQueryEnd(const gl::Context *context, QueryMtl *query);
-    void onOcclusionQueryDestroy(const gl::Context *context, QueryMtl *query);
+    angle::Result onOcclusionQueryBegin(QueryMtl &query);
+    void onOcclusionQueryEnd();
+    void onOcclusionQueryDestroy(QueryMtl &query);
 
     // Useful for temporarily pause then restart occlusion query during clear/blit with draw.
-    bool hasActiveOcclusionQuery() const { return mOcclusionQuery; }
+    bool isOcclusionQueryEnabledInRenderPass() const
+    {
+        return mOcclusionQueryIsEnabledInRenderPass;
+    }
     // Disable the occlusion query in the current render pass.
     // The render pass must already started.
-    void disableActiveOcclusionQueryInRenderPass();
+    void disableOcclusionQueryInRenderPass();
     // Re-enable the occlusion query in the current render pass.
     // The render pass must already started.
     // NOTE: the old query's result will be retained and combined with the new result.
-    angle::Result restartActiveOcclusionQueryInRenderPass();
+    angle::Result enableOcclusionQueryInRenderPass();
 
     // Invoke by TransformFeedbackMtl
     void onTransformFeedbackActive(const gl::Context *context, TransformFeedbackMtl *xfb);
@@ -536,8 +539,6 @@ class ContextMtl : public ContextImpl, public mtl::Context
                                          bool xfbPass,
                                          bool *pipelineDescChanged);
 
-    angle::Result startOcclusionQueryInRenderPass(QueryMtl *query, bool clearOldValue);
-
     angle::Result checkCommandBufferError();
 
     // Dirty bits.
@@ -607,7 +608,6 @@ class ContextMtl : public ContextImpl, public mtl::Context
     FramebufferMtl *mDrawFramebuffer  = nullptr;
     VertexArrayMtl *mVertexArray      = nullptr;
     ProgramExecutableMtl *mExecutable = nullptr;
-    QueryMtl *mOcclusionQuery         = nullptr;
 
     using DirtyBits = angle::BitSet<DIRTY_BIT_MAX>;
 
@@ -658,6 +658,9 @@ class ContextMtl : public ContextImpl, public mtl::Context
     id<MTLTexture> mRasterizationRateMapTexture;
 
     mtl::ContextDevice mContextDevice;
+
+    mtl::BufferRef mOcclusionQueryResultBuffer;
+    bool mOcclusionQueryIsEnabledInRenderPass{false};
 };
 
 }  // namespace rx
diff --git a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm
index 644b213beee9..8703f6c2d578 100644
--- a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm
+++ b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm
@@ -1823,7 +1823,7 @@ void ContextMtl::endEncoding(bool forceSaveRenderPassContent)
             mRenderEncoder.setStoreAction(MTLStoreActionStore);
         }
 
-        disableActiveOcclusionQueryInRenderPass();
+        disableOcclusionQueryInRenderPass();
 
         mOcclusionQueryPool.prepareRenderPassVisibilityPoolBuffer(this);
 
@@ -2281,84 +2281,67 @@ void ContextMtl::onBackbufferResized(const gl::Context *context, WindowSurfaceMt
     onDrawFrameBufferChangedState(context, framebuffer, true);
 }
 
-angle::Result ContextMtl::onOcclusionQueryBegin(const gl::Context *context, QueryMtl *query)
+angle::Result ContextMtl::onOcclusionQueryBegin(QueryMtl &query)
 {
-    ASSERT(mOcclusionQuery == nullptr);
-    mOcclusionQuery = query;
-
+    ASSERT(!mOcclusionQueryResultBuffer);  // Frontend guarantees none active at the time.
+    const mtl::BufferRef &resultBuffer = query.getVisibilityResultBuffer();
+    bool isEnabledInRenderPass;
     if (mRenderEncoder.valid())
     {
-        // if render pass has started, start the query in the encoder
-        return startOcclusionQueryInRenderPass(query, true);
+        size_t resultOffset;
+        ANGLE_TRY(mOcclusionQueryPool.beginQuery(this, resultBuffer, &resultOffset));
+        mRenderEncoder.setVisibilityResultMode(MTLVisibilityResultModeBoolean, resultOffset);
+        // Result is available after flush.
+        mCmdBuffer.setWriteDependency(resultBuffer, /*isRenderCommand=*/true);
+        isEnabledInRenderPass = true;
     }
     else
     {
-        query->resetVisibilityResult(this);
+        // Reset the occlusion query result stored in buffer to zero.
+        // Later draws will use continueQuery() to enable the visibility buffer writes.
+        auto blitEncoder = getBlitCommandEncoder();
+        blitEncoder->fillBuffer(resultBuffer, NSMakeRange(0, mtl::kOcclusionQueryResultSize), 0);
+        resultBuffer->syncContent(this, blitEncoder);
+        isEnabledInRenderPass = false;
     }
-
+    mOcclusionQueryResultBuffer          = resultBuffer;
+    mOcclusionQueryIsEnabledInRenderPass = isEnabledInRenderPass;
     return angle::Result::Continue;
 }
-void ContextMtl::onOcclusionQueryEnd(const gl::Context *context, QueryMtl *query)
-{
-    ASSERT(mOcclusionQuery == query);
-
-    if (mRenderEncoder.valid())
-    {
-        // if render pass has started, end the query in the encoder
-        disableActiveOcclusionQueryInRenderPass();
-    }
 
-    mOcclusionQuery = nullptr;
-}
-void ContextMtl::onOcclusionQueryDestroy(const gl::Context *context, QueryMtl *query)
+void ContextMtl::onOcclusionQueryEnd()
 {
-    if (query->getAllocatedVisibilityOffsets().empty())
-    {
-        return;
-    }
-    if (mOcclusionQuery == query)
-    {
-        onOcclusionQueryEnd(context, query);
-    }
-    mOcclusionQueryPool.deallocateQueryOffset(this, query);
+    ASSERT(mOcclusionQueryResultBuffer);  // Frontend guarantees one active at the time.
+    disableOcclusionQueryInRenderPass();
+    mOcclusionQueryResultBuffer = nullptr;
 }
 
-void ContextMtl::disableActiveOcclusionQueryInRenderPass()
+void ContextMtl::onOcclusionQueryDestroy(QueryMtl &query)
 {
-    if (!mOcclusionQuery || mOcclusionQuery->getAllocatedVisibilityOffsets().empty())
-    {
-        return;
-    }
-
-    ASSERT(mRenderEncoder.valid());
-    mRenderEncoder.setVisibilityResultMode(MTLVisibilityResultModeDisabled,
-                                           mOcclusionQuery->getAllocatedVisibilityOffsets().back());
+    // On normal operation frontend guaraantees that end is called before destroy.
+    // On context destruction, active query is destroyed without end.
+    // Discard is valid for both.
+    mOcclusionQueryPool.discardQuery(query.getVisibilityResultBuffer());
 }
 
-angle::Result ContextMtl::restartActiveOcclusionQueryInRenderPass()
+void ContextMtl::disableOcclusionQueryInRenderPass()
 {
-    if (!mOcclusionQuery || mOcclusionQuery->getAllocatedVisibilityOffsets().empty())
+    if (mOcclusionQueryResultBuffer && mOcclusionQueryIsEnabledInRenderPass)
     {
-        return angle::Result::Continue;
+        ASSERT(mRenderEncoder.valid());
+        mRenderEncoder.setVisibilityResultMode(MTLVisibilityResultModeDisabled, 0);
+        mOcclusionQueryIsEnabledInRenderPass = false;
     }
-
-    return startOcclusionQueryInRenderPass(mOcclusionQuery, false);
 }
 
-angle::Result ContextMtl::startOcclusionQueryInRenderPass(QueryMtl *query, bool clearOldValue)
+angle::Result ContextMtl::enableOcclusionQueryInRenderPass()
 {
     ASSERT(mRenderEncoder.valid());
-
-    ANGLE_TRY(mOcclusionQueryPool.allocateQueryOffset(this, query, clearOldValue));
-
-    mRenderEncoder.setVisibilityResultMode(MTLVisibilityResultModeBoolean,
-                                           query->getAllocatedVisibilityOffsets().back());
-
-    // We need to mark the query's buffer as being written in this command buffer now. Since the
-    // actual writing is deferred until the render pass ends and user could try to read the query
-    // result before the render pass ends.
-    mCmdBuffer.setWriteDependency(query->getVisibilityResultBuffer(), /*isRenderCommand=*/true);
-
+    size_t resultOffset;
+    ANGLE_TRY(mOcclusionQueryPool.continueQuery(this, mOcclusionQueryResultBuffer, &resultOffset));
+    mRenderEncoder.setVisibilityResultMode(MTLVisibilityResultModeBoolean, resultOffset);
+    mCmdBuffer.setWriteDependency(mOcclusionQueryResultBuffer, /*isRenderCommand=*/true);
+    mOcclusionQueryIsEnabledInRenderPass = true;
     return angle::Result::Continue;
 }
 
@@ -2586,11 +2569,11 @@ angle::Result ContextMtl::setupDrawImpl(const gl::Context *context,
         ANGLE_TRY(handleDirtyRenderPass(context));
     }
 
-    if (mOcclusionQuery && mOcclusionQueryPool.getNumRenderPassAllocatedQueries() == 0)
+    if (mOcclusionQueryResultBuffer && !mOcclusionQueryIsEnabledInRenderPass)
     {
         // The occlusion query is still active, and a new render pass has started.
         // We need to continue the querying process in the new render encoder.
-        ANGLE_TRY(startOcclusionQueryInRenderPass(mOcclusionQuery, false));
+        ANGLE_TRY(enableOcclusionQueryInRenderPass());
     }
 
     bool isPipelineDescChanged;
diff --git a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.h b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.h
index 363d7657a2b9..104bc924afcc 100644
--- a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.h
+++ b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.h
@@ -19,50 +19,6 @@ namespace rx
 
 class ContextMtl;
 
-// The class represents offset(s) allocated in the visiblity buffer for an occlusion query.
-// See doc/OcclusionQuery.md.
-// An occlusion query might have more than one offsets allocated, but all of them must be adjacent
-// to each other. Multiple offsets typically allocated when the query is paused and resumed during
-// viewport clear emulation with draw operations. In such case, Metal doesn't allow an offset to
-// be reused in a render pass, hence multiple offsets will be allocated, and their values will
-// be accumulated.
-class VisibilityBufferOffsetsMtl
-{
-  public:
-    void clear() { mStartOffset = mNumOffsets = 0; }
-    bool empty() const { return mNumOffsets == 0; }
-    uint32_t size() const { return mNumOffsets; }
-
-    // Return last offset
-    uint32_t back() const
-    {
-        ASSERT(!empty());
-        return mStartOffset + (mNumOffsets - 1) * mtl::kOcclusionQueryResultSize;
-    }
-
-    uint32_t front() const
-    {
-        ASSERT(!empty());
-        return mStartOffset;
-    }
-
-    void setStartOffset(uint32_t offset)
-    {
-        ASSERT(empty());
-        mStartOffset = offset;
-        mNumOffsets  = 1;
-    }
-    void addOffset()
-    {
-        ASSERT(!empty());
-        mNumOffsets++;
-    }
-
-  private:
-    uint32_t mStartOffset = 0;
-    uint32_t mNumOffsets  = 0;
-};
-
 class QueryMtl : public QueryImpl
 {
   public:
@@ -80,24 +36,9 @@ class QueryMtl : public QueryImpl
     angle::Result getResult(const gl::Context *context, GLuint64 *params) override;
     angle::Result isResultAvailable(const gl::Context *context, bool *available) override;
 
-    // Get allocated offsets in the render pass's occlusion query pool.
-    const VisibilityBufferOffsetsMtl &getAllocatedVisibilityOffsets() const
-    {
-        return mVisibilityBufferOffsets;
-    }
-    // Set first allocated offset in the render pass's occlusion query pool.
-    void setFirstAllocatedVisibilityOffset(uint32_t off)
-    {
-        mVisibilityBufferOffsets.setStartOffset(off);
-    }
-    // Add more offset allocated for the occlusion query
-    void addAllocatedVisibilityOffset() { mVisibilityBufferOffsets.addOffset(); }
-
-    void clearAllocatedVisibilityOffsets() { mVisibilityBufferOffsets.clear(); }
     // Returns the buffer containing the final occlusion query result.
     const mtl::BufferRef &getVisibilityResultBuffer() const { return mVisibilityResultBuffer; }
-    // Reset the occlusion query result stored in buffer to zero
-    void resetVisibilityResult(ContextMtl *contextMtl);
+
     void onTransformFeedbackEnd(const gl::Context *context);
 
     // If the timestamp query is still active upon context switch,
@@ -110,8 +51,7 @@ class QueryMtl : public QueryImpl
     template <typename T>
     angle::Result waitAndGetResult(const gl::Context *context, T *params);
 
-    // List of offsets in the render pass's occlusion query pool buffer allocated for this query
-    VisibilityBufferOffsetsMtl mVisibilityBufferOffsets;
+    // Buffer holding the final combined visibility result for this query.
     mtl::BufferRef mVisibilityResultBuffer;
 
     size_t mTransformFeedbackPrimitivesDrawn = 0;
diff --git a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.mm b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.mm
index 14908006ca4e..94ebb5b9827f 100644
--- a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.mm
+++ b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.mm
@@ -24,9 +24,9 @@ QueryMtl::~QueryMtl() {}
 void QueryMtl::onDestroy(const gl::Context *context)
 {
     ContextMtl *contextMtl = mtl::GetImpl(context);
-    if (!getAllocatedVisibilityOffsets().empty())
+    if (mVisibilityResultBuffer)
     {
-        contextMtl->onOcclusionQueryDestroy(context, this);
+        contextMtl->onOcclusionQueryDestroy(*this);
     }
     mVisibilityResultBuffer = nullptr;
 }
@@ -51,7 +51,7 @@ angle::Result QueryMtl::begin(const gl::Context *context)
                 }
             }
 
-            ANGLE_TRY(contextMtl->onOcclusionQueryBegin(context, this));
+            ANGLE_TRY(contextMtl->onOcclusionQueryBegin(*this));
             break;
         case gl::QueryType::TransformFeedbackPrimitivesWritten:
             mTransformFeedbackPrimitivesDrawn = 0;
@@ -85,7 +85,7 @@ angle::Result QueryMtl::end(const gl::Context *context)
     {
         case gl::QueryType::AnySamples:
         case gl::QueryType::AnySamplesConservative:
-            contextMtl->onOcclusionQueryEnd(context, this);
+            contextMtl->onOcclusionQueryEnd();
             break;
         case gl::QueryType::TransformFeedbackPrimitivesWritten:
             onTransformFeedbackEnd(context);
@@ -207,18 +207,6 @@ angle::Result QueryMtl::getResult(const gl::Context *context, GLuint64 *params)
     return waitAndGetResult(context, params);
 }
 
-void QueryMtl::resetVisibilityResult(ContextMtl *contextMtl)
-{
-    // Occlusion query buffer must be allocated in QueryMtl::begin
-    ASSERT(mVisibilityResultBuffer);
-
-    // Fill the query's buffer with zeros
-    auto blitEncoder = contextMtl->getBlitCommandEncoder();
-    blitEncoder->fillBuffer(mVisibilityResultBuffer, NSMakeRange(0, mtl::kOcclusionQueryResultSize),
-                            0);
-    mVisibilityResultBuffer->syncContent(contextMtl, blitEncoder);
-}
-
 void QueryMtl::onTransformFeedbackEnd(const gl::Context *context)
 {
     gl::TransformFeedback *transformFeedback = context->getState().getCurrentTransformFeedback();
diff --git a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_command_buffer.mm b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_command_buffer.mm
index 3934b7d2f8ff..1d93726a5702 100644
--- a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_command_buffer.mm
+++ b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_command_buffer.mm
@@ -1542,8 +1542,7 @@ void RenderCommandEncoder::endEncodingImpl(bool considerDiscardSimulation)
                                 objCRenderPassDesc.stencilAttachment))
         hasAttachment = true;
 
-    // Set visibility result buffer
-    if (mOcclusionQueryPool.getNumRenderPassAllocatedQueries())
+    if (mOcclusionQueryPool.hasPendingVisibilityResults())
     {
         objCRenderPassDesc.visibilityResultBuffer =
             mOcclusionQueryPool.getRenderPassVisibilityPoolBuffer()->get();
diff --git a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.h b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.h
index 77db2e871c48..e7dae17b2dc1 100644
--- a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.h
+++ b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.h
@@ -20,11 +20,37 @@ namespace rx
 {
 
 class ContextMtl;
-class QueryMtl;
 
 namespace mtl
 {
 
+// Tracks allocations of visibility buffer offsets for a query within the render pass.
+// An occlusion query might have more than one offsets allocated, but all of them must be adjacent
+// to each other. Multiple offsets typically allocated when the query is paused and resumed during
+// viewport clear emulation with draw operations. In such case, Metal doesn't allow an offset to
+// be reused in a render pass, hence multiple offsets will be allocated, and their values will
+// be accumulated.
+class VisibilityBufferOffsetsMtl
+{
+  public:
+    VisibilityBufferOffsetsMtl(const BufferRef &buffer, size_t startOffset, size_t size)
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/LayoutTests/platform/mac-wk2/TestExpectations b/LayoutTests/platform/mac-wk2/TestExpectations
index 5661950f388b..5066d4b54544 100644
--- a/LayoutTests/platform/mac-wk2/TestExpectations
+++ b/LayoutTests/platform/mac-wk2/TestExpectations
@@ -2309,6 +2309,8 @@ webkit.org/b/315877 [ Sequoia Debug x86_64 ] webgl/2.0.y/conformance2/textures/m
 webkit.org/b/315877 [ Sequoia Debug x86_64 ] webgl/2.0.y/conformance2/textures/webgl_canvas/tex-3d-r11f_g11f_b10f-rgb-float.html [ Pass Timeout ]
 webkit.org/b/315877 [ Sequoia Debug x86_64 ] webgl/2.0.y/conformance2/textures/image_bitmap_from_video/tex-2d-rgba32f-rgba-float.html [ Pass Failure ]
 
+webkit.org/b/318751 [ Debug arm64 ] webgl/2.0.y/conformance2/textures/misc/tex-unpack-params.html [ Pass Failure ]
+
 webkit.org/b/315881 [ Sequoia Release x86_64 ] fast/attachment/cocoa/wide-attachment-class.html [ Pass ImageOnlyFailure ]
 webkit.org/b/315881 [ Sequoia Release x86_64 ] fast/attachment/cocoa/wide-attachment-default-icon.html [ Pass ImageOnlyFailure ]
 webkit.org/b/315881 [ Sequoia Release x86_64 ] fast/attachment/cocoa/wide-attachment-folder-icon.html [ Pass ImageOnlyFailure ]
diff --git a/Source/ThirdParty/ANGLE/src/tests/gl_tests/OcclusionQueriesTest.cpp b/Source/ThirdParty/ANGLE/src/tests/gl_tests/OcclusionQueriesTest.cpp
index 68952ac962bb..79f3796023f9 100644
--- a/Source/ThirdParty/ANGLE/src/tests/gl_tests/OcclusionQueriesTest.cpp
+++ b/Source/ThirdParty/ANGLE/src/tests/gl_tests/OcclusionQueriesTest.cpp
@@ -1248,6 +1248,140 @@ TEST_P(OcclusionQueriesTestES3, QueryReuseWithMultipleFBOs)
     EXPECT_GL_TRUE(result);
 }
 
+// Test that deleting an occlusion query after a failed begin does not cause use-after-free.
+// A prior query primes the pool. The second query triggers pool doubling which under fault
+// injection fails with OOM. Deleting the query and flushing must not crash.
+TEST_P(OcclusionQueriesTestES3, OcclusionQueryDeleteAfterFailedBeginNoUAF)
+{
+    ANGLE_GL_PROGRAM(program, essl1_shaders::vs::SimpleForPoints(), essl1_shaders::fs::Green());
+    glUseProgram(program);
+    glViewport(0, 0, getWindowWidth(), getWindowHeight());
+
+    // Start render pass and prime the pool with a successful query.
+    glDrawArrays(GL_POINTS, 0, 1);
+    GLQuery q1;
+    glBeginQuery(GL_ANY_SAMPLES_PASSED, q1);
+    glEndQuery(GL_ANY_SAMPLES_PASSED);
+    ASSERT_GL_NO_ERROR();
+
+    // Second query triggers pool doubling. Under fault injection this fails with OOM.
+    GLuint q2;
+    glGenQueries(1, &q2);
+    glBeginQuery(GL_ANY_SAMPLES_PASSED, q2);
+    GLenum err = glGetError();
+    if (err == GL_OUT_OF_MEMORY)
+    {
+        // To get here, a OOM must be injected by hand in visibility buffer doubling.
+        fprintf(stderr, "got expected out of memory\n");
+    }
+    // Delete the query and flush — must not crash.
+    glDeleteQueries(1, &q2);
+    glFinish();
+    glEndQuery(GL_ANY_SAMPLES_PASSED);
+    EXPECT_PIXEL_COLOR_EQ(getWindowWidth() / 2 - 1, getWindowHeight() / 2, GLColor::green);
+}
+
+// Test deleting a occlusion query while it is active.
+TEST_P(OcclusionQueriesTestES3, DeleteActiveOcclusionQueryLeavesValidState)
+{
+    GLuint queryId = 0;
+    glGenQueries(1, &queryId);
+    glBeginQuery(GL_ANY_SAMPLES_PASSED, queryId);
+    drawQuad(mProgram, essl1_shaders::PositionAttrib(), 0.5f);
+    glEndQuery(GL_ANY_SAMPLES_PASSED);
+    ASSERT_GL_NO_ERROR();
+
+    GLuint queryResult = 0;
+    glGetQueryObjectuiv(queryId, GL_QUERY_RESULT, &queryResult);
+    ASSERT_GL_NO_ERROR();
+    EXPECT_EQ(queryResult, static_cast<GLuint>(GL_TRUE));
+
+    glBeginQuery(GL_ANY_SAMPLES_PASSED, queryId);
+    EXPECT_GL_NO_ERROR();
+
+    drawQuad(mProgram, essl1_shaders::PositionAttrib(), 0.5f);
+    ASSERT_GL_NO_ERROR();
+
+    GLint activeQueryId = 0;
+    glGetQueryiv(GL_ANY_SAMPLES_PASSED, GL_CURRENT_QUERY, &activeQueryId);
+    EXPECT_GL_NO_ERROR();
+    EXPECT_EQ(static_cast<GLuint>(activeQueryId), queryId);
+
+    glDeleteQueries(1, &queryId);
+    EXPECT_GL_NO_ERROR();
+
+    EXPECT_GL_FALSE(glIsQuery(queryId));
+
+    // NOTE: This is unspecified and confusing: what should the return value be
+    // if the query has been deleted? If 0, then it's confusing because EndQuery
+    // doesn't fail. If non-zero, how can it return deleted value?
+    // If EndQuery should fail and return should be 0?
+    GLint activeQueryAfterDelete = 0;
+    glGetQueryiv(GL_ANY_SAMPLES_PASSED, GL_CURRENT_QUERY, &activeQueryAfterDelete);
+    EXPECT_GL_NO_ERROR();
+    EXPECT_EQ(static_cast<GLuint>(activeQueryAfterDelete), queryId);
+
+    drawQuad(mProgram, essl1_shaders::PositionAttrib(), 0.8f);
+    EXPECT_GL_NO_ERROR();
+
+    glEndQuery(GL_ANY_SAMPLES_PASSED);
+    EXPECT_GL_NO_ERROR();  // NOTE: This is unspecified and confusing.
+    glEndQuery(GL_ANY_SAMPLES_PASSED);
+    EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+    EXPECT_GL_NO_ERROR();
+}
+
+// Test that a query spanning two render passes (broken by glClear) accumulates results correctly.
+// First draw fails depth test (no samples pass), then glClear breaks the render pass, then
+// second draw passes. Query result must be TRUE.
+TEST_P(OcclusionQueriesTestES3, QuerySpansRenderPassBreak)
+{
+    ANGLE_GL_PROGRAM(program, essl1_shaders::vs::SimpleForPoints(), essl1_shaders::fs::Green());
+    glUseProgram(program);
+    glViewport(0, 0, getWindowWidth(), getWindowHeight());
+
+    glEnable(GL_DEPTH_TEST);
+    glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
+    glClear(GL_COLOR_BUFFER_BIT);
+    
+    // Draw with GL_NEVER does not pass samples.
+    glDepthFunc(GL_NEVER);
+
+    GLQuery query;
+    glBeginQuery(GL_ANY_SAMPLES_PASSED, query);
+    glDrawArrays(GL_POINTS, 0, 1);
+    glEndQuery(GL_ANY_SAMPLES_PASSED);
+    GLuint result = GL_FALSE;
+    glGetQueryObjectuiv(query, GL_QUERY_RESULT, &result);
+    EXPECT_GL_NO_ERROR();
+
+    // Test setup validation: draw with GL_NEVER does not pass samples.
+    EXPECT_GL_FALSE(result);
+    EXPECT_PIXEL_COLOR_EQ(getWindowWidth() / 2 - 1, getWindowHeight() / 2, GLColor::black);
+
+    // Test the sample accumulation across render passes:
+    // Draw with GL_NEVER -> no samples pass yet.
+    // Break render pass
+    // Draw with GL_ALWAYS -> samples pass.
+    glBeginQuery(GL_ANY_SAMPLES_PASSED, query);
+    glDrawArrays(GL_POINTS, 0, 1);
+    glClear(GL_COLOR_BUFFER_BIT);
+    glDepthFunc(GL_ALWAYS);
+    glDrawArrays(GL_POINTS, 0, 1);
+    glEndQuery(GL_ANY_SAMPLES_PASSED);
+    EXPECT_GL_NO_ERROR();
+
+    // Query should report TRUE because the second draw passed samples.
+    result = GL_FALSE;
+    glGetQueryObjectuiv(query, GL_QUERY_RESULT, &result);
+    EXPECT_GL_NO_ERROR();
+    EXPECT_GL_TRUE(result);
+
+    EXPECT_PIXEL_COLOR_EQ(getWindowWidth() / 2 - 1, getWindowHeight() / 2, GLColor::green);
+}
+
 ANGLE_INSTANTIATE_TEST_ES2_AND_ES3(OcclusionQueriesTest);
 
 GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(OcclusionQueriesTestES3);
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.