Skip to content

Support select() and wait() on Group#551

Merged
lalinsky merged 1 commit into
mainfrom
select-on-group
Jul 6, 2026
Merged

Support select() and wait() on Group#551
lalinsky merged 1 commit into
mainfrom
select-on-group

Conversation

@lalinsky

@lalinsky lalinsky commented Jul 6, 2026

Copy link
Copy Markdown
Owner

What

Adds the select() future protocol to Group, so a group's completion (all tasks drained to zero) can be raced against other futures:

var group: zio.Group = .init;
try group.spawn(worker, .{});
// ...
const result = try zio.select(.{ .group = &group, .recv = channel.asyncReceive() });
switch (result) {
    .group => {},   // all group tasks finished
    .recv  => |v| ...,
}

Single-future wait(&group) works too.

Why this approach

Group's storage is pinned to std.Io.Group's two words (token + state) via the fromStd pointer cast, so there is no room to add a waiter queue. Instead of splitting the type or stealing bits, the waiter parks on the same futex address that unregisterGroupTask already wakes when the task counter hits zero.

This means:

  • No new storage on Group — layout stays identical to std.Io.Group, fromStd is unchanged.
  • No lifetime hazard — parking lives in the global futex bucket, and Futex.wake only hashes the address (never dereferences the group) and is already stack-safe. The group can be freed the instant completion fires.
  • No single-consumer restriction — a Group.wait()er and a select waiter can coexist on the same address; the wake fans out to both.

Changes

  • Futex: FutexWaiter now references an externally-owned Waiter (was embedded), and a non-blocking prepareWait()/cancelWait() pair is added — the async counterpart of wait(), used by the select protocol. The caller owns the predicate check.
  • Group: implements Result / WaitContext / getResult / asyncWait / asyncCancelWait. asyncWait checks the counter, parks, then re-checks to close the lost-wakeup race.

Semantics

Unlike Group.wait(), observing a group via select() / wait() does not close it and does not participate in fail_fast — it purely waits for the task counter to reach zero, leaving the group reusable afterward.

Note for a future fail_fast early-wake (the TODOs in group.zig): select rides the completion futex, so an early wake while counter > 0 would make select report completion prematurely. Not an issue today since no such wake exists.

Tests

Added to group.zig: group-wins, other-future-wins, already-drained fast path, and single-future wait(&group) — all asserting the group remains usable after being observed. Full suite: 525/525 pass.

Add the select() future protocol to Group so a group's completion (all
tasks drained) can be raced against other futures, e.g.
select(.{ .group = &group, .recv = channel.asyncReceive() }).

Rather than add a waiter queue to Group (whose layout is pinned to
std.Io.Group's two words), the waiter parks on the same futex address
that unregisterGroupTask already wakes when the task counter reaches
zero. This needs no per-group storage and reuses the existing, already
stack-safe futex wake path.

To support this, FutexWaiter now references an externally-owned Waiter
instead of embedding one, and Futex gains a non-blocking prepareWait()/
cancelWait() pair (the async counterpart of wait()) used by the protocol.

Unlike Group.wait(), observing a group via select()/wait() does not close
it and does not participate in fail_fast: it purely waits for the counter
to reach zero, leaving the group reusable.
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Group gains a select()/wait()-compatible future protocol (Result, WaitContext, getResult, asyncWait, asyncCancelWait) that parks on the group's futex state without closing it. Futex.zig's internal FutexWaiter is exported and refactored to hold a Waiter pointer instead of an owned value, with new prepareWait/cancelWait registration APIs backing this.

Changes

Futex waiter refactor and Group select protocol

Layer / File(s) Summary
FutexWaiter externalization and prepare/cancel API
src/sync/Futex.zig
FutexWaiter becomes pub, its waiter field switches from an owned Waiter to a *Waiter pointer with default field values; wait() and timedWaitClock() now pass a stack-local Waiter by pointer; new prepareWait() and cancelWait() functions register/remove nodes from bucket queues outside the blocking wait path.
Group future protocol for select/wait
src/group.zig
Adds a Waiter import and new public protocol members (Result, WaitContext, getResult, asyncWait, asyncCancelWait) that use prepareWait/cancelWait to detect when the group's task counter reaches zero via fast-path check plus race-safe double-check/cancel, with new tests covering select() precedence between the group and a competing future, the already-drained fast path, and reusable non-closing drains via wait().

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • lalinsky/zio#180: Both PRs modify FutexWaiter/wait-wake mechanics in src/sync/Futex.zig that this PR's Group protocol depends on.
  • lalinsky/zio#187: Builds on the same futex-based group completion model (task counter, wake handling) that this PR's Group select protocol extends.
  • lalinsky/zio#263: Both touch Futex.zig's waiter-node handling — this PR changes the node model/API, the other changes wake()'s traversal over the same queued nodes.

Poem

A waiter once bound tight and owned,
now holds a pointer, freely loaned.
The Group can wait, then walk away,
no closing doors, it lives to play.
select() picks winners, fast or slow —
🐰 futex naps, then off we go.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change: adding select() and wait() support for Group.
Description check ✅ Passed The description is directly about the Group select()/wait() changes and the futex-based implementation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch select-on-group

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/sync/Futex.zig (1)

248-272: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

prepareWait/cancelWait look correct; just note the waiter = undefined sharp edge.

node.* = .{ .waiter = waiter, .address = address } fully re-inits the node (queue links, in_list, padding) so reuse across register/cancel cycles is clean. The no-value-check contract is documented, and asyncWait in group.zig closes the lost-wakeup race with its post-registration double check, so this half holds up.

One thing worth keeping in the docstring: cancelWait derives the bucket from node.address, so it's only valid after a matching prepareWait. Calling it on a node that never went through prepareWait (e.g. an asyncWait fast-path that returned "ready" without registering) would hash a garbage address and hit remove() on a node that was never in the list. The current protocol avoids that, but the field default waiter: *Waiter = undefined plus address: usize = 0 makes it easy to trip later.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sync/Futex.zig` around lines 248 - 272, `FutexWaiter`’s default `waiter =
undefined` and `address = 0` make `cancelWait` fragile if it is ever called
before `prepareWait`. Update the `FutexWaiter` initialization/contract so
unprepared nodes are clearly invalid or safely initialized, and add a guard or
assertion in `cancelWait`/`removeFromBucket` to reject nodes that have not been
registered by `prepareWait`. Also keep the `prepareWait`/`cancelWait` doc
comments explicit that `cancelWait` is only valid after a matching
`prepareWait`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/sync/Futex.zig`:
- Around line 248-272: `FutexWaiter`’s default `waiter = undefined` and `address
= 0` make `cancelWait` fragile if it is ever called before `prepareWait`. Update
the `FutexWaiter` initialization/contract so unprepared nodes are clearly
invalid or safely initialized, and add a guard or assertion in
`cancelWait`/`removeFromBucket` to reject nodes that have not been registered by
`prepareWait`. Also keep the `prepareWait`/`cancelWait` doc comments explicit
that `cancelWait` is only valid after a matching `prepareWait`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f3ea7896-b21a-40ad-aad9-32625b76eebe

📥 Commits

Reviewing files that changed from the base of the PR and between a9f14d2 and 4503403.

📒 Files selected for processing (2)
  • src/group.zig
  • src/sync/Futex.zig

@lalinsky
lalinsky merged commit 8f19f48 into main Jul 6, 2026
27 of 29 checks passed
@lalinsky
lalinsky deleted the select-on-group branch July 6, 2026 16:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant