Skip to content

macOS 26 / Metal: encoder-level wgpuCommandEncoderWriteTimestamp causes the submission to never complete #624

Description

@hughryan

Environment

  • wgpu-native v29.0.1.1 (wgpu-macos-aarch64-release prebuilt)
  • macOS 26.5.2 (build 25F84), Apple M2, Metal backend
  • Timestamp period reported: 1 ns/tick

Summary

With timestamp-query and the native TimestampQueryInsideEncoders feature both advertised by the adapter and both accepted by requestDevice, a call to wgpuCommandEncoderWriteTimestamp at command-encoder level (outside any pass) produces a submission that never completes: wgpuDevicePoll(wait = true) on the submission index blocks indefinitely. No validation error and no device-lost callback is reported.

Bisected:

  • The minimal wedging case is an encoder holding one writeTimestamp and nothing else — no copy, no resolveQuerySet, no readback.
  • A control run that keeps the copy + resolve + readback and drops only the writeTimestamp calls completes normally.
  • Plain wgpuQueueSubmit + wgpuDevicePoll(wait, NULL) blocks identically to wgpuQueueSubmitForIndex + poll-on-index, so it is not an artifact of the native submit/poll extras.

Expected vs actual

Expected: the submission completes and the queries resolve to nonzero, nondecreasing timestamps.
Actual: the submission never completes (a 10 s watchdog in the reproducer exits the process; without it, the wait blocks forever).

Possibly related

This may share a root cause with gfx-rs/wgpu#9414 (Metal timestamp queries returning zeros on macOS 26, attributed to the macOS 26 counter-sampling changes), but the symptom here is stronger — a hang rather than zeros — so filing separately.

Reproducer

Self-contained C program below; build against a stock wgpu-native install:

cc -std=c11 -O2 main.c -o repro -I"$WGPU/include" \
   "$WGPU/lib/libwgpu_native.dylib" -rpath "$WGPU/lib"
./repro                       # reproduces the wedge (exit 1)
./repro --minimal             # smallest wedging encoder
./repro --no-write-timestamp  # control: completes normally (exit 0)
main.c
/*
 * wgpu-native v29.0.1.1 — encoder-level wgpuCommandEncoderWriteTimestamp
 * makes the submission never complete (Metal backend).
 *
 * Environment this was captured on:
 *   macOS 26.5.2 (build 25F84), Apple M2, Metal backend,
 *   wgpu-native v29.0.1.1 (wgpu-macos-aarch64-release prebuilt).
 *
 * Sequence:
 *   - request an adapter/device with the "timestamp-query" feature and the
 *     native TimestampQueryInsideEncoders feature (both advertised by the
 *     adapter, both accepted by requestDevice),
 *   - create a 2-entry timestamp query set,
 *   - on a command encoder, outside of any pass:
 *       writeTimestamp(0) -> copyBufferToBuffer -> writeTimestamp(1),
 *   - resolveQuerySet into a buffer, copy it to a mappable buffer, finish,
 *   - submit and wait for that submission with wgpuDevicePoll(wait = true).
 *
 * Expected: the submission completes and the two queries resolve to nonzero,
 *           nondecreasing timestamps.
 * Actual:   the submission never completes. wgpuDevicePoll(wait = true) with
 *           the submission index blocks indefinitely and no validation error
 *           or device-lost callback is reported. A 10 s watchdog in this
 *           program prints a diagnostic and exits nonzero instead of hanging.
 *
 * The encoder-level timestamp writes are the whole ingredient:
 *   --no-write-timestamp  keeps the copy, the resolve and the readback and
 *                         drops only the two writeTimestamp calls: the
 *                         submission then completes normally.
 *   --minimal             submits an encoder holding ONE writeTimestamp and
 *                         nothing else — no copy, no resolveQuerySet, no
 *                         readback: that alone wedges the submission too.
 *
 * The wedge is not an artifact of the wgpu-native submit/poll extras used
 * here: replacing wgpuQueueSubmitForIndex + wgpuDevicePoll(wait, &index) with
 * plain wgpuQueueSubmit + wgpuDevicePoll(wait, NULL) blocks identically.
 *
 * Build (any stock wgpu-native install; WGPU is its root directory):
 *   cc -std=c11 -O2 main.c -o repro -I"$WGPU/include" \
 *      "$WGPU/lib/libwgpu_native.dylib" -rpath "$WGPU/lib"
 *
 * Run:
 *   ./repro                       # reproduces the wedge (exit code 1)
 *   ./repro --minimal             # smallest wedging encoder
 *   ./repro --no-write-timestamp  # control: completes normally
 */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>

#include <webgpu/webgpu.h>
#include <webgpu/wgpu.h>

#define WATCHDOG_SECONDS 10

/* wgpu-native's native feature for timestamp writes outside of a pass. */
#define FEATURE_TIMESTAMP_QUERY_INSIDE_ENCODERS \
    ((WGPUFeatureName) WGPUNativeFeature_TimestampQueryInsideEncoders)

static const char kWatchdogMessage[] =
    "FAIL: submit did not complete within 10s (queue wedged)\n";

static void on_watchdog(int signum)
{
    (void) signum;
    /* Async-signal-safe: write(2) and _exit(2) only. */
    ssize_t ignored = write(STDOUT_FILENO, kWatchdogMessage,
                            sizeof(kWatchdogMessage) - 1);
    (void) ignored;
    _exit(1);
}

static void arm_watchdog(void)
{
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = on_watchdog;
    sigaction(SIGALRM, &sa, NULL);
    alarm(WATCHDOG_SECONDS);
}

static void disarm_watchdog(void)
{
    alarm(0);
}

static void die(const char* what)
{
    printf("FAIL: %s\n", what);
    exit(2);
}

static WGPUStringView view(const char* text)
{
    WGPUStringView v;
    v.data   = text;
    v.length = WGPU_STRLEN;
    return v;
}

static void print_view(const char* label, WGPUStringView v)
{
    printf("%s%.*s\n", label, (int) (v.data ? v.length : 0),
           v.data ? v.data : "");
}

static void on_uncaptured_error(WGPUDevice const* device, WGPUErrorType type,
                                WGPUStringView message, void* u1, void* u2)
{
    (void) device;
    (void) u1;
    (void) u2;
    printf("uncaptured error (type 0x%08x): %.*s\n", (unsigned) type,
           (int) (message.data ? message.length : 0),
           message.data ? message.data : "");
}

static void on_device_lost(WGPUDevice const* device, WGPUDeviceLostReason reason,
                           WGPUStringView message, void* u1, void* u2)
{
    (void) device;
    (void) u1;
    (void) u2;
    printf("device lost (reason 0x%08x): %.*s\n", (unsigned) reason,
           (int) (message.data ? message.length : 0),
           message.data ? message.data : "");
}

static void on_adapter(WGPURequestAdapterStatus status, WGPUAdapter adapter,
                       WGPUStringView message, void* userdata1, void* userdata2)
{
    (void) userdata2;
    if (status != WGPURequestAdapterStatus_Success)
        printf("requestAdapter failed (status 0x%08x): %.*s\n",
               (unsigned) status, (int) (message.data ? message.length : 0),
               message.data ? message.data : "");
    *(WGPUAdapter*) userdata1 = adapter;
}

static void on_device(WGPURequestDeviceStatus status, WGPUDevice device,
                      WGPUStringView message, void* userdata1, void* userdata2)
{
    (void) userdata2;
    if (status != WGPURequestDeviceStatus_Success)
        printf("requestDevice failed (status 0x%08x): %.*s\n",
               (unsigned) status, (int) (message.data ? message.length : 0),
               message.data ? message.data : "");
    *(WGPUDevice*) userdata1 = device;
}

static void on_map(WGPUMapAsyncStatus status, WGPUStringView message,
                   void* userdata1, void* userdata2)
{
    (void) userdata2;
    if (status != WGPUMapAsyncStatus_Success)
        printf("mapAsync failed (status 0x%08x): %.*s\n", (unsigned) status,
               (int) (message.data ? message.length : 0),
               message.data ? message.data : "");
    *(int*) userdata1 = 1;
}

static WGPUBuffer make_buffer(WGPUDevice device, const char* label,
                              uint64_t size, WGPUBufferUsage usage)
{
    WGPUBufferDescriptor desc;
    memset(&desc, 0, sizeof(desc));
    desc.label = view(label);
    desc.usage = usage;
    desc.size  = size;

    WGPUBuffer buffer = wgpuDeviceCreateBuffer(device, &desc);
    if (!buffer)
        die("createBuffer returned NULL");
    return buffer;
}

int main(int argc, char** argv)
{
    /* Number of encoder-level writeTimestamp calls, and whether the encoder
       carries anything else at all. Both are only there to show which
       ingredient wedges the submission. */
    int timestampCount = 2;
    int minimal        = 0;
    for (int i = 1; i < argc; ++i)
    {
        if (strcmp(argv[i], "--no-write-timestamp") == 0)
            timestampCount = 0;
        else if (strcmp(argv[i], "--minimal") == 0)
        {
            timestampCount = 1;
            minimal        = 1;
        }
    }

    printf("encoder-level writeTimestamp calls: %d\n", timestampCount);
    printf("other commands in the encoder:      %s\n",
           minimal ? "none" : "copy, resolveQuerySet, copy");

    WGPUInstance instance = wgpuCreateInstance(NULL);
    if (!instance)
        die("createInstance returned NULL");

    WGPURequestAdapterOptions adapterOptions;
    memset(&adapterOptions, 0, sizeof(adapterOptions));
    adapterOptions.powerPreference = WGPUPowerPreference_HighPerformance;

    WGPUAdapter adapter = NULL;
    WGPURequestAdapterCallbackInfo adapterCallback;
    memset(&adapterCallback, 0, sizeof(adapterCallback));
    adapterCallback.mode      = WGPUCallbackMode_AllowProcessEvents;
    adapterCallback.callback  = on_adapter;
    adapterCallback.userdata1 = &adapter;
    wgpuInstanceRequestAdapter(instance, &adapterOptions, adapterCallback);
    for (int spins = 0; !adapter && spins < 1000; ++spins)
        wgpuInstanceProcessEvents(instance);
    if (!adapter)
        die("no adapter");

    WGPUAdapterInfo info;
    memset(&info, 0, sizeof(info));
    if (wgpuAdapterGetInfo(adapter, &info) == WGPUStatus_Success)
    {
        print_view("adapter:      ", info.device);
        printf("backend type: 0x%08x (5 = Metal)\n", (unsigned) info.backendType);
    }

    int hasTimestamp = wgpuAdapterHasFeature(adapter, WGPUFeatureName_TimestampQuery) != 0;
    int hasInEncoder = wgpuAdapterHasFeature(adapter, FEATURE_TIMESTAMP_QUERY_INSIDE_ENCODERS) != 0;
    printf("adapter advertises timestamp-query:                 %s\n",
           hasTimestamp ? "yes" : "no");
    printf("adapter advertises TimestampQueryInsideEncoders:    %s\n",
           hasInEncoder ? "yes" : "no");
    if (!hasTimestamp || !hasInEncoder)
        die("adapter does not advertise the required timestamp features");

    WGPUFeatureName required[2];
    required[0] = WGPUFeatureName_TimestampQuery;
    required[1] = FEATURE_TIMESTAMP_QUERY_INSIDE_ENCODERS;

    WGPUDeviceDescriptor deviceDesc;
    memset(&deviceDesc, 0, sizeof(deviceDesc));
    deviceDesc.label                                = view("repro device");
    deviceDesc.requiredFeatureCount                 = 2;
    deviceDesc.requiredFeatures                     = required;
    deviceDesc.uncapturedErrorCallbackInfo.callback = on_uncaptured_error;
    deviceDesc.deviceLostCallbackInfo.mode          = WGPUCallbackMode_AllowProcessEvents;
    deviceDesc.deviceLostCallbackInfo.callback      = on_device_lost;

    WGPUDevice device = NULL;
    WGPURequestDeviceCallbackInfo deviceCallback;
    memset(&deviceCallback, 0, sizeof(deviceCallback));
    deviceCallback.mode      = WGPUCallbackMode_AllowProcessEvents;
    deviceCallback.callback  = on_device;
    deviceCallback.userdata1 = &device;
    wgpuAdapterRequestDevice(adapter, &deviceDesc, deviceCallback);
    for (int spins = 0; !device && spins < 1000; ++spins)
        wgpuInstanceProcessEvents(instance);
    if (!device)
        die("no device");

    WGPUQueue queue = wgpuDeviceGetQueue(device);
    printf("timestamp period: %g ns/tick\n",
           (double) wgpuQueueGetTimestampPeriod(queue));

    WGPUQuerySetDescriptor querySetDesc;
    memset(&querySetDesc, 0, sizeof(querySetDesc));
    querySetDesc.label = view("timestamps");
    querySetDesc.type  = WGPUQueryType_Timestamp;
    querySetDesc.count = 2;
    WGPUQuerySet querySet = wgpuDeviceCreateQuerySet(device, &querySetDesc);
    if (!querySet)
        die("createQuerySet returned NULL");

    const uint64_t kPayloadBytes = 256;
    const uint64_t kResultBytes  = 2 * sizeof(uint64_t);

    WGPUBuffer source = make_buffer(device, "copy source", kPayloadBytes,
                                    WGPUBufferUsage_CopySrc | WGPUBufferUsage_CopyDst);
    WGPUBuffer sink   = make_buffer(device, "copy sink", kPayloadBytes,
                                    WGPUBufferUsage_CopyDst);
    WGPUBuffer resolved = make_buffer(device, "resolved queries", kResultBytes,
                                      WGPUBufferUsage_QueryResolve | WGPUBufferUsage_CopySrc);
    WGPUBuffer readback = make_buffer(device, "readback", kResultBytes,
                                      WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead);

    WGPUCommandEncoderDescriptor encoderDesc;
    memset(&encoderDesc, 0, sizeof(encoderDesc));
    encoderDesc.label = view("repro encoder");
    WGPUCommandEncoder encoder = wgpuDeviceCreateCommandEncoder(device, &encoderDesc);

    if (timestampCount >= 1)
        wgpuCommandEncoderWriteTimestamp(encoder, querySet, 0);
    if (!minimal)
        wgpuCommandEncoderCopyBufferToBuffer(encoder, source, 0, sink, 0, kPayloadBytes);
    if (timestampCount >= 2)
        wgpuCommandEncoderWriteTimestamp(encoder, querySet, 1);

    if (!minimal)
    {
        wgpuCommandEncoderResolveQuerySet(encoder, querySet, 0, 2, resolved, 0);
        wgpuCommandEncoderCopyBufferToBuffer(encoder, resolved, 0, readback, 0, kResultBytes);
    }

    WGPUCommandBuffer commands = wgpuCommandEncoderFinish(encoder, NULL);
    if (!commands)
        die("commandEncoderFinish returned NULL");

    printf("submitting...\n");
    fflush(stdout);

    arm_watchdog();
    WGPUSubmissionIndex submission = wgpuQueueSubmitForIndex(queue, 1, &commands);
    wgpuDevicePoll(device, 1 /* wait */, &submission);
    disarm_watchdog();

    printf("submission %llu completed\n", (unsigned long long) submission);

    if (minimal)
    {
        /* Nothing was resolved, so there is nothing to read back: reaching
           this line at all means the wedge did not happen. */
        printf("PASS: submission completed\n");
        return 0;
    }

    int mapped = 0;
    WGPUBufferMapCallbackInfo mapCallback;
    memset(&mapCallback, 0, sizeof(mapCallback));
    mapCallback.mode      = WGPUCallbackMode_AllowProcessEvents;
    mapCallback.callback  = on_map;
    mapCallback.userdata1 = &mapped;
    wgpuBufferMapAsync(readback, WGPUMapMode_Read, 0, (size_t) kResultBytes, mapCallback);

    arm_watchdog();
    while (!mapped)
    {
        wgpuDevicePoll(device, 1 /* wait */, NULL);
        wgpuInstanceProcessEvents(instance);
    }
    disarm_watchdog();

    const uint64_t* ticks =
        (const uint64_t*) wgpuBufferGetConstMappedRange(readback, 0, (size_t) kResultBytes);
    if (!ticks)
        die("getConstMappedRange returned NULL");

    printf("timestamp[0] = %llu ticks\n", (unsigned long long) ticks[0]);
    printf("timestamp[1] = %llu ticks\n", (unsigned long long) ticks[1]);

    if (timestampCount == 0)
    {
        /* No timestamp was ever written, so zeros are expected here; the
           point of the control run is that the submission completes. */
        printf("PASS: control run completed (no timestamps written)\n");
        return 0;
    }

    int ok = ticks[0] != 0 && ticks[1] != 0 && ticks[1] >= ticks[0];
    printf("%s\n", ok ? "PASS: two nonzero, nondecreasing timestamps"
                      : "FAIL: timestamps are zero or decreasing");
    return ok ? 0 : 1;
}

Observed output

# captured 2026-07-27
# macOS 26.5.2 (build 25F84), Apple M2, Metal backend
# wgpu-native v29.0.1.1 (wgpu-macos-aarch64-release prebuilt)
# cc -std=c11 -O2 -Wall -Wextra main.c -o repro (Apple clang)

$ ./repro
encoder-level writeTimestamp calls: 2
other commands in the encoder:      copy, resolveQuerySet, copy
adapter:      Apple M2
backend type: 0x00000005 (5 = Metal)
adapter advertises timestamp-query:                 yes
adapter advertises TimestampQueryInsideEncoders:    yes
timestamp period: 1 ns/tick
submitting...
FAIL: submit did not complete within 10s (queue wedged)
$ echo $?
1

$ ./repro --minimal
encoder-level writeTimestamp calls: 1
other commands in the encoder:      none
adapter:      Apple M2
backend type: 0x00000005 (5 = Metal)
adapter advertises timestamp-query:                 yes
adapter advertises TimestampQueryInsideEncoders:    yes
timestamp period: 1 ns/tick
submitting...
FAIL: submit did not complete within 10s (queue wedged)
$ echo $?
1

$ ./repro --no-write-timestamp
encoder-level writeTimestamp calls: 0
other commands in the encoder:      copy, resolveQuerySet, copy
adapter:      Apple M2
backend type: 0x00000005 (5 = Metal)
adapter advertises timestamp-query:                 yes
adapter advertises TimestampQueryInsideEncoders:    yes
timestamp period: 1 ns/tick
submitting...
submission 1 completed
timestamp[0] = 0 ticks
timestamp[1] = 0 ticks
PASS: control run completed (no timestamps written)
$ echo $?
0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions