Add WGPUFuture, and implement for WorkDone in Metal + wire

This implements the WGPUFuture version of OnSubmittedWorkDone for the
Metal backend, as well as in the wire (over the old implementation).

This is a partial implementation of WGPUFuture. It sits alongside the
old API until it's complete and has all of the breaking API changes
we're going to make. (See bug dawn:2021.)

For more on WGPUFuture, see the design doc in bug dawn:1987.

Bug: dawn:1987, dawn:2021
Change-Id: If0c82279addf419ef16cc0280416ce2bfb71895f
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/137502
Kokoro: Kokoro <noreply+kokoro@google.com>
Auto-Submit: Kai Ninomiya <kainino@chromium.org>
Commit-Queue: Austin Eng <enga@chromium.org>
Reviewed-by: Loko Kung <lokokung@google.com>
Reviewed-by: Austin Eng <enga@chromium.org>
diff --git a/CPPLINT.cfg b/CPPLINT.cfg
index 407dec8..c19a658 100644
--- a/CPPLINT.cfg
+++ b/CPPLINT.cfg
@@ -1,5 +1,8 @@
 set noparent
 
+# Dawn has something called CHECK, but it doesn't have everything Chromium has (like CHECK_GE).
+filter=-readability/check
+
 # This set of removals is set to match the set of
 # OFF_UNLESS_MANUALLY_ENABLED_LINT_FEATURES from the depot_tools
 # presubmit_canned_checks.py file.
diff --git a/dawn.json b/dawn.json
index 2b2685f..df50b2d 100644
--- a/dawn.json
+++ b/dawn.json
@@ -29,6 +29,7 @@
     "create instance": {
         "category": "function",
         "returns": "instance",
+        "_comment": "TODO(crbug.com/dawn/1987): The return type should be nullable; null is returned in error cases.",
         "args": [
             {"name": "descriptor", "type": "instance descriptor", "annotation": "const*", "optional": true}
         ]
@@ -42,7 +43,7 @@
         "category": "function",
         "returns": "proc",
         "args": [
-            {"name": "device", "type": "device"},
+            {"name": "device", "type": "device", "optional": true},
             {"name": "proc name", "type": "char", "annotation": "const*"}
         ]
     },
@@ -1951,6 +1952,15 @@
                 "name": "process events"
             },
             {
+                "name": "wait any",
+                "returns": "wait status",
+                "args": [
+                    {"name": "future count", "type": "size_t"},
+                    {"name": "futures", "type": "future wait info", "annotation": "*", "length": "future count"},
+                    {"name": "timeout NS", "type": "uint64_t"}
+                ]
+            },
+            {
                 "name": "request adapter",
                 "args": [
                     {"name": "options", "type": "request adapter options", "annotation": "const*", "optional": true, "no_default": true},
@@ -1960,10 +1970,63 @@
             }
         ]
     },
+    "callback mode": {
+        "_comment": "TODO(crbug.com/dawn/1987): Change this to an enum, and always return a future (https://github.com/webgpu-native/webgpu-headers/issues/199#issuecomment-1710784711).",
+        "category": "bitmask",
+        "values": [
+            {"name": "future", "value": 1},
+            {"name": "process events", "value": 2},
+            {"name": "spontaneous", "value": 4}
+        ]
+    },
+    "future": {
+        "category": "structure",
+        "members": [
+            {"name": "id", "type": "uint64_t"}
+        ]
+    },
+    "wait status": {
+        "category": "enum",
+        "_comment": "TODO(crbug.com/dawn/1987): This could be possibly be [[nodiscard]].",
+        "emscripten_no_enum_table": true,
+        "values": [
+            {"name": "success", "value": 0},
+            {"name": "timed out", "value": 1},
+            {"name": "unsupported timeout", "value": 2},
+            {"name": "unsupported count", "value": 3},
+            {"name": "unsupported mixed sources", "value": 4},
+            {"name": "unknown", "value": 5}
+        ]
+    },
+    "future wait info": {
+        "category": "structure",
+        "members": [
+            {"name": "future", "type": "future"},
+            {"name": "completed", "type": "bool", "default": "false"}
+        ]
+    },
+    "instance features": {
+        "category": "structure",
+        "extensible": "in",
+        "members": [
+            {"name": "timed wait any enable", "type": "bool", "default": "false"},
+            {"name": "timed wait any max count", "type": "size_t", "default": "0"}
+        ]
+    },
     "instance descriptor": {
         "category": "structure",
         "extensible": "in",
-        "members": []
+        "members": [
+            {"name": "features", "type": "instance features"}
+        ]
+    },
+    "get instance features": {
+        "category": "function",
+        "_comment": "TODO(crbug.com/dawn/1987): Figure out how to return error codes for functions like this (https://github.com/webgpu-native/webgpu-headers/issues/115).",
+        "returns": "bool",
+        "args": [
+            {"name": "features", "type": "instance features", "annotation": "*"}
+        ]
     },
     "vertex attribute": {
         "category": "structure",
@@ -2198,6 +2261,15 @@
                 ]
             },
             {
+                "name": "on submitted work done f",
+                "_comment": "TODO(crbug.com/dawn/2021): This is dawn/emscripten-only until we rename it to replace the old API. See bug for details.",
+                "tags": ["dawn", "emscripten"],
+                "returns": "future",
+                "args": [
+                    {"name": "callback info", "type": "queue work done callback info"}
+                ]
+            },
+            {
                 "name": "write buffer",
                 "args": [
                     {"name": "buffer", "type": "buffer"},
@@ -2261,6 +2333,15 @@
             {"name": "userdata", "type": "void *"}
         ]
     },
+    "queue work done callback info": {
+        "category": "structure",
+        "extensible": "in",
+        "members": [
+            {"name": "mode", "type": "callback mode"},
+            {"name": "callback", "type": "queue work done callback"},
+            {"name": "userdata", "type": "void *"}
+        ]
+    },
     "queue work done status": {
         "category": "enum",
         "emscripten_no_enum_table": true,
diff --git a/dawn_wire.json b/dawn_wire.json
index 5b78ea5..9137f2d 100644
--- a/dawn_wire.json
+++ b/dawn_wire.json
@@ -172,6 +172,7 @@
     },
     "special items": {
         "client_side_structures": [
+            "FutureWaitInfo",
             "SurfaceDescriptorFromMetalLayer",
             "SurfaceDescriptorFromWindowsHWND",
             "SurfaceDescriptorFromXlibWindow",
@@ -207,6 +208,7 @@
             "QuerySetGetType",
             "QuerySetGetCount",
             "QueueOnSubmittedWorkDone",
+            "QueueOnSubmittedWorkDoneF",
             "QueueWriteBuffer",
             "QueueWriteTexture",
             "TextureGetWidth",
@@ -226,6 +228,8 @@
             "DeviceGetQueue",
             "DeviceGetSupportedSurfaceUsage",
             "DeviceInjectError",
+            "InstanceProcessEvents",
+            "InstanceWaitAny",
             "SwapChainGetCurrentTexture"
         ],
         "client_special_objects": [
diff --git a/generator/templates/api.h b/generator/templates/api.h
index d3f7735..1f98bac 100644
--- a/generator/templates/api.h
+++ b/generator/templates/api.h
@@ -188,7 +188,9 @@
 {% for function in by_category["function"] %}
     {{API}}_EXPORT {{as_cType(function.return_type.name)}} {{as_cMethod(None, function.name)}}(
             {%- for arg in function.arguments -%}
-                {% if not loop.first %}, {% endif %}{{as_annotated_cType(arg)}}
+                {% if not loop.first %}, {% endif -%}
+                {%- if arg.optional %}{{API}}_NULLABLE {% endif -%}
+                {{as_annotated_cType(arg)}}
             {%- endfor -%}
         ) {{API}}_FUNCTION_ATTRIBUTE;
 {% endfor %}
diff --git a/generator/templates/api_cpp.cpp b/generator/templates/api_cpp.cpp
index c326363..68caffe 100644
--- a/generator/templates/api_cpp.cpp
+++ b/generator/templates/api_cpp.cpp
@@ -92,6 +92,8 @@
                 {{as_varName(arg.name)}}.Get()
             {%- elif arg.type.category == "enum" or arg.type.category == "bitmask" -%}
                 static_cast<{{as_cType(arg.type.name)}}>({{as_varName(arg.name)}})
+            {%- elif arg.type.category == "structure" -%}
+                *reinterpret_cast<{{as_cType(arg.type.name)}} const*>(&{{as_varName(arg.name)}})
             {%- elif arg.type.category in ["function pointer", "native"] -%}
                 {{as_varName(arg.name)}}
             {%- else -%}
diff --git a/generator/templates/dawn/wire/client/ApiProcs.cpp b/generator/templates/dawn/wire/client/ApiProcs.cpp
index 82bb340..ce227bf 100644
--- a/generator/templates/dawn/wire/client/ApiProcs.cpp
+++ b/generator/templates/dawn/wire/client/ApiProcs.cpp
@@ -14,6 +14,7 @@
 
 #include "dawn/wire/client/ApiObjects.h"
 #include "dawn/wire/client/Client.h"
+#include "dawn/wire/client/Instance.h"
 
 #include <algorithm>
 #include <cstring>
@@ -122,11 +123,6 @@
     {% endfor %}
 
     namespace {
-        WGPUInstance ClientCreateInstance(WGPUInstanceDescriptor const* descriptor) {
-            UNREACHABLE();
-            return nullptr;
-        }
-
         struct ProcEntry {
             WGPUProc proc;
             const char* name;
@@ -154,15 +150,14 @@
             return entry->proc;
         }
 
-        // Special case the two free-standing functions of the API.
-        if (strcmp(procName, "wgpuGetProcAddress") == 0) {
-            return reinterpret_cast<WGPUProc>(ClientGetProcAddress);
-        }
+        // Special case the free-standing functions of the API.
+        // TODO(dawn:1238) Checking string one by one is slow, it needs to be optimized.
+        {% for function in by_category["function"] %}
+            if (strcmp(procName, "{{as_cMethod(None, function.name)}}") == 0) {
+                return reinterpret_cast<WGPUProc>(Client{{as_cppType(function.name)}});
+            }
 
-        if (strcmp(procName, "wgpuCreateInstance") == 0) {
-            return reinterpret_cast<WGPUProc>(ClientCreateInstance);
-        }
-
+        {% endfor %}
         return nullptr;
     }
 
diff --git a/src/dawn/common/BUILD.gn b/src/dawn/common/BUILD.gn
index cacfd25..bd5dc19 100644
--- a/src/dawn/common/BUILD.gn
+++ b/src/dawn/common/BUILD.gn
@@ -242,6 +242,8 @@
       "CoreFoundationRef.h",
       "DynamicLib.cpp",
       "DynamicLib.h",
+      "FutureUtils.cpp",
+      "FutureUtils.h",
       "GPUInfo.cpp",
       "GPUInfo.h",
       "HashUtils.h",
diff --git a/src/dawn/common/CMakeLists.txt b/src/dawn/common/CMakeLists.txt
index aa20870..6ebe743 100644
--- a/src/dawn/common/CMakeLists.txt
+++ b/src/dawn/common/CMakeLists.txt
@@ -45,6 +45,8 @@
     "CoreFoundationRef.h"
     "DynamicLib.cpp"
     "DynamicLib.h"
+    "FutureUtils.cpp"
+    "FutureUtils.h"
     "GPUInfo.cpp"
     "GPUInfo.h"
     "HashUtils.h"
diff --git a/src/dawn/common/FutureUtils.cpp b/src/dawn/common/FutureUtils.cpp
new file mode 100644
index 0000000..db5d44b
--- /dev/null
+++ b/src/dawn/common/FutureUtils.cpp
@@ -0,0 +1,40 @@
+// Copyright 2023 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "dawn/common/FutureUtils.h"
+
+#include "dawn/common/Assert.h"
+
+namespace dawn {
+
+CallbackMode ValidateAndFlattenCallbackMode(WGPUCallbackModeFlags mode) {
+    switch (mode) {
+        case WGPUCallbackMode_Spontaneous:
+            return CallbackMode::Spontaneous;
+        case WGPUCallbackMode_Future:
+            return CallbackMode::Future;
+        case WGPUCallbackMode_Future | WGPUCallbackMode_Spontaneous:
+            return CallbackMode::FutureOrSpontaneous;
+        case WGPUCallbackMode_ProcessEvents:
+            return CallbackMode::ProcessEvents;
+        case WGPUCallbackMode_ProcessEvents | WGPUCallbackMode_Spontaneous:
+            return CallbackMode::ProcessEventsOrSpontaneous;
+        default:
+            // These cases are undefined behaviors according to the API contract.
+            ASSERT(false);
+            return CallbackMode::Spontaneous;
+    }
+}
+
+}  // namespace dawn
diff --git a/src/dawn/common/FutureUtils.h b/src/dawn/common/FutureUtils.h
new file mode 100644
index 0000000..a062735
--- /dev/null
+++ b/src/dawn/common/FutureUtils.h
@@ -0,0 +1,51 @@
+// Copyright 2023 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_DAWN_COMMON_FUTUREUTILS_H_
+#define SRC_DAWN_COMMON_FUTUREUTILS_H_
+
+#include <cstddef>
+#include <cstdint>
+
+#include "dawn/webgpu.h"
+
+namespace dawn {
+
+using FutureID = uint64_t;
+constexpr FutureID kNullFutureID = 0;
+
+constexpr size_t kTimedWaitAnyMaxCountDefault = 64;
+
+enum class EventCompletionType {
+    // The event is completing because it became ready.
+    Ready,
+    // The event is completing because the instance is shutting down.
+    Shutdown,
+};
+
+// Flattened version of the wgpu::CallbackMode flags.
+// (This will disappear when that API changes to use an enum instead of flags.)
+enum class [[nodiscard]] CallbackMode {
+    Spontaneous,
+    Future,
+    FutureOrSpontaneous,
+    ProcessEvents,
+    ProcessEventsOrSpontaneous,
+};
+
+CallbackMode ValidateAndFlattenCallbackMode(WGPUCallbackModeFlags mode);
+
+}  // namespace dawn
+
+#endif  // SRC_DAWN_COMMON_FUTUREUTILS_H_
diff --git a/src/dawn/native/BUILD.gn b/src/dawn/native/BUILD.gn
index 2bd4a68..91a37a6 100644
--- a/src/dawn/native/BUILD.gn
+++ b/src/dawn/native/BUILD.gn
@@ -266,6 +266,8 @@
     "ErrorInjector.h",
     "ErrorScope.cpp",
     "ErrorScope.h",
+    "EventManager.cpp",
+    "EventManager.h",
     "ExecutionQueue.cpp",
     "ExecutionQueue.h",
     "ExternalTexture.cpp",
@@ -351,6 +353,8 @@
     "Surface.h",
     "SwapChain.cpp",
     "SwapChain.h",
+    "SystemEvent.cpp",
+    "SystemEvent.h",
     "Texture.cpp",
     "Texture.h",
     "TintUtils.cpp",
diff --git a/src/dawn/native/CMakeLists.txt b/src/dawn/native/CMakeLists.txt
index 7547f0f..03b486e 100644
--- a/src/dawn/native/CMakeLists.txt
+++ b/src/dawn/native/CMakeLists.txt
@@ -116,6 +116,8 @@
     "ErrorInjector.h"
     "ErrorScope.cpp"
     "ErrorScope.h"
+    "EventManager.cpp"
+    "EventManager.h"
     "Features.cpp"
     "Features.h"
     "ExternalTexture.cpp"
@@ -138,6 +140,8 @@
     "IntegerTypes.h"
     "Limits.cpp"
     "Limits.h"
+    "SystemEvent.cpp"
+    "SystemEvent.h"
     "ObjectBase.cpp"
     "ObjectBase.h"
     "PassResourceUsage.cpp"
diff --git a/src/dawn/native/Device.cpp b/src/dawn/native/Device.cpp
index 1700cd4..88d20db 100644
--- a/src/dawn/native/Device.cpp
+++ b/src/dawn/native/Device.cpp
@@ -750,6 +750,10 @@
     return &mObjectLists[type];
 }
 
+InstanceBase* DeviceBase::GetInstance() const {
+    return mAdapter->GetPhysicalDevice()->GetInstance();
+}
+
 AdapterBase* DeviceBase::GetAdapter() const {
     return mAdapter.Get();
 }
@@ -2051,6 +2055,13 @@
     return 4u;
 }
 
+bool DeviceBase::WaitAnyImpl(size_t futureCount,
+                             TrackedFutureWaitInfo* futures,
+                             Nanoseconds timeout) {
+    // Default for backends which don't actually need to do anything special in this case.
+    return WaitAnySystemEvent(futureCount, futures, timeout);
+}
+
 MaybeError DeviceBase::CopyFromStagingToBuffer(BufferBase* source,
                                                uint64_t sourceOffset,
                                                BufferBase* destination,
diff --git a/src/dawn/native/Device.h b/src/dawn/native/Device.h
index 3081c75..88b02f1 100644
--- a/src/dawn/native/Device.h
+++ b/src/dawn/native/Device.h
@@ -59,6 +59,7 @@
 struct CallbackTask;
 struct InternalPipelineStore;
 struct ShaderModuleParseResult;
+struct TrackedFutureWaitInfo;
 
 using WGSLExtensionSet = std::unordered_set<std::string>;
 
@@ -157,6 +158,7 @@
 
     MaybeError ValidateObject(const ApiObjectBase* object) const;
 
+    InstanceBase* GetInstance() const;
     AdapterBase* GetAdapter() const;
     PhysicalDeviceBase* GetPhysicalDevice() const;
     virtual dawn::platform::Platform* GetPlatform() const;
@@ -430,6 +432,10 @@
 
     virtual void AppendDebugLayerMessages(ErrorData* error) {}
 
+    [[nodiscard]] virtual bool WaitAnyImpl(size_t futureCount,
+                                           TrackedFutureWaitInfo* futures,
+                                           Nanoseconds timeout);
+
     // It is guaranteed that the wrapped mutex will outlive the Device (if the Device is deleted
     // before the AutoLockAndHoldRef).
     [[nodiscard]] Mutex::AutoLockAndHoldRef GetScopedLockSafeForDelete();
diff --git a/src/dawn/native/EventManager.cpp b/src/dawn/native/EventManager.cpp
new file mode 100644
index 0000000..e03deb3
--- /dev/null
+++ b/src/dawn/native/EventManager.cpp
@@ -0,0 +1,315 @@
+// Copyright 2023 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "dawn/native/EventManager.h"
+
+#include <algorithm>
+#include <functional>
+#include <utility>
+#include <vector>
+
+#include "dawn/common/Assert.h"
+#include "dawn/common/FutureUtils.h"
+#include "dawn/native/Device.h"
+#include "dawn/native/IntegerTypes.h"
+#include "dawn/native/SystemEvent.h"
+
+namespace dawn::native {
+
+namespace {
+
+wgpu::WaitStatus WaitImpl(std::vector<TrackedFutureWaitInfo>& futures, Nanoseconds timeout) {
+    // Sort the futures by how they'll be waited (their GetWaitDevice).
+    // This lets us do each wait on a slice of the array.
+    std::sort(futures.begin(), futures.end(), [](const auto& a, const auto& b) {
+        // operator<() is undefined behavior for arbitrary pointers, but std::less{}() is defined.
+        return std::less<DeviceBase*>{}(a.event->GetWaitDevice(), b.event->GetWaitDevice());
+    });
+
+    if (timeout > Nanoseconds(0)) {
+        ASSERT(futures.size() <= kTimedWaitAnyMaxCountDefault);
+
+        // If there's a timeout, check that there isn't a mix of wait devices.
+        if (futures.front().event->GetWaitDevice() != futures.back().event->GetWaitDevice()) {
+            return wgpu::WaitStatus::UnsupportedMixedSources;
+        }
+    }
+
+    // Actually do the poll or wait to find out if any of the futures became ready.
+    // Here, there's either only one iteration, or timeout is 0, so we know the
+    // timeout won't get stacked multiple times.
+    bool anySuccess = false;
+    // Find each slice of the array (sliced by wait device), and wait on it.
+    for (size_t sliceStart = 0; sliceStart < futures.size();) {
+        DeviceBase* waitDevice = futures[sliceStart].event->GetWaitDevice();
+        size_t sliceLength = 1;
+        while (sliceStart + sliceLength < futures.size() &&
+               (futures[sliceStart + sliceLength].event->GetWaitDevice()) == waitDevice) {
+            sliceLength++;
+        }
+
+        {
+            bool success;
+            if (waitDevice) {
+                success = waitDevice->WaitAnyImpl(sliceLength, &futures[sliceStart], timeout);
+            } else {
+                success = WaitAnySystemEvent(sliceLength, &futures[sliceStart], timeout);
+            }
+            anySuccess |= success;
+        }
+
+        sliceStart += sliceLength;
+    }
+    if (!anySuccess) {
+        return wgpu::WaitStatus::TimedOut;
+    }
+    return wgpu::WaitStatus::Success;
+}
+
+}  // namespace
+
+// EventManager
+
+EventManager::EventManager() {
+    mTrackers.emplace();  // Construct the non-movable inner struct.
+}
+
+EventManager::~EventManager() {
+    ASSERT(!mTrackers.has_value());
+}
+
+MaybeError EventManager::Initialize(const InstanceDescriptor* descriptor) {
+    if (descriptor) {
+        if (descriptor->features.timedWaitAnyMaxCount > kTimedWaitAnyMaxCountDefault) {
+            // We don't yet support a higher timedWaitAnyMaxCount because it would be complicated
+            // to implement on Windows, and it isn't that useful to implement only on non-Windows.
+            return DAWN_VALIDATION_ERROR("Requested timedWaitAnyMaxCount is not supported");
+        }
+        mTimedWaitAnyEnable = descriptor->features.timedWaitAnyEnable;
+        mTimedWaitAnyMaxCount =
+            std::max(kTimedWaitAnyMaxCountDefault, descriptor->features.timedWaitAnyMaxCount);
+    }
+
+    return {};
+}
+
+void EventManager::ShutDown() {
+    mTrackers.reset();
+}
+
+FutureID EventManager::TrackEvent(WGPUCallbackModeFlags mode, Ref<TrackedEvent>&& future) {
+    switch (ValidateAndFlattenCallbackMode(mode)) {
+        case CallbackMode::Spontaneous:
+            // We don't need to track the future because some other code is responsible for
+            // completing it, and we aren't returning an ID so we don't need to be able to query it.
+            return kNullFutureID;
+        case CallbackMode::Future:
+        case CallbackMode::FutureOrSpontaneous: {
+            FutureID futureID = mNextFutureID++;
+            if (mTrackers.has_value()) {
+                mTrackers->futures->emplace(futureID, std::move(future));
+            }
+            return futureID;
+        }
+        case CallbackMode::ProcessEvents:
+        case CallbackMode::ProcessEventsOrSpontaneous: {
+            FutureID futureID = mNextFutureID++;
+            if (mTrackers.has_value()) {
+                mTrackers->pollEvents->emplace(futureID, std::move(future));
+            }
+            // Return null future, because the user didn't actually ask for a future.
+            return kNullFutureID;
+        }
+    }
+}
+
+void EventManager::ProcessPollEvents() {
+    ASSERT(mTrackers.has_value());
+
+    std::vector<TrackedFutureWaitInfo> futures;
+    mTrackers->pollEvents.Use([&](auto trackedPollEvents) {
+        futures.reserve(trackedPollEvents->size());
+
+        for (auto& [futureID, event] : *trackedPollEvents) {
+            futures.push_back(
+                TrackedFutureWaitInfo{futureID, TrackedEvent::WaitRef{event.Get()}, 0, false});
+        }
+
+        // The WaitImpl is inside of the lock to prevent any two ProcessEvents calls from
+        // calling competing OS wait syscalls at the same time.
+        wgpu::WaitStatus waitStatus = WaitImpl(futures, Nanoseconds(0));
+        if (waitStatus == wgpu::WaitStatus::TimedOut) {
+            return;
+        }
+        ASSERT(waitStatus == wgpu::WaitStatus::Success);
+
+        for (TrackedFutureWaitInfo& future : futures) {
+            if (future.ready) {
+                trackedPollEvents->erase(future.futureID);
+            }
+        }
+    });
+
+    for (TrackedFutureWaitInfo& future : futures) {
+        if (future.ready) {
+            ASSERT(future.event->mCallbackMode & WGPUCallbackMode_ProcessEvents);
+            future.event->EnsureComplete(EventCompletionType::Ready);
+        }
+    }
+}
+
+wgpu::WaitStatus EventManager::WaitAny(size_t count, FutureWaitInfo* infos, Nanoseconds timeout) {
+    ASSERT(mTrackers.has_value());
+
+    // Validate for feature support.
+    if (timeout > Nanoseconds(0)) {
+        if (!mTimedWaitAnyEnable) {
+            return wgpu::WaitStatus::UnsupportedTimeout;
+        }
+        if (count > mTimedWaitAnyMaxCount) {
+            return wgpu::WaitStatus::UnsupportedCount;
+        }
+        // UnsupportedMixedSources is validated later, in WaitImpl.
+    }
+
+    if (count == 0) {
+        return wgpu::WaitStatus::Success;
+    }
+
+    // Look up all of the futures and build a list of `TrackedFutureWaitInfo`s.
+    std::vector<TrackedFutureWaitInfo> futures;
+    futures.reserve(count);
+    bool anyCompleted = false;
+    mTrackers->futures.Use([&](auto trackedFutures) {
+        FutureID firstInvalidFutureID = mNextFutureID;
+        for (size_t i = 0; i < count; ++i) {
+            FutureID futureID = infos[i].future.id;
+
+            // Check for cases that are undefined behavior in the API contract.
+            ASSERT(futureID != 0);
+            ASSERT(futureID < firstInvalidFutureID);
+            // TakeWaitRef below will catch if the future is waited twice at the
+            // same time (unless it's already completed).
+
+            auto it = trackedFutures->find(futureID);
+            if (it == trackedFutures->end()) {
+                infos[i].completed = true;
+                anyCompleted = true;
+            } else {
+                infos[i].completed = false;
+                TrackedEvent* event = it->second.Get();
+                futures.push_back(
+                    TrackedFutureWaitInfo{futureID, TrackedEvent::WaitRef{event}, i, false});
+            }
+        }
+    });
+    // If any completed, return immediately.
+    if (anyCompleted) {
+        return wgpu::WaitStatus::Success;
+    }
+    // Otherwise, we should have successfully looked up all of them.
+    ASSERT(futures.size() == count);
+
+    wgpu::WaitStatus waitStatus = WaitImpl(futures, timeout);
+    if (waitStatus != wgpu::WaitStatus::Success) {
+        return waitStatus;
+    }
+
+    // For any futures that we're about to complete, first ensure they're untracked. It's OK if
+    // something actually isn't tracked anymore (because it completed elsewhere while waiting.)
+    mTrackers->futures.Use([&](auto trackedFutures) {
+        for (const TrackedFutureWaitInfo& future : futures) {
+            if (future.ready) {
+                trackedFutures->erase(future.futureID);
+            }
+        }
+    });
+
+    // Finally, call callbacks and update return values.
+    for (TrackedFutureWaitInfo& future : futures) {
+        if (future.ready) {
+            // Set completed before calling the callback.
+            infos[future.indexInInfos].completed = true;
+            // TODO(crbug.com/dawn/1987): Guarantee the event ordering from the JS spec.
+            ASSERT(future.event->mCallbackMode & WGPUCallbackMode_Future);
+            future.event->EnsureComplete(EventCompletionType::Ready);
+        }
+    }
+
+    return wgpu::WaitStatus::Success;
+}
+
+// EventManager::TrackedEvent
+
+EventManager::TrackedEvent::TrackedEvent(DeviceBase* device,
+                                         WGPUCallbackModeFlags callbackMode,
+                                         SystemEventReceiver&& receiver)
+    : mDevice(device), mCallbackMode(callbackMode), mReceiver(std::move(receiver)) {}
+
+EventManager::TrackedEvent::~TrackedEvent() {
+    ASSERT(mCompleted);
+}
+
+const SystemEventReceiver& EventManager::TrackedEvent::GetReceiver() const {
+    return mReceiver;
+}
+
+DeviceBase* EventManager::TrackedEvent::GetWaitDevice() const {
+    return MustWaitUsingDevice() ? mDevice.Get() : nullptr;
+}
+
+void EventManager::TrackedEvent::EnsureComplete(EventCompletionType completionType) {
+    bool alreadyComplete = mCompleted.exchange(true);
+    if (!alreadyComplete) {
+        Complete(completionType);
+    }
+}
+
+void EventManager::TrackedEvent::CompleteIfSpontaneous() {
+    if (mCallbackMode & WGPUCallbackMode_Spontaneous) {
+        bool alreadyComplete = mCompleted.exchange(true);
+        // If it was already complete, but there was an error, we have no place
+        // to report it, so ASSERT. This shouldn't happen.
+        ASSERT(!alreadyComplete);
+        Complete(EventCompletionType::Ready);
+    }
+}
+
+// EventManager::TrackedEvent::WaitRef
+
+EventManager::TrackedEvent::WaitRef::WaitRef(TrackedEvent* event) : mRef(event) {
+#if DAWN_ENABLE_ASSERTS
+    bool wasAlreadyWaited = mRef->mCurrentlyBeingWaited.exchange(true);
+    ASSERT(!wasAlreadyWaited);
+#endif
+}
+
+EventManager::TrackedEvent::WaitRef::~WaitRef() {
+#if DAWN_ENABLE_ASSERTS
+    if (mRef.Get() != nullptr) {
+        bool wasAlreadyWaited = mRef->mCurrentlyBeingWaited.exchange(false);
+        ASSERT(wasAlreadyWaited);
+    }
+#endif
+}
+
+EventManager::TrackedEvent* EventManager::TrackedEvent::WaitRef::operator->() {
+    return mRef.Get();
+}
+
+const EventManager::TrackedEvent* EventManager::TrackedEvent::WaitRef::operator->() const {
+    return mRef.Get();
+}
+
+}  // namespace dawn::native
diff --git a/src/dawn/native/EventManager.h b/src/dawn/native/EventManager.h
new file mode 100644
index 0000000..7eba4eb
--- /dev/null
+++ b/src/dawn/native/EventManager.h
@@ -0,0 +1,184 @@
+// Copyright 2023 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_DAWN_NATIVE_EVENTMANAGER_H_
+#define SRC_DAWN_NATIVE_EVENTMANAGER_H_
+
+#include <atomic>
+#include <cstdint>
+#include <mutex>
+#include <optional>
+#include <unordered_map>
+#include <vector>
+
+#include "dawn/common/FutureUtils.h"
+#include "dawn/common/MutexProtected.h"
+#include "dawn/common/NonCopyable.h"
+#include "dawn/common/Ref.h"
+#include "dawn/native/Error.h"
+#include "dawn/native/IntegerTypes.h"
+#include "dawn/native/SystemEvent.h"
+
+namespace dawn::native {
+
+struct InstanceDescriptor;
+
+// Subcomponent of the Instance which tracks callback events for the Future-based callback
+// entrypoints. All events from this instance (regardless of whether from an adapter, device, queue,
+// etc.) are tracked here, and used by the instance-wide ProcessEvents and WaitAny entrypoints.
+//
+// TODO(crbug.com/dawn/1987): Can this eventually replace CallbackTaskManager?
+// TODO(crbug.com/dawn/1987): There are various ways to optimize ProcessEvents/WaitAny:
+// - Only pay attention to the earliest serial on each queue.
+// - Spontaneously set events as "early-ready" in other places when we see serials advance, e.g.
+//   Submit, or when checking a later wait before an earlier wait.
+// - For thread-driven events (async pipeline compilation and Metal queue events), defer tracking
+//   for ProcessEvents until the event is already completed.
+// - Avoid creating OS events until they're actually needed (see the todo in TrackedEvent).
+class EventManager final : NonMovable {
+  public:
+    EventManager();
+    ~EventManager();
+
+    MaybeError Initialize(const InstanceDescriptor*);
+    // Called by WillDropLastExternalRef. Once shut down, the EventManager stops tracking anything.
+    // It drops any refs to TrackedEvents, to break reference cycles. If doing so frees the last ref
+    // of any uncompleted TrackedEvents, they'll get completed with EventCompletionType::Shutdown.
+    void ShutDown();
+
+    class TrackedEvent;
+    // Track a TrackedEvent and give it a FutureID.
+    [[nodiscard]] FutureID TrackEvent(WGPUCallbackModeFlags mode, Ref<TrackedEvent>&&);
+    void ProcessPollEvents();
+    [[nodiscard]] wgpu::WaitStatus WaitAny(size_t count,
+                                           FutureWaitInfo* infos,
+                                           Nanoseconds timeout);
+
+  private:
+    struct Trackers : dawn::NonMovable {
+        // Tracks Futures (used by WaitAny).
+        MutexProtected<std::unordered_map<FutureID, Ref<TrackedEvent>>> futures;
+        // Tracks events polled by ProcessEvents.
+        MutexProtected<std::unordered_map<FutureID, Ref<TrackedEvent>>> pollEvents;
+    };
+
+    bool mTimedWaitAnyEnable = false;
+    size_t mTimedWaitAnyMaxCount = kTimedWaitAnyMaxCountDefault;
+    std::atomic<FutureID> mNextFutureID = 1;
+
+    // Freed once the user has dropped their last ref to the Instance, so can't call WaitAny or
+    // ProcessEvents anymore. This breaks reference cycles.
+    std::optional<Trackers> mTrackers;
+};
+
+// Base class for the objects that back WGPUFutures. TrackedEvent is responsible for the lifetime
+// the callback it contains. If TrackedEvent gets destroyed before it completes, it's responsible
+// for cleaning up (by calling the callback with an "Unknown" status).
+//
+// For Future-based and ProcessEvents-based TrackedEvents, the EventManager will track them for
+// completion in WaitAny or ProcessEvents. However, once the Instance has lost all its external
+// refs, the user can't call either of those methods anymore, so EventManager will stop holding refs
+// 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 {
+  protected:
+    // Note: TrackedEvents are (currently) only for Device events. Events like RequestAdapter and
+    // RequestDevice complete immediately in dawn native, so should never need to be tracked.
+    TrackedEvent(DeviceBase* device,
+                 WGPUCallbackModeFlags callbackMode,
+                 SystemEventReceiver&& receiver);
+
+  public:
+    // Subclasses must implement this to complete the event (if not completed) with
+    // EventCompletionType::Shutdown.
+    ~TrackedEvent() override;
+
+    class WaitRef;
+
+    const SystemEventReceiver& GetReceiver() const;
+    DeviceBase* GetWaitDevice() const;
+
+  protected:
+    void EnsureComplete(EventCompletionType);
+    void CompleteIfSpontaneous();
+
+    // True if the event can only be waited using its device (e.g. with vkWaitForFences).
+    // False if it can be waited using OS-level wait primitives (WaitAnySystemEvent).
+    virtual bool MustWaitUsingDevice() const = 0;
+    virtual void Complete(EventCompletionType) = 0;
+
+    // This creates a temporary ref cycle (Device->Instance->EventManager->TrackedEvent).
+    // This is OK because the instance will clear out the EventManager on shutdown.
+    // TODO(crbug.com/dawn/1987): This is a bit fragile. Is it possible to remove the ref cycle?
+    Ref<DeviceBase> mDevice;
+    WGPUCallbackModeFlags mCallbackMode;
+
+#if DAWN_ENABLE_ASSERTS
+    std::atomic<bool> mCurrentlyBeingWaited;
+#endif
+
+  private:
+    friend class EventManager;
+
+    // TODO(crbug.com/dawn/1987): Optimize by creating an SystemEventReceiver only once actually
+    // needed (the user asks for a timed wait or an OS event handle). This should be generally
+    // achievable:
+    // - For thread-driven events (async pipeline compilation and Metal queue events), use a mutex
+    //   or atomics to atomically:
+    //   - On wait: { check if mKnownReady. if not, create the SystemEventPipe }
+    //   - On signal: { check if there's an SystemEventPipe. if not, set mKnownReady }
+    // - For D3D12/Vulkan fences, on timed waits, first use GetCompletedValue/GetFenceStatus, then
+    //   create an OS event if it's not ready yet (and we don't have one yet).
+    //
+    // This abstraction should probably be hidden from TrackedEvent - previous attempts to do
+    // something similar in TrackedEvent turned out to be quite confusing. It can instead be an
+    // "optimization" to the SystemEvent* or a layer between TrackedEvent and SystemEventReceiver.
+    SystemEventReceiver mReceiver;
+    // Callback has been called.
+    std::atomic<bool> mCompleted = false;
+};
+
+// A Ref<TrackedEvent>, but ASSERTing that a future isn't used concurrently in multiple
+// WaitAny/ProcessEvents call (by checking that there's never more than one WaitRef for a
+// TrackedEvent). For WaitAny, this checks the embedder's behavior, but for ProcessEvents this is
+// only an internal ASSERT (it's supposed to be synchronized so that this never happens).
+class EventManager::TrackedEvent::WaitRef : dawn::NonCopyable {
+  public:
+    WaitRef(WaitRef&& rhs) = default;
+    WaitRef& operator=(WaitRef&& rhs) = default;
+
+    explicit WaitRef(TrackedEvent* future);
+    ~WaitRef();
+
+    TrackedEvent* operator->();
+    const TrackedEvent* operator->() const;
+
+  private:
+    Ref<TrackedEvent> mRef;
+};
+
+// TrackedEvent::WaitRef plus a few extra fields needed for some implementations.
+// Sometimes they'll be unused, but that's OK; it simplifies code reuse.
+struct TrackedFutureWaitInfo {
+    FutureID futureID;
+    EventManager::TrackedEvent::WaitRef event;
+    // Used by EventManager::ProcessPollEvents
+    size_t indexInInfos;
+    // Used by EventManager::ProcessPollEvents and ::WaitAny
+    bool ready;
+};
+
+}  // namespace dawn::native
+
+#endif  // SRC_DAWN_NATIVE_EVENTMANAGER_H_
diff --git a/src/dawn/native/Instance.cpp b/src/dawn/native/Instance.cpp
index 8434971..e658369 100644
--- a/src/dawn/native/Instance.cpp
+++ b/src/dawn/native/Instance.cpp
@@ -17,6 +17,7 @@
 #include <utility>
 
 #include "dawn/common/Assert.h"
+#include "dawn/common/FutureUtils.h"
 #include "dawn/common/GPUInfo.h"
 #include "dawn/common/Log.h"
 #include "dawn/common/SystemUtils.h"
@@ -48,8 +49,6 @@
 #include "dawn/native/X11Functions.h"
 #endif  // defined(DAWN_USE_X11)
 
-#include <optional>
-
 namespace dawn::native {
 
 // Forward definitions of each backend's "Connect" function that creates new BackendConnection.
@@ -96,6 +95,16 @@
 
 }  // anonymous namespace
 
+wgpu::Bool APIGetInstanceFeatures(InstanceFeatures* features) {
+    if (features->nextInChain != nullptr) {
+        return false;
+    }
+
+    features->timedWaitAnyEnable = true;
+    features->timedWaitAnyMaxCount = kTimedWaitAnyMaxCountDefault;
+    return true;
+}
+
 InstanceBase* APICreateInstance(const InstanceDescriptor* descriptor) {
     return InstanceBase::Create(descriptor).Detach();
 }
@@ -132,7 +141,10 @@
 
 void InstanceBase::WillDropLastExternalRef() {
     // InstanceBase uses RefCountedWithExternalCount to break refcycles.
-    //
+
+    // Stop tracking events. See comment on ShutDown.
+    mEventManager.ShutDown();
+
     // InstanceBase holds backends which hold Refs to PhysicalDeviceBases discovered, which hold
     // Refs back to the InstanceBase.
     // In order to break this cycle and prevent leaks, when the application drops the last external
@@ -174,6 +186,8 @@
     mDefaultPlatform = std::make_unique<dawn::platform::Platform>();
     SetPlatform(dawnDesc != nullptr ? dawnDesc->platform : mDefaultPlatform.get());
 
+    DAWN_TRY(mEventManager.Initialize(descriptor));
+
     return {};
 }
 
@@ -560,6 +574,13 @@
     }
 
     mCallbackTaskManager->Flush();
+    mEventManager.ProcessPollEvents();
+}
+
+wgpu::WaitStatus InstanceBase::APIWaitAny(size_t count,
+                                          FutureWaitInfo* futures,
+                                          uint64_t timeoutNS) {
+    return mEventManager.WaitAny(count, futures, Nanoseconds(timeoutNS));
 }
 
 const std::vector<std::string>& InstanceBase::GetRuntimeSearchPaths() const {
@@ -570,6 +591,10 @@
     return mCallbackTaskManager;
 }
 
+EventManager* InstanceBase::GetEventManager() {
+    return &mEventManager;
+}
+
 void InstanceBase::ConsumeError(std::unique_ptr<ErrorData> error) {
     ASSERT(error != nullptr);
     dawn::ErrorLog() << error->GetFormattedMessage();
diff --git a/src/dawn/native/Instance.h b/src/dawn/native/Instance.h
index ccf6b9d..1d7f09e 100644
--- a/src/dawn/native/Instance.h
+++ b/src/dawn/native/Instance.h
@@ -20,7 +20,6 @@
 #include <mutex>
 #include <set>
 #include <string>
-#include <unordered_map>
 #include <unordered_set>
 #include <vector>
 
@@ -30,6 +29,7 @@
 #include "dawn/native/Adapter.h"
 #include "dawn/native/BackendConnection.h"
 #include "dawn/native/BlobCache.h"
+#include "dawn/native/EventManager.h"
 #include "dawn/native/Features.h"
 #include "dawn/native/RefCountedWithExternalCount.h"
 #include "dawn/native/Toggles.h"
@@ -50,6 +50,7 @@
 using BackendsArray = ityp::
     array<wgpu::BackendType, std::unique_ptr<BackendConnection>, kEnumCount<wgpu::BackendType>>;
 
+wgpu::Bool APIGetInstanceFeatures(InstanceFeatures* features);
 InstanceBase* APICreateInstance(const InstanceDescriptor* descriptor);
 
 // This is called InstanceBase for consistency across the frontend, even if the backends don't
@@ -139,6 +140,7 @@
     const std::vector<std::string>& GetRuntimeSearchPaths() const;
 
     const Ref<CallbackTaskManager>& GetCallbackTaskManager() const;
+    EventManager* GetEventManager();
 
     // Get backend-independent libraries that need to be loaded dynamically.
     const X11Functions* GetOrLoadX11Functions();
@@ -146,6 +148,9 @@
     // Dawn API
     Surface* APICreateSurface(const SurfaceDescriptor* descriptor);
     void APIProcessEvents();
+    [[nodiscard]] wgpu::WaitStatus APIWaitAny(size_t count,
+                                              FutureWaitInfo* futures,
+                                              uint64_t timeoutNS);
 
   private:
     explicit InstanceBase(const TogglesState& instanceToggles);
@@ -205,6 +210,7 @@
 #endif  // defined(DAWN_USE_X11)
 
     Ref<CallbackTaskManager> mCallbackTaskManager;
+    EventManager mEventManager;
 
     std::set<DeviceBase*> mDevicesList;
     mutable std::mutex mDevicesListMutex;
diff --git a/src/dawn/native/IntegerTypes.h b/src/dawn/native/IntegerTypes.h
index b4645fd..8c7cc1d 100644
--- a/src/dawn/native/IntegerTypes.h
+++ b/src/dawn/native/IntegerTypes.h
@@ -21,6 +21,7 @@
 #include "dawn/common/TypedInteger.h"
 
 namespace dawn::native {
+
 // Binding numbers in the shader and BindGroup/BindGroupLayoutDescriptors
 using BindingNumber = TypedInteger<struct BindingNumberT, uint32_t>;
 constexpr BindingNumber kMaxBindingsPerBindGroupTyped = BindingNumber(kMaxBindingsPerBindGroup);
@@ -72,6 +73,8 @@
 // other pipelines.
 using PipelineCompatibilityToken = TypedInteger<struct PipelineCompatibilityTokenT, uint64_t>;
 
+using Nanoseconds = TypedInteger<struct NanosecondsT, uint64_t>;
+
 }  // namespace dawn::native
 
 #endif  // SRC_DAWN_NATIVE_INTEGERTYPES_H_
diff --git a/src/dawn/native/ObjectBase.cpp b/src/dawn/native/ObjectBase.cpp
index 62fc893..b575e7e 100644
--- a/src/dawn/native/ObjectBase.cpp
+++ b/src/dawn/native/ObjectBase.cpp
@@ -16,6 +16,7 @@
 #include <utility>
 
 #include "absl/strings/str_format.h"
+#include "dawn/native/Adapter.h"
 #include "dawn/native/Device.h"
 #include "dawn/native/ObjectBase.h"
 #include "dawn/native/ObjectType_autogen.h"
@@ -36,6 +37,10 @@
 
 ObjectBase::ObjectBase(DeviceBase* device, ErrorTag) : ErrorMonad(kError), mDevice(device) {}
 
+InstanceBase* ObjectBase::GetInstance() const {
+    return mDevice->GetAdapter()->GetPhysicalDevice()->GetInstance();
+}
+
 DeviceBase* ObjectBase::GetDevice() const {
     return mDevice.Get();
 }
diff --git a/src/dawn/native/ObjectBase.h b/src/dawn/native/ObjectBase.h
index e3eb0bd..e29d2ac 100644
--- a/src/dawn/native/ObjectBase.h
+++ b/src/dawn/native/ObjectBase.h
@@ -48,6 +48,7 @@
     explicit ObjectBase(DeviceBase* device);
     ObjectBase(DeviceBase* device, ErrorTag tag);
 
+    InstanceBase* GetInstance() const;
     DeviceBase* GetDevice() const;
 
   private:
diff --git a/src/dawn/native/Queue.cpp b/src/dawn/native/Queue.cpp
index c31e92d..6d86b2b 100644
--- a/src/dawn/native/Queue.cpp
+++ b/src/dawn/native/Queue.cpp
@@ -20,6 +20,7 @@
 #include <vector>
 
 #include "dawn/common/Constants.h"
+#include "dawn/common/FutureUtils.h"
 #include "dawn/common/ityp_span.h"
 #include "dawn/native/Buffer.h"
 #include "dawn/native/CommandBuffer.h"
@@ -29,14 +30,18 @@
 #include "dawn/native/CopyTextureForBrowserHelper.h"
 #include "dawn/native/Device.h"
 #include "dawn/native/DynamicUploader.h"
+#include "dawn/native/EventManager.h"
 #include "dawn/native/ExternalTexture.h"
+#include "dawn/native/Instance.h"
 #include "dawn/native/ObjectType_autogen.h"
 #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"
+#include "dawn/webgpu.h"
 
 namespace dawn::native {
 
@@ -172,8 +177,51 @@
     void ForceEventualFlushOfCommands() override { UNREACHABLE(); }
     MaybeError WaitForIdleForDestruction() override { UNREACHABLE(); }
 };
+
+struct WorkDoneEvent final : public EventManager::TrackedEvent {
+    std::optional<WGPUQueueWorkDoneStatus> mEarlyStatus;
+    WGPUQueueWorkDoneCallback mCallback;
+    void* mUserdata;
+
+    // Create an event backed by the given SystemEventReceiver.
+    WorkDoneEvent(DeviceBase* device,
+                  const WGPUQueueWorkDoneCallbackInfo& callbackInfo,
+                  SystemEventReceiver&& receiver)
+        : TrackedEvent(device, callbackInfo.mode, std::move(receiver)),
+          mCallback(callbackInfo.callback),
+          mUserdata(callbackInfo.userdata) {}
+
+    // Create an event that's ready at creation (for errors, etc.)
+    WorkDoneEvent(DeviceBase* device,
+                  const WGPUQueueWorkDoneCallbackInfo& callbackInfo,
+                  WGPUQueueWorkDoneStatus earlyStatus)
+        : WorkDoneEvent(device, callbackInfo, SystemEventReceiver::CreateAlreadySignaled()) {
+        CompleteIfSpontaneous();
+    }
+
+    ~WorkDoneEvent() override { EnsureComplete(EventCompletionType::Shutdown); }
+
+    // TODO(crbug.com/dawn/1987): When adding support for mixed sources, return false here when
+    // the device has the mixed sources feature enabled, and so can expose the fence as an OS event.
+    bool MustWaitUsingDevice() const override { return true; }
+
+    void Complete(EventCompletionType completionType) override {
+        // WorkDoneEvent has no error cases other than the mEarlyStatus ones.
+        WGPUQueueWorkDoneStatus status = WGPUQueueWorkDoneStatus_Success;
+        if (completionType == EventCompletionType::Shutdown) {
+            status = WGPUQueueWorkDoneStatus_Unknown;
+        } else if (mEarlyStatus) {
+            status = mEarlyStatus.value();
+        }
+
+        mCallback(status, mUserdata);
+    }
+};
+
 }  // namespace
 
+// TrackTaskCallback
+
 void TrackTaskCallback::SetFinishedSerial(ExecutionSerial serial) {
     mSerial = serial;
 }
@@ -238,6 +286,39 @@
                  uint64_t(GetDevice()->GetPendingCommandSerial()));
 }
 
+WGPUFuture QueueBase::APIOnSubmittedWorkDoneF(const WGPUQueueWorkDoneCallbackInfo& callbackInfo) {
+    // TODO(crbug.com/dawn/1987): Once we always return a future, change this to log to the instance
+    // (note, not raise a validation error to the device) and return the null future.
+    ASSERT(callbackInfo.nextInChain == nullptr);
+
+    Ref<EventManager::TrackedEvent> event;
+
+    WGPUQueueWorkDoneStatus validationEarlyStatus;
+    if (GetDevice()->ConsumedError(ValidateOnSubmittedWorkDone(0, &validationEarlyStatus))) {
+        // TODO(crbug.com/dawn/1987): This is here to pretend that things succeed when the device is
+        // lost. When the old OnSubmittedWorkDone is removed then we can update
+        // ValidateOnSubmittedWorkDone to just return the correct thing here.
+        if (validationEarlyStatus == WGPUQueueWorkDoneStatus_DeviceLost) {
+            validationEarlyStatus = WGPUQueueWorkDoneStatus_Success;
+        }
+
+        // Note: if the callback is spontaneous, it'll get called in here.
+        event = AcquireRef(new WorkDoneEvent(GetDevice(), callbackInfo, validationEarlyStatus));
+    } else {
+        event = AcquireRef(new WorkDoneEvent(GetDevice(), callbackInfo, InsertWorkDoneEvent()));
+    }
+
+    FutureID futureID =
+        GetInstance()->GetEventManager()->TrackEvent(callbackInfo.mode, std::move(event));
+
+    return WGPUFuture{futureID};
+}
+
+SystemEventReceiver QueueBase::InsertWorkDoneEvent() {
+    // TODO(crbug.com/dawn/1987): Implement this in all backends and remove this default impl
+    CHECK(false);
+}
+
 void QueueBase::TrackTask(std::unique_ptr<TrackTaskCallback> task, ExecutionSerial serial) {
     // If the task depends on a serial which is not submitted yet, force a flush.
     if (serial > GetLastSubmittedCommandSerial()) {
diff --git a/src/dawn/native/Queue.h b/src/dawn/native/Queue.h
index d7aa81f..1c03a9f 100644
--- a/src/dawn/native/Queue.h
+++ b/src/dawn/native/Queue.h
@@ -24,6 +24,7 @@
 #include "dawn/native/Forward.h"
 #include "dawn/native/IntegerTypes.h"
 #include "dawn/native/ObjectBase.h"
+#include "dawn/native/SystemEvent.h"
 
 #include "dawn/native/DawnNative.h"
 #include "dawn/native/dawn_platform.h"
@@ -59,6 +60,7 @@
     void APIOnSubmittedWorkDone(uint64_t signalValue,
                                 WGPUQueueWorkDoneCallback callback,
                                 void* userdata);
+    WGPUFuture APIOnSubmittedWorkDoneF(const WGPUQueueWorkDoneCallbackInfo& callbackInfo);
     void APIWriteBuffer(BufferBase* buffer, uint64_t bufferOffset, const void* data, size_t size);
     void APIWriteTexture(const ImageCopyTexture* destination,
                          const void* data,
@@ -86,7 +88,9 @@
   protected:
     QueueBase(DeviceBase* device, const QueueDescriptor* descriptor);
     QueueBase(DeviceBase* device, ObjectBase::ErrorTag tag, const char* label);
+
     void DestroyImpl() override;
+    virtual SystemEventReceiver InsertWorkDoneEvent();
 
   private:
     MaybeError WriteTextureInternal(const ImageCopyTexture* destination,
diff --git a/src/dawn/native/SystemEvent.cpp b/src/dawn/native/SystemEvent.cpp
new file mode 100644
index 0000000..c1ad4a0
--- /dev/null
+++ b/src/dawn/native/SystemEvent.cpp
@@ -0,0 +1,222 @@
+// Copyright 2023 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "dawn/native/SystemEvent.h"
+#include <limits>
+#include "dawn/common/Assert.h"
+
+#if DAWN_PLATFORM_IS(WINDOWS)
+#include <windows.h>
+#elif DAWN_PLATFORM_IS(POSIX)
+#include <sys/poll.h>
+#include <unistd.h>
+#endif
+
+#include <tuple>
+#include <utility>
+#include <vector>
+
+#include "dawn/native/EventManager.h"
+
+namespace dawn::native {
+
+namespace {
+
+template <typename T, T Infinity>
+T ToMillisecondsGeneric(Nanoseconds timeout) {
+    uint64_t ns = uint64_t{timeout};
+    uint64_t ms = 0;
+    if (ns > 0) {
+        ms = (ns - 1) / 1'000'000 + 1;
+        if (ms > std::numeric_limits<T>::max()) {
+            return Infinity;  // Round long timeout up to infinity
+        }
+    }
+    return static_cast<T>(ms);
+}
+
+#if DAWN_PLATFORM_IS(WINDOWS)
+// #define ToMilliseconds ToMillisecondsGeneric<DWORD, INFINITE>
+#elif DAWN_PLATFORM_IS(POSIX)
+#define ToMilliseconds ToMillisecondsGeneric<int, -1>
+#endif
+
+#if DAWN_PLATFORM_IS(WINDOWS)
+HANDLE AsHANDLE(const SystemEventPrimitive& p) {
+    return reinterpret_cast<HANDLE>(p.value);
+}
+#elif DAWN_PLATFORM_IS(POSIX)
+int AsFD(const SystemEventPrimitive& p) {
+    ASSERT(p.value <= std::numeric_limits<int>::max());
+    return p.value;
+}
+#endif
+
+}  // namespace
+
+// SystemEventPrimitive
+
+SystemEventPrimitive::SystemEventPrimitive(void* win32Handle)
+    : value(reinterpret_cast<uintptr_t>(win32Handle)) {
+#if DAWN_PLATFORM_IS(WINDOWS)
+    static_assert(std::is_same_v<void*, HANDLE>);
+    ASSERT(win32Handle != nullptr);
+#else
+    ASSERT(false);  // Wrong platform.
+#endif
+}
+
+SystemEventPrimitive::SystemEventPrimitive(int posixFd) : value(posixFd) {
+#if DAWN_PLATFORM_IS(POSIX)
+    static_assert(sizeof(uintptr_t) >= sizeof(int));
+    ASSERT(posixFd > 0);
+#else
+    ASSERT(false);  // Wrong platform.
+#endif
+}
+
+SystemEventPrimitive::~SystemEventPrimitive() {
+    if (IsValid()) {
+        Close();
+    }
+}
+
+SystemEventPrimitive::SystemEventPrimitive(SystemEventPrimitive&& rhs) {
+    *this = std::move(rhs);
+}
+
+SystemEventPrimitive& SystemEventPrimitive::operator=(SystemEventPrimitive&& rhs) {
+    if (this != &rhs) {
+        if (IsValid()) {
+            Close();
+        }
+        std::swap(value, rhs.value);
+    }
+    return *this;
+}
+
+bool SystemEventPrimitive::IsValid() const {
+    return value != kInvalid;
+}
+
+void SystemEventPrimitive::Close() {
+    ASSERT(IsValid());
+
+#if DAWN_PLATFORM_IS(WINDOWS)
+    CloseHandle(AsHANDLE(*this));
+#elif DAWN_PLATFORM_IS(POSIX)
+    close(AsFD(*this));
+#else
+    CHECK(false);  // Not implemented.
+#endif
+
+    value = kInvalid;
+}
+
+// SystemEventReceiver
+
+SystemEventReceiver SystemEventReceiver::CreateAlreadySignaled() {
+    SystemEventPipeSender sender;
+    SystemEventReceiver receiver;
+    std::tie(sender, receiver) = CreateSystemEventPipe();
+    std::move(sender).Signal();
+    return receiver;
+}
+
+// SystemEventPipeSender
+
+SystemEventPipeSender::~SystemEventPipeSender() {
+    // Make sure it's been Signaled (or is empty) before being dropped.
+    // Dropping this would "leak" the receiver (it'll never get signalled).
+    ASSERT(!mPrimitive.IsValid());
+}
+
+void SystemEventPipeSender::Signal() && {
+    ASSERT(mPrimitive.IsValid());
+#if DAWN_PLATFORM_IS(WINDOWS)
+    // This is not needed on Windows yet. It's implementable using SetEvent().
+    UNREACHABLE();
+#elif DAWN_PLATFORM_IS(POSIX)
+    // Send one byte to signal the receiver
+    char zero[1] = {0};
+    int status = write(AsFD(mPrimitive), zero, 1);
+    CHECK(status >= 0);
+#else
+    // Not implemented for this platform.
+    CHECK(false);
+#endif
+
+    mPrimitive.Close();
+}
+
+// standalone functions
+
+bool WaitAnySystemEvent(size_t count, TrackedFutureWaitInfo* futures, Nanoseconds timeout) {
+#if DAWN_PLATFORM_IS(WINDOWS)
+    // TODO(crbug.com/dawn/1987): Implement this.
+    CHECK(false);
+#elif DAWN_PLATFORM_IS(POSIX)
+    std::vector<pollfd> pollfds(count);
+    for (size_t i = 0; i < count; ++i) {
+        int fd = AsFD(futures[i].event->GetReceiver().mPrimitive);
+        pollfds[i] = pollfd{fd, POLLIN, 0};
+    }
+
+    int status = poll(pollfds.data(), pollfds.size(), ToMilliseconds(timeout));
+
+    CHECK(status >= 0);
+    if (status == 0) {
+        return false;
+    }
+
+    for (size_t i = 0; i < count; ++i) {
+        int revents = pollfds[i].revents;
+        static constexpr int kAllowedEvents = POLLIN | POLLHUP;
+        CHECK((revents & kAllowedEvents) == revents);
+    }
+
+    for (size_t i = 0; i < count; ++i) {
+        bool ready = (pollfds[i].revents & POLLIN) != 0;
+        futures[i].ready = ready;
+    }
+
+    return true;
+#else
+    CHECK(false);  // Not implemented.
+#endif
+}
+
+std::pair<SystemEventPipeSender, SystemEventReceiver> CreateSystemEventPipe() {
+#if DAWN_PLATFORM_IS(WINDOWS)
+    // This is not needed on Windows yet. It's implementable using CreateEvent().
+    UNREACHABLE();
+#elif DAWN_PLATFORM_IS(POSIX)
+    int pipeFds[2];
+    int status = pipe(pipeFds);
+    CHECK(status >= 0);
+
+    SystemEventReceiver receiver;
+    receiver.mPrimitive = SystemEventPrimitive{pipeFds[0]};
+
+    SystemEventPipeSender sender;
+    sender.mPrimitive = SystemEventPrimitive{pipeFds[1]};
+
+    return std::make_pair(std::move(sender), std::move(receiver));
+#else
+    // Not implemented for this platform.
+    CHECK(false);
+#endif
+}
+
+}  // namespace dawn::native
diff --git a/src/dawn/native/SystemEvent.h b/src/dawn/native/SystemEvent.h
new file mode 100644
index 0000000..0e7f12f
--- /dev/null
+++ b/src/dawn/native/SystemEvent.h
@@ -0,0 +1,115 @@
+// Copyright 2023 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_DAWN_NATIVE_SYSTEMEVENT_H_
+#define SRC_DAWN_NATIVE_SYSTEMEVENT_H_
+
+#include <utility>
+
+#include "dawn/common/NonCopyable.h"
+#include "dawn/common/Platform.h"
+#include "dawn/native/IntegerTypes.h"
+
+namespace dawn::native {
+
+struct TrackedFutureWaitInfo;
+class SystemEventPipeSender;
+
+// Either a Win32 HANDLE or a POSIX fd (int) depending on OS, represented as a uintptr_t with
+// necessary conversions.
+class SystemEventPrimitive : NonCopyable {
+  public:
+    SystemEventPrimitive() = default;
+    // void* is the typedef of HANDLE in Win32.
+    explicit SystemEventPrimitive(void* win32Handle);
+    explicit SystemEventPrimitive(int posixFd);
+    ~SystemEventPrimitive();
+
+    SystemEventPrimitive(SystemEventPrimitive&&);
+    SystemEventPrimitive& operator=(SystemEventPrimitive&&);
+
+    bool IsValid() const;
+    void Close();
+
+    static constexpr uintptr_t kInvalid = 0;
+    // The underlying primitive, either a Win32 HANDLE (void*) or a POSIX fd (int), cast to
+    // uintptr_t. We treat 0 as the "invalid" value, even for POSIX.
+    uintptr_t value = kInvalid;
+};
+
+// SystemEventReceiver holds an OS event primitive (Win32 Event Object or POSIX file descriptor (fd)
+// that will be signalled by some other thing: either an OS integration like SetEventOnCompletion(),
+// or our own code like SystemEventPipeSender.
+//
+// SystemEventReceiver is one-time-use (to make it easier to use correctly) - once it's been
+// signalled, it won't ever get reset (become unsignalled). Instead, if we want to reuse underlying
+// OS objects, it should be reset and recycled *after* the SystemEventReceiver and
+// SystemEventPipeSender have been destroyed.
+class SystemEventReceiver final : NonCopyable {
+  public:
+    static SystemEventReceiver CreateAlreadySignaled();
+
+    SystemEventReceiver() = default;
+    SystemEventReceiver(SystemEventReceiver&&) = default;
+    SystemEventReceiver& operator=(SystemEventReceiver&&) = default;
+
+  private:
+    friend bool WaitAnySystemEvent(size_t, TrackedFutureWaitInfo*, Nanoseconds);
+    friend std::pair<SystemEventPipeSender, SystemEventReceiver> CreateSystemEventPipe();
+    SystemEventPrimitive mPrimitive;
+};
+
+// See CreateSystemEventPipe.
+class SystemEventPipeSender final : NonCopyable {
+  public:
+    SystemEventPipeSender() = default;
+    SystemEventPipeSender(SystemEventPipeSender&&) = default;
+    SystemEventPipeSender& operator=(SystemEventPipeSender&&) = default;
+    ~SystemEventPipeSender();
+
+    void Signal() &&;
+
+  private:
+    friend std::pair<SystemEventPipeSender, SystemEventReceiver> CreateSystemEventPipe();
+    SystemEventPrimitive mPrimitive;
+};
+
+// Implementation of WaitAny when backed by SystemEventReceiver.
+// Returns true if some future is now ready, false if not (it timed out).
+[[nodiscard]] bool WaitAnySystemEvent(size_t count,
+                                      TrackedFutureWaitInfo* futures,
+                                      Nanoseconds timeout);
+
+// CreateSystemEventPipe provides an SystemEventReceiver that can be signalled by Dawn code. This is
+// useful for queue completions on Metal (where Metal signals us by calling a callback) and for
+// async pipeline creations that happen in a worker-thread task.
+//
+// We use OS events even for these because, unlike C++/pthreads primitives (mutexes, atomics,
+// condvars, etc.), it's possible to wait-any on them (wait for any of a list of events to fire).
+// Other use-cases in Dawn that don't require wait-any should generally use C++ primitives, for
+// example for signalling the completion of other types of worker-thread work that don't need to
+// signal a WGPUFuture.
+//
+// SystemEventReceiver is one-time-use (see its docs), so there's no way to reset an
+// SystemEventPipeSender.
+//
+// - On Windows, SystemEventReceiver is a Win32 Event Object, so we can create one with
+//   CreateEvent() and signal it with SetEvent().
+// - On POSIX, SystemEventReceiver is a file descriptor (fd), so we can create one with pipe(), and
+//   signal it by write()ing into the pipe (to make it become readable, though we won't read() it).
+std::pair<SystemEventPipeSender, SystemEventReceiver> CreateSystemEventPipe();
+
+}  // namespace dawn::native
+
+#endif  // SRC_DAWN_NATIVE_SYSTEMEVENT_H_
diff --git a/src/dawn/native/metal/QueueMTL.h b/src/dawn/native/metal/QueueMTL.h
index 892d539..49dcc12 100644
--- a/src/dawn/native/metal/QueueMTL.h
+++ b/src/dawn/native/metal/QueueMTL.h
@@ -16,7 +16,11 @@
 #define SRC_DAWN_NATIVE_METAL_QUEUEMTL_H_
 
 #import <Metal/Metal.h>
+
+#include "dawn/common/MutexProtected.h"
+#include "dawn/common/SerialQueue.h"
 #include "dawn/native/Queue.h"
+#include "dawn/native/SystemEvent.h"
 #include "dawn/native/metal/CommandRecordingContext.h"
 
 namespace dawn::native::metal {
@@ -39,7 +43,9 @@
     ~Queue() override;
 
     MaybeError Initialize();
+    void UpdateWaitingEvents(ExecutionSerial completedSerial);
 
+    SystemEventReceiver InsertWorkDoneEvent() override;
     MaybeError SubmitImpl(uint32_t commandCount, CommandBufferBase* const* commands) override;
     bool HasPendingCommands() const override;
     ResultOrError<ExecutionSerial> CheckAndUpdateCompletedSerials() override;
@@ -61,6 +67,12 @@
     // 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`.
     NSPRef<id> mMtlSharedEvent = nullptr;
+
+    // This mutex must be held to access mWaitingEvents (which may happen in a Metal driver thread).
+    // TODO(crbug.com/dawn/1987): 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<SerialQueue<ExecutionSerial, SystemEventPipeSender>> mWaitingEvents;
 };
 
 }  // namespace dawn::native::metal
diff --git a/src/dawn/native/metal/QueueMTL.mm b/src/dawn/native/metal/QueueMTL.mm
index f193516..635fc04 100644
--- a/src/dawn/native/metal/QueueMTL.mm
+++ b/src/dawn/native/metal/QueueMTL.mm
@@ -41,6 +41,7 @@
 void Queue::Destroy() {
     // Forget all pending commands.
     mCommandContext.AcquireCommands();
+    UpdateWaitingEvents(kMaxExecutionSerial);
     mCommandQueue = nullptr;
     mLastSubmittedCommands = nullptr;
     mMtlSharedEvent = nullptr;
@@ -61,6 +62,42 @@
     return mCommandContext.PrepareNextCommandBuffer(*mCommandQueue);
 }
 
+void Queue::UpdateWaitingEvents(ExecutionSerial completedSerial) {
+    ASSERT(mCompletedSerial >= uint64_t(completedSerial) || completedSerial == kMaxExecutionSerial);
+    mWaitingEvents.Use([&](auto waitingEvents) {
+        for (auto& waiting : waitingEvents->IterateUpTo(completedSerial)) {
+            std::move(waiting).Signal();
+        }
+        waitingEvents->ClearUpTo(completedSerial);
+    });
+}
+
+SystemEventReceiver Queue::InsertWorkDoneEvent() {
+    ExecutionSerial serial = GetScheduledWorkDoneSerial();
+
+    // TODO(crbug.com/dawn/1987): Optimize to not create a pipe for every WorkDone/MapAsync event.
+    // Possible ways to do this:
+    // - Don't create the pipe until needed (see the todo on TrackedEvent::mReceiver).
+    // - Dedup event pipes when one serial is needed for multiple events (and add a
+    //   SystemEventReceiver::Duplicate() method which dup()s its underlying pipe receiver).
+    // - Create a pipe each for each new serial instead of for each requested event (tradeoff).
+    SystemEventPipeSender sender;
+    SystemEventReceiver receiver;
+    std::tie(sender, receiver) = CreateSystemEventPipe();
+
+    mWaitingEvents.Use([&](auto waitingEvents) {
+        // Check for device loss while the lock is held. Otherwise, we could enqueue the event
+        // after mWaitingEvents has been flushed for device loss, and it'll never get cleaned up.
+        if (GetDevice()->IsLost() || mCompletedSerial >= uint64_t(serial)) {
+            std::move(sender).Signal();
+        } else {
+            waitingEvents->Enqueue(std::move(sender), serial);
+        }
+    });
+
+    return receiver;
+}
+
 MaybeError Queue::WaitForIdleForDestruction() {
     // Forget all pending commands.
     mCommandContext.AcquireCommands();
@@ -139,6 +176,8 @@
                                uint64_t(pendingSerial));
         ASSERT(uint64_t(pendingSerial) > mCompletedSerial.load());
         this->mCompletedSerial = uint64_t(pendingSerial);
+
+        this->UpdateWaitingEvents(pendingSerial);
     }];
 
     TRACE_EVENT_ASYNC_BEGIN0(platform, GPUWork, "DeviceMTL::SubmitPendingCommandBuffer",
diff --git a/src/dawn/samples/SampleUtils.cpp b/src/dawn/samples/SampleUtils.cpp
index 289c33c..e6e8830 100644
--- a/src/dawn/samples/SampleUtils.cpp
+++ b/src/dawn/samples/SampleUtils.cpp
@@ -129,7 +129,9 @@
         return wgpu::Device();
     }
 
-    instance = std::make_unique<dawn::native::Instance>();
+    WGPUInstanceDescriptor instanceDescriptor{};
+    instanceDescriptor.features.timedWaitAnyEnable = true;
+    instance = std::make_unique<dawn::native::Instance>(&instanceDescriptor);
 
     wgpu::RequestAdapterOptions options = {};
     options.backendType = backendType;
diff --git a/src/dawn/tests/BUILD.gn b/src/dawn/tests/BUILD.gn
index 696e32f..048b3c7 100644
--- a/src/dawn/tests/BUILD.gn
+++ b/src/dawn/tests/BUILD.gn
@@ -536,6 +536,7 @@
     "end2end/DualSourceBlendTests.cpp",
     "end2end/DynamicBufferOffsetTests.cpp",
     "end2end/EntryPointTests.cpp",
+    "end2end/EventTests.cpp",
     "end2end/ExperimentalDP4aTests.cpp",
     "end2end/ExternalTextureTests.cpp",
     "end2end/FirstIndexOffsetTests.cpp",
diff --git a/src/dawn/tests/DawnTest.cpp b/src/dawn/tests/DawnTest.cpp
index 374bb69..70a4c1d 100644
--- a/src/dawn/tests/DawnTest.cpp
+++ b/src/dawn/tests/DawnTest.cpp
@@ -400,8 +400,9 @@
     dawnInstanceDesc.platform = platform;
     dawnInstanceDesc.nextInChain = &instanceToggles;
 
-    wgpu::InstanceDescriptor instanceDesc;
+    wgpu::InstanceDescriptor instanceDesc{};
     instanceDesc.nextInChain = &dawnInstanceDesc;
+    instanceDesc.features.timedWaitAnyEnable = !UsesWire();
 
     auto instance = std::make_unique<native::Instance>(
         reinterpret_cast<const WGPUInstanceDescriptor*>(&instanceDesc));
diff --git a/src/dawn/tests/end2end/BasicTests.cpp b/src/dawn/tests/end2end/BasicTests.cpp
index a84d7ed..9d69cca 100644
--- a/src/dawn/tests/end2end/BasicTests.cpp
+++ b/src/dawn/tests/end2end/BasicTests.cpp
@@ -12,8 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+#include "dawn/common/FutureUtils.h"
 #include "dawn/tests/DawnTest.h"
-
 #include "dawn/utils/WGPUHelpers.h"
 
 namespace dawn {
@@ -61,6 +61,20 @@
     ASSERT_DEVICE_ERROR(queue.WriteBuffer(buffer, 1000, &value, sizeof(value)));
 }
 
+TEST_P(BasicTests, GetInstanceFeatures) {
+    wgpu::InstanceFeatures instanceFeatures{};
+    bool success = wgpu::GetInstanceFeatures(&instanceFeatures);
+    EXPECT_TRUE(success);
+    EXPECT_EQ(instanceFeatures.timedWaitAnyEnable, !UsesWire());
+    EXPECT_EQ(instanceFeatures.timedWaitAnyMaxCount, kTimedWaitAnyMaxCountDefault);
+    EXPECT_EQ(instanceFeatures.nextInChain, nullptr);
+
+    wgpu::ChainedStruct chained{};
+    instanceFeatures.nextInChain = &chained;
+    success = wgpu::GetInstanceFeatures(&instanceFeatures);
+    EXPECT_FALSE(success);
+}
+
 DAWN_INSTANTIATE_TEST(BasicTests,
                       D3D11Backend(),
                       D3D12Backend(),
diff --git a/src/dawn/tests/end2end/EventTests.cpp b/src/dawn/tests/end2end/EventTests.cpp
new file mode 100644
index 0000000..1b19de3
--- /dev/null
+++ b/src/dawn/tests/end2end/EventTests.cpp
@@ -0,0 +1,571 @@
+// Copyright 2023 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <atomic>
+#include <cstdint>
+#include <utility>
+#include <vector>
+
+#include "dawn/common/FutureUtils.h"
+#include "dawn/tests/DawnTest.h"
+#include "dawn/webgpu.h"
+
+namespace dawn {
+namespace {
+
+std::pair<wgpu::Instance, wgpu::Device> CreateExtraInstance(wgpu::InstanceDescriptor* desc) {
+    // IMPORTANT: DawnTest overrides RequestAdapter and RequestDevice and mixes
+    // up the two instances. We use these to bypass the override.
+    auto* requestAdapter = reinterpret_cast<WGPUProcInstanceRequestAdapter>(
+        wgpuGetProcAddress(nullptr, "wgpuInstanceRequestAdapter"));
+    auto* requestDevice = reinterpret_cast<WGPUProcAdapterRequestDevice>(
+        wgpuGetProcAddress(nullptr, "wgpuAdapterRequestDevice"));
+
+    wgpu::Instance instance2 = wgpu::CreateInstance(desc);
+
+    wgpu::Adapter adapter2;
+    requestAdapter(
+        instance2.Get(), nullptr,
+        [](WGPURequestAdapterStatus status, WGPUAdapter adapter, const char*, void* userdata) {
+            ASSERT_EQ(status, WGPURequestAdapterStatus_Success);
+            *reinterpret_cast<wgpu::Adapter*>(userdata) = wgpu::Adapter(adapter);
+        },
+        &adapter2);
+    ASSERT(adapter2);
+
+    wgpu::Device device2;
+    requestDevice(
+        adapter2.Get(), nullptr,
+        [](WGPURequestDeviceStatus status, WGPUDevice device, const char*, void* userdata) {
+            ASSERT_EQ(status, WGPURequestDeviceStatus_Success);
+            *reinterpret_cast<wgpu::Device*>(userdata) = wgpu::Device(device);
+        },
+        &device2);
+    ASSERT(device2);
+
+    return std::pair(std::move(instance2), std::move(device2));
+}
+
+// EventCompletionTests
+
+enum class WaitType {
+    TimedWaitAny,
+    SpinWaitAny,
+    SpinProcessEvents,
+};
+
+enum class WaitTypeAndCallbackMode {
+    TimedWaitAny_Future,
+    TimedWaitAny_FutureSpontaneous,
+    SpinWaitAny_Future,
+    SpinWaitAny_FutureSpontaneous,
+    SpinProcessEvents_ProcessEvents,
+    SpinProcessEvents_ProcessEventsSpontaneous,
+};
+
+std::ostream& operator<<(std::ostream& o, WaitTypeAndCallbackMode waitMode) {
+    switch (waitMode) {
+        case WaitTypeAndCallbackMode::TimedWaitAny_Future:
+            return o << "TimedWaitAny_Future";
+        case WaitTypeAndCallbackMode::SpinWaitAny_Future:
+            return o << "SpinWaitAny_Future";
+        case WaitTypeAndCallbackMode::SpinProcessEvents_ProcessEvents:
+            return o << "SpinProcessEvents_ProcessEvents";
+        case WaitTypeAndCallbackMode::TimedWaitAny_FutureSpontaneous:
+            return o << "TimedWaitAny_FutureSpontaneous";
+        case WaitTypeAndCallbackMode::SpinWaitAny_FutureSpontaneous:
+            return o << "SpinWaitAny_FutureSpontaneous";
+        case WaitTypeAndCallbackMode::SpinProcessEvents_ProcessEventsSpontaneous:
+            return o << "SpinProcessEvents_ProcessEventsSpontaneous";
+    }
+}
+
+DAWN_TEST_PARAM_STRUCT(EventCompletionTestParams, WaitTypeAndCallbackMode);
+
+class EventCompletionTests : public DawnTestWithParams<EventCompletionTestParams> {
+  protected:
+    wgpu::Instance testInstance;
+    wgpu::Device testDevice;
+    wgpu::Queue testQueue;
+    std::vector<wgpu::FutureWaitInfo> mFutures;
+    std::atomic<uint64_t> mCallbacksCompletedCount = 0;
+    uint64_t mCallbacksIssuedCount = 0;
+    uint64_t mCallbacksWaitedCount = 0;
+
+    void SetUp() override {
+        DawnTestWithParams::SetUp();
+        WaitTypeAndCallbackMode mode = GetParam().mWaitTypeAndCallbackMode;
+        if (UsesWire()) {
+            DAWN_TEST_UNSUPPORTED_IF(mode == WaitTypeAndCallbackMode::TimedWaitAny_Future ||
+                                     mode ==
+                                         WaitTypeAndCallbackMode::TimedWaitAny_FutureSpontaneous);
+        }
+        testInstance = GetInstance();
+        testDevice = device;
+        testQueue = queue;
+        // Make sure these aren't used accidentally (unfortunately can't do the same for instance):
+        device = nullptr;
+        queue = nullptr;
+    }
+
+    void UseSecondInstance() {
+        wgpu::InstanceDescriptor desc;
+        desc.features.timedWaitAnyEnable = !UsesWire();
+        std::tie(testInstance, testDevice) = CreateExtraInstance(&desc);
+        testQueue = testDevice.GetQueue();
+    }
+
+    void LoseTestDevice() {
+        EXPECT_CALL(mDeviceLostCallback,
+                    Call(WGPUDeviceLostReason_Undefined, testing::_, testing::_))
+            .Times(1);
+        testDevice.ForceLoss(wgpu::DeviceLostReason::Undefined, "Device lost for testing");
+        testDevice.Tick();
+    }
+
+    void TrivialSubmit() {
+        wgpu::CommandBuffer cb = testDevice.CreateCommandEncoder().Finish();
+        testQueue.Submit(1, &cb);
+    }
+
+    wgpu::CallbackMode GetCallbackMode() {
+        switch (GetParam().mWaitTypeAndCallbackMode) {
+            case WaitTypeAndCallbackMode::TimedWaitAny_Future:
+            case WaitTypeAndCallbackMode::SpinWaitAny_Future:
+                return wgpu::CallbackMode::Future;
+            case WaitTypeAndCallbackMode::SpinProcessEvents_ProcessEvents:
+                return wgpu::CallbackMode::ProcessEvents;
+            case WaitTypeAndCallbackMode::TimedWaitAny_FutureSpontaneous:
+            case WaitTypeAndCallbackMode::SpinWaitAny_FutureSpontaneous:
+                return wgpu::CallbackMode::Future | wgpu::CallbackMode::Spontaneous;
+            case WaitTypeAndCallbackMode::SpinProcessEvents_ProcessEventsSpontaneous:
+                return wgpu::CallbackMode::ProcessEvents | wgpu::CallbackMode::Spontaneous;
+        }
+    }
+
+    bool IsSpontaneous() { return GetCallbackMode() & wgpu::CallbackMode::Spontaneous; }
+
+    void TrackForTest(wgpu::Future future) {
+        mCallbacksIssuedCount++;
+
+        switch (GetParam().mWaitTypeAndCallbackMode) {
+            case WaitTypeAndCallbackMode::TimedWaitAny_Future:
+            case WaitTypeAndCallbackMode::TimedWaitAny_FutureSpontaneous:
+            case WaitTypeAndCallbackMode::SpinWaitAny_Future:
+            case WaitTypeAndCallbackMode::SpinWaitAny_FutureSpontaneous:
+                mFutures.push_back(wgpu::FutureWaitInfo{future, false});
+                break;
+            case WaitTypeAndCallbackMode::SpinProcessEvents_ProcessEvents:
+            case WaitTypeAndCallbackMode::SpinProcessEvents_ProcessEventsSpontaneous:
+                ASSERT_EQ(future.id, 0ull);
+                break;
+        }
+    }
+
+    wgpu::Future OnSubmittedWorkDone(WGPUQueueWorkDoneStatus expectedStatus) {
+        struct Userdata {
+            EventCompletionTests* self;
+            WGPUQueueWorkDoneStatus expectedStatus;
+        };
+        Userdata* userdata = new Userdata{this, expectedStatus};
+
+        return testQueue.OnSubmittedWorkDoneF({
+            nullptr,
+            GetCallbackMode(),
+            [](WGPUQueueWorkDoneStatus status, void* userdata) {
+                Userdata* u = reinterpret_cast<Userdata*>(userdata);
+                u->self->mCallbacksCompletedCount++;
+                ASSERT_EQ(status, u->expectedStatus);
+                delete u;
+            },
+            userdata,
+        });
+    }
+
+    void TestWaitAll(bool loopOnlyOnce = false) {
+        switch (GetParam().mWaitTypeAndCallbackMode) {
+            case WaitTypeAndCallbackMode::TimedWaitAny_Future:
+            case WaitTypeAndCallbackMode::TimedWaitAny_FutureSpontaneous:
+                return TestWaitImpl(WaitType::TimedWaitAny);
+            case WaitTypeAndCallbackMode::SpinWaitAny_Future:
+            case WaitTypeAndCallbackMode::SpinWaitAny_FutureSpontaneous:
+                return TestWaitImpl(WaitType::SpinWaitAny);
+            case WaitTypeAndCallbackMode::SpinProcessEvents_ProcessEvents:
+            case WaitTypeAndCallbackMode::SpinProcessEvents_ProcessEventsSpontaneous:
+                return TestWaitImpl(WaitType::SpinProcessEvents);
+        }
+    }
+
+    void TestWaitIncorrectly() {
+        switch (GetParam().mWaitTypeAndCallbackMode) {
+            case WaitTypeAndCallbackMode::TimedWaitAny_Future:
+            case WaitTypeAndCallbackMode::TimedWaitAny_FutureSpontaneous:
+            case WaitTypeAndCallbackMode::SpinWaitAny_Future:
+            case WaitTypeAndCallbackMode::SpinWaitAny_FutureSpontaneous:
+                return TestWaitImpl(WaitType::SpinProcessEvents);
+            case WaitTypeAndCallbackMode::SpinProcessEvents_ProcessEvents:
+            case WaitTypeAndCallbackMode::SpinProcessEvents_ProcessEventsSpontaneous:
+                return TestWaitImpl(WaitType::SpinWaitAny);
+        }
+    }
+
+  private:
+    void TestWaitImpl(WaitType waitType, bool loopOnlyOnce = false) {
+        uint64_t oldCompletedCount = mCallbacksCompletedCount;
+
+        const auto start = std::chrono::high_resolution_clock::now();
+        auto testTimeExceeded = [=]() -> bool {
+            return std::chrono::high_resolution_clock::now() - start > std::chrono::seconds(5);
+        };
+
+        switch (waitType) {
+            case WaitType::TimedWaitAny: {
+                bool emptyWait = mFutures.size() == 0;
+                // Loop at least once so we can test it with 0 futures.
+                do {
+                    ASSERT_FALSE(testTimeExceeded());
+                    ASSERT(!UsesWire());
+                    wgpu::WaitStatus status;
+
+                    uint64_t oldCompletionCount = mCallbacksCompletedCount;
+                    // Any futures should succeed within a few milliseconds at most.
+                    status = testInstance.WaitAny(mFutures.size(), mFutures.data(), UINT64_MAX);
+                    ASSERT_EQ(status, wgpu::WaitStatus::Success);
+                    bool mayHaveCompletedEarly = IsSpontaneous();
+                    if (!mayHaveCompletedEarly && !emptyWait) {
+                        ASSERT_GT(mCallbacksCompletedCount, oldCompletionCount);
+                    }
+
+                    // Verify this succeeds instantly because some futures completed already.
+                    status = testInstance.WaitAny(mFutures.size(), mFutures.data(), 0);
+                    ASSERT_EQ(status, wgpu::WaitStatus::Success);
+
+                    RemoveCompletedFutures();
+                    if (loopOnlyOnce) {
+                        break;
+                    }
+                } while (mFutures.size() > 0);
+            } break;
+            case WaitType::SpinWaitAny: {
+                bool emptyWait = mFutures.size() == 0;
+                // Loop at least once so we can test it with 0 futures.
+                do {
+                    ASSERT_FALSE(testTimeExceeded());
+
+                    uint64_t oldCompletionCount = mCallbacksCompletedCount;
+                    FlushWire();
+                    testDevice.Tick();
+                    auto status = testInstance.WaitAny(mFutures.size(), mFutures.data(), 0);
+                    if (status == wgpu::WaitStatus::TimedOut) {
+                        continue;
+                    }
+                    ASSERT_TRUE(status == wgpu::WaitStatus::Success);
+                    bool mayHaveCompletedEarly = IsSpontaneous();
+                    if (!mayHaveCompletedEarly && !emptyWait) {
+                        ASSERT_GT(mCallbacksCompletedCount, oldCompletionCount);
+                    }
+
+                    RemoveCompletedFutures();
+                    if (loopOnlyOnce) {
+                        break;
+                    }
+                } while (mFutures.size() > 0);
+            } break;
+            case WaitType::SpinProcessEvents: {
+                do {
+                    ASSERT_FALSE(testTimeExceeded());
+
+                    FlushWire();
+                    testDevice.Tick();
+                    testInstance.ProcessEvents();
+
+                    if (loopOnlyOnce) {
+                        break;
+                    }
+                } while (mCallbacksCompletedCount < mCallbacksIssuedCount);
+            } break;
+        }
+
+        if (!IsSpontaneous()) {
+            ASSERT_EQ(mCallbacksCompletedCount - oldCompletedCount,
+                      mCallbacksIssuedCount - mCallbacksWaitedCount);
+        }
+        ASSERT_EQ(mCallbacksCompletedCount, mCallbacksIssuedCount);
+        mCallbacksWaitedCount = mCallbacksCompletedCount;
+    }
+
+    void RemoveCompletedFutures() {
+        size_t oldSize = mFutures.size();
+        if (oldSize > 0) {
+            mFutures.erase(
+                std::remove_if(mFutures.begin(), mFutures.end(),
+                               [](const wgpu::FutureWaitInfo& info) { return info.completed; }),
+                mFutures.end());
+            ASSERT_LT(mFutures.size(), oldSize);
+        }
+    }
+};
+
+// Wait when no events have been requested.
+TEST_P(EventCompletionTests, NoEvents) {
+    TestWaitAll();
+}
+
+// WorkDone event after submitting some trivial work.
+TEST_P(EventCompletionTests, WorkDoneSimple) {
+    TrivialSubmit();
+    TrackForTest(OnSubmittedWorkDone(WGPUQueueWorkDoneStatus_Success));
+    TestWaitAll();
+}
+
+// WorkDone event before device loss, wait afterward.
+TEST_P(EventCompletionTests, WorkDoneAcrossDeviceLoss) {
+    TrivialSubmit();
+    TrackForTest(OnSubmittedWorkDone(WGPUQueueWorkDoneStatus_Success));
+    TestWaitAll();
+}
+
+// WorkDone event after device loss.
+TEST_P(EventCompletionTests, WorkDoneAfterDeviceLoss) {
+    TrivialSubmit();
+    LoseTestDevice();
+    ASSERT_DEVICE_ERROR_ON(testDevice,
+                           TrackForTest(OnSubmittedWorkDone(WGPUQueueWorkDoneStatus_Success)));
+    TestWaitAll();
+}
+
+// WorkDone event twice after submitting some trivial work.
+TEST_P(EventCompletionTests, WorkDoneTwice) {
+    TrivialSubmit();
+    TrackForTest(OnSubmittedWorkDone(WGPUQueueWorkDoneStatus_Success));
+    TrackForTest(OnSubmittedWorkDone(WGPUQueueWorkDoneStatus_Success));
+    TestWaitAll();
+}
+
+// WorkDone event without ever having submitted any work.
+TEST_P(EventCompletionTests, WorkDoneNoWork) {
+    TrackForTest(OnSubmittedWorkDone(WGPUQueueWorkDoneStatus_Success));
+    TestWaitAll();
+    TrackForTest(OnSubmittedWorkDone(WGPUQueueWorkDoneStatus_Success));
+    TrackForTest(OnSubmittedWorkDone(WGPUQueueWorkDoneStatus_Success));
+    TestWaitAll();
+}
+
+// WorkDone event after all work has completed already.
+TEST_P(EventCompletionTests, WorkDoneAlreadyCompleted) {
+    TrivialSubmit();
+    TrackForTest(OnSubmittedWorkDone(WGPUQueueWorkDoneStatus_Success));
+    TestWaitAll();
+    TrackForTest(OnSubmittedWorkDone(WGPUQueueWorkDoneStatus_Success));
+    TestWaitAll();
+}
+
+// WorkDone events waited in reverse order.
+TEST_P(EventCompletionTests, WorkDoneOutOfOrder) {
+    // With ProcessEvents or Spontaneous we can't control the order of completion.
+    DAWN_TEST_UNSUPPORTED_IF(GetCallbackMode() &
+                             (wgpu::CallbackMode::ProcessEvents | wgpu::CallbackMode::Spontaneous));
+
+    TrivialSubmit();
+    wgpu::Future f1 = OnSubmittedWorkDone(WGPUQueueWorkDoneStatus_Success);
+    TrivialSubmit();
+    wgpu::Future f2 = OnSubmittedWorkDone(WGPUQueueWorkDoneStatus_Success);
+
+    // When using WaitAny, normally callback ordering guarantees would guarantee f1 completes before
+    // f2. But if we wait on f2 first, then f2 is allowed to complete first because f1 still hasn't
+    // had an opportunity to complete.
+    TrackForTest(f2);
+    TestWaitAll();
+    TrackForTest(f1);
+    TestWaitAll(true);
+}
+
+constexpr WGPUQueueWorkDoneStatus kStatusUninitialized =
+    static_cast<WGPUQueueWorkDoneStatus>(INT32_MAX);
+
+TEST_P(EventCompletionTests, WorkDoneDropInstanceBeforeEvent) {
+    // TODO(crbug.com/dawn/1987): Wire does not implement instance destruction correctly yet.
+    DAWN_TEST_UNSUPPORTED_IF(UsesWire());
+
+    UseSecondInstance();
+    testInstance = nullptr;  // Drop the last external ref to the instance.
+
+    WGPUQueueWorkDoneStatus status = kStatusUninitialized;
+    testQueue.OnSubmittedWorkDoneF({nullptr, GetCallbackMode(),
+                                    [](WGPUQueueWorkDoneStatus status, void* userdata) {
+                                        *reinterpret_cast<WGPUQueueWorkDoneStatus*>(userdata) =
+                                            status;
+                                    },
+                                    &status});
+
+    // Callback should have been called immediately because we leaked it since there's no way to
+    // call WaitAny or ProcessEvents anymore.
+    //
+    // TODO(crbug.com/dawn/1987): Once Spontaneous is implemented, this should no longer expect the
+    // callback to be cleaned up immediately (and should expect it to happen on a future Tick).
+    ASSERT_EQ(status, WGPUQueueWorkDoneStatus_Unknown);
+}
+
+TEST_P(EventCompletionTests, WorkDoneDropInstanceAfterEvent) {
+    // TODO(crbug.com/dawn/1987): Wire does not implement instance destruction correctly yet.
+    DAWN_TEST_UNSUPPORTED_IF(UsesWire());
+
+    UseSecondInstance();
+
+    WGPUQueueWorkDoneStatus status = kStatusUninitialized;
+    testQueue.OnSubmittedWorkDoneF({nullptr, GetCallbackMode(),
+                                    [](WGPUQueueWorkDoneStatus status, void* userdata) {
+                                        *reinterpret_cast<WGPUQueueWorkDoneStatus*>(userdata) =
+                                            status;
+                                    },
+                                    &status});
+
+    ASSERT_EQ(status, kStatusUninitialized);
+
+    testInstance = nullptr;  // Drop the last external ref to the instance.
+
+    // Callback should have been called immediately because we leaked it since there's no way to
+    // call WaitAny or ProcessEvents anymore.
+    //
+    // TODO(crbug.com/dawn/1987): Once Spontaneous is implemented, this should no longer expect the
+    // callback to be cleaned up immediately (and should expect it to happen on a future Tick).
+    ASSERT_EQ(status, WGPUQueueWorkDoneStatus_Unknown);
+}
+
+// TODO(crbug.com/dawn/1987):
+// - Test any reentrancy guarantees (for ProcessEvents or WaitAny inside a callback),
+//   to make sure things don't blow up and we don't attempt to hold locks recursively.
+// - Other tests?
+
+DAWN_INSTANTIATE_TEST_P(EventCompletionTests,
+                        // TODO(crbug.com/dawn/1987): Enable tests for the rest of the backends.
+                        {MetalBackend()},
+                        {
+                            WaitTypeAndCallbackMode::TimedWaitAny_Future,
+                            WaitTypeAndCallbackMode::TimedWaitAny_FutureSpontaneous,
+                            WaitTypeAndCallbackMode::SpinWaitAny_Future,
+                            WaitTypeAndCallbackMode::SpinWaitAny_FutureSpontaneous,
+                            WaitTypeAndCallbackMode::SpinProcessEvents_ProcessEvents,
+                            WaitTypeAndCallbackMode::SpinProcessEvents_ProcessEventsSpontaneous,
+
+                            // TODO(crbug.com/dawn/1987): The cases with the Spontaneous flag
+                            // enabled were added before we implemented all of the spontaneous
+                            // completions. They might accidentally be overly strict.
+
+                            // TODO(crbug.com/dawn/1987): Make guarantees that Spontaneous callbacks
+                            // get called (as long as you're hitting "checkpoints"), and add the
+                            // corresponding tests, for example:
+                            // - SpinProcessEvents_Spontaneous,
+                            // - SpinSubmit_Spontaneous,
+                            // - SpinCheckpoint_Spontaneous (if wgpuDeviceCheckpoint is added).
+                            // - (Note we don't want to guarantee Tick will process events - we
+                            //   could even test that it doesn't, if we make that true.)
+                        });
+
+// WaitAnyTests
+
+class WaitAnyTests : public DawnTest {};
+
+TEST_P(WaitAnyTests, UnsupportedTimeout) {
+    wgpu::Instance instance2;
+    wgpu::Device device2;
+
+    if (UsesWire()) {
+        // The wire (currently) never supports timedWaitAnyEnable, so we can run this test on the
+        // default instance/device.
+        instance2 = GetInstance();
+        device2 = device;
+    } else {
+        // When not using the wire, DawnTest will unconditionally set timedWaitAnyEnable since it's
+        // useful for other tests. For this test, we need it to be false to test validation.
+        wgpu::InstanceDescriptor desc;
+        desc.features.timedWaitAnyEnable = false;
+        std::tie(instance2, device2) = CreateExtraInstance(&desc);
+    }
+
+    // UnsupportedTimeout is still validated if no futures are passed.
+    for (uint64_t timeout : {uint64_t(1), uint64_t(0), UINT64_MAX}) {
+        ASSERT_EQ(instance2.WaitAny(0, nullptr, timeout),
+                  timeout > 0 ? wgpu::WaitStatus::UnsupportedTimeout : wgpu::WaitStatus::Success);
+    }
+
+    for (uint64_t timeout : {uint64_t(1), uint64_t(0), UINT64_MAX}) {
+        wgpu::FutureWaitInfo info{device2.GetQueue().OnSubmittedWorkDoneF(
+            {nullptr, wgpu::CallbackMode::Future, [](WGPUQueueWorkDoneStatus, void*) {}, nullptr})};
+        wgpu::WaitStatus status = instance2.WaitAny(1, &info, timeout);
+        if (timeout == 0) {
+            ASSERT_TRUE(status == wgpu::WaitStatus::Success ||
+                        status == wgpu::WaitStatus::TimedOut);
+        } else {
+            ASSERT_EQ(status, wgpu::WaitStatus::UnsupportedTimeout);
+        }
+    }
+}
+
+TEST_P(WaitAnyTests, UnsupportedCount) {
+    for (uint64_t timeout : {uint64_t(0), uint64_t(1)}) {
+        // We don't support values higher than the default (64), and if you ask for lower than 64
+        // you still get 64. DawnTest doesn't request anything (so requests 0) so gets 64.
+        for (size_t count : {kTimedWaitAnyMaxCountDefault, kTimedWaitAnyMaxCountDefault + 1}) {
+            std::vector<wgpu::FutureWaitInfo> infos;
+            for (size_t i = 0; i < count; ++i) {
+                infos.push_back(
+                    {queue.OnSubmittedWorkDoneF({nullptr, wgpu::CallbackMode::Future,
+                                                 [](WGPUQueueWorkDoneStatus, void*) {}, nullptr})});
+            }
+            wgpu::WaitStatus status = GetInstance().WaitAny(infos.size(), infos.data(), timeout);
+            if (timeout == 0) {
+                ASSERT_TRUE(status == wgpu::WaitStatus::Success ||
+                            status == wgpu::WaitStatus::TimedOut);
+            } else if (UsesWire()) {
+                // Wire doesn't support timeouts at all.
+                ASSERT_EQ(status, wgpu::WaitStatus::UnsupportedTimeout);
+            } else if (count <= 64) {
+                ASSERT_EQ(status, wgpu::WaitStatus::Success);
+            } else {
+                ASSERT_EQ(status, wgpu::WaitStatus::UnsupportedCount);
+            }
+        }
+    }
+}
+
+TEST_P(WaitAnyTests, UnsupportedMixedSources) {
+    wgpu::Device device2 = CreateDevice();
+    wgpu::Queue queue2 = device2.GetQueue();
+    for (uint64_t timeout : {uint64_t(0), uint64_t(1)}) {
+        std::vector<wgpu::FutureWaitInfo> infos{{
+            {queue.OnSubmittedWorkDoneF({nullptr, wgpu::CallbackMode::Future,
+                                         [](WGPUQueueWorkDoneStatus, void*) {}, nullptr})},
+            {queue2.OnSubmittedWorkDoneF({nullptr, wgpu::CallbackMode::Future,
+                                          [](WGPUQueueWorkDoneStatus, void*) {}, nullptr})},
+        }};
+        wgpu::WaitStatus status = GetInstance().WaitAny(infos.size(), infos.data(), timeout);
+        if (timeout == 0) {
+            ASSERT_TRUE(status == wgpu::WaitStatus::Success ||
+                        status == wgpu::WaitStatus::TimedOut);
+        } else if (UsesWire()) {
+            // Wire doesn't support timeouts at all.
+            ASSERT_EQ(status, wgpu::WaitStatus::UnsupportedTimeout);
+        } else {
+            ASSERT_EQ(status, wgpu::WaitStatus::UnsupportedMixedSources);
+        }
+    }
+}
+
+DAWN_INSTANTIATE_TEST(WaitAnyTests,
+                      // TODO(crbug.com/dawn/1987): Enable tests for the rest of the backends.
+                      MetalBackend());
+
+}  // anonymous namespace
+}  // namespace dawn
diff --git a/src/dawn/wire/BUILD.gn b/src/dawn/wire/BUILD.gn
index b159113..9d7fa3a 100644
--- a/src/dawn/wire/BUILD.gn
+++ b/src/dawn/wire/BUILD.gn
@@ -86,6 +86,8 @@
     "client/ClientInlineMemoryTransferService.cpp",
     "client/Device.cpp",
     "client/Device.h",
+    "client/EventManager.cpp",
+    "client/EventManager.h",
     "client/Instance.cpp",
     "client/Instance.h",
     "client/LimitsAndFeatures.cpp",
diff --git a/src/dawn/wire/CMakeLists.txt b/src/dawn/wire/CMakeLists.txt
index 7b7df16..d1a1f3c 100644
--- a/src/dawn/wire/CMakeLists.txt
+++ b/src/dawn/wire/CMakeLists.txt
@@ -59,6 +59,8 @@
     "client/ClientInlineMemoryTransferService.cpp"
     "client/Device.cpp"
     "client/Device.h"
+    "client/EventManager.cpp"
+    "client/EventManager.h"
     "client/Instance.cpp"
     "client/Instance.h"
     "client/LimitsAndFeatures.cpp"
diff --git a/src/dawn/wire/ObjectHandle.cpp b/src/dawn/wire/ObjectHandle.cpp
index 62d9f80..db747f8 100644
--- a/src/dawn/wire/ObjectHandle.cpp
+++ b/src/dawn/wire/ObjectHandle.cpp
@@ -14,11 +14,15 @@
 
 #include "dawn/wire/ObjectHandle.h"
 
+#include "dawn/common/Assert.h"
+
 namespace dawn::wire {
 
 ObjectHandle::ObjectHandle() = default;
 ObjectHandle::ObjectHandle(ObjectId id, ObjectGeneration generation)
-    : id(id), generation(generation) {}
+    : id(id), generation(generation) {
+    ASSERT(id != 0);
+}
 
 ObjectHandle::ObjectHandle(const volatile ObjectHandle& rhs)
     : id(rhs.id), generation(rhs.generation) {}
@@ -41,4 +45,9 @@
     generation = rhs.generation;
     return *this;
 }
+
+bool ObjectHandle::IsValid() const {
+    return id > 0;
+}
+
 }  // namespace dawn::wire
diff --git a/src/dawn/wire/ObjectHandle.h b/src/dawn/wire/ObjectHandle.h
index 3e6cf64..1335efb 100644
--- a/src/dawn/wire/ObjectHandle.h
+++ b/src/dawn/wire/ObjectHandle.h
@@ -21,9 +21,12 @@
 
 using ObjectId = uint32_t;
 using ObjectGeneration = uint32_t;
+
+// ObjectHandle identifies some WebGPU object in the wire.
+// An ObjectHandle will never be reused, so can be used to uniquely identify an object forever.
 struct ObjectHandle {
-    ObjectId id;
-    ObjectGeneration generation;
+    ObjectId id = 0;
+    ObjectGeneration generation = 0;
 
     ObjectHandle();
     ObjectHandle(ObjectId id, ObjectGeneration generation);
@@ -42,6 +45,8 @@
     }
     ObjectHandle& AssignFrom(const ObjectHandle& rhs);
     ObjectHandle& AssignFrom(const volatile ObjectHandle& rhs);
+
+    bool IsValid() const;
 };
 
 }  // namespace dawn::wire
diff --git a/src/dawn/wire/client/Client.cpp b/src/dawn/wire/client/Client.cpp
index 5feaca1..27bec54 100644
--- a/src/dawn/wire/client/Client.cpp
+++ b/src/dawn/wire/client/Client.cpp
@@ -38,7 +38,10 @@
 }  // anonymous namespace
 
 Client::Client(CommandSerializer* serializer, MemoryTransferService* memoryTransferService)
-    : ClientBase(), mSerializer(serializer), mMemoryTransferService(memoryTransferService) {
+    : ClientBase(),
+      mSerializer(serializer),
+      mMemoryTransferService(memoryTransferService),
+      mEventManager(this) {
     if (mMemoryTransferService == nullptr) {
         // If a MemoryTransferService is not provided, fall back to inline memory.
         mOwnedMemoryTransferService = CreateInlineMemoryTransferService();
@@ -48,6 +51,7 @@
 
 Client::~Client() {
     DestroyAllObjects();
+    mEventManager.ShutDown();
 }
 
 void Client::DestroyAllObjects() {
@@ -142,6 +146,10 @@
     Free(FromAPI(reservation.instance));
 }
 
+EventManager* Client::GetEventManager() {
+    return &mEventManager;
+}
+
 void Client::Disconnect() {
     mDisconnected = true;
     mSerializer = ChunkedCommandSerializer(NoopCommandSerializer::GetInstance());
@@ -160,6 +168,7 @@
             object->value()->CancelCallbacksForDisconnect();
         }
     }
+    mEventManager.ShutDown();
 }
 
 bool Client::IsDisconnected() const {
diff --git a/src/dawn/wire/client/Client.h b/src/dawn/wire/client/Client.h
index 6a5e02f..7828535 100644
--- a/src/dawn/wire/client/Client.h
+++ b/src/dawn/wire/client/Client.h
@@ -27,6 +27,7 @@
 #include "dawn/wire/WireCmd_autogen.h"
 #include "dawn/wire/WireDeserializeAllocator.h"
 #include "dawn/wire/client/ClientBase_autogen.h"
+#include "dawn/wire/client/EventManager.h"
 #include "dawn/wire/client/ObjectStore.h"
 
 namespace dawn::wire::client {
@@ -91,6 +92,8 @@
         mSerializer.SerializeCommand(cmd, *this, std::forward<Extensions>(es)...);
     }
 
+    EventManager* GetEventManager();
+
     void Disconnect();
     bool IsDisconnected() const;
 
@@ -105,6 +108,7 @@
     MemoryTransferService* mMemoryTransferService = nullptr;
     std::unique_ptr<MemoryTransferService> mOwnedMemoryTransferService = nullptr;
     PerObjectType<LinkedList<ObjectBase>> mObjects;
+    EventManager mEventManager;
     bool mDisconnected = false;
 };
 
diff --git a/src/dawn/wire/client/EventManager.cpp b/src/dawn/wire/client/EventManager.cpp
new file mode 100644
index 0000000..efa4902
--- /dev/null
+++ b/src/dawn/wire/client/EventManager.cpp
@@ -0,0 +1,172 @@
+// Copyright 2023 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+#include "dawn/wire/ObjectHandle.h"
+#include "dawn/wire/client/Client.h"
+#include "dawn/wire/client/EventManager.h"
+
+namespace dawn::wire::client {
+
+// EventManager
+
+EventManager::EventManager(Client* client) : mClient(client) {}
+
+FutureID EventManager::TrackEvent(WGPUCallbackModeFlags mode, EventCallback&& callback) {
+    DAWN_UNUSED(ValidateAndFlattenCallbackMode(mode));
+
+    FutureID futureID = mNextFutureID++;
+
+    if (mClient->IsDisconnected()) {
+        callback(EventCompletionType::Shutdown);
+        return futureID;
+    }
+
+    mTrackedEvents.Use([&](auto trackedEvents) {
+        auto [it, inserted] =
+            trackedEvents->emplace(futureID, TrackedEvent(mode, std::move(callback)));
+        ASSERT(inserted);
+    });
+
+    return futureID;
+}
+
+void EventManager::ShutDown() {
+    // Call any outstanding callbacks before destruction.
+    while (true) {
+        std::unordered_map<FutureID, TrackedEvent> movedEvents;
+        mTrackedEvents.Use([&](auto trackedEvents) { movedEvents = std::move(*trackedEvents); });
+
+        if (movedEvents.empty()) {
+            break;
+        }
+
+        for (auto& [futureID, trackedEvent] : movedEvents) {
+            // Event should be already marked Ready since events are actually driven by
+            // RequestTrackers (at the time of this writing), which all shut down before this.
+            ASSERT(trackedEvent.mReady);
+            trackedEvent.mCallback(EventCompletionType::Shutdown);
+            trackedEvent.mCallback = nullptr;
+        }
+    }
+}
+
+void EventManager::SetFutureReady(FutureID futureID) {
+    ASSERT(futureID > 0);
+    mTrackedEvents.Use([&](auto trackedEvents) {
+        TrackedEvent& trackedEvent = trackedEvents->at(futureID);  // Asserts futureID is in the map
+        trackedEvent.mReady = true;
+    });
+    // TODO(crbug.com/dawn/1987): Handle spontaneous completions.
+}
+
+void EventManager::ProcessPollEvents() {
+    std::vector<TrackedEvent> eventsToCompleteNow;
+
+    // TODO(crbug.com/dawn/1987): EventManager shouldn't bother to track ProcessEvents-type events
+    // until they've completed. We can queue them up when they're received on the wire. (Before that
+    // point, the RequestTracker tracks them. If/when we merge this with RequestTracker, then we'll
+    // track both here but still don't need to queue them for ProcessEvents until they complete.)
+    mTrackedEvents.Use([&](auto trackedEvents) {
+        for (auto it = trackedEvents->begin(); it != trackedEvents->end();) {
+            TrackedEvent& event = it->second;
+            bool shouldRemove = (event.mMode & WGPUCallbackMode_ProcessEvents) && event.mReady;
+            if (!shouldRemove) {
+                ++it;
+                continue;
+            }
+
+            // mCallback may still be null if it's stale (was already spontaneously completed).
+            if (event.mCallback) {
+                eventsToCompleteNow.emplace_back(std::move(event));
+            }
+            it = trackedEvents->erase(it);
+        }
+    });
+
+    for (TrackedEvent& event : eventsToCompleteNow) {
+        ASSERT(event.mReady && event.mCallback);
+        event.mCallback(EventCompletionType::Ready);
+        event.mCallback = nullptr;
+    }
+}
+
+WGPUWaitStatus EventManager::WaitAny(size_t count, WGPUFutureWaitInfo* infos, uint64_t timeoutNS) {
+    // Validate for feature support.
+    if (timeoutNS > 0) {
+        // Wire doesn't support timedWaitEnable (for now). (There's no UnsupportedCount or
+        // UnsupportedMixedSources validation here, because those only apply to timed waits.)
+        //
+        // TODO(crbug.com/dawn/1987): CreateInstance needs to validate timedWaitEnable was false.
+        return WGPUWaitStatus_UnsupportedTimeout;
+    }
+
+    if (count == 0) {
+        return WGPUWaitStatus_Success;
+    }
+
+    std::vector<TrackedEvent> eventsToCompleteNow;
+    bool anyCompleted = false;
+    const FutureID firstInvalidFutureID = mNextFutureID;
+    mTrackedEvents.Use([&](auto trackedEvents) {
+        for (size_t i = 0; i < count; ++i) {
+            FutureID futureID = infos[i].future.id;
+            ASSERT(futureID < firstInvalidFutureID);
+
+            auto it = trackedEvents->find(futureID);
+            if (it == trackedEvents->end()) {
+                infos[i].completed = true;
+                anyCompleted = true;
+                continue;
+            }
+
+            TrackedEvent& event = it->second;
+            ASSERT(event.mMode & WGPUCallbackMode_Future);
+            // Early update .completed, in prep to complete the callback if ready.
+            infos[i].completed = event.mReady;
+            if (event.mReady) {
+                anyCompleted = true;
+                if (event.mCallback) {
+                    eventsToCompleteNow.emplace_back(std::move(event));
+                }
+                trackedEvents->erase(it);
+            }
+        }
+    });
+
+    // TODO(crbug.com/dawn/1987): Guarantee the event ordering from the JS spec.
+    for (TrackedEvent& event : eventsToCompleteNow) {
+        ASSERT(event.mReady && event.mCallback);
+        // .completed has already been set to true (before the callback, per API contract).
+        event.mCallback(EventCompletionType::Ready);
+        event.mCallback = nullptr;
+    }
+
+    return anyCompleted ? WGPUWaitStatus_Success : WGPUWaitStatus_TimedOut;
+}
+
+// EventManager::TrackedEvent
+
+EventManager::TrackedEvent::TrackedEvent(WGPUCallbackModeFlags mode, EventCallback&& callback)
+    : mMode(mode), mCallback(callback) {}
+
+EventManager::TrackedEvent::~TrackedEvent() {
+    // Make sure we're not dropping a callback on the floor.
+    ASSERT(!mCallback);
+}
+
+}  // namespace dawn::wire::client
diff --git a/src/dawn/wire/client/EventManager.h b/src/dawn/wire/client/EventManager.h
new file mode 100644
index 0000000..0b689d6
--- /dev/null
+++ b/src/dawn/wire/client/EventManager.h
@@ -0,0 +1,77 @@
+// Copyright 2023 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_DAWN_WIRE_CLIENT_EVENTMANAGER_H_
+#define SRC_DAWN_WIRE_CLIENT_EVENTMANAGER_H_
+
+#include <cstddef>
+#include <functional>
+#include <unordered_map>
+
+#include "dawn/common/FutureUtils.h"
+#include "dawn/common/MutexProtected.h"
+#include "dawn/common/NonCopyable.h"
+#include "dawn/common/Ref.h"
+#include "dawn/webgpu.h"
+#include "dawn/wire/ObjectHandle.h"
+
+namespace dawn::wire::client {
+
+class Client;
+
+// Code to run to complete the event (after receiving a ready notification from the wire).
+using EventCallback = std::function<void(EventCompletionType)>;
+
+// Subcomponent which tracks callback events for the Future-based callback
+// entrypoints. All events from this instance (regardless of whether from an adapter, device, queue,
+// etc.) are tracked here, and used by the instance-wide ProcessEvents and WaitAny entrypoints.
+//
+// TODO(crbug.com/dawn/1987): This should probably be merged together with RequestTracker.
+class EventManager final : NonMovable {
+  public:
+    explicit EventManager(Client*);
+    ~EventManager() = default;
+
+    FutureID TrackEvent(WGPUCallbackModeFlags mode, EventCallback&& callback);
+    void ShutDown();
+    void SetFutureReady(FutureID futureID);
+    void ProcessPollEvents();
+    WGPUWaitStatus WaitAny(size_t count, WGPUFutureWaitInfo* infos, uint64_t timeoutNS);
+
+  private:
+    struct TrackedEvent : dawn::NonCopyable {
+        TrackedEvent(WGPUCallbackModeFlags mode, EventCallback&& callback);
+        ~TrackedEvent();
+
+        TrackedEvent(TrackedEvent&&) = default;
+        TrackedEvent& operator=(TrackedEvent&&) = default;
+
+        WGPUCallbackModeFlags mMode;
+        // Callback. Falsey if already called.
+        EventCallback mCallback;
+        // These states don't need to be atomic because they're always protected by
+        // mTrackedEventsMutex (or moved out to a local variable).
+        bool mReady = false;
+    };
+
+    Client* mClient;
+
+    // Tracks all kinds of events (for both WaitAny and ProcessEvents).
+    MutexProtected<std::unordered_map<FutureID, TrackedEvent>> mTrackedEvents;
+    std::atomic<FutureID> mNextFutureID = 1;
+};
+
+}  // namespace dawn::wire::client
+
+#endif  // SRC_DAWN_WIRE_CLIENT_EVENTMANAGER_H_
diff --git a/src/dawn/wire/client/Instance.cpp b/src/dawn/wire/client/Instance.cpp
index efd9b74..fe82dbd 100644
--- a/src/dawn/wire/client/Instance.cpp
+++ b/src/dawn/wire/client/Instance.cpp
@@ -18,6 +18,25 @@
 
 namespace dawn::wire::client {
 
+// Free-standing API functions
+
+WGPUBool ClientGetInstanceFeatures(WGPUInstanceFeatures* features) {
+    if (features->nextInChain != nullptr) {
+        return false;
+    }
+
+    features->timedWaitAnyEnable = false;
+    features->timedWaitAnyMaxCount = kTimedWaitAnyMaxCountDefault;
+    return true;
+}
+
+WGPUInstance ClientCreateInstance(WGPUInstanceDescriptor const* descriptor) {
+    UNREACHABLE();
+    return nullptr;
+}
+
+// Instance
+
 Instance::~Instance() {
     mRequestAdapterRequests.CloseAll([](RequestAdapterData* request) {
         request->callback(WGPURequestAdapterStatus_Unknown, nullptr,
@@ -100,4 +119,35 @@
     return true;
 }
 
+void Instance::ProcessEvents() {
+    // TODO(crbug.com/dawn/1987): This should only process events for this Instance, not others
+    // on the same client. When EventManager is moved to Instance, this can be fixed.
+    GetClient()->GetEventManager()->ProcessPollEvents();
+
+    // TODO(crbug.com/dawn/1987): The responsibility of ProcessEvents here is a bit mixed. It both
+    // processes events coming in from the server, and also prompts the server to check for and
+    // forward over new events - which won't be received until *after* this client-side
+    // ProcessEvents completes.
+    //
+    // Fixing this nicely probably requires the server to more self-sufficiently
+    // forward the events, which is half of making the wire fully invisible to use (which we might
+    // like to do, someday, but not soon). This is easy for immediate events (like requestDevice)
+    // and thread-driven events (async pipeline creation), but harder for queue fences where we have
+    // to wait on the backend and then trigger Dawn code to forward the event.
+    //
+    // In the meantime, we could maybe do this on client->server flush to keep this concern in the
+    // wire instead of in the API itself, but otherwise it's not significantly better so we just
+    // keep it here for now for backward compatibility.
+    InstanceProcessEventsCmd cmd;
+    cmd.self = ToAPI(this);
+    GetClient()->SerializeCommand(cmd);
+}
+
+WGPUWaitStatus Instance::WaitAny(size_t count, WGPUFutureWaitInfo* infos, uint64_t timeoutNS) {
+    // In principle the EventManager should be on the Instance, not the Client.
+    // But it's hard to get from an object to its Instance right now, so we can
+    // store it on the Client.
+    return GetClient()->GetEventManager()->WaitAny(count, infos, timeoutNS);
+}
+
 }  // namespace dawn::wire::client
diff --git a/src/dawn/wire/client/Instance.h b/src/dawn/wire/client/Instance.h
index 1400565..dcb8dc8 100644
--- a/src/dawn/wire/client/Instance.h
+++ b/src/dawn/wire/client/Instance.h
@@ -16,7 +16,6 @@
 #define SRC_DAWN_WIRE_CLIENT_INSTANCE_H_
 
 #include "dawn/webgpu.h"
-
 #include "dawn/wire/WireClient.h"
 #include "dawn/wire/WireCmd_autogen.h"
 #include "dawn/wire/client/ObjectBase.h"
@@ -24,6 +23,9 @@
 
 namespace dawn::wire::client {
 
+WGPUBool ClientGetInstanceFeatures(WGPUInstanceFeatures* features);
+WGPUInstance ClientCreateInstance(WGPUInstanceDescriptor const* descriptor);
+
 class Instance final : public ObjectBase {
   public:
     using ObjectBase::ObjectBase;
@@ -42,6 +44,9 @@
                                   uint32_t featuresCount,
                                   const WGPUFeatureName* features);
 
+    void ProcessEvents();
+    WGPUWaitStatus WaitAny(size_t count, WGPUFutureWaitInfo* infos, uint64_t timeoutNS);
+
   private:
     struct RequestAdapterData {
         WGPURequestAdapterCallback callback = nullptr;
diff --git a/src/dawn/wire/client/Queue.cpp b/src/dawn/wire/client/Queue.cpp
index bc5a4bb..70f505c 100644
--- a/src/dawn/wire/client/Queue.cpp
+++ b/src/dawn/wire/client/Queue.cpp
@@ -15,7 +15,7 @@
 #include "dawn/wire/client/Queue.h"
 
 #include "dawn/wire/client/Client.h"
-#include "dawn/wire/client/Device.h"
+#include "dawn/wire/client/EventManager.h"
 
 namespace dawn::wire::client {
 
@@ -52,6 +52,44 @@
     client->SerializeCommand(cmd);
 }
 
+WGPUFuture Queue::OnSubmittedWorkDoneF(const WGPUQueueWorkDoneCallbackInfo& callbackInfo) {
+    // TODO(crbug.com/dawn/1987): Once we always return a future, change this to log to the instance
+    // (note, not raise a validation error to the device) and return the null future.
+    ASSERT(callbackInfo.nextInChain == nullptr);
+
+    Client* client = GetClient();
+    FutureID futureIDInternal = client->GetEventManager()->TrackEvent(
+        callbackInfo.mode, [=](EventCompletionType completionType) {
+            WGPUQueueWorkDoneStatus status = completionType == EventCompletionType::Shutdown
+                                                 ? WGPUQueueWorkDoneStatus_Unknown
+                                                 : WGPUQueueWorkDoneStatus_Success;
+            callbackInfo.callback(status, callbackInfo.userdata);
+        });
+
+    struct Lambda {
+        Client* client;
+        FutureID futureIDInternal;
+    };
+    Lambda* lambda = new Lambda{client, futureIDInternal};
+    uint64_t serial = mOnWorkDoneRequests.Add(
+        {[](WGPUQueueWorkDoneStatus /* ignored */, void* userdata) {
+             auto* lambda = static_cast<Lambda*>(userdata);
+             lambda->client->GetEventManager()->SetFutureReady(lambda->futureIDInternal);
+             delete lambda;
+         },
+         lambda});
+
+    QueueOnSubmittedWorkDoneCmd cmd;
+    cmd.queueId = GetWireId();
+    cmd.signalValue = 0;
+    cmd.requestSerial = serial;
+
+    client->SerializeCommand(cmd);
+
+    FutureID futureID = (callbackInfo.mode & WGPUCallbackMode_Future) ? futureIDInternal : 0;
+    return {futureID};
+}
+
 void Queue::WriteBuffer(WGPUBuffer cBuffer, uint64_t bufferOffset, const void* data, size_t size) {
     Buffer* buffer = FromAPI(cBuffer);
 
diff --git a/src/dawn/wire/client/Queue.h b/src/dawn/wire/client/Queue.h
index 28424c0..17edf06 100644
--- a/src/dawn/wire/client/Queue.h
+++ b/src/dawn/wire/client/Queue.h
@@ -34,6 +34,7 @@
     void OnSubmittedWorkDone(uint64_t signalValue,
                              WGPUQueueWorkDoneCallback callback,
                              void* userdata);
+    WGPUFuture OnSubmittedWorkDoneF(const WGPUQueueWorkDoneCallbackInfo& callbackInfo);
     void WriteBuffer(WGPUBuffer cBuffer, uint64_t bufferOffset, const void* data, size_t size);
     void WriteTexture(const WGPUImageCopyTexture* destination,
                       const void* data,