Wine-NSPA – Aggregate-Wait and Async Completion

This page documents Wine-NSPA’s aggregate-wait kernel primitive and the dispatcher-owned same-thread async-completion path built on it.


Table of Contents

  1. Overview
  2. Why the old bridge was wrong
  3. Aggregate-wait kernel primitive
  4. Dispatcher-owned io_uring and same-thread completion
  5. Validation and deployment
  6. Relationship to the broader decomposition plan
  7. References

1. Overview

Aggregate-wait is the kernel-side wait primitive that lets the gamma dispatcher block on request traffic, deferred-completion wakeups, and teardown wakeups in one place while keeping receive, CQE drain, and reply signaling on the same RT thread.

That is the architectural role of aggregate-wait plus the dispatcher’s ring-ownership and same-thread completion work. Gamma already gave Wine-NSPA the correct request-side priority inheritance story: client threads do CHANNEL_SEND_PI, the kernel enqueues by priority, and the wineserver dispatcher runs the handler at the right effective priority. What gamma lacked was the matching async completion-side wait primitive.

The first async-completion prototype used the wineserver main thread as the CQE drain site. That proved the basic mechanism but broke the more important invariant: the thread that received the request was no longer the thread that completed and replied to it.

The aggregate-wait kernel extension and the accompanying dispatcher restructure fix that. The dispatcher owns all three parts of the async path:

  1. receive request from the channel
  2. submit deferred work to its per-process io_uring
  3. drain completion and issue CHANNEL_REPLY

The same RT thread handles the full lifecycle.

Coverage

Layer Landed change Why it matters
Kernel NTSYNC_IOC_AGGREGATE_WAIT One wait covers NTSync objects plus pollable fds
Kernel Channel notify-only support inside aggregate-wait lets the dispatcher block on the channel without consuming the entry in the aggregate ioctl itself
Kernel post-aggregate-wait PI follow-ons stable boost propagation for aggregate-waiting dispatchers
Userspace struct nspa_uring_instance per process dispatcher-local ring + eventfd + fixed pending pool
Userspace struct nspa_dispatcher_ctx single owner for channel fd, shutdown eventfd, and ring lifetime
Userspace aggregate-wait dispatcher loop same-thread request receive, CQE drain, and reply

2. Why the old bridge was wrong

The rejected shape was:

Rejected cross-thread bridge Dispatcher pthread Wineserver main thread `CHANNEL_RECV2` dispatcher owns request entry handler submits SQE deferred path returns to channel receive loop request is no longer owned by the dispatcher `main_loop_epoll` main thread owns uring wake CQE arrives later main thread drains CQE callback restores state and writes reply `CHANNEL_REPLY` issued cross-thread ownership jump after SQE submit completion timing depends on the main-thread wake path Why it was rejected request receive, CQE drain, and reply signaling no longer live on one RT thread reply timing depends on main-thread wake timing and main-thread contention

The problem was not that the code path was impossible. The problem was that it was the wrong ownership model for an RT request path:

That shape showed up exactly where expected: real workloads tolerated it structurally, but timing-sensitive application behavior did not.


3. Aggregate-wait kernel primitive

Patch 1010 adds NTSYNC_IOC_AGGREGATE_WAIT: a heterogeneous wait that combines NTSync object sources, pollable fd sources, and an optional absolute deadline.

The dispatcher is the first consumer, but the primitive is intentionally general.

Patch 1010: heterogeneous wait surface Object sources event / mutex / semaphore channel notify-only source PI-visible registration for NTSync-backed sources `NTSYNC_IOC_AGGREGATE_WAIT` copy source array register object waits and poll waits sleep once return `fired_index` + `fired_events` deadline expiry returns `NTSYNC_AGG_TIMEOUT` FD sources uring eventfd future fd-poll / timer wake sources poll semantics, no intrinsic PI owner Kernel follow-ups required for production stability post-1010 follow-ups added SEND_PI any-waiters fallback and wake-after-boost ordering so aggregate-waiting dispatchers inherit priority correctly

UAPI shape


struct ntsync_aggregate_source {
    __u32 type;          /* NTSYNC_AGG_OBJECT | NTSYNC_AGG_FD */
    __u32 events;        /* FD source: POLLIN / POLLOUT / POLLERR / POLLHUP */
    __u64 handle_or_fd;  /* ntsync object handle, or unix fd */
};

struct ntsync_aggregate_wait_args {
    __u32 nb_sources;
    __u32 reserved;
    __u64 sources;       /* user pointer to struct ntsync_aggregate_source[] */
    struct __kernel_timespec deadline; /* CLOCK_MONOTONIC ABSTIME or {0,0} */
    __u32 fired_index;
    __u32 fired_events;
    __u32 flags;
    __u32 owner;
};

#define NTSYNC_AGG_OBJECT        0x1
#define NTSYNC_AGG_FD            0x2
#define NTSYNC_AGG_MAX           64
#define NTSYNC_AGG_FLAG_REALTIME 0x1
#define NTSYNC_AGG_TIMEOUT       0xFFFFFFFFu
#define NTSYNC_IOC_AGGREGATE_WAIT _IOWR('N', 0x95, struct ntsync_aggregate_wait_args)

Semantics that matter for gamma


4. Dispatcher-owned io_uring and same-thread completion

4.1 Dispatcher-owned io_uring

This step did not make handlers async by itself. It put the ring and its state in the correct ownership domain first.

The old global-ring direction was abandoned. The landed design keeps one nspa_uring_instance per gamma channel / per Wine process, stored alongside the dispatcher context.


struct nspa_dispatcher_ctx {
    int channel_fd;
    int shutdown_efd;
    struct nspa_uring_instance uring;
};

Key properties:

4.2 Aggregate-wait dispatcher loop

The dispatcher waits on three sources:

  1. channel object: request available
  2. uring eventfd: completion available
  3. shutdown eventfd: process teardown requested
Dispatcher aggregate-wait topology Dispatcher context channel fd + shutdown eventfd + `nspa_uring_instance` one context per Wine process, freed by the dispatcher on exit source 0: channel object aggregate-wait fires dispatcher follows with `CHANNEL_RECV2` handler runs under existing `global_lock` discipline source 1: uring eventfd drain eventfd `nspa_uring_drain()` runs inline on the dispatcher CQE callback issues `CHANNEL_REPLY` on that same thread source 2: shutdown eventfd destroy path writes `1` aggregate-wait returns dispatcher drains and frees its own context Operational invariants same RT thread receives the request, drains completion, and signals the reply aggregate-wait, `RECV2`, and `TRY_RECV2` keep receive, drain, and reply on one loop

4.3 Dispatcher behavior

The loop is:

  1. build the aggregate source table from {channel, uring eventfd if active, shutdown eventfd}
  2. call NTSYNC_IOC_AGGREGATE_WAIT
  3. if the fired source is the channel:
  4. if the fired source is the uring eventfd:
  5. if the fired source is shutdown_efd:

4.4 Required kernel surface

The current dispatcher runtime assumes the full post-1011 wait surface:

That requirement is deliberate. The older sticky fallback ladder was useful while aggregate-wait and thread-token receive were landing, but it was retired once those ioctls became the project baseline. The current production loop is therefore simpler: one aggregate wait, one RECV2, zero or more TRY_RECV2 drains, and the same-thread reply path.


5. Validation and deployment

Production state

Item Value
Kernel side Wine-NSPA ntsync overlay with aggregate-wait and burst drain support
Wine userspace state Dispatcher-owned ring and aggregate-wait loop; async CreateFile and burst drain both build on the same base
Current wait shape AGGREGATE_WAIT over channel + uring eventfd + shutdown eventfd
Current follow-ons on top of this base dispatcher-owned async CreateFile; post-reply TRY_RECV2 burst drain

Validation results

Test Result
test-aggregate-wait 9/9 PASS
channel-PI propagation sub-test PASS
1k mixed-concurrency stress PASS
30k stress + full native ntsync suite PASS, dmesg clean
PE matrix 32 PASS / 0 FAIL / 0 TIMEOUT, including dispatcher-burst
Ableton level 2/3 on the current path PASS
Aggregate-wait in normal production use PASS

The follow-up kernel fixes matter here. The first 1010 cut exposed exactly the kind of PI edge that the dispatcher cannot tolerate: an aggregate-waiting dispatcher must still be visible to SEND_PI wake/boost logic and must not be woken before the new boost state is established. The current overlay includes those corrections.


6. Relationship to the broader decomposition plan

The public decomposition plan still has queued work in front of it, but the aggregate-wait story is no longer purely hypothetical.

Implemented:

Still queued:

So the right interpretation is:

That is a better architectural state than the earlier plan assumed. Future work no longer needs to prove the syscall shape from scratch; it can build on a production consumer.


7. References