Metal: Implement begin/end access synchronization with MTLSharedEvent

Bug: b/252731382
Change-Id: Ie2bf978c10dcb7b2c03a2c7ff81ddd8b9b77ac20
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/106760
Reviewed-by: Shrek Shao <shrekshao@google.com>
Kokoro: Kokoro <noreply+kokoro@google.com>
Commit-Queue: Austin Eng <enga@chromium.org>
diff --git a/include/dawn/native/MetalBackend.h b/include/dawn/native/MetalBackend.h
index 72a9cb0..b9ab707 100644
--- a/include/dawn/native/MetalBackend.h
+++ b/include/dawn/native/MetalBackend.h
@@ -15,6 +15,8 @@
 #ifndef INCLUDE_DAWN_NATIVE_METALBACKEND_H_
 #define INCLUDE_DAWN_NATIVE_METALBACKEND_H_
 
+#include <vector>
+
 #include "dawn/dawn_wsi.h"
 #include "dawn/native/DawnNative.h"
 
@@ -38,19 +40,47 @@
     AdapterDiscoveryOptions();
 };
 
+struct DAWN_NATIVE_EXPORT ExternalImageMTLSharedEventDescriptor {
+    // Shared event handle `id<MTLSharedEvent>`.
+    // This never passes ownership to the callee (when used as an input
+    // parameter) or to the caller (when used as a return value or output parameter).
+#ifdef __OBJC__
+    id<MTLSharedEvent> sharedEvent = nil;
+    static_assert(sizeof(id<MTLSharedEvent>) == sizeof(void*));
+    static_assert(alignof(id<MTLSharedEvent>) == alignof(void*));
+#else
+    void* sharedEvent = nullptr;
+#endif
+
+    // The value that was previously signaled on this event and should be waited on.
+    uint64_t signaledValue = 0;
+};
+
 struct DAWN_NATIVE_EXPORT ExternalImageDescriptorIOSurface : ExternalImageDescriptor {
   public:
     ExternalImageDescriptorIOSurface();
+    ~ExternalImageDescriptorIOSurface();
 
     IOSurfaceRef ioSurface;
 
     // This has been deprecated.
     uint32_t plane;
+
+    // A list of events to wait on before accessing the texture.
+    std::vector<ExternalImageMTLSharedEventDescriptor> waitEvents;
+};
+
+struct DAWN_NATIVE_EXPORT ExternalImageIOSurfaceEndAccessDescriptor
+    : ExternalImageMTLSharedEventDescriptor {
+    bool isInitialized;
 };
 
 DAWN_NATIVE_EXPORT WGPUTexture WrapIOSurface(WGPUDevice device,
                                              const ExternalImageDescriptorIOSurface* descriptor);
 
+DAWN_NATIVE_EXPORT void IOSurfaceEndAccess(WGPUTexture texture,
+                                           ExternalImageIOSurfaceEndAccessDescriptor* descriptor);
+
 // When making Metal interop with other APIs, we need to be careful that QueueSubmit doesn't
 // mean that the operations will be visible to other APIs/Metal devices right away. macOS
 // does have a global queue of graphics operations, but the command buffers are inserted there
diff --git a/src/dawn/native/metal/CommandBufferMTL.mm b/src/dawn/native/metal/CommandBufferMTL.mm
index a5e5fbe..2bb83ee 100644
--- a/src/dawn/native/metal/CommandBufferMTL.mm
+++ b/src/dawn/native/metal/CommandBufferMTL.mm
@@ -761,6 +761,10 @@
             case Command::BeginComputePass: {
                 BeginComputePassCmd* cmd = mCommands.NextCommand<BeginComputePassCmd>();
 
+                for (TextureBase* texture :
+                     GetResourceUsages().computePasses[nextComputePassNumber].referencedTextures) {
+                    ToBackend(texture)->SynchronizeTextureBeforeUse(commandContext);
+                }
                 for (const SyncScopeResourceUsage& scope :
                      GetResourceUsages().computePasses[nextComputePassNumber].dispatchUsages) {
                     LazyClearSyncScope(scope, commandContext);
@@ -776,6 +780,20 @@
             case Command::BeginRenderPass: {
                 BeginRenderPassCmd* cmd = mCommands.NextCommand<BeginRenderPassCmd>();
 
+                for (TextureBase* texture :
+                     this->GetResourceUsages().renderPasses[nextRenderPassNumber].textures) {
+                    ToBackend(texture)->SynchronizeTextureBeforeUse(commandContext);
+                }
+                for (ExternalTextureBase* externalTexture : this->GetResourceUsages()
+                                                                .renderPasses[nextRenderPassNumber]
+                                                                .externalTextures) {
+                    for (auto& view : externalTexture->GetTextureViews()) {
+                        if (view.Get()) {
+                            Texture* texture = ToBackend(view->GetTexture());
+                            texture->SynchronizeTextureBeforeUse(commandContext);
+                        }
+                    }
+                }
                 LazyClearSyncScope(GetResourceUsages().renderPasses[nextRenderPassNumber],
                                    commandContext);
                 commandContext->EndBlit();
@@ -831,6 +849,7 @@
                 buffer->EnsureDataInitialized(commandContext);
                 EnsureDestinationTextureInitialized(commandContext, texture, dst, copySize);
 
+                texture->SynchronizeTextureBeforeUse(commandContext);
                 RecordCopyBufferToTexture(commandContext, buffer->GetMTLBuffer(), buffer->GetSize(),
                                           src.offset, src.bytesPerRow, src.rowsPerImage, texture,
                                           dst.mipLevel, dst.origin, dst.aspect, copySize);
@@ -852,6 +871,7 @@
 
                 buffer->EnsureDataInitializedAsDestination(commandContext, copy);
 
+                texture->SynchronizeTextureBeforeUse(commandContext);
                 texture->EnsureSubresourceContentInitialized(
                     commandContext, GetSubresourcesAffectedByCopy(src, copySize));
 
@@ -941,6 +961,8 @@
                 Texture* srcTexture = ToBackend(copy->source.texture.Get());
                 Texture* dstTexture = ToBackend(copy->destination.texture.Get());
 
+                srcTexture->SynchronizeTextureBeforeUse(commandContext);
+                dstTexture->SynchronizeTextureBeforeUse(commandContext);
                 srcTexture->EnsureSubresourceContentInitialized(
                     commandContext, GetSubresourcesAffectedByCopy(copy->source, copy->copySize));
                 EnsureDestinationTextureInitialized(commandContext, dstTexture, copy->destination,
diff --git a/src/dawn/native/metal/CommandRecordingContext.h b/src/dawn/native/metal/CommandRecordingContext.h
index ca096bb..811592c 100644
--- a/src/dawn/native/metal/CommandRecordingContext.h
+++ b/src/dawn/native/metal/CommandRecordingContext.h
@@ -22,6 +22,11 @@
 
 namespace dawn::native::metal {
 
+struct MTLSharedEventAndSignalValue {
+    NSPRef<id> sharedEvent;
+    uint64_t signaledValue;
+};
+
 // This class wraps a MTLCommandBuffer and tracks which Metal encoder is open.
 // Only one encoder may be open at a time.
 class CommandRecordingContext : NonMovable {
diff --git a/src/dawn/native/metal/DeviceMTL.h b/src/dawn/native/metal/DeviceMTL.h
index fef04bf..f36c228 100644
--- a/src/dawn/native/metal/DeviceMTL.h
+++ b/src/dawn/native/metal/DeviceMTL.h
@@ -18,6 +18,7 @@
 #include <atomic>
 #include <memory>
 #include <mutex>
+#include <vector>
 
 #include "dawn/native/dawn_platform.h"
 
@@ -33,6 +34,7 @@
 namespace dawn::native::metal {
 
 struct KalmanInfo;
+struct ExternalImageMTLSharedEventDescriptor;
 
 class Device final : public DeviceBase {
   public:
@@ -53,8 +55,12 @@
         Device::SubmitMode submitMode = Device::SubmitMode::Normal);
     MaybeError SubmitPendingCommandBuffer();
 
-    Ref<Texture> CreateTextureWrappingIOSurface(const ExternalImageDescriptor* descriptor,
-                                                IOSurfaceRef ioSurface);
+    void ExportLastSignaledEvent(ExternalImageMTLSharedEventDescriptor* desc);
+
+    Ref<Texture> CreateTextureWrappingIOSurface(
+        const ExternalImageDescriptor* descriptor,
+        IOSurfaceRef ioSurface,
+        std::vector<MTLSharedEventAndSignalValue> waitEvents);
     void WaitForCommandsToBeScheduled();
 
     ResultOrError<std::unique_ptr<StagingBufferBase>> CreateStagingBuffer(size_t size) override;
@@ -134,6 +140,7 @@
     ResultOrError<ExecutionSerial> CheckAndUpdateCompletedSerials() override;
 
     NSPRef<id<MTLDevice>> mMtlDevice;
+    NSPRef<id> mMtlSharedEvent = nil;  // MTLSharedEvent not available until macOS 10.14+.
     NSPRef<id<MTLCommandQueue>> mCommandQueue;
 
     CommandRecordingContext mCommandContext;
diff --git a/src/dawn/native/metal/DeviceMTL.mm b/src/dawn/native/metal/DeviceMTL.mm
index ee4952f..5073e0c 100644
--- a/src/dawn/native/metal/DeviceMTL.mm
+++ b/src/dawn/native/metal/DeviceMTL.mm
@@ -146,6 +146,10 @@
         return DAWN_INTERNAL_ERROR("Failed to allocate MTLCommandQueue.");
     }
 
+    if (@available(macOS 10.14, *)) {
+        mMtlSharedEvent.Acquire([*mMtlDevice newSharedEvent]);
+    }
+
     DAWN_TRY(mCommandContext.PrepareNextCommandBuffer(*mCommandQueue));
 
     if (HasFeature(Feature::TimestampQuery) &&
@@ -428,11 +432,21 @@
 
     TRACE_EVENT_ASYNC_BEGIN0(GetPlatform(), GPUWork, "DeviceMTL::SubmitPendingCommandBuffer",
                              uint64_t(pendingSerial));
+    if (@available(macOS 10.14, *)) {
+        id rawEvent = *mMtlSharedEvent;
+        id<MTLSharedEvent> sharedEvent = static_cast<id<MTLSharedEvent>>(rawEvent);
+        [*pendingCommands encodeSignalEvent:sharedEvent value:static_cast<uint64_t>(pendingSerial)];
+    }
     [*pendingCommands commit];
 
     return mCommandContext.PrepareNextCommandBuffer(*mCommandQueue);
 }
 
+void Device::ExportLastSignaledEvent(ExternalImageMTLSharedEventDescriptor* desc) {
+    desc->sharedEvent = *mMtlSharedEvent;
+    desc->signaledValue = static_cast<uint64_t>(GetLastSubmittedCommandSerial());
+}
+
 ResultOrError<std::unique_ptr<StagingBufferBase>> Device::CreateStagingBuffer(size_t size) {
     std::unique_ptr<StagingBufferBase> stagingBuffer = std::make_unique<StagingBuffer>(size, this);
     DAWN_TRY(stagingBuffer->Initialize());
@@ -471,6 +485,7 @@
                                                 TextureCopy* dst,
                                                 const Extent3D& copySizePixels) {
     Texture* texture = ToBackend(dst->texture.Get());
+    texture->SynchronizeTextureBeforeUse(GetPendingCommandContext());
     EnsureDestinationTextureInitialized(GetPendingCommandContext(DeviceBase::SubmitMode::Passive),
                                         texture, *dst, copySizePixels);
 
@@ -481,8 +496,10 @@
     return {};
 }
 
-Ref<Texture> Device::CreateTextureWrappingIOSurface(const ExternalImageDescriptor* descriptor,
-                                                    IOSurfaceRef ioSurface) {
+Ref<Texture> Device::CreateTextureWrappingIOSurface(
+    const ExternalImageDescriptor* descriptor,
+    IOSurfaceRef ioSurface,
+    std::vector<MTLSharedEventAndSignalValue> waitEvents) {
     const TextureDescriptor* textureDescriptor = FromAPI(descriptor->cTextureDescriptor);
     if (ConsumedError(ValidateIsAlive())) {
         return nullptr;
@@ -495,7 +512,9 @@
     }
 
     Ref<Texture> result;
-    if (ConsumedError(Texture::CreateFromIOSurface(this, descriptor, ioSurface), &result)) {
+    if (ConsumedError(
+            Texture::CreateFromIOSurface(this, descriptor, ioSurface, std::move(waitEvents)),
+            &result)) {
         return nullptr;
     }
     return result;
diff --git a/src/dawn/native/metal/MetalBackend.mm b/src/dawn/native/metal/MetalBackend.mm
index d414020..9bdf138 100644
--- a/src/dawn/native/metal/MetalBackend.mm
+++ b/src/dawn/native/metal/MetalBackend.mm
@@ -17,6 +17,7 @@
 
 #include "dawn/native/MetalBackend.h"
 
+#include "dawn/native/metal/CommandRecordingContext.h"
 #include "dawn/native/metal/DeviceMTL.h"
 #include "dawn/native/metal/TextureMTL.h"
 
@@ -28,13 +29,26 @@
 ExternalImageDescriptorIOSurface::ExternalImageDescriptorIOSurface()
     : ExternalImageDescriptor(ExternalImageType::IOSurface) {}
 
+ExternalImageDescriptorIOSurface::~ExternalImageDescriptorIOSurface() = default;
+
 WGPUTexture WrapIOSurface(WGPUDevice device, const ExternalImageDescriptorIOSurface* cDescriptor) {
     Device* backendDevice = ToBackend(FromAPI(device));
-    Ref<TextureBase> texture =
-        backendDevice->CreateTextureWrappingIOSurface(cDescriptor, cDescriptor->ioSurface);
+    std::vector<MTLSharedEventAndSignalValue> waitEvents;
+    for (const auto& waitEvent : cDescriptor->waitEvents) {
+        waitEvents.push_back(
+            {static_cast<id<MTLSharedEvent>>(waitEvent.sharedEvent), waitEvent.signaledValue});
+    }
+    Ref<TextureBase> texture = backendDevice->CreateTextureWrappingIOSurface(
+        cDescriptor, cDescriptor->ioSurface, std::move(waitEvents));
     return ToAPI(texture.Detach());
 }
 
+void IOSurfaceEndAccess(WGPUTexture cTexture,
+                        ExternalImageIOSurfaceEndAccessDescriptor* descriptor) {
+    Texture* texture = ToBackend(FromAPI(cTexture));
+    texture->IOSurfaceEndAccess(descriptor);
+}
+
 void WaitForCommandsToBeScheduled(WGPUDevice device) {
     ToBackend(FromAPI(device))->WaitForCommandsToBeScheduled();
 }
diff --git a/src/dawn/native/metal/TextureMTL.h b/src/dawn/native/metal/TextureMTL.h
index 3a9c3d8..0d5a3c8 100644
--- a/src/dawn/native/metal/TextureMTL.h
+++ b/src/dawn/native/metal/TextureMTL.h
@@ -17,17 +17,20 @@
 
 #include <IOSurface/IOSurfaceRef.h>
 #import <Metal/Metal.h>
+#include <vector>
 
 #include "dawn/native/Texture.h"
 
 #include "dawn/common/CoreFoundationRef.h"
 #include "dawn/common/NSRef.h"
 #include "dawn/native/DawnNative.h"
+#include "dawn/native/MetalBackend.h"
 
 namespace dawn::native::metal {
 
 class CommandRecordingContext;
 class Device;
+struct MTLSharedEventAndSignalValue;
 
 MTLPixelFormat MetalPixelFormat(wgpu::TextureFormat format);
 MaybeError ValidateIOSurfaceCanBeWrapped(const DeviceBase* device,
@@ -40,7 +43,8 @@
     static ResultOrError<Ref<Texture>> CreateFromIOSurface(
         Device* device,
         const ExternalImageDescriptor* descriptor,
-        IOSurfaceRef ioSurface);
+        IOSurfaceRef ioSurface,
+        std::vector<MTLSharedEventAndSignalValue> waitEvents);
     static Ref<Texture> CreateWrapping(Device* device,
                                        const TextureDescriptor* descriptor,
                                        NSPRef<id<MTLTexture>> wrapped);
@@ -54,6 +58,9 @@
     void EnsureSubresourceContentInitialized(CommandRecordingContext* commandContext,
                                              const SubresourceRange& range);
 
+    void SynchronizeTextureBeforeUse(CommandRecordingContext* commandContext);
+    void IOSurfaceEndAccess(ExternalImageIOSurfaceEndAccessDescriptor* descriptor);
+
   private:
     using TextureBase::TextureBase;
     ~Texture() override;
@@ -63,7 +70,8 @@
     MaybeError InitializeAsInternalTexture(const TextureDescriptor* descriptor);
     MaybeError InitializeFromIOSurface(const ExternalImageDescriptor* descriptor,
                                        const TextureDescriptor* textureDescriptor,
-                                       IOSurfaceRef ioSurface);
+                                       IOSurfaceRef ioSurface,
+                                       std::vector<MTLSharedEventAndSignalValue> waitEvents);
     void InitializeAsWrapping(const TextureDescriptor* descriptor, NSPRef<id<MTLTexture>> wrapped);
 
     void DestroyImpl() override;
@@ -76,6 +84,7 @@
 
     MTLTextureUsage mMtlUsage;
     CFRef<IOSurfaceRef> mIOSurface = nullptr;
+    std::vector<MTLSharedEventAndSignalValue> mWaitEvents;
 };
 
 class TextureView final : public TextureViewBase {
diff --git a/src/dawn/native/metal/TextureMTL.mm b/src/dawn/native/metal/TextureMTL.mm
index 98d4f31..0df13ea 100644
--- a/src/dawn/native/metal/TextureMTL.mm
+++ b/src/dawn/native/metal/TextureMTL.mm
@@ -691,14 +691,17 @@
 }
 
 // static
-ResultOrError<Ref<Texture>> Texture::CreateFromIOSurface(Device* device,
-                                                         const ExternalImageDescriptor* descriptor,
-                                                         IOSurfaceRef ioSurface) {
+ResultOrError<Ref<Texture>> Texture::CreateFromIOSurface(
+    Device* device,
+    const ExternalImageDescriptor* descriptor,
+    IOSurfaceRef ioSurface,
+    std::vector<MTLSharedEventAndSignalValue> waitEvents) {
     const TextureDescriptor* textureDescriptor = FromAPI(descriptor->cTextureDescriptor);
 
     Ref<Texture> texture =
         AcquireRef(new Texture(device, textureDescriptor, TextureState::OwnedExternal));
-    DAWN_TRY(texture->InitializeFromIOSurface(descriptor, textureDescriptor, ioSurface));
+    DAWN_TRY(texture->InitializeFromIOSurface(descriptor, textureDescriptor, ioSurface,
+                                              std::move(waitEvents)));
     return texture;
 }
 
@@ -739,8 +742,10 @@
 
 MaybeError Texture::InitializeFromIOSurface(const ExternalImageDescriptor* descriptor,
                                             const TextureDescriptor* textureDescriptor,
-                                            IOSurfaceRef ioSurface) {
+                                            IOSurfaceRef ioSurface,
+                                            std::vector<MTLSharedEventAndSignalValue> waitEvents) {
     mIOSurface = ioSurface;
+    mWaitEvents = std::move(waitEvents);
 
     // Uses WGPUTexture which wraps multiplanar ioSurface needs to create
     // texture view explicitly. Wrap the ioSurface and delay to extract
@@ -763,6 +768,31 @@
     return {};
 }
 
+void Texture::SynchronizeTextureBeforeUse(CommandRecordingContext* commandContext) {
+    if (@available(macOS 10.14, *)) {
+        if (!mWaitEvents.empty()) {
+            // There may be an open blit encoder from a copy command or writeBuffer.
+            // Wait events are only allowed if there is no encoder open.
+            commandContext->EndBlit();
+        }
+        auto commandBuffer = commandContext->GetCommands();
+        // Consume the wait events on the texture. They will be empty after this loop.
+        for (auto waitEvent : std::move(mWaitEvents)) {
+            id rawEvent = *waitEvent.sharedEvent;
+            id<MTLSharedEvent> sharedEvent = static_cast<id<MTLSharedEvent>>(rawEvent);
+            [commandBuffer encodeWaitForEvent:sharedEvent value:waitEvent.signaledValue];
+        }
+    }
+}
+
+void Texture::IOSurfaceEndAccess(ExternalImageIOSurfaceEndAccessDescriptor* descriptor) {
+    ASSERT(descriptor);
+    ToBackend(GetDevice())->ExportLastSignaledEvent(descriptor);
+    descriptor->isInitialized = IsSubresourceContentInitialized(GetAllSubresources());
+    // Destroy the texture as it should not longer be used after EndAccess.
+    Destroy();
+}
+
 Texture::Texture(DeviceBase* dev, const TextureDescriptor* desc, TextureState st)
     : TextureBase(dev, desc, st) {}
 
diff --git a/src/dawn/tests/end2end/IOSurfaceWrappingTests.cpp b/src/dawn/tests/end2end/IOSurfaceWrappingTests.cpp
index e9f5b59..6650b74 100644
--- a/src/dawn/tests/end2end/IOSurfaceWrappingTests.cpp
+++ b/src/dawn/tests/end2end/IOSurfaceWrappingTests.cpp
@@ -449,6 +449,143 @@
     // wrap ioSurface and ensure color is not visible when isInitialized set to false
     wgpu::Texture ioSurfaceTexture = WrapIOSurface(&textureDescriptor, ioSurface.get(), false);
     EXPECT_PIXEL_RGBA8_EQ(utils::RGBA8(0, 0, 0, 0), ioSurfaceTexture, 0, 0);
+
+    dawn::native::metal::ExternalImageIOSurfaceEndAccessDescriptor endAccessDesc;
+    dawn::native::metal::IOSurfaceEndAccess(ioSurfaceTexture.Get(), &endAccessDesc);
+    EXPECT_TRUE(endAccessDesc.isInitialized);
+}
+
+// Test that exporting a texture wrapping an IOSurface sets the isInitialized bit to
+// false if the contents are discard.
+TEST_P(IOSurfaceUsageTests, UninitializedOnEndAccess) {
+    DAWN_TEST_UNSUPPORTED_IF(UsesWire());
+
+    ScopedIOSurfaceRef ioSurface = CreateSinglePlaneIOSurface(1, 1, kCVPixelFormatType_32RGBA, 4);
+    uint32_t data = 0x04030201;
+
+    IOSurfaceLock(ioSurface.get(), 0, nullptr);
+    memcpy(IOSurfaceGetBaseAddress(ioSurface.get()), &data, sizeof(data));
+    IOSurfaceUnlock(ioSurface.get(), 0, nullptr);
+
+    wgpu::TextureDescriptor textureDescriptor;
+    textureDescriptor.dimension = wgpu::TextureDimension::e2D;
+    textureDescriptor.format = wgpu::TextureFormat::RGBA8Unorm;
+    textureDescriptor.size = {1, 1, 1};
+    textureDescriptor.sampleCount = 1;
+    textureDescriptor.mipLevelCount = 1;
+    textureDescriptor.usage = wgpu::TextureUsage::RenderAttachment | wgpu::TextureUsage::CopySrc;
+
+    // Wrap ioSurface
+    wgpu::Texture ioSurfaceTexture = WrapIOSurface(&textureDescriptor, ioSurface.get(), true);
+
+    // Uninitialize the teuxture with a render pass.
+    utils::ComboRenderPassDescriptor renderPassDescriptor({ioSurfaceTexture.CreateView()});
+    renderPassDescriptor.cColorAttachments[0].storeOp = wgpu::StoreOp::Discard;
+    wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+    encoder.BeginRenderPass(&renderPassDescriptor).End();
+    wgpu::CommandBuffer commandBuffer = encoder.Finish();
+    queue.Submit(1, &commandBuffer);
+
+    dawn::native::metal::ExternalImageIOSurfaceEndAccessDescriptor endAccessDesc;
+    dawn::native::metal::IOSurfaceEndAccess(ioSurfaceTexture.Get(), &endAccessDesc);
+    EXPECT_FALSE(endAccessDesc.isInitialized);
+}
+
+// Test that an IOSurface may be imported across multiple devices.
+TEST_P(IOSurfaceUsageTests, WriteThenConcurrentReadThenWrite) {
+    DAWN_TEST_UNSUPPORTED_IF(UsesWire());
+
+    ScopedIOSurfaceRef ioSurface = CreateSinglePlaneIOSurface(1, 1, kCVPixelFormatType_32RGBA, 4);
+    uint32_t data = 0x04030201;
+
+    IOSurfaceLock(ioSurface.get(), 0, nullptr);
+    memcpy(IOSurfaceGetBaseAddress(ioSurface.get()), &data, sizeof(data));
+    IOSurfaceUnlock(ioSurface.get(), 0, nullptr);
+
+    // Make additional devices. We will import with the writeDevice, then read it concurrently with
+    // both readDevices.
+    wgpu::Device writeDevice = device;
+    wgpu::Device readDevice1 = CreateDevice();
+    wgpu::Device readDevice2 = CreateDevice();
+
+    wgpu::TextureDescriptor textureDesc;
+    textureDesc.dimension = wgpu::TextureDimension::e2D;
+    textureDesc.format = wgpu::TextureFormat::RGBA8Unorm;
+    textureDesc.size = {1, 1, 1};
+    textureDesc.sampleCount = 1;
+    textureDesc.mipLevelCount = 1;
+    textureDesc.usage = wgpu::TextureUsage::RenderAttachment | wgpu::TextureUsage::CopySrc;
+
+    // Wrap ioSurface
+    dawn::native::metal::ExternalImageDescriptorIOSurface writeExternDesc;
+    writeExternDesc.cTextureDescriptor =
+        reinterpret_cast<const WGPUTextureDescriptor*>(&textureDesc);
+    writeExternDesc.ioSurface = ioSurface.get();
+    writeExternDesc.isInitialized = true;
+
+    wgpu::Texture writeTexture = wgpu::Texture::Acquire(
+        dawn::native::metal::WrapIOSurface(writeDevice.Get(), &writeExternDesc));
+
+    // Clear the texture to green.
+    {
+        utils::ComboRenderPassDescriptor renderPassDescriptor({writeTexture.CreateView()});
+        renderPassDescriptor.cColorAttachments[0].clearValue = {0.0, 1.0, 0.0, 1.0};
+        wgpu::CommandEncoder encoder = writeDevice.CreateCommandEncoder();
+        encoder.BeginRenderPass(&renderPassDescriptor).End();
+        wgpu::CommandBuffer commandBuffer = encoder.Finish();
+        writeDevice.GetQueue().Submit(1, &commandBuffer);
+    }
+
+    // End access.
+    dawn::native::metal::ExternalImageIOSurfaceEndAccessDescriptor endWriteAccessDesc;
+    dawn::native::metal::IOSurfaceEndAccess(writeTexture.Get(), &endWriteAccessDesc);
+    EXPECT_TRUE(endWriteAccessDesc.isInitialized);
+
+    dawn::native::metal::ExternalImageDescriptorIOSurface externDesc;
+    externDesc.cTextureDescriptor = reinterpret_cast<const WGPUTextureDescriptor*>(&textureDesc);
+    externDesc.ioSurface = ioSurface.get();
+    externDesc.isInitialized = true;
+    externDesc.waitEvents.push_back(
+        {endWriteAccessDesc.sharedEvent, endWriteAccessDesc.signaledValue});
+
+    // Wrap on two separate devices to read it.
+    wgpu::Texture readTexture1 =
+        wgpu::Texture::Acquire(dawn::native::metal::WrapIOSurface(readDevice1.Get(), &externDesc));
+    wgpu::Texture readTexture2 =
+        wgpu::Texture::Acquire(dawn::native::metal::WrapIOSurface(readDevice2.Get(), &externDesc));
+
+    // Expect the texture to be green
+    EXPECT_TEXTURE_EQ(readDevice1, utils::RGBA8(0, 255, 0, 255), readTexture1, {0, 0});
+    EXPECT_TEXTURE_EQ(readDevice2, utils::RGBA8(0, 255, 0, 255), readTexture2, {0, 0});
+
+    // End access on both read textures.
+    dawn::native::metal::ExternalImageIOSurfaceEndAccessDescriptor endReadAccessDesc1;
+    dawn::native::metal::IOSurfaceEndAccess(readTexture1.Get(), &endReadAccessDesc1);
+    EXPECT_TRUE(endReadAccessDesc1.isInitialized);
+
+    dawn::native::metal::ExternalImageIOSurfaceEndAccessDescriptor endReadAccessDesc2;
+    dawn::native::metal::IOSurfaceEndAccess(readTexture2.Get(), &endReadAccessDesc2);
+    EXPECT_TRUE(endReadAccessDesc2.isInitialized);
+
+    // Import again for writing. It should not race with the previous reads.
+    writeExternDesc.waitEvents = {endReadAccessDesc1, endReadAccessDesc2};
+    writeExternDesc.isInitialized = true;
+    writeTexture = wgpu::Texture::Acquire(
+        dawn::native::metal::WrapIOSurface(writeDevice.Get(), &writeExternDesc));
+
+    // Clear the texture to blue.
+    {
+        utils::ComboRenderPassDescriptor renderPassDescriptor({writeTexture.CreateView()});
+        renderPassDescriptor.cColorAttachments[0].clearValue = {0.0, 0.0, 1.0, 1.0};
+        wgpu::CommandEncoder encoder = writeDevice.CreateCommandEncoder();
+        encoder.BeginRenderPass(&renderPassDescriptor).End();
+        wgpu::CommandBuffer commandBuffer = encoder.Finish();
+        writeDevice.GetQueue().Submit(1, &commandBuffer);
+    }
+    // Finally, expect the contents to be blue now.
+    EXPECT_TEXTURE_EQ(writeDevice, utils::RGBA8(0, 0, 255, 255), writeTexture, {0, 0});
+    dawn::native::metal::IOSurfaceEndAccess(writeTexture.Get(), &endWriteAccessDesc);
+    EXPECT_TRUE(endWriteAccessDesc.isInitialized);
 }
 
 DAWN_INSTANTIATE_TEST(IOSurfaceValidationTests, MetalBackend());