Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Best practices for pw_async2 development and testing, covering futures, values, tasks, dispatchers, polling, and coroutines.
.claude/skills/pigweed-project-pw-async2/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 138% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 60% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 68% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 134% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 114% | 0% |
Use this skill when writing, reviewing, refactoring, or testing code that uses Pigweed's pw_async2 cooperative asynchronous framework.
Structure pw_async2 systems using a strict 3-tier execution graph:
pw::async2::Task or uses CoroTask/FuncTask.Dispatcher; holds task lifecycle state.FutureCore, NO Wakers, NO providers, NO dynamic allocation.Context&.ValueFuture<T>, TimeFuture<Clock>, Channel Receiver/Sender.FutureCore / Waker / Provider; interacts with ISRs/timers.pw::async2::Task only for top-level entrypoints (background worker loops, main event loops). Do NOT make mid-level operations Tasks.
returned by factory functions. Store child futures inline as member variables.
ValueFuture, TimeFuture,Channel) or FutureCore only when interfacing directly with hardware, timers, or event providers.
Design asynchronous code top-down from the caller's perspective rather than bottom-up from low-level wakers or hardware polling.
The recommended mental model for writing a composite future is:
co_await, loops, and early returns.
co_await -> class member fields.co_await suspension points -> state enum and child subfuture members.std::variant to optimize memory layout.while (true) switch (state_) loop in Pend().PW_CO_TRY and return values -> Ready() returns.> !TIP] > For a detailed guide on top-down design, the step-by-step transformation > workflow, and bottom-up pitfalls to avoid, view > references/top_down_design.md.
A Future is a stack-allocated state machine representing an asynchronous operation that produces a value_type upon completion.
Future C++ conceptFutures do NOT derive from a common polymorphic base class. Instead, they satisfy the pw::async2::Future concept defined in pw_async2/future.h. Any concrete type F is a Future if it exposes:
typename F::value_type: Result type (e.g. pw::Result<int>, pw::Status,void).
Poll<value_type> Pend(Context& cx): Advances the operation. ReturnsReady(T) when complete or Pending() when blocked.
bool is_pendable() const: Returns true if active/pollable (false whendefault-constructed / uninitialized or completed).
bool is_complete() const: Returns true if completed.non-pendable state), destructible, movable.
> !TIP] > > ALWAYS verify custom future types with static_assert: Immediately after > defining any custom future class, add a static assertion to enforce compliance > with pw::async2::Future at compile time: > > cpp > static_assert(pw::async2::Future<MyCustomFuture>); >
empty, uninitialized state where is_pendable() and is_complete() return false. Polling an uninitialized future is an error (PW_CRASH).
polled via Pend(Context& cx).
Future by value on thestack or inline inside a parent struct. There is no std::shared_ptr or hidden heap allocation.
immediately and synchronously. Leaf futures unlist from providers and drop wakers; composite futures recursively destruct child subfutures inline.
Pend() returns Ready(), the operation isfinal. Do NOT poll a completed future again.
Avoid writing pendable functions—helper methods or standalone functions that accept Context& cx and return Poll<T> directly:
cpp// AVOID: Pendable helper function Poll<pw::Result<int>> PendReadSensor(Context& cx, Sensor& sensor, int& retries);
Pendable functions operate within pw_async2's informed poll model, but they make the asynchronous contract bespoke and inscrutable:
what state the function maintains, where that state is stored, or who currently owns it. State easily leaks across parent Task member variables or lambda captures, creating brittle, error-prone state management.
is pendable or complete (is_pendable(), is_complete()), forcing callers to maintain external flags to track execution state.
dropping a value object. Destructing a task with active pendable sub-operations risks leaving underlying resources or timers in indeterminate states.
co_await,PW_AWAIT, Select, or Join an arbitrary Poll<T> function. All pw_async2 combinators, macros, and coroutines require a concrete type fulfilling the Future concept.
Pendable functions should be avoided for general application logic and public/module APIs. They are acceptable only for very low-level internal helper functions that are strictly private to a single class or file and consumed from a single caller by design.
In all other cases, wrap mid-level async operations in a concrete Future type returned by value.
Leaf futures interact directly with event providers (hardware interrupts, timers, etc.) and manage waker registration.
> !NOTE] > Prefer pre-built primitives: In most cases, do NOT write a custom leaf > future from scratch. Use existing primitives like ValueFuture<T> (with > ValueProvider or ValueListProvider), Notification, or Channel. > Pre-built primitives handle synchronization, thread safety, and waker > management automatically.
When a custom leaf future is required (e.g. interfacing with a low-level hardware driver):
pw::async2::FutureCore member to manage wakers, intrusive listmembership, and state tracking.
move-constructible and move-assignable (ButtonFuture(ButtonFuture&&) = default;). FutureCore handles movability automatically: when a future is moved by value (e.g. returned from a factory function or stored in a task member), FutureCore automatically updates its intrusive list pointers in the provider's FutureList and transfers the Waker to the new memory location. Note that FutureCore itself is lock-free; synchronizing access across threads or ISR contexts is the responsibility of the user (e.g. via spinlocks, mutexes, or global locks).
Pend(cx) to core_.DoPend(*this, cx).DoPend(Context& cx) callback friended by FutureCore.pw::async2::FutureList inside the providerclass.
For full implementation patterns, see:
pw_async2/futures.rst (Section: _Implementing a future_)
custom_future.cc
Composite futures encapsulate multi-step asynchronous operations without using FutureCore or registering wakers:
FutureCore or use Wakers.Context& cx down transitively into subfutures via PW_AWAIT orsubfuture_.Pend(cx).
while (true) switch (state_)) to pendnewly created subfutures within the same Pend(cx) call. Returning Pending() before pending a new subfuture prevents waker registration, causing the task to stall indefinitely.
For step-by-step guidance on transforming a sequential coroutine mental model into a manual composite future, see references/top_down_design.md.
For full implementation patterns, see:
pw_async2/futures.rst (Section: _Implementing a composite future_)
composite_future.cc
Async functions MUST return Future objects directly by value. Do NOT wrap futures in pw::Result<Future> or std::optional<Future>.
Why:
pass the returned future to combinators (Select, Join), macros (PW_AWAIT), or coroutines (co_await). Wrapping the future in a pw::Result or std::optional breaks composition, forcing callers to perform awkward nested unwrapping before polling.
value), move the pw::Result<T>, pw::Status, or std::optional<T> inside the future's result type (value_type), e.g., ReadSensorFuture::value_type is pw::Result<int>.
argument validation, return a future that immediately resolves to that error state rather than failing out-of-band.
cpp// ✅ CORRECT: Returns Future directly; fallibility lives inside value_type // (pw::Result<int>) ReadSensorWithRetryFuture ReadSensorWithRetry(Sensor& sensor, ...); // ❌ WRONG: Wrapping the Future itself breaks composability and co_await pw::Result<ReadSensorWithRetryFuture> ReadSensorWithRetry(Sensor& sensor); // ❌ std::optional<ReadSensorWithRetryFuture> TryReadSensor(Sensor& sensor); // ❌
Validate arguments synchronously in the factory function before future construction. Return an immediately-resolving error future if validation fails:
cppinline ReadSensorWithRetryFuture ReadSensorWithRetry( MockSensor& sensor, SimulatedTimeProvider<SystemClock>& time_provider, int max_retries = 3) { if (max_retries <= 0) { return ReadSensorWithRetryFuture(pw::Status::InvalidArgument()); } return ReadSensorWithRetryFuture(sensor, time_provider, max_retries); }
Make composite future constructors private and friend the factory function to prevent callers from bypassing validation.
Read(), Write()).NEVER use Async in function names (AsyncRead() ❌) or name after future types (GetReadFuture() ❌).
Try (std::optional<T>TryRead()).
Blocking (pw::Result<T>BlockingRead()).
Subclass pw::async2::Task and implement DoPend(Context& cx):
cppclass SensorReaderTask : public pw::async2::Task { public: SensorReaderTask(MockSensor& sensor, SimulatedTimeProvider<SystemClock>& time_provider) : sensor_(&sensor), time_provider_(&time_provider) {} pw::Result<int> result() const { return result_; } private: Poll<> DoPend(Context& cx) override { // Provision futures on first poll if (!read_retry_future_.is_pendable()) { read_retry_future_ = ReadSensorWithRetry(*sensor_, *time_provider_, 2); } PW_AWAIT(auto res, read_retry_future_, cx); result_ = res; return pw::async2::Ready(); } MockSensor* sensor_ = nullptr; SimulatedTimeProvider<SystemClock>* time_provider_ = nullptr; ReadSensorWithRetryFuture read_retry_future_; pw::Result<int> result_ = pw::Status::Unknown(); };
> !IMPORTANT] > Lazy Future Provisioning: Do NOT construct futures in a Task's > constructor. Constructing futures eagerly in constructors can trigger side > effects (e.g. initiating hardware transactions or timer registrations) before > the task is posted to or polled by a dispatcher, defeating lazy execution > invariants. Always provision subfutures lazily inside DoPend(Context& cx) > when !subfuture_.is_pendable().
Every posted task MUST be explicitly deregistered before destruction:
cppSensorReaderTask task(sensor, time_provider); dispatcher.Post(task); // ... run dispatcher ... // MUST call Deregister() or BlockingJoin() before destruction! task.Deregister();
pw::async2::Coro)> !NOTE] > Prefer manual state machines using composite futures and PW_AWAIT by default. > C++20 coroutines require dynamic allocation for coroutine frames via > CoroContext / pw::Allocator, introducing a opaque allocations whose sizes > vary by toolchain, causing heap fragmentation risks and runtime allocation > failure paths. Handwritten futures provide deterministic inline storage. > > However, always use the coroutine mental model to design the control flow > before writing the manual future (see > references/top_down_design.md). > Use Coro<T> when coroutines are explicitly requested or already established > in the codebase.
If working with coroutines, view references/coroutines.md for detailed patterns, error handling with PW_CO_TRY / PW_CO_TRY_ASSIGN, and memory guidelines.
pw::async2::Channel)If working with channels for message passing or sync-to-async bridging, view references/channels.md for channel creation (SPSC/MPSC/SPMC/MPMC), Send/Receive futures, and non-blocking Try/Blocking APIs.
If writing unit tests for pw_async2 tasks, futures, channels, or time-dependent operations, view references/testing.md for deterministic unit testing patterns with DispatcherForTest and SimulatedTimeProvider.
coroutine mental model before implementing manual composite futures; group disjoint transient subfutures in std::variant to minimize struct size.
static_assert(pw::async2::Future<MyCustomFuture>);.
PW_AWAIT bydefault to avoid opaque dynamic coroutine frame allocations. Only use C++20 coroutines (Coro<T>) if explicitly requested or already established in the codebase.
Context&and returning Poll<T> for general application logic. Construct and return a concrete Future type by value instead (except for low-level internal helpers private to a single caller).
stack/inline allocated; channels support both allocator-backed (pw::allocator::Allocator) and static (ChannelStorage) allocation.
Async naming: Name functions Read(), not AsyncRead().Future<T> directly by value, notResult<Future>.
task.Deregister() or task.BlockingJoin()before destroying a posted task.
DoPend() (when !future.is_pendable()), never eagerly in constructors.
Pend() on a future after itreturns Ready().
Pend()immediately (while (true) switch) to ensure newly created subfutures are pended immediately so their wakers get registered.
TimeProvider<Clock>& for timeoperations; never use static wall-clock sleeps.
DispatcherForTest::RunUntilStalled() andSimulatedTimeProvider::AdvanceUntilNextExpiration().
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 49,076 | 32,104 | -35% | 1 | 1 | 0% | 3,878 | 9,216 | +138% | 0 | 0 | — |
case-02 | fail→pass | 71,800 | 42,111 | -41% | 1 | 1 | 0% | 5,381 | 8,616 | +60% | 0 | 0 | — |
case-03 | fail→pass | 30,056 | 31,319 | +4% | 1 | 1 | 0% | 5,637 | 9,467 | +68% | 0 | 0 | — |
case-04 | pass→pass | 18,337 | 36,902 | +101% | 1 | 1 | 0% | 3,024 | 7,810 | +158% | 0 | 0 | — |
case-05 | pass→pass | 54,538 | 18,775 | -66% | 1 | 1 | 0% | 3,586 | 7,735 | +116% | 0 | 0 | — |
case-06 | pass→pass | 23,144 | 55,971 | +142% | 1 | 1 | 0% | 4,416 | 8,457 | +92% | 0 | 0 | — |
case-07 | fail→pass | 21,381 | 17,168 | -20% | 1 | 1 | 0% | 3,248 | 7,591 | +134% | 0 | 0 | — |
case-08 | fail→pass | 36,244 | 16,865 | -53% | 1 | 1 | 0% | 3,558 | 7,630 | +114% | 0 | 0 | — |
case-09 | fail→pass | 36,586 | 8,610 | -76% | 1 | 1 | 0% | 2,313 | 5,786 | +150% | 0 | 0 | — |
case-10 | pass→pass | 25,307 | 12,736 | -50% | 1 | 1 | 0% | 2,472 | 6,508 | +163% | 0 | 0 | — |
case-11 | fail→pass | 32,173 | 18,949 | -41% | 1 | 1 | 0% | 2,130 | 6,472 | +204% | 0 | 0 | — |
case-12 | pass→pass | 14,485 | 32,127 | +122% | 1 | 1 | 0% | 2,479 | 6,232 | +151% | 0 | 0 | — |
case-13 | fail→pass | 17,424 | 19,765 | +13% | 1 | 1 | 0% | 2,593 | 7,948 | +207% | 0 | 0 | — |
case-14 | fail→pass | 20,570 | 18,161 | -12% | 1 | 1 | 0% | 3,309 | 7,527 | +127% | 0 | 0 | — |
case-15 | pass→pass | 35,746 | 13,678 | -62% | 1 | 1 | 0% | 3,007 | 6,719 | +123% | 0 | 0 | — |
case-16 | pass→pass | 25,405 | 40,502 | +59% | 1 | 1 | 0% | 3,249 | 7,052 | +117% | 0 | 0 | — |
case-17 | pass→pass | 12,228 | 7,791 | -36% | 1 | 1 | 0% | 1,938 | 5,723 | +195% | 0 | 0 | — |
case-18 | pass→pass | 20,013 | 14,136 | -29% | 1 | 1 | 0% | 2,736 | 6,669 | +144% | 0 | 0 | — |
case-19 | fail→pass | 19,555 | 49,969 | +156% | 1 | 1 | 0% | 3,339 | 8,200 | +146% | 0 | 0 | — |
case-20 | fail→pass | 11,735 | 5,407 | -54% | 1 | 1 | 0% | 1,728 | 5,211 | +202% | 0 | 0 | — |
case-21 | fail→pass | 12,541 | 5,396 | -57% | 1 | 1 | 0% | 1,725 | 5,181 | +200% | 0 | 0 | — |
case-22 | pass→pass | 15,534 | 10,200 | -34% | 1 | 1 | 0% | 2,412 | 6,020 | +150% | 0 | 0 | — |
case-23 | fail→pass | 76,437 | 21,402 | -72% | 1 | 1 | 0% | 2,902 | 6,526 | +125% | 0 | 0 | — |
case-24 | fail→pass | 31,935 | 13,233 | -59% | 1 | 1 | 0% | 2,553 | 6,897 | +170% | 0 | 0 | — |
case-25 | pass→pass | 13,342 | 14,463 | +8% | 1 | 1 | 0% | 2,356 | 6,905 | +193% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 25 cases were attempted. The headline lift of +56 percentage points is the difference between those two pass rates over the 25 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.