Skip to content

← Async From First Principles step 16 of 25

Hard Research

Structural pinning and safe pin projection by hand

Implement a future wrapper that counts how often it is polled. Store its child future inline and project a pinned reference to that field when polling it.

pub struct Timed<F> {
    inner: F,
    polls: u32,
}

pub fn count_polls(pends: u32) -> (u32, u32)

Keep Timed::new, Countdown, block_on and count_polls as supplied. Implement Future for Timed<F> for any F: Future, without adding an F: Unpin bound or boxing the child.

Required polling behavior

On each call to Timed::poll:

  1. Increment the poll count, including the call that completes the child.
  2. Poll inner exactly once with the supplied context.
  3. Return Pending if the child is pending, or Ready((polls, value)) with its output if the child is ready.

count_polls wraps an async block that waits for Countdown and then returns 7. The countdown returns Pending exactly pends times before returning Ready, so the required result is (pends + 1, 7).

count_polls(0) -> (1, 7)
count_polls(4) -> (5, 7)

Exercise inputs satisfy pends < 100_000, the limit of the supplied driver. The driver repeatedly polls with a no-op waker; it is a small test helper, not an executor that waits for notifications. Do not poll a completed child again. Poll counts in this exercise fit in u32.

Why ordinary mutable access fails

The starter tries &mut *self where self is Pin<&mut Timed<F>>. Its inline F is not known to implement Unpin, so the pinned wrapper does not expose an unrestricted mutable reference. This causes E0596.

A boxed child is a valid design in other code: moving a box does not move its allocation. Here the goal is to support an inline child, avoiding that separate allocation and learning the obligations of field projection.

Choose which fields are structurally pinned

Treat inner as structurally pinned: pinning Timed also promises that this field will remain pinned, so it can be polled as Pin<&mut F>.

Treat polls as an ordinary mutable field. Updating a counter does not move the child. Structural pinning is a choice made by the type's API; pinning a parent does not automatically impose the same API on every field.

To implement the projection, first obtain access to the fields with get_unchecked_mut, then construct Pin<&mut F> with Pin::new_unchecked. Both operations are unsafe because their contracts must be justified by the wrapper's implementation. Use the mutable parent reference only for operations that preserve the pinned child.

Justify the projection

The supplied representation supports the required promises:

  • Leave Unpin automatic. With these fields, Timed<F> implements Unpin when F does. The counter adds no restriction.
  • The wrapper has no custom Drop implementation that moves its child. Normal field destruction drops the child in place. A future custom destructor would have to preserve that property even though it receives &mut self.
  • The wrapper is not packed. A packed representation can cause unaligned fields and moves made for destruction, which conflict with this projection.
  • No operation available through a pinned wrapper may move out the pinned child or expose unrestricted mutable access that permits such a move.

The last point does not ban an ordinary into_inner(self) -> F method on an owned, unpinned wrapper. Safe code cannot call it through Pin<&mut Timed<F>> by moving out a non-Copy wrapper. What matters is whether the API actually makes a forbidden move possible after pinning.

Add a // SAFETY: comment to each unsafe block explaining why the particular operation preserves these promises. The projection must remain valid through the child's destruction, not just until the current call to poll returns.

Do not override Unpin unconditionally

Adding impl<F> Unpin for Timed<F> {} would let callers recover ordinary mutable access even when the child relies on a stable address. Combined with this wrapper's pinned-child projection, that permits safe callers to move a child after it has been polled in place. The generic wrapper would be unsound.

The unsafe projection and the Unpin policy must agree. A blanket Unpin implementation is not intrinsically wrong for every wrapper; it is wrong for this inline, structurally pinned child when F is unrestricted.

Understand the limits of the harness

The async child ensures that your implementation works with a future that does not implement Unpin. It does not prove that a passing solution preserves all pinning invariants. In particular, an unsound unconditional Unpin implementation can still produce the expected counts.

The harness checks only the supplied countdown behavior. The generic Future implementation must still forward the context, preserve the child's output, and uphold its projection contract for other valid futures. Output tests are not a substitute for that reasoning.

In application libraries, pin-projection macros can generate the field access API and associated checks. This exercise asks for the projection by hand so that the safety argument is visible.

A passing submission must compile, pass the tests, and pass clippy -D warnings, while respecting the inline-child and safety requirements.