Reland "[ios blink] Implement futures using WaitListEvent"

This is a reland of commit abc42736ef28ed8fcc11e66814907febff6ee930

The only change over the original is the correct initialization of the
`mSignaled` atomic_bool in WaitListEvent.

Original change's description:
> [ios blink] Implement futures using WaitListEvent
>
> Currently, most futures are implemented using SystemEvent. This includes
> already completed and non-progressing events. This is problematic on iOS
> where BrowserEngineKit child processes are not allowed to open fds, mach
> ports, etc.
>
> This CL introduces WaitListEvent which mimics the base::WaitableEvent
> implementation in Chromium for POSIX platforms. The event internally
> maintains a list of waiters corresponding to a WaitAny call. In WaitAny,
> we create a SyncWaiter that's signaled using a condition variable. The
> waiter is added to each event that the WaitAny is waiting on. The events
> also have a mutex to allow multiple threads to wait on them. We acquire
> the event locks in a globally consistent order (sorted by address) to
> prevent lock order inversion. WaitListEvents can also be waited on
> asynchronously by returning a SystemEventReceiver which allows mixing
> waits on SystemEvents and WaitListEvents.
>
> In addition, this CL changes how already signaled and non-progressing
> TrackedEvents are represented. Already signaled events are backed by
> WaitListEvents and non-progressing events become a flag on TrackedEvent.
>
> The code in EventManager is also cleaned up to aid readability and fix
> an edge case of waiting on multiple queues with zero timeout - we could
> end up ticking the same queue multiple times causing a subtle race
> between MapAsync and OnSubmittedWorkDone futures completion.
>
> Bug: 407801085
> Change-Id: I1c5deb8097339be5beb5e9021d753998a074bea3
> Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/234277
> Reviewed-by: Loko Kung <lokokung@google.com>
> Auto-Submit: Sunny Sachanandani <sunnyps@chromium.org>
> Commit-Queue: Sunny Sachanandani <sunnyps@chromium.org>
> Reviewed-by: Kai Ninomiya <kainino@chromium.org>
> Commit-Queue: Kai Ninomiya <kainino@chromium.org>

Bug: 407801085
Change-Id: I254398256851f2310ddfe906c79d389dae1d3d77
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/237719
Commit-Queue: Corentin Wallez <cwallez@chromium.org>
Reviewed-by: Kai Ninomiya <kainino@chromium.org>
Commit-Queue: Sunny Sachanandani <sunnyps@chromium.org>
Reviewed-by: Corentin Wallez <cwallez@chromium.org>
Commit-Queue: Kai Ninomiya <kainino@chromium.org>
Auto-Submit: Sunny Sachanandani <sunnyps@chromium.org>
diff --git a/src/dawn/native/BUILD.gn b/src/dawn/native/BUILD.gn
index fd055fc..d3bdd62 100644
--- a/src/dawn/native/BUILD.gn
+++ b/src/dawn/native/BUILD.gn
@@ -398,6 +398,8 @@
     "ValidationUtils.h",
     "VisitableMembers.h",
     "WaitAnySystemEvent.h",
+    "WaitListEvent.cpp",
+    "WaitListEvent.h",
     "dawn_platform.h",
     "stream/BlobSource.cpp",
     "stream/BlobSource.h",
diff --git a/src/dawn/native/Buffer.cpp b/src/dawn/native/Buffer.cpp
index f99f976..46aebb1 100644
--- a/src/dawn/native/Buffer.cpp
+++ b/src/dawn/native/Buffer.cpp
@@ -212,7 +212,7 @@
                   const std::string& message,
                   WGPUMapAsyncStatus status)
         : TrackedEvent(static_cast<wgpu::CallbackMode>(callbackInfo.mode),
-                       SystemEvent::CreateSignaled()),
+                       TrackedEvent::Completed{}),
           mBufferOrError(BufferErrorData{status, message}),
           mCallback(callbackInfo.callback),
           mUserdata1(callbackInfo.userdata1),
@@ -224,7 +224,7 @@
     ~MapAsyncEvent() override { EnsureComplete(EventCompletionType::Shutdown); }
 
     void Complete(EventCompletionType completionType) override {
-        if (const auto* queueAndSerial = std::get_if<QueueAndSerial>(&GetCompletionData())) {
+        if (const auto* queueAndSerial = GetIfQueueAndSerial()) {
             TRACE_EVENT_ASYNC_END0(queueAndSerial->queue->GetDevice()->GetPlatform(), General,
                                    "Buffer::APIMapAsync",
                                    uint64_t(queueAndSerial->completionSerial));
diff --git a/src/dawn/native/CMakeLists.txt b/src/dawn/native/CMakeLists.txt
index 06d7845..2ef2e07 100644
--- a/src/dawn/native/CMakeLists.txt
+++ b/src/dawn/native/CMakeLists.txt
@@ -153,6 +153,7 @@
     "ValidationUtils.h"
     "VisitableMembers.h"
     "WaitAnySystemEvent.h"
+    "WaitListEvent.h"
     "webgpu_absl_format.h"
 )
 
@@ -253,6 +254,7 @@
     "Toggles.cpp"
     "utils/WGPUHelpers.cpp"
     "ValidationUtils.cpp"
+    "WaitListEvent.cpp"
     "webgpu_absl_format.cpp"
 )
 
diff --git a/src/dawn/native/CreatePipelineAsyncEvent.cpp b/src/dawn/native/CreatePipelineAsyncEvent.cpp
index 844d1d2..3599b45 100644
--- a/src/dawn/native/CreatePipelineAsyncEvent.cpp
+++ b/src/dawn/native/CreatePipelineAsyncEvent.cpp
@@ -41,7 +41,7 @@
 #include "dawn/native/EventManager.h"
 #include "dawn/native/Instance.h"
 #include "dawn/native/RenderPipeline.h"
-#include "dawn/native/SystemEvent.h"
+#include "dawn/native/WaitListEvent.h"
 #include "dawn/native/dawn_platform_autogen.h"
 #include "dawn/native/utils/WGPUHelpers.h"
 #include "dawn/native/wgpu_structs_autogen.h"
@@ -96,8 +96,8 @@
     DeviceBase* device,
     const CreatePipelineAsyncCallbackInfo& callbackInfo,
     Ref<PipelineType> pipeline,
-    Ref<SystemEvent> systemEvent)
-    : TrackedEvent(static_cast<wgpu::CallbackMode>(callbackInfo.mode), std::move(systemEvent)),
+    Ref<WaitListEvent> event)
+    : TrackedEvent(static_cast<wgpu::CallbackMode>(callbackInfo.mode), std::move(event)),
       mCallback(callbackInfo.callback),
       mUserdata1(callbackInfo.userdata1),
       mUserdata2(callbackInfo.userdata2),
diff --git a/src/dawn/native/CreatePipelineAsyncEvent.h b/src/dawn/native/CreatePipelineAsyncEvent.h
index b2fefa6..a6bd1e0 100644
--- a/src/dawn/native/CreatePipelineAsyncEvent.h
+++ b/src/dawn/native/CreatePipelineAsyncEvent.h
@@ -56,12 +56,12 @@
   public:
     using CallbackType = decltype(std::declval<CreatePipelineAsyncCallbackInfo>().callback);
 
-    // Create an event backed by the given system event (for async pipeline creation goes through
+    // Create an event backed by the given wait list event (for async pipeline creation goes through
     // the backend).
     CreatePipelineAsyncEvent(DeviceBase* device,
                              const CreatePipelineAsyncCallbackInfo& callbackInfo,
                              Ref<PipelineType> pipeline,
-                             Ref<SystemEvent> systemEvent);
+                             Ref<WaitListEvent> event);
     // Create an event that's ready at creation (for cached results)
     CreatePipelineAsyncEvent(DeviceBase* device,
                              const CreatePipelineAsyncCallbackInfo& callbackInfo,
diff --git a/src/dawn/native/Device.cpp b/src/dawn/native/Device.cpp
index e6f0c63..ce36dc6 100644
--- a/src/dawn/native/Device.cpp
+++ b/src/dawn/native/Device.cpp
@@ -77,6 +77,7 @@
 #include "dawn/native/SwapChain.h"
 #include "dawn/native/Texture.h"
 #include "dawn/native/ValidationUtils_autogen.h"
+#include "dawn/native/WaitListEvent.h"
 #include "dawn/native/utils/WGPUHelpers.h"
 #include "dawn/platform/DawnPlatform.h"
 #include "dawn/platform/metrics/HistogramMacros.h"
@@ -154,7 +155,7 @@
 
 DeviceBase::DeviceLostEvent::DeviceLostEvent(const WGPUDeviceLostCallbackInfo& callbackInfo)
     : TrackedEvent(static_cast<wgpu::CallbackMode>(callbackInfo.mode),
-                   SystemEvent::CreateNonProgressingEvent()),
+                   TrackedEvent::NonProgressing{}),
       mCallback(callbackInfo.callback),
       mUserdata1(callbackInfo.userdata1),
       mUserdata2(callbackInfo.userdata2) {}
@@ -1282,7 +1283,7 @@
     // New pipeline: create an event backed by system event that is really async.
     Ref<CreateComputePipelineAsyncEvent> event = AcquireRef(new CreateComputePipelineAsyncEvent(
         this, callbackInfo, std::move(uninitializedComputePipeline),
-        AcquireRef(new SystemEvent())));
+        AcquireRef(new WaitListEvent())));
     Future future = GetFuture(event);
     InitializeComputePipelineAsyncImpl(std::move(event));
     return future;
@@ -1350,7 +1351,8 @@
 
     // New pipeline: create an event backed by system event that is really async.
     Ref<CreateRenderPipelineAsyncEvent> event = AcquireRef(new CreateRenderPipelineAsyncEvent(
-        this, callbackInfo, std::move(uninitializedRenderPipeline), AcquireRef(new SystemEvent())));
+        this, callbackInfo, std::move(uninitializedRenderPipeline),
+        AcquireRef(new WaitListEvent())));
     Future future = GetFuture(event);
     InitializeRenderPipelineAsyncImpl(std::move(event));
     return future;
diff --git a/src/dawn/native/EventManager.cpp b/src/dawn/native/EventManager.cpp
index 835f92c..2780fe2 100644
--- a/src/dawn/native/EventManager.cpp
+++ b/src/dawn/native/EventManager.cpp
@@ -29,6 +29,7 @@
 
 #include <algorithm>
 #include <functional>
+#include <type_traits>
 #include <utility>
 #include <vector>
 
@@ -58,199 +59,186 @@
     bool ready;
 };
 
-// Wrapper around an iterator to yield system event receiver and a pointer
-// to the ready bool. We pass this into WaitAnySystemEvent so it can extract
-// the receivers and get pointers to the ready status - without allocating
-// duplicate storage to store the receivers and ready bools.
-class SystemEventAndReadyStateIterator {
+// Wrapper around an iterator to yield event specific objects and a pointer
+// to the ready bool. We pass this into helpers so that they can extract
+// the event specific objects and get pointers to the ready status - without
+// allocating duplicate storage to store the objects and ready bools.
+template <typename Traits>
+class WrappingIterator {
   public:
-    using WrappedIter = std::vector<TrackedFutureWaitInfo>::iterator;
-
     // Specify required iterator traits.
-    using value_type = std::pair<const SystemEventReceiver&, bool*>;
-    using difference_type = typename WrappedIter::difference_type;
-    using iterator_category = typename WrappedIter::iterator_category;
+    using value_type = typename Traits::value_type;
+    using difference_type = typename Traits::WrappedIter::difference_type;
+    using iterator_category = typename Traits::WrappedIter::iterator_category;
     using pointer = value_type*;
     using reference = value_type&;
 
-    SystemEventAndReadyStateIterator() = default;
-    SystemEventAndReadyStateIterator(const SystemEventAndReadyStateIterator&) = default;
-    SystemEventAndReadyStateIterator& operator=(const SystemEventAndReadyStateIterator&) = default;
+    WrappingIterator() = default;
+    WrappingIterator(const WrappingIterator&) = default;
+    WrappingIterator& operator=(const WrappingIterator&) = default;
 
-    explicit SystemEventAndReadyStateIterator(WrappedIter wrappedIt) : mWrappedIt(wrappedIt) {}
+    explicit WrappingIterator(typename Traits::WrappedIter wrappedIt) : mWrappedIt(wrappedIt) {}
 
-    bool operator!=(const SystemEventAndReadyStateIterator& rhs) const {
-        return rhs.mWrappedIt != mWrappedIt;
-    }
-    bool operator==(const SystemEventAndReadyStateIterator& rhs) const {
-        return rhs.mWrappedIt == mWrappedIt;
-    }
-    difference_type operator-(const SystemEventAndReadyStateIterator& rhs) const {
+    bool operator!=(const WrappingIterator& rhs) const { return rhs.mWrappedIt != mWrappedIt; }
+    bool operator==(const WrappingIterator& rhs) const { return rhs.mWrappedIt == mWrappedIt; }
+
+    difference_type operator-(const WrappingIterator& rhs) const {
         return mWrappedIt - rhs.mWrappedIt;
     }
 
-    SystemEventAndReadyStateIterator operator+(difference_type rhs) const {
-        return SystemEventAndReadyStateIterator{mWrappedIt + rhs};
+    WrappingIterator operator+(difference_type rhs) const {
+        return WrappingIterator{mWrappedIt + rhs};
     }
 
-    SystemEventAndReadyStateIterator& operator++() {
+    WrappingIterator& operator++() {
         ++mWrappedIt;
         return *this;
     }
 
-    value_type operator*() {
-        return {
-            std::get<Ref<SystemEvent>>(mWrappedIt->event->GetCompletionData())
-                ->GetOrCreateSystemEventReceiver(),
-            &mWrappedIt->ready,
-        };
-    }
+    value_type operator*() { return Traits::Deref(mWrappedIt); }
 
   private:
-    WrappedIter mWrappedIt;
+    typename Traits::WrappedIter mWrappedIt;
 };
 
-// Wait/poll the queue for futures in range [begin, end). `waitSerial` should be
-// the serial after which at least one future should be complete. All futures must
-// have completion data of type QueueAndSerial.
-// Returns true if at least one future is ready. If no futures are ready or the wait
-// timed out, returns false.
-bool WaitQueueSerialsImpl(DeviceBase* device,
-                          QueueBase* queue,
-                          ExecutionSerial waitSerial,
-                          std::vector<TrackedFutureWaitInfo>::iterator begin,
-                          std::vector<TrackedFutureWaitInfo>::iterator end,
-                          Nanoseconds timeout) {
-    bool success = false;
-    // TODO(dawn:1662): Make error handling thread-safe.
-    auto deviceLock(device->GetScopedLock());
-    if (device->ConsumedError([&]() -> MaybeError {
-            if (waitSerial > queue->GetLastSubmittedCommandSerial()) {
-                // Serial has not been submitted yet. Submit it now.
-                // TODO(dawn:1413): This doesn't need to be a full tick. It just needs to
-                // flush work up to `waitSerial`. This should be done after the
-                // ExecutionQueue / ExecutionContext refactor.
-                queue->ForceEventualFlushOfCommands();
-                DAWN_TRY(device->Tick());
-            }
-            // Check the completed serial.
-            ExecutionSerial completedSerial = queue->GetCompletedCommandSerial();
-            if (completedSerial < waitSerial) {
-                if (timeout > Nanoseconds(0)) {
-                    // Wait on the serial if it hasn't passed yet.
-                    DAWN_TRY_ASSIGN(success, queue->WaitForQueueSerial(waitSerial, timeout));
-                }
-                // Update completed serials.
-                DAWN_TRY(queue->CheckPassedSerials());
-                completedSerial = queue->GetCompletedCommandSerial();
-            }
-            // Poll futures for completion.
-            for (auto it = begin; it != end; ++it) {
-                ExecutionSerial serial =
-                    std::get<QueueAndSerial>(it->event->GetCompletionData()).completionSerial;
-                if (serial <= completedSerial) {
-                    success = true;
-                    it->ready = true;
-                }
-            }
-            return {};
-        }())) {
-        // There was an error. Pending submit may have failed or waiting for fences
-        // may have lost the device. The device is lost inside ConsumedError.
-        // Mark all futures as ready.
-        for (auto it = begin; it != end; ++it) {
-            it->ready = true;
+struct ExtractSystemEventAndReadyStateTraits {
+    using WrappedIter = std::vector<TrackedFutureWaitInfo>::iterator;
+    using value_type = std::pair<const SystemEventReceiver&, bool*>;
+
+    static value_type Deref(const WrappedIter& wrappedIt) {
+        if (auto event = wrappedIt->event->GetIfWaitListEvent()) {
+            return {event->WaitAsync(), &wrappedIt->ready};
         }
-        success = true;
+        DAWN_ASSERT(wrappedIt->event->GetIfSystemEvent());
+        return {
+            wrappedIt->event->GetIfSystemEvent()->GetOrCreateSystemEventReceiver(),
+            &wrappedIt->ready,
+        };
+    }
+};
+
+using SystemEventAndReadyStateIterator = WrappingIterator<ExtractSystemEventAndReadyStateTraits>;
+
+struct ExtractWaitListEventAndReadyStateTraits {
+    using WrappedIter = std::vector<TrackedFutureWaitInfo>::iterator;
+    using value_type = std::pair<Ref<WaitListEvent>, bool*>;
+
+    static value_type Deref(const WrappedIter& wrappedIt) {
+        DAWN_ASSERT(wrappedIt->event->GetIfWaitListEvent());
+        return {wrappedIt->event->GetIfWaitListEvent(), &wrappedIt->ready};
+    }
+};
+
+using WaitListEventAndReadyStateIterator =
+    WrappingIterator<ExtractWaitListEventAndReadyStateTraits>;
+
+// Returns true if at least one future is ready.
+bool PollFutures(std::vector<TrackedFutureWaitInfo>& futures) {
+    bool success = false;
+    for (auto& future : futures) {
+        if (future.event->IsReadyToComplete()) {
+            success = true;
+            future.ready = true;
+        }
     }
     return success;
 }
 
+// Wait/poll queues with given `timeout`. `queueWaitSerials` should contain per queue, the serial up
+// to which we should flush the queue if needed.
+using QueueWaitSerialsMap = absl::flat_hash_map<QueueBase*, ExecutionSerial>;
+void WaitQueueSerials(const QueueWaitSerialsMap& queueWaitSerials, Nanoseconds timeout) {
+    // TODO(dawn:1662): Make error handling thread-safe.
+    // Poll/wait on queues up to the lowest wait serial, but do this once per queue instead of
+    // per event so that events with same serial complete at the same time instead of racing.
+    for (const auto& queueAndSerial : queueWaitSerials) {
+        auto* queue = queueAndSerial.first;
+        auto waitSerial = queueAndSerial.second;
+
+        auto* device = queue->GetDevice();
+        auto deviceLock(device->GetScopedLock());
+
+        [[maybe_unused]] bool error = device->ConsumedError(
+            [&]() -> MaybeError {
+                if (waitSerial > queue->GetLastSubmittedCommandSerial()) {
+                    // Serial has not been submitted yet. Submit it now.
+                    DAWN_TRY(queue->EnsureCommandsFlushed(waitSerial));
+                }
+                // Check the completed serial.
+                if (waitSerial > queue->GetCompletedCommandSerial()) {
+                    if (timeout > Nanoseconds(0)) {
+                        // Wait on the serial if it hasn't passed yet.
+                        [[maybe_unused]] bool waitResult = false;
+                        DAWN_TRY_ASSIGN(waitResult, queue->WaitForQueueSerial(waitSerial, timeout));
+                    }
+                    // Update completed serials.
+                    DAWN_TRY(queue->CheckPassedSerials());
+                }
+                return {};
+            }(),
+            "waiting for work in %s.", queue);
+    }
+}
+
 // We can replace the std::vector& when std::span is available via C++20.
 wgpu::WaitStatus WaitImpl(const InstanceBase* instance,
                           std::vector<TrackedFutureWaitInfo>& futures,
                           Nanoseconds timeout) {
-    auto begin = futures.begin();
-    const auto end = futures.end();
-    bool anySuccess = false;
-    // The following loop will partition [begin, end) based on the type of wait is required.
-    // After each partition, it will wait/poll on the first partition, then advance `begin`
-    // to the start of the next partition. Note that for timeout > 0 and unsupported mixed
-    // sources, we validate that there is a single partition. If there is only one, then the
-    // loop runs only once and the timeout does not stack.
-    while (begin != end) {
-        const auto& first = begin->event->GetCompletionData();
+    bool foundSystemEvent = false;
+    bool foundWaitListEvent = false;
+    QueueWaitSerialsMap queueLowestWaitSerials;
 
-        DeviceBase* waitDevice;
-        ExecutionSerial lowestWaitSerial;
-        if (std::holds_alternative<Ref<SystemEvent>>(first)) {
-            waitDevice = nullptr;
-        } else {
-            const auto& queueAndSerial = std::get<QueueAndSerial>(first);
-            waitDevice = queueAndSerial.queue->GetDevice();
-            lowestWaitSerial = queueAndSerial.completionSerial;
+    for (const auto& future : futures) {
+        if (future.event->GetIfSystemEvent()) {
+            foundSystemEvent = true;
         }
-        // Partition the remaining futures based on whether they match the same completion
-        // data type as the first. Also keep track of the lowest wait serial.
-        const auto mid =
-            std::partition(std::next(begin), end, [&](const TrackedFutureWaitInfo& info) {
-                const auto& completionData = info.event->GetCompletionData();
-                if (std::holds_alternative<Ref<SystemEvent>>(completionData)) {
-                    return waitDevice == nullptr;
-                } else {
-                    const auto& queueAndSerial = std::get<QueueAndSerial>(completionData);
-                    if (waitDevice == queueAndSerial.queue->GetDevice()) {
-                        lowestWaitSerial =
-                            std::min(lowestWaitSerial, queueAndSerial.completionSerial);
-                        return true;
-                    } else {
-                        return false;
-                    }
-                }
-            });
-
-        // There's a mix of wait sources if partition yielded an iterator that is not at the end.
-        if (mid != end) {
-            if (timeout > Nanoseconds(0)) {
-                // Multi-source wait is unsupported.
-                // TODO(dawn:2062): Implement support for this when the device supports it.
-                // It should eventually gather the lowest serial from the queue(s), transform them
-                // into completion events, and wait on all of the events. Then for any queues that
-                // saw a completion, poll all futures related to that queue for completion.
-                instance->EmitLog(WGPULoggingType_Error,
-                                  "Mixed source waits with timeouts are not currently supported.");
-                return wgpu::WaitStatus::Error;
+        if (future.event->GetIfWaitListEvent()) {
+            foundWaitListEvent = true;
+        }
+        if (const auto* queueAndSerial = future.event->GetIfQueueAndSerial()) {
+            auto [it, inserted] = queueLowestWaitSerials.insert(
+                {queueAndSerial->queue.Get(), queueAndSerial->completionSerial});
+            if (!inserted) {
+                it->second = std::min(it->second, queueAndSerial->completionSerial);
             }
         }
-
-        bool success;
-        if (waitDevice) {
-            success = WaitQueueSerialsImpl(waitDevice, std::get<QueueAndSerial>(first).queue.Get(),
-                                           lowestWaitSerial, begin, mid, timeout);
-        } else {
-            if (timeout > Nanoseconds(0)) {
-                success = WaitAnySystemEvent(SystemEventAndReadyStateIterator{begin},
-                                             SystemEventAndReadyStateIterator{mid}, timeout);
-            } else {
-                // Poll the completion events.
-                success = false;
-                for (auto it = begin; it != mid; ++it) {
-                    if (std::get<Ref<SystemEvent>>(it->event->GetCompletionData())->IsSignaled()) {
-                        it->ready = true;
-                        success = true;
-                    }
-                }
-            }
-        }
-        anySuccess |= success;
-
-        // Advance the iterator to the next partition.
-        begin = mid;
     }
-    if (!anySuccess) {
-        return wgpu::WaitStatus::TimedOut;
+
+    if (timeout == Nanoseconds(0)) {
+        // This is a no-op if `queueLowestWaitSerials` is empty.
+        WaitQueueSerials(queueLowestWaitSerials, timeout);
+        return PollFutures(futures) ? wgpu::WaitStatus::Success : wgpu::WaitStatus::TimedOut;
     }
-    return wgpu::WaitStatus::Success;
+
+    // We can't have a mix of system/wait-list events and queue-serial events or queue-serial events
+    // from multiple queues with a non-zero timeout.
+    if (queueLowestWaitSerials.size() > 1 ||
+        (!queueLowestWaitSerials.empty() && (foundWaitListEvent || foundSystemEvent))) {
+        // Multi-source wait is unsupported.
+        // TODO(dawn:2062): Implement support for this when the device supports it.
+        // It should eventually gather the lowest serial from the queue(s), transform them
+        // into completion events, and wait on all of the events. Then for any queues that
+        // saw a completion, poll all futures related to that queue for completion.
+        instance->EmitLog(WGPULoggingType_Error,
+                          "Mixed source waits with timeouts are not currently supported.");
+        return wgpu::WaitStatus::Error;
+    }
+
+    bool success = false;
+    if (foundSystemEvent) {
+        // Can upgrade wait list events to system events.
+        success = WaitAnySystemEvent(SystemEventAndReadyStateIterator{futures.begin()},
+                                     SystemEventAndReadyStateIterator{futures.end()}, timeout);
+    } else if (foundWaitListEvent) {
+        success =
+            WaitListEvent::WaitAny(WaitListEventAndReadyStateIterator{futures.begin()},
+                                   WaitListEventAndReadyStateIterator{futures.end()}, timeout);
+    } else {
+        // This is a no-op if `queueLowestWaitSerials` is empty.
+        WaitQueueSerials(queueLowestWaitSerials, timeout);
+        success = PollFutures(futures);
+    }
+    return success ? wgpu::WaitStatus::Success : wgpu::WaitStatus::TimedOut;
 }
 
 // Reorder callbacks to enforce callback ordering required by the spec.
@@ -327,17 +315,7 @@
 
     // Handle the event now if it's spontaneous and ready.
     if (event->mCallbackMode == wgpu::CallbackMode::AllowSpontaneous) {
-        bool isReady = false;
-        auto completionData = event->GetCompletionData();
-        if (std::holds_alternative<Ref<SystemEvent>>(completionData)) {
-            isReady = std::get<Ref<SystemEvent>>(completionData)->IsSignaled();
-        }
-        if (std::holds_alternative<QueueAndSerial>(completionData)) {
-            auto& queueAndSerial = std::get<QueueAndSerial>(completionData);
-            isReady = queueAndSerial.completionSerial <=
-                      queueAndSerial.queue->GetCompletedCommandSerial();
-        }
-        if (isReady) {
+        if (event->IsReadyToComplete()) {
             event->EnsureComplete(EventCompletionType::Ready);
             return futureID;
         }
@@ -360,15 +338,6 @@
 }
 
 void EventManager::SetFutureReady(TrackedEvent* event) {
-    auto completionData = event->GetCompletionData();
-    if (std::holds_alternative<Ref<SystemEvent>>(completionData)) {
-        std::get<Ref<SystemEvent>>(completionData)->Signal();
-    }
-    if (std::holds_alternative<QueueAndSerial>(completionData)) {
-        auto& queueAndSerial = std::get<QueueAndSerial>(completionData);
-        queueAndSerial.completionSerial = queueAndSerial.queue->GetCompletedCommandSerial();
-    }
-
     // Sometimes, events might become ready before they are even tracked. This can happen because
     // tracking is ordered to uphold callback ordering, but events may become ready in any order. If
     // the event is spontaneous, it will be completed when it is tracked.
@@ -376,6 +345,8 @@
         return;
     }
 
+    event->SetReadyToComplete();
+
     // Handle spontaneous completion now.
     if (event->mCallbackMode == wgpu::CallbackMode::AllowSpontaneous) {
         mEvents.Use([&](auto events) {
@@ -406,11 +377,7 @@
                 // Figure out if there are any progressing events. If we only have non-progressing
                 // events, we need to return false to indicate that there isn't any polling work to
                 // be done.
-                auto completionData = event->GetCompletionData();
-                if (std::holds_alternative<Ref<SystemEvent>>(completionData)) {
-                    hasProgressingEvents |=
-                        std::get<Ref<SystemEvent>>(completionData)->IsProgressing();
-                } else {
+                if (event->IsProgressing()) {
                     hasProgressingEvents = true;
                 }
 
@@ -538,6 +505,10 @@
 // EventManager::TrackedEvent
 
 EventManager::TrackedEvent::TrackedEvent(wgpu::CallbackMode callbackMode,
+                                         Ref<WaitListEvent> completionEvent)
+    : mCallbackMode(callbackMode), mCompletionData(std::move(completionEvent)) {}
+
+EventManager::TrackedEvent::TrackedEvent(wgpu::CallbackMode callbackMode,
                                          Ref<SystemEvent> completionEvent)
     : mCallbackMode(callbackMode), mCompletionData(std::move(completionEvent)) {}
 
@@ -547,7 +518,14 @@
     : mCallbackMode(callbackMode), mCompletionData(QueueAndSerial{queue, completionSerial}) {}
 
 EventManager::TrackedEvent::TrackedEvent(wgpu::CallbackMode callbackMode, Completed tag)
-    : TrackedEvent(callbackMode, SystemEvent::CreateSignaled()) {}
+    : mCallbackMode(callbackMode), mCompletionData(AcquireRef(new WaitListEvent())) {
+    GetIfWaitListEvent()->Signal();
+}
+
+EventManager::TrackedEvent::TrackedEvent(wgpu::CallbackMode callbackMode, NonProgressing tag)
+    : mCallbackMode(callbackMode),
+      mCompletionData(AcquireRef(new WaitListEvent())),
+      mIsProgressing(false) {}
 
 EventManager::TrackedEvent::~TrackedEvent() {
     DAWN_ASSERT(mFutureID != kNullFutureID);
@@ -558,9 +536,31 @@
     return {mFutureID};
 }
 
-const EventManager::TrackedEvent::CompletionData& EventManager::TrackedEvent::GetCompletionData()
-    const {
-    return mCompletionData;
+bool EventManager::TrackedEvent::IsReadyToComplete() const {
+    bool isReady = false;
+    if (auto event = GetIfSystemEvent()) {
+        isReady = event->IsSignaled();
+    }
+    if (auto event = GetIfWaitListEvent()) {
+        isReady = event->IsSignaled();
+    }
+    if (const auto* queueAndSerial = GetIfQueueAndSerial()) {
+        isReady =
+            queueAndSerial->completionSerial <= queueAndSerial->queue->GetCompletedCommandSerial();
+    }
+    return isReady;
+}
+
+void EventManager::TrackedEvent::SetReadyToComplete() {
+    if (auto event = GetIfSystemEvent()) {
+        event->Signal();
+    }
+    if (auto event = GetIfWaitListEvent()) {
+        event->Signal();
+    }
+    if (auto* queueAndSerial = std::get_if<QueueAndSerial>(&mCompletionData)) {
+        queueAndSerial->completionSerial = queueAndSerial->queue->GetCompletedCommandSerial();
+    }
 }
 
 void EventManager::TrackedEvent::EnsureComplete(EventCompletionType completionType) {
diff --git a/src/dawn/native/EventManager.h b/src/dawn/native/EventManager.h
index 6ef872b..a6a4ec0 100644
--- a/src/dawn/native/EventManager.h
+++ b/src/dawn/native/EventManager.h
@@ -43,6 +43,7 @@
 #include "dawn/native/Forward.h"
 #include "dawn/native/IntegerTypes.h"
 #include "dawn/native/SystemEvent.h"
+#include "dawn/native/WaitListEvent.h"
 #include "partition_alloc/pointers/raw_ptr.h"
 
 namespace dawn::native {
@@ -116,7 +117,58 @@
 // to any TrackedEvents. Any which are not ref'd elsewhere (in order to be `Spontaneous`ly
 // completed) will be cleaned up at that time.
 class EventManager::TrackedEvent : public RefCounted {
+  public:
+    // Subclasses must implement this to complete the event (if not completed) with
+    // EventCompletionType::Shutdown.
+    ~TrackedEvent() override;
+
+    Future GetFuture() const;
+
+    bool IsProgressing() const { return mIsProgressing; }
+
+    bool IsReadyToComplete() const;
+
+    const QueueAndSerial* GetIfQueueAndSerial() const {
+        return std::get_if<QueueAndSerial>(&mCompletionData);
+    }
+
+    Ref<SystemEvent> GetIfSystemEvent() const {
+        if (auto* event = std::get_if<Ref<SystemEvent>>(&mCompletionData)) {
+            return *event;
+        }
+        return nullptr;
+    }
+
+    Ref<WaitListEvent> GetIfWaitListEvent() const {
+        if (auto* event = std::get_if<Ref<WaitListEvent>>(&mCompletionData)) {
+            return *event;
+        }
+        return nullptr;
+    }
+
+    // Events may be one of three types:
+    // - A queue and the ExecutionSerial after which the event will be completed.
+    //   Used for queue completion.
+    // - A SystemEvent which will be signaled usually by the OS / GPU driver. It stores a boolean
+    //   that we can check instead of polling with the OS, or it can be transformed lazily into a
+    //   SystemEventReceiver.
+    // - A WaitListEvent which will be signaled from our code, usually on a separate thread. It also
+    //   stores an atomic boolean that we can check instead of waiting synchronously, or it can be
+    //   transformed into a SystemEventReceiver for asynchronous waits.
+    // The queue ref creates a temporary ref cycle
+    // (Queue->Device->Instance->EventManager->TrackedEvent). This is OK because the instance will
+    // clear out the EventManager on shutdown.
+    // TODO(crbug.com/dawn/2067): This is a bit fragile. Is it possible to remove the ref cycle?
   protected:
+    friend class EventManager;
+
+    using CompletionData = std::variant<QueueAndSerial, Ref<SystemEvent>, Ref<WaitListEvent>>;
+
+    // Create an event from a WaitListEvent that can be signaled and waited-on in user-space only in
+    // the current process. Note that events like RequestAdapter and RequestDevice complete
+    // immediately in dawn native, and may use an already-completed event.
+    TrackedEvent(wgpu::CallbackMode callbackMode, Ref<WaitListEvent> completionEvent);
+
     // Create an event from a SystemEvent. Note that events like RequestAdapter and
     // RequestDevice complete immediately in dawn native, and may use an already-completed event.
     TrackedEvent(wgpu::CallbackMode callbackMode, Ref<SystemEvent> completionEvent);
@@ -126,33 +178,17 @@
                  QueueBase* queue,
                  ExecutionSerial completionSerial);
 
-    struct Completed {};
     // Create a TrackedEvent that is already completed.
+    struct Completed {};
     TrackedEvent(wgpu::CallbackMode callbackMode, Completed tag);
 
-  public:
-    // Subclasses must implement this to complete the event (if not completed) with
-    // EventCompletionType::Shutdown.
-    ~TrackedEvent() override;
+    // Some SystemEvents may be non-progressing, i.e. DeviceLost. We tag these events so that we can
+    // correctly return whether there is progressing work when users are polling.
+    struct NonProgressing {};
+    TrackedEvent(wgpu::CallbackMode callbackMode, NonProgressing tag);
 
-    Future GetFuture() const;
+    void SetReadyToComplete();
 
-    // Events may be one of two types:
-    // - A queue and the ExecutionSerial after which the event will be completed.
-    //   Used for queue completion.
-    // - A SystemEvent which will be signaled from our code, usually on a separate thread.
-    //   It stores a boolean that we can check instead of polling with the OS, or it can be
-    //   transformed lazily into a SystemEventReceiver. Used for async pipeline creation, and Metal
-    //   queue completion.
-    // The queue ref creates a temporary ref cycle
-    // (Queue->Device->Instance->EventManager->TrackedEvent). This is OK because the instance will
-    // clear out the EventManager on shutdown.
-    // TODO(crbug.com/dawn/2067): This is a bit fragile. Is it possible to remove the ref cycle?
-    using CompletionData = std::variant<QueueAndSerial, Ref<SystemEvent>>;
-
-    const CompletionData& GetCompletionData() const;
-
-  protected:
     void EnsureComplete(EventCompletionType);
     virtual void Complete(EventCompletionType) = 0;
 
@@ -164,9 +200,8 @@
 #endif
 
   private:
-    friend class EventManager;
-
     CompletionData mCompletionData;
+    const bool mIsProgressing = true;
     // Callback has been called.
     std::atomic<bool> mCompleted = false;
 };
diff --git a/src/dawn/native/Queue.cpp b/src/dawn/native/Queue.cpp
index c4f3532..ef30378 100644
--- a/src/dawn/native/Queue.cpp
+++ b/src/dawn/native/Queue.cpp
@@ -55,7 +55,6 @@
 #include "dawn/native/QuerySet.h"
 #include "dawn/native/RenderPassEncoder.h"
 #include "dawn/native/RenderPipeline.h"
-#include "dawn/native/SystemEvent.h"
 #include "dawn/native/Texture.h"
 #include "dawn/platform/DawnPlatform.h"
 #include "dawn/platform/tracing/TraceEvent.h"
diff --git a/src/dawn/native/Queue.h b/src/dawn/native/Queue.h
index bcc3777..1e166a8 100644
--- a/src/dawn/native/Queue.h
+++ b/src/dawn/native/Queue.h
@@ -38,7 +38,6 @@
 #include "dawn/native/Forward.h"
 #include "dawn/native/IntegerTypes.h"
 #include "dawn/native/ObjectBase.h"
-#include "dawn/native/SystemEvent.h"
 #include "partition_alloc/pointers/raw_ptr.h"
 
 #include "dawn/native/DawnNative.h"
diff --git a/src/dawn/native/SystemEvent.cpp b/src/dawn/native/SystemEvent.cpp
index f8dd1c8..2b8fe06 100644
--- a/src/dawn/native/SystemEvent.cpp
+++ b/src/dawn/native/SystemEvent.cpp
@@ -52,6 +52,7 @@
 SystemEventReceiver::SystemEventReceiver(SystemHandle primitive)
     : mPrimitive(std::move(primitive)) {}
 
+// static
 SystemEventReceiver SystemEventReceiver::CreateAlreadySignaled() {
     SystemEventPipeSender sender;
     SystemEventReceiver receiver;
@@ -131,21 +132,6 @@
 // SystemEvent
 
 // static
-Ref<SystemEvent> SystemEvent::CreateSignaled() {
-    auto ev = AcquireRef(new SystemEvent());
-    ev->Signal();
-    return ev;
-}
-
-// static
-Ref<SystemEvent> SystemEvent::CreateNonProgressingEvent() {
-    return AcquireRef(new SystemEvent(kNonProgressingPayload));
-}
-
-bool SystemEvent::IsProgressing() const {
-    return GetRefCountPayload() != kNonProgressingPayload;
-}
-
 bool SystemEvent::IsSignaled() const {
     return mSignaled.load(std::memory_order_acquire);
 }
diff --git a/src/dawn/native/SystemEvent.h b/src/dawn/native/SystemEvent.h
index 391d430..2a649dc 100644
--- a/src/dawn/native/SystemEvent.h
+++ b/src/dawn/native/SystemEvent.h
@@ -105,12 +105,6 @@
 
 class SystemEvent : public RefCounted {
   public:
-    using RefCounted::RefCounted;
-
-    static Ref<SystemEvent> CreateSignaled();
-    static Ref<SystemEvent> CreateNonProgressingEvent();
-
-    bool IsProgressing() const;
     bool IsSignaled() const;
     void Signal();
 
@@ -119,10 +113,6 @@
     const SystemEventReceiver& GetOrCreateSystemEventReceiver();
 
   private:
-    // Some SystemEvents may be non-progressing, i.e. DeviceLost. We tag these events so that we can
-    // correctly return whether there is progressing work when users are polling.
-    static constexpr uint64_t kNonProgressingPayload = 1;
-
     // mSignaled indicates whether the event has already been signaled.
     // It is stored outside the mPipe mutex so its status can quickly be checked without
     // acquiring a lock.
diff --git a/src/dawn/native/WaitListEvent.cpp b/src/dawn/native/WaitListEvent.cpp
new file mode 100644
index 0000000..8a2f422
--- /dev/null
+++ b/src/dawn/native/WaitListEvent.cpp
@@ -0,0 +1,73 @@
+// Copyright 2025 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#include "dawn/native/WaitListEvent.h"
+
+namespace dawn::native {
+
+WaitListEvent::WaitListEvent() = default;
+WaitListEvent::~WaitListEvent() = default;
+
+bool WaitListEvent::IsSignaled() const {
+    return mSignaled.load(std::memory_order_acquire);
+}
+
+void WaitListEvent::Signal() {
+    std::lock_guard<std::mutex> lock(mMutex);
+    DAWN_ASSERT(!mSignaled);
+    mSignaled.store(true, std::memory_order_release);
+    for (SyncWaiter* w : std::move(mSyncWaiters)) {
+        {
+            std::lock_guard<std::mutex> waiterLock(w->mutex);
+            w->waitDone = true;
+        }
+        w->cv.notify_all();
+    }
+    for (auto& sender : std::move(mAsyncWaiters)) {
+        std::move(sender).Signal();
+    }
+}
+
+bool WaitListEvent::Wait(Nanoseconds timeout) {
+    bool ready = false;
+    std::array<std::pair<Ref<WaitListEvent>, bool*>, 1> events{{{this, &ready}}};
+    return WaitListEvent::WaitAny(events.begin(), events.end(), timeout);
+}
+
+SystemEventReceiver WaitListEvent::WaitAsync() {
+    std::lock_guard<std::mutex> lock(mMutex);
+    if (IsSignaled()) {
+        return SystemEventReceiver::CreateAlreadySignaled();
+    }
+    SystemEventPipeSender sender;
+    SystemEventReceiver receiver;
+    std::tie(sender, receiver) = CreateSystemEventPipe();
+    mAsyncWaiters.push_back(std::move(sender));
+    return receiver;
+}
+
+}  // namespace dawn::native
diff --git a/src/dawn/native/WaitListEvent.h b/src/dawn/native/WaitListEvent.h
new file mode 100644
index 0000000..1addd70
--- /dev/null
+++ b/src/dawn/native/WaitListEvent.h
@@ -0,0 +1,202 @@
+// Copyright 2025 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#ifndef SRC_DAWN_NATIVE_WAITLISTEVENT_H_
+#define SRC_DAWN_NATIVE_WAITLISTEVENT_H_
+
+#include <algorithm>
+#include <chrono>
+#include <condition_variable>
+#include <limits>
+#include <mutex>
+#include <utility>
+#include <vector>
+
+#include "dawn/common/RefCounted.h"
+#include "dawn/native/IntegerTypes.h"
+#include "dawn/native/SystemEvent.h"
+#include "partition_alloc/pointers/raw_ptr.h"
+
+namespace dawn::native {
+
+class WaitListEvent : public RefCounted {
+  public:
+    WaitListEvent();
+
+    bool IsSignaled() const;
+    void Signal();
+    bool Wait(Nanoseconds timeout);
+    SystemEventReceiver WaitAsync();
+
+    template <typename It>
+    static bool WaitAny(It eventAndReadyStateBegin, It eventAndReadyStateEnd, Nanoseconds timeout);
+
+  private:
+    ~WaitListEvent() override;
+
+    struct SyncWaiter {
+        std::condition_variable cv;
+        std::mutex mutex;
+        bool waitDone = false;
+    };
+
+    mutable std::mutex mMutex;
+    std::atomic_bool mSignaled{false};
+    std::vector<raw_ptr<SyncWaiter>> mSyncWaiters;
+    std::vector<SystemEventPipeSender> mAsyncWaiters;
+};
+
+template <typename It>
+bool WaitListEvent::WaitAny(It eventAndReadyStateBegin,
+                            It eventAndReadyStateEnd,
+                            Nanoseconds timeout) {
+    static_assert(std::is_base_of_v<std::random_access_iterator_tag,
+                                    typename std::iterator_traits<It>::iterator_category>);
+    static_assert(std::is_same_v<typename std::iterator_traits<It>::value_type,
+                                 std::pair<Ref<WaitListEvent>, bool*>>);
+
+    const size_t count = std::distance(eventAndReadyStateBegin, eventAndReadyStateEnd);
+    if (count == 0) {
+        return false;
+    }
+
+    struct EventState {
+        WaitListEvent* event = nullptr;
+        size_t origIndex;
+        bool isReady = false;
+    };
+    std::vector<EventState> events(count);
+
+    for (size_t i = 0; i < count; i++) {
+        const auto& event = (*(eventAndReadyStateBegin + i)).first;
+        events[i].event = event.Get();
+        events[i].origIndex = i;
+    }
+    // Sort the events by address to get a globally consistent order.
+    std::sort(events.begin(), events.end(),
+              [](const auto& lhs, const auto& rhs) { return lhs.event < rhs.event; });
+
+    // Acquire locks in order and enqueue our waiter.
+    bool foundSignaled = false;
+    for (size_t i = 0; i < count; i++) {
+        WaitListEvent* event = events[i].event;
+        // Skip over multiple waits on the same event, but ensure that we store the same ready state
+        // for duplicates.
+        if (i > 0 && event == events[i - 1].event) {
+            events[i].isReady = events[i - 1].isReady;
+            continue;
+        }
+        event->mMutex.lock();
+        // Check `IsSignaled()` after acquiring the lock so that it doesn't become true immediately
+        // before we acquire the lock - we assume that it is safe to enqueue our waiter after this
+        // point if the event is not already signaled.
+        if (event->IsSignaled()) {
+            events[i].isReady = true;
+            foundSignaled = true;
+        }
+    }
+
+    // If any of the events were already signaled, early out after unlocking the events in reverse
+    // order to prevent lock order inversion.
+    if (foundSignaled) {
+        for (size_t i = 0; i < count; i++) {
+            WaitListEvent* event = events[count - 1 - i].event;
+            // Use the cached value of `IsSignaled()` because we might have unlocked the event
+            // already if it was a duplicate and checking `IsSignaled()` without the lock is racy
+            // and can cause different values of isReady for multiple waits on the same event.
+            if (events[count - 1 - i].isReady) {
+                bool* isReady =
+                    (*(eventAndReadyStateBegin + events[count - 1 - i].origIndex)).second;
+                *isReady = true;
+            }
+            // Skip over multiple waits on the same event.
+            if (i > 0 && event == events[count - i].event) {
+                continue;
+            }
+            event->mMutex.unlock();
+        }
+        return true;
+    }
+
+    // We have acquired locks for all the events we're going to wait on - enqueue our waiter now
+    // after locking it since it could be woken up as soon as the event is unlocked and unlock the
+    // events in reverse order now to prevent lock order inversion.
+    SyncWaiter waiter;
+    std::unique_lock<std::mutex> waiterLock(waiter.mutex);
+    for (size_t i = 0; i < count; i++) {
+        WaitListEvent* event = events[count - 1 - i].event;
+        // Skip over multiple waits on the same event.
+        if (i > 0 && event == events[count - i].event) {
+            continue;
+        }
+        event->mSyncWaiters.push_back(&waiter);
+        event->mMutex.unlock();
+    }
+
+    // Any values larger than those representatable by std::chrono::nanoseconds will be treated as
+    // infinite waits - in particular this covers values greater than INT64_MAX.
+    static constexpr uint64_t kMaxDurationNanos = std::chrono::nanoseconds::max().count();
+    if (timeout > Nanoseconds(kMaxDurationNanos)) {
+        waiter.cv.wait(waiterLock, [&waiter]() { return waiter.waitDone; });
+    } else {
+        waiter.cv.wait_for(waiterLock, std::chrono::nanoseconds(static_cast<uint64_t>(timeout)),
+                           [&waiter]() { return waiter.waitDone; });
+    }
+
+    // Remove our waiter from the events.
+    for (size_t i = 0; i < count; i++) {
+        WaitListEvent* event = events[i].event;
+        // Skip over multiple waits on the same event, but ensure that we store the same ready state
+        // for duplicates.
+        if (i > 0 && event == events[i - 1].event) {
+            events[i].isReady = events[i - 1].isReady;
+        } else {
+            // We could be woken by the condition variable before the atomic release store to
+            // `mSignaled` is visible - locking the mutex ensures that the atomic acquire load in
+            // `IsSignaled()` sees the correct value.
+            std::lock_guard<std::mutex> eventLock(event->mMutex);
+            if (event->IsSignaled()) {
+                events[i].isReady = true;
+            }
+            event->mSyncWaiters.erase(
+                std::remove(event->mSyncWaiters.begin(), event->mSyncWaiters.end(), &waiter),
+                event->mSyncWaiters.end());
+        }
+        if (events[i].isReady) {
+            bool* isReady = (*(eventAndReadyStateBegin + events[i].origIndex)).second;
+            *isReady = true;
+            foundSignaled = true;
+        }
+    }
+
+    DAWN_ASSERT(!waiter.waitDone || foundSignaled);
+    return foundSignaled;
+}
+
+}  // namespace dawn::native
+
+#endif  // SRC_DAWN_NATIVE_WAITLISTEVENT_H_
diff --git a/src/dawn/native/metal/QueueMTL.h b/src/dawn/native/metal/QueueMTL.h
index 67a11c2..1e03df3 100644
--- a/src/dawn/native/metal/QueueMTL.h
+++ b/src/dawn/native/metal/QueueMTL.h
@@ -35,7 +35,7 @@
 #include "dawn/common/SerialMap.h"
 #include "dawn/native/EventManager.h"
 #include "dawn/native/Queue.h"
-#include "dawn/native/SystemEvent.h"
+#include "dawn/native/WaitListEvent.h"
 #include "dawn/native/metal/CommandRecordingContext.h"
 #include "dawn/native/metal/SharedFenceMTL.h"
 
@@ -53,7 +53,7 @@
     id<MTLSharedEvent> GetMTLSharedEvent() const;
     ResultOrError<Ref<SharedFence>> GetOrCreateSharedFence();
 
-    Ref<SystemEvent> CreateWorkDoneSystemEvent(ExecutionSerial serial);
+    Ref<WaitListEvent> CreateWorkDoneEvent(ExecutionSerial serial);
     ResultOrError<bool> WaitForQueueSerial(ExecutionSerial serial, Nanoseconds timeout) override;
 
   private:
@@ -88,7 +88,7 @@
     // TODO(crbug.com/dawn/2065): If we atomically knew a conservative lower bound on the
     // mWaitingEvents serials, we could avoid taking this lock sometimes. Optimize if needed.
     // See old draft code: https://dawn-review.googlesource.com/c/dawn/+/137502/29
-    MutexProtected<SerialMap<ExecutionSerial, Ref<SystemEvent>>> mWaitingEvents;
+    MutexProtected<SerialMap<ExecutionSerial, Ref<WaitListEvent>>> mWaitingEvents;
 
     // A shared event that can be exported for synchronization with other users of Metal.
     // MTLSharedEvent is not available until macOS 10.14+ so use just `id`.
diff --git a/src/dawn/native/metal/QueueMTL.mm b/src/dawn/native/metal/QueueMTL.mm
index f1d3f04f..b51248a 100644
--- a/src/dawn/native/metal/QueueMTL.mm
+++ b/src/dawn/native/metal/QueueMTL.mm
@@ -33,7 +33,6 @@
 #include "dawn/native/Commands.h"
 #include "dawn/native/DynamicUploader.h"
 #include "dawn/native/MetalBackend.h"
-#include "dawn/native/WaitAnySystemEvent.h"
 #include "dawn/native/metal/CommandBufferMTL.h"
 #include "dawn/native/metal/DeviceMTL.h"
 #include "dawn/platform/DawnPlatform.h"
@@ -252,10 +251,9 @@
     }
 }
 
-Ref<SystemEvent> Queue::CreateWorkDoneSystemEvent(ExecutionSerial serial) {
-    Ref<SystemEvent> completionEvent = AcquireRef(new SystemEvent());
+Ref<WaitListEvent> Queue::CreateWorkDoneEvent(ExecutionSerial serial) {
+    Ref<WaitListEvent> completionEvent = AcquireRef(new WaitListEvent());
     mWaitingEvents.Use([&](auto events) {
-        SystemEventReceiver receiver;
         // Now that we hold the lock, check against mCompletedSerial before inserting.
         // This serial may have just completed. If it did, mark the event complete.
         // Also check for device loss. Otherwise, we could enqueue the event
@@ -273,11 +271,7 @@
 }
 
 ResultOrError<bool> Queue::WaitForQueueSerial(ExecutionSerial serial, Nanoseconds timeout) {
-    Ref<SystemEvent> event = CreateWorkDoneSystemEvent(serial);
-    bool ready = false;
-    std::array<std::pair<const dawn::native::SystemEventReceiver&, bool*>, 1> events{
-        {{event->GetOrCreateSystemEventReceiver(), &ready}}};
-    return WaitAnySystemEvent(events.begin(), events.end(), timeout);
+    return CreateWorkDoneEvent(serial)->Wait(timeout);
 }
 
 }  // namespace dawn::native::metal
diff --git a/src/dawn/tests/BUILD.gn b/src/dawn/tests/BUILD.gn
index cb9b140..05ab8ec 100644
--- a/src/dawn/tests/BUILD.gn
+++ b/src/dawn/tests/BUILD.gn
@@ -780,6 +780,7 @@
     "white_box/SharedBufferMemoryTests.h",
     "white_box/SharedTextureMemoryTests.cpp",
     "white_box/SharedTextureMemoryTests.h",
+    "white_box/WaitListEventTests.cpp",
   ]
   libs = []
 
diff --git a/src/dawn/tests/white_box/WaitListEventTests.cpp b/src/dawn/tests/white_box/WaitListEventTests.cpp
new file mode 100644
index 0000000..b97c7c9
--- /dev/null
+++ b/src/dawn/tests/white_box/WaitListEventTests.cpp
@@ -0,0 +1,351 @@
+// Copyright 2025 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#include <chrono>
+#include <thread>
+#include <utility>
+#include <vector>
+
+#include "dawn/common/Ref.h"
+#include "dawn/native/WaitAnySystemEvent.h"
+#include "dawn/native/WaitListEvent.h"
+#include "dawn/tests/DawnTest.h"
+
+namespace dawn::native {
+namespace {
+
+constexpr uint64_t kZeroDurationNs = 0;
+constexpr uint64_t kShortDurationNs = 1000000;
+constexpr uint64_t kMediumDurationNs = 50000000;
+
+// Helper to wait on a SystemEventReceiver with a timeout
+bool WaitOnReceiver(const SystemEventReceiver& receiver, Nanoseconds timeout) {
+    bool ready = false;
+    std::pair<const SystemEventReceiver&, bool*> event = {receiver, &ready};
+    return WaitAnySystemEvent(&event, &event + 1, timeout);
+}
+
+class WaitListEventTests : public DawnTest {
+  protected:
+    void SetUp() override {
+        DawnTest::SetUp();
+        DAWN_TEST_UNSUPPORTED_IF(UsesWire());
+    }
+};
+
+// Test basic signaling and state checking
+TEST(WaitListEventTests, SignalAndCheck) {
+    Ref<WaitListEvent> event = AcquireRef(new WaitListEvent());
+    EXPECT_FALSE(event->IsSignaled());
+    event->Signal();
+    EXPECT_TRUE(event->IsSignaled());
+}
+
+// Test waiting on an already signaled event
+TEST(WaitListEventTests, WaitAlreadySignaled) {
+    Ref<WaitListEvent> event = AcquireRef(new WaitListEvent());
+    event->Signal();
+    EXPECT_TRUE(event->IsSignaled());
+    // Wait with zero timeout should return true immediately
+    EXPECT_TRUE(event->Wait(Nanoseconds(kZeroDurationNs)));
+    // Wait with non-zero timeout should return true immediately
+    EXPECT_TRUE(event->Wait(Nanoseconds(kShortDurationNs)));
+}
+
+// Test waiting on an event that gets signaled later
+TEST(WaitListEventTests, WaitThenSignal) {
+    Ref<WaitListEvent> event = AcquireRef(new WaitListEvent());
+    EXPECT_FALSE(event->IsSignaled());
+
+    std::thread signaler([&]() {
+        std::this_thread::sleep_for(std::chrono::nanoseconds(kShortDurationNs));
+        event->Signal();
+    });
+
+    // Wait for longer than the signal delay
+    EXPECT_TRUE(event->Wait(Nanoseconds(kMediumDurationNs)));
+    EXPECT_TRUE(event->IsSignaled());
+
+    signaler.join();
+}
+
+// Test waiting with a timeout that expires
+TEST(WaitListEventTests, WaitTimeout) {
+    Ref<WaitListEvent> event = AcquireRef(new WaitListEvent());
+    EXPECT_FALSE(event->IsSignaled());
+
+    // Wait for a short duration, expect timeout
+    EXPECT_FALSE(event->Wait(Nanoseconds(kShortDurationNs)));
+    EXPECT_FALSE(event->IsSignaled());
+}
+
+// Test waiting with a zero timeout
+TEST(WaitListEventTests, WaitZeroTimeout) {
+    Ref<WaitListEvent> event = AcquireRef(new WaitListEvent());
+    EXPECT_FALSE(event->IsSignaled());
+    // Wait with zero timeout should return false immediately
+    EXPECT_FALSE(event->Wait(Nanoseconds(kZeroDurationNs)));
+    EXPECT_FALSE(event->IsSignaled());
+
+    event->Signal();
+    EXPECT_TRUE(event->IsSignaled());
+    // Wait with zero timeout should return true immediately
+    EXPECT_TRUE(event->Wait(Nanoseconds(kZeroDurationNs)));
+}
+
+// Test WaitAsync on an already signaled event
+TEST(WaitListEventTests, WaitAsyncAlreadySignaled) {
+    Ref<WaitListEvent> event = AcquireRef(new WaitListEvent());
+    event->Signal();
+    EXPECT_TRUE(event->IsSignaled());
+
+    SystemEventReceiver receiver = event->WaitAsync();
+    // The receiver should be immediately ready
+    EXPECT_TRUE(WaitOnReceiver(receiver, Nanoseconds(kZeroDurationNs)));
+}
+
+// Test WaitAsync, signaling the event later
+TEST(WaitListEventTests, WaitAsyncThenSignal) {
+    Ref<WaitListEvent> event = AcquireRef(new WaitListEvent());
+    EXPECT_FALSE(event->IsSignaled());
+
+    SystemEventReceiver receiver = event->WaitAsync();
+
+    // Check it's not ready yet
+    EXPECT_FALSE(WaitOnReceiver(receiver, Nanoseconds(kZeroDurationNs)));
+
+    std::thread signaler([&]() {
+        std::this_thread::sleep_for(std::chrono::nanoseconds(kShortDurationNs));
+        event->Signal();
+    });
+
+    // Wait for the receiver to become signaled
+    EXPECT_TRUE(WaitOnReceiver(receiver, Nanoseconds(kMediumDurationNs)));
+    EXPECT_TRUE(event->IsSignaled());
+
+    signaler.join();
+}
+
+// Test WaitAny with an empty list
+TEST(WaitListEventTests, WaitAnyEmpty) {
+    std::array<std::pair<Ref<WaitListEvent>, bool*>, 0> events;
+    EXPECT_FALSE(
+        WaitListEvent::WaitAny(events.begin(), events.end(), Nanoseconds(kShortDurationNs)));
+}
+
+// Test WaitAny where one event is already signaled
+TEST(WaitListEventTests, WaitAnyOneAlreadySignaled) {
+    Ref<WaitListEvent> event1 = AcquireRef(new WaitListEvent());
+    Ref<WaitListEvent> event2 = AcquireRef(new WaitListEvent());
+    event1->Signal();
+
+    bool ready1 = false;
+    bool ready2 = false;
+    std::array<std::pair<Ref<WaitListEvent>, bool*>, 2> events = {
+        {{event1, &ready1}, {event2, &ready2}}};
+
+    EXPECT_TRUE(
+        WaitListEvent::WaitAny(events.begin(), events.end(), Nanoseconds(kShortDurationNs)));
+    EXPECT_TRUE(ready1);
+    EXPECT_FALSE(ready2);
+}
+
+// Test WaitAny where one event is signaled while waiting
+TEST(WaitListEventTests, WaitAnySignalDuringWait) {
+    Ref<WaitListEvent> event1 = AcquireRef(new WaitListEvent());
+    Ref<WaitListEvent> event2 = AcquireRef(new WaitListEvent());
+
+    bool ready1 = false;
+    bool ready2 = false;
+    std::array<std::pair<Ref<WaitListEvent>, bool*>, 2> events = {
+        {{event1, &ready1}, {event2, &ready2}}};
+
+    std::thread signaler([&]() {
+        std::this_thread::sleep_for(std::chrono::nanoseconds(kShortDurationNs));
+        event2->Signal();  // Signal the second event
+    });
+
+    EXPECT_TRUE(
+        WaitListEvent::WaitAny(events.begin(), events.end(), Nanoseconds(kMediumDurationNs)));
+    EXPECT_FALSE(ready1);
+    EXPECT_TRUE(ready2);  // Expect the second event to be ready
+
+    signaler.join();
+}
+
+// Test WaitAny with a timeout
+TEST(WaitListEventTests, WaitAnyTimeout) {
+    Ref<WaitListEvent> event1 = AcquireRef(new WaitListEvent());
+    Ref<WaitListEvent> event2 = AcquireRef(new WaitListEvent());
+
+    bool ready1 = false;
+    bool ready2 = false;
+    std::array<std::pair<Ref<WaitListEvent>, bool*>, 2> events = {
+        {{event1, &ready1}, {event2, &ready2}}};
+
+    EXPECT_FALSE(
+        WaitListEvent::WaitAny(events.begin(), events.end(), Nanoseconds(kShortDurationNs)));
+    EXPECT_FALSE(ready1);
+    EXPECT_FALSE(ready2);
+}
+
+// Test WaitAny with zero timeout
+TEST(WaitListEventTests, WaitAnyZeroTimeout) {
+    Ref<WaitListEvent> event1 = AcquireRef(new WaitListEvent());
+    Ref<WaitListEvent> event2 = AcquireRef(new WaitListEvent());
+
+    bool ready1 = false;
+    bool ready2 = false;
+    std::array<std::pair<Ref<WaitListEvent>, bool*>, 2> events = {
+        {{event1, &ready1}, {event2, &ready2}}};
+
+    // No events signaled
+    EXPECT_FALSE(
+        WaitListEvent::WaitAny(events.begin(), events.end(), Nanoseconds(kZeroDurationNs)));
+    EXPECT_FALSE(ready1);
+    EXPECT_FALSE(ready2);
+
+    // Signal one event
+    event1->Signal();
+    EXPECT_TRUE(WaitListEvent::WaitAny(events.begin(), events.end(), Nanoseconds(kZeroDurationNs)));
+    EXPECT_TRUE(ready1);
+    EXPECT_FALSE(ready2);
+}
+
+// Test WaitAny with the same event multiple times
+TEST(WaitListEventTests, WaitAnyDuplicateEvents) {
+    Ref<WaitListEvent> event = AcquireRef(new WaitListEvent());
+
+    bool ready1 = false;
+    bool ready2 = false;
+    std::vector<std::pair<Ref<WaitListEvent>, bool*>> events = {
+        {event, &ready1}, {event, &ready2}  // Same event again
+    };
+
+    std::thread signaler([&]() {
+        std::this_thread::sleep_for(std::chrono::nanoseconds(kShortDurationNs));
+        event->Signal();
+    });
+
+    EXPECT_TRUE(
+        WaitListEvent::WaitAny(events.begin(), events.end(), Nanoseconds(kMediumDurationNs)));
+    // Both ready flags corresponding to the same event should be true
+    EXPECT_TRUE(ready1);
+    EXPECT_TRUE(ready2);
+
+    signaler.join();
+}
+
+// Test WaitAny with the same event multiple times, already signaled
+TEST(WaitListEventTests, WaitAnyDuplicateEventsAlreadySignaled) {
+    Ref<WaitListEvent> event = AcquireRef(new WaitListEvent());
+
+    bool ready1 = false;
+    bool ready2 = false;
+    std::vector<std::pair<Ref<WaitListEvent>, bool*>> events = {
+        {event, &ready1}, {event, &ready2}  // Same event again
+    };
+
+    // Signal the event *before* waiting
+    event->Signal();
+    EXPECT_TRUE(event->IsSignaled());
+
+    // WaitAny should return immediately since the event is already signaled
+    EXPECT_TRUE(
+        WaitListEvent::WaitAny(events.begin(), events.end(), Nanoseconds(kMediumDurationNs)));
+
+    // Both ready flags corresponding to the same event should be true
+    EXPECT_TRUE(ready1);
+    EXPECT_TRUE(ready2);
+}
+
+// Test multiple threads waiting on the same event
+TEST(WaitListEventTests, WaitMultiThreadedSingleEvent) {
+    Ref<WaitListEvent> event = AcquireRef(new WaitListEvent());
+
+    constexpr size_t kNumWaiters = 5;
+    std::array<std::thread, kNumWaiters> waiters;
+    std::array<std::optional<bool>, kNumWaiters> results;
+
+    for (size_t i = 0; i < kNumWaiters; ++i) {
+        waiters[i] = std::thread(
+            [&results, &event, i]() { results[i] = event->Wait(Nanoseconds(kMediumDurationNs)); });
+    }
+
+    // Give waiters time to start waiting
+    std::this_thread::sleep_for(std::chrono::nanoseconds(kShortDurationNs));
+    event->Signal();
+
+    // Check all waiters returned true
+    for (size_t i = 0; i < kNumWaiters; ++i) {
+        waiters[i].join();
+        EXPECT_TRUE(results[i].has_value());
+        EXPECT_TRUE(results[i].value());
+    }
+    EXPECT_TRUE(event->IsSignaled());
+}
+
+// Test multiple threads waiting on different events via WaitAny
+TEST(WaitListEventTests, WaitAnyMultiThreaded) {
+    Ref<WaitListEvent> event1 = AcquireRef(new WaitListEvent());
+    Ref<WaitListEvent> event2 = AcquireRef(new WaitListEvent());
+    Ref<WaitListEvent> event3 = AcquireRef(new WaitListEvent());
+
+    bool ready1 = false;
+    bool ready2 = false;
+    bool ready3 = false;
+    std::array<std::pair<Ref<WaitListEvent>, bool*>, 3> events = {
+        {{event1, &ready1}, {event2, &ready2}, {event3, &ready3}}};
+
+    // Start a thread that waits on any of the events
+    bool waitResult = false;
+    std::thread waiter([&]() {
+        waitResult =
+            WaitListEvent::WaitAny(events.begin(), events.end(), Nanoseconds(kMediumDurationNs));
+    });
+
+    // Start another thread that signals one of the events
+    std::thread signaler([&]() {
+        std::this_thread::sleep_for(std::chrono::nanoseconds(kShortDurationNs));
+        event2->Signal();  // Signal the middle event
+    });
+
+    waiter.join();
+
+    // Check that the waiting thread completes successfully
+    EXPECT_TRUE(waitResult);
+
+    // Check that the correct ready flag was set
+    EXPECT_FALSE(ready1);
+    EXPECT_TRUE(ready2);
+    EXPECT_FALSE(ready3);
+
+    signaler.join();
+}
+
+}  // namespace
+}  // namespace dawn::native