[Metal] Support queue operations on standalone devices (#24840)

The device-group topology changes in #24248 leave frontier_tracker null
until a device joins a group. PR #24474 added guards for standalone CUDA
and HIP devices, but Metal was omitted. This PR simply adds the
equivalent Metal guard (and a regression test).

Fixes #24835.

AI assistance disclosure: I used OpenAI Codex substantially to inspect
the implementation and history, implement the change, and prepare the
regression test. I reviewed and understand the resulting code and
analysis, and wrote this description.

Signed-off-by: Jeremy Howard <github@jhoward.fastmail.fm>
diff --git a/runtime/src/iree/hal/drivers/metal/CMakeLists.txt b/runtime/src/iree/hal/drivers/metal/CMakeLists.txt
index bb2d61b..11e9914 100644
--- a/runtime/src/iree/hal/drivers/metal/CMakeLists.txt
+++ b/runtime/src/iree/hal/drivers/metal/CMakeLists.txt
@@ -53,3 +53,20 @@
     "-framework Metal"
   PUBLIC
 )
+
+iree_cc_test(
+  NAME
+    metal_device_test
+  SRCS
+    "metal_device_test.cc"
+  DEPS
+    ::metal
+    iree::async::util::proactor_pool
+    iree::base
+    iree::base::threading
+    iree::hal
+    iree::testing::gtest
+    iree::testing::gtest_main
+  LABELS
+    "driver=metal"
+)
diff --git a/runtime/src/iree/hal/drivers/metal/metal_device.m b/runtime/src/iree/hal/drivers/metal/metal_device.m
index 1de65b8..9671655 100644
--- a/runtime/src/iree/hal/drivers/metal/metal_device.m
+++ b/runtime/src/iree/hal/drivers/metal/metal_device.m
@@ -98,7 +98,10 @@
 // Advances the frontier tracker epoch for the device.
 // Called at submit time ([commandBuffer commit]) because the Metal command
 // queue is FIFO-ordered: submission order = causal ordering.
+// No-op for standalone devices not assigned to a topology group
+// (frontier_tracker is NULL until iree_hal_device_assign_topology_info).
 static void iree_hal_metal_device_advance_frontier(iree_hal_metal_device_t* device) {
+  if (!device->frontier_tracker) return;
   uint64_t epoch =
       (uint64_t)iree_atomic_fetch_add(&device->epoch, 1, iree_memory_order_acq_rel) + 1;
   iree_async_frontier_tracker_advance(device->frontier_tracker, device->axis, epoch);
diff --git a/runtime/src/iree/hal/drivers/metal/metal_device_test.cc b/runtime/src/iree/hal/drivers/metal/metal_device_test.cc
new file mode 100644
index 0000000..0c28f52
--- /dev/null
+++ b/runtime/src/iree/hal/drivers/metal/metal_device_test.cc
@@ -0,0 +1,63 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include "iree/async/util/proactor_pool.h"
+#include "iree/base/api.h"
+#include "iree/base/threading/numa.h"
+#include "iree/hal/api.h"
+#include "iree/hal/drivers/metal/api.h"
+#include "iree/testing/gtest.h"
+#include "iree/testing/status_matchers.h"
+
+namespace iree::hal::metal {
+namespace {
+
+TEST(MetalDeviceTest, StandaloneDeviceQueueAllocaDoesNotCrash) {
+  iree_hal_metal_device_params_t device_params;
+  iree_hal_metal_device_params_initialize(&device_params);
+
+  iree_hal_driver_t* driver = nullptr;
+  IREE_ASSERT_OK(iree_hal_metal_driver_create(
+      IREE_SV("metal"), &device_params, iree_allocator_system(), &driver));
+
+  iree_async_proactor_pool_t* proactor_pool = nullptr;
+  IREE_ASSERT_OK(iree_async_proactor_pool_create(
+      iree_numa_node_count(), /*node_ids=*/nullptr,
+      iree_async_proactor_pool_options_default(), iree_allocator_system(),
+      &proactor_pool));
+
+  iree_hal_device_create_params_t create_params =
+      iree_hal_device_create_params_default();
+  create_params.proactor_pool = proactor_pool;
+  iree_hal_device_t* device = nullptr;
+  iree_status_t status = iree_hal_driver_create_default_device(
+      driver, &create_params, iree_allocator_system(), &device);
+  if (!iree_status_is_ok(status)) {
+    iree_status_ignore(status);
+    iree_async_proactor_pool_release(proactor_pool);
+    iree_hal_driver_release(driver);
+    GTEST_SKIP() << "No Metal device available";
+  }
+
+  iree_hal_buffer_params_t buffer_params = {0};
+  buffer_params.type = IREE_HAL_MEMORY_TYPE_OPTIMAL_FOR_DEVICE;
+  buffer_params.access = IREE_HAL_MEMORY_ACCESS_ALL;
+  buffer_params.usage = IREE_HAL_BUFFER_USAGE_TRANSFER;
+  iree_hal_buffer_t* buffer = nullptr;
+  IREE_ASSERT_OK(iree_hal_device_queue_alloca(
+      device, IREE_HAL_QUEUE_AFFINITY_ANY, iree_hal_semaphore_list_empty(),
+      iree_hal_semaphore_list_empty(), /*pool=*/nullptr, buffer_params,
+      /*allocation_size=*/1024, IREE_HAL_ALLOCA_FLAG_NONE, &buffer));
+  ASSERT_NE(buffer, nullptr);
+
+  iree_hal_buffer_release(buffer);
+  iree_hal_device_release(device);
+  iree_async_proactor_pool_release(proactor_pool);
+  iree_hal_driver_release(driver);
+}
+
+}  // namespace
+}  // namespace iree::hal::metal