Skip to content

Provide a shared one-shot completion primitive #222

Description

@tisonkun

Background

Asyncband currently provides two nearby but different contracts:

  • oneshot transfers one value from one sender to one receiver;
  • OnceCell stores one initialized value, with initialization driven through the cell API.

Neither directly models one producer completing a result that can be observed by multiple independent waiters, including waiters created after completion. This pattern is known as Deferred in Cats Effect, Promise in ZIO, and CompletableDeferred in Kotlin coroutines.

The primitive is useful when one operation publishes a handshake result, negotiated metadata, configuration, or initialization outcome to several dependent operations. It does not run that operation and does not spawn tasks; it only stores and distributes its eventual result.

Tracked by #218.

Proposed minimum contract

The API should separate producer and observer capabilities, provisionally called Completer<T> and Completion<T>:

  • there is one completion capability and a cloneable observer capability;
  • the value can be completed exactly once;
  • completion wakes all current observers;
  • observers created or awaited after completion immediately see the same value;
  • cancelling one wait only unsubscribes that waiter;
  • dropping the completer without a value transitions observers to an explicit closed state rather than leaving them pending forever;
  • the primitive remains runtime agnostic and makes progress only through explicit calls, drop, and polling.

The final module and type names remain an API decision. Completion describes the mechanism without importing JavaScript's full Promise scheduling and chaining expectations.

Design considerations

Shared value representation

A value cannot be moved independently into multiple observers without an ownership policy. The main options are:

  1. store Arc<T> internally and return Arc<T>, avoiding a Clone bound but exposing shared ownership;
  2. return a borrow tied to the observer handle, which is allocation-efficient but complicates owned futures and lifetimes;
  3. require T: Clone when awaiting, which keeps returned values ordinary but excludes non-cloneable results and makes wake cost depend on observer count; or
  4. return a dedicated guard that dereferences to T, hiding but not eliminating shared storage.

This choice is observable and must be settled before implementation. Requiring T: Clone on the primitive itself should be avoided unless a concrete API benefit justifies it.

Producer cardinality

A single non-cloneable completer makes the producer role clear and gives completer drop an unambiguous closed transition. A cloneable completer would support racing producers, but then closure could occur only after every producer is dropped and the API would overlap more heavily with OnceCell::set.

The preferred initial direction is one non-cloneable completer. complete(value) should report failure and return ownership of value if another completion has already won or no observers remain, rather than silently discarding it.

Completion payload versus transport error

The primitive should store an arbitrary T. Callers can choose T = Result<V, E> when completion itself represents success or failure. Closure caused by dropping the completer is transport state and must remain distinct from a user-provided error value.

Observer lifecycle

Completion<T> should be cloneable. Dropping one observer must not affect other observers or erase an already completed value. The issue should decide whether dropping all observers lets the completer detect disconnection and recover a submitted value, mirroring channel-style producer feedback, or whether completion remains valid without current observers.

Cancellation and waiter cleanup

A pending wait is cancel safe: dropping it removes or invalidates only its waiter registration. Completion racing with cancellation must result in either that waiter receiving the shared result or being cleanly removed, with no lost wake-up and no stale waker retained indefinitely.

Relationship with existing primitives

OnceCell may provide useful storage concepts, but shared completion adds a wait-only capability, explicit producer closure, and multi-observer wake-up semantics. oneshot may provide endpoint and error naming patterns, but changing oneshot to clone its receiver would alter its ownership contract. The new primitive should therefore remain nominally distinct even if it reuses internal machinery.

Key trade-offs

  • Arc<T> or a guard versus borrowed output versus T: Clone.
  • A single completer with simple closure semantics versus multiple racing completers.
  • Channel-style disconnected feedback versus completion that remains valid with zero current observers.
  • Reusing OnceCell or oneshot internals versus a small dedicated state machine.
  • Returning a named wait future versus an async fn, especially for owned and cancellation-safe variants.

Non-goals

The first version should not include:

  • task spawning or execution ownership;
  • promise chaining, callbacks, executors, or microtask scheduling;
  • reset or reuse after completion;
  • progress updates or multiple values;
  • built-in timeout, retry, or cancellation of the producer's work;
  • implicit error aggregation.

Acceptance criteria

  • Producer and observer capabilities are distinct and documented.
  • Exactly one value can win completion, and losing completion attempts preserve ownership of their value.
  • Every current and future observer receives the same completed result under the selected ownership model.
  • Dropping the completer before completion produces a documented closed result.
  • Cancelling or dropping one observer does not affect other observers and does not leak its waker.
  • Races among completion, completer drop, observer drop, and waiter cancellation have deterministic regression tests.
  • The distinction from oneshot and OnceCell is documented with examples.
  • No executor, timer, or runtime-specific dependency is introduced.
  • Repository build, test, lint, and formatting workflows pass through cargo x.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions