Skip to content
Back to skills

Perl Io Async Future

ASecurity

Use when writing async Perl — IO::Async, Future, Future::AsyncAwait, Net::Async::*, futures lost to GC, cancellation, requests that hang.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 4, 2026
ai-agentsgoexpresstestinggit

Works with

  • cli

Security analysis

A100/100

Scanned October 4, 2026

npx -y skills add Getty/skills --skill perl-io-async-future --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Perl Io Async Future?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Perl Io Async Future
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/getty-perl-io-async-future-skills/badge)](https://www.skillsdirectory.com/skills/getty-perl-io-async-future-skills)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: perl-io-async-future
description: "Use when writing async Perl — IO::Async, Future, Future::AsyncAwait, Net::Async::*, futures lost to GC, cancellation, requests that hang."
---

# Perl IO::Async + Future — Patterns & Pitfalls

PEVANS-style async Perl. The framework is small but its lifetime rules are *unforgiving*: a Future that nobody holds gets garbage-collected mid-flight, and the symptom is rarely a clear error.

## The One Rule That Causes 80% of Bugs

**You MUST retain every Future you care about until it is ready.**

A Future is just a Perl object. When the last reference drops, it is destroyed — even if it represents an in-flight async operation. The op may continue, complete, or wedge, but nobody will hear about it. With `Future::AsyncAwait` you'll see:

> Suspended async sub Foo::bar lost its returning future

Without async/await, you'll see *nothing* — just a hang or a callback that never fires.

**Fix:** store the Future on `$self->{_some_future}` (or in any container that outlives the operation). Local-variable-only is a bug unless the local *waits* (`->get`, `await`) before returning.

---

## Pattern 1 — Notifier Subclass Skeleton

```perl
package Net::Async::Foo;
use strict;
use warnings;
use parent 'IO::Async::Notifier';

use Future;
use Future::AsyncAwait;
use IO::Async::Stream;

sub configure {
  my ($self, %params) = @_;
  for my $key (qw(host port on_connect on_error)) {
    $self->{$key} = delete $params{$key} if exists $params{$key};
  }
  $self->{host} //= 'localhost';
  $self->SUPER::configure(%params);   # MUST chain — unknown keys die here
}

sub host { $_[0]->{host} }
```

**Rules:**
- `delete` your keys from `%params` before `SUPER::configure` — leftover keys throw `configure_unknown`.
- Parameter validation/defaults go in `configure`, **not** `new` (Notifier owns construction).
- A Notifier only knows its loop after `$loop->add($notifier)` — anything that calls `$self->loop` must run after that.
- Child notifiers (`IO::Async::Stream`, `IO::Async::Timer`) attach via `$self->add_child($child)`; they inherit the loop automatically.

---

## Pattern 2 — TCP Connect (the GC trap)

```perl
async sub connect {
  my ($self) = @_;

  my $stream = IO::Async::Stream->new(
    on_read       => sub { $self->_on_read(@_) },
    on_read_eof   => sub { $self->_on_disconnect('read_eof') },
    on_read_error => sub { $self->_on_error("read: $_[1]") },
  );
  $self->{_stream} = $stream;
  $self->add_child($stream);

  # ⚠️ RETAIN the connect Future. If GC'd, the stream never gets its
  # handle and on_read never fires — silent hang.
  $self->{_tcp_connect_future} = $stream->connect(
    host    => $self->{host},
    service => $self->{port},
  )->on_fail(sub {
    my $err = shift;
    $self->{_connect_future}->fail("connect: $err")
      unless $self->{_connect_future}->is_ready;
  });

  $self->{_connect_future} = $self->loop->new_future;
  return await $self->{_connect_future};
}
```

**Why both futures?** `_tcp_connect_future` resolves when the *socket* is up. `_connect_future` resolves when your *protocol handshake* is done. The handshake completes inside `on_read`, so you need a separate Future to await.

Clean up `_tcp_connect_future` once the handshake succeeds (`delete $self->{_tcp_connect_future}`).

---

## Pattern 3 — Reconnect Without Losing Futures

The reconnect path is where Future-lifetime bugs cluster. Wrong:

```perl
# ❌ BUG: $f is local to the closure, GC'd as soon as _reconnect returns
sub _reconnect {
  my $self = shift;
  my $f = $self->loop->delay_future(after => 2)->then(sub { $self->connect });
}
```

Right:

```perl
sub _reconnect_attempt {
  my ($self) = @_;
  return if $self->{_connected};

  weaken(my $weak = $self);

  $self->{_reconnect_future} = $self->loop
    ->delay_future(after => $self->{reconnect_wait})
    ->then(sub {
      my $self = $weak or return Future->done;
      return Future->done if $self->{_connected};
      return $self->connect;
    })
    ->on_done(sub {
      my $self = $weak or return;
      delete $self->{_reconnect_future};
    })
    ->on_fail(sub {
      my $self = $weak or return;
      delete $self->{_reconnect_future};
      $self->_reconnect_attempt;        # try again
    });
}
```

**Rules:**
- Store the *whole chain* on the object (`$self->{_reconnect_future}`), not just the leaf.
- `weaken` `$self` inside callbacks — otherwise the chain holds the object alive forever.
- Always `delete $self->{_reconnect_future}` in both `on_done` and `on_fail`, or you'll guard out future reconnects with `return if $self->{_reconnect_future}`.
- On disconnect, **cancel the old `_connect_future`** so its async sub unwinds:
  ```perl
  if (my $f = delete $self->{_connect_future}) {
      $f->fail("disconnected: $reason") unless $f->is_ready;
  }
  ```

---

## Pattern 4 — Future Composition

```perl
# Sequential dependency (then = "after this, do that")
$f1->then(sub { do_b(@_) })           # done → done branch
   ->else(sub { recover(@_) })        # fail → recovery branch
   ->then(sub { do_c(@_) });

# Parallel, all must succeed (fails fast, cancels siblings)
my $f = Future->needs_all($fa, $fb, $fc);
my @results = await $f;

# Race — first one wins, losers are cancelled
my $f = Future->wait_any($work, $self->loop->delay_future(after => 5)
                                   ->then_fail('timeout'));

# Observe without composing (does NOT chain — return value ignored)
$f->on_done(sub { warn "done: @_" });
$f->on_fail(sub { warn "failed: $_[0]" });
```

**`then` vs `on_done`:**
- `then` returns a *new* Future; the callback returns a Future to chain. Use for control flow.
- `on_done` returns the *same* Future; callback return value is discarded. Use for side-effects/observation.

**Protective composition** (Future ≥ 0.51): pass extra siblings via `also =>` to keep them alive without making cancellation propagate:
```perl
Future->needs_all($f1, also => $f2, $f3);
```

---

## Pattern 5 — `->retain` for Fire-and-Forget

When you legitimately want to start an op and not wait, but still need it to *finish*:

```perl
$self->_log_async($msg)
  ->on_fail(sub { warn "log failed: $_[0]" })
  ->retain;
```

`->retain` parks the Future on an internal global until it completes, then drops it. Without `retain`, the chain has no holder and gets GC'd before it runs. Use this only when you genuinely don't need the result — otherwise hold the Future yourself.

---

## Pattern 6 — Future::AsyncAwait (`async`/`await`)

```perl
use Future::AsyncAwait;

async sub fetch_user {
  my ($self, $id) = @_;
  my $row = await $self->db->query("SELECT ...", $id);
  my $perms = await $self->perms->for($id);
  return { %$row, perms => $perms };
}

# Caller MUST hold the returned Future:
my $f = $client->fetch_user(42);
my $user = await $f;        # ok
# OR:
my $user = await $client->fetch_user(42);   # also ok — await holds it
```

**Pitfalls:**
- **Returning a Future from `async sub` without `await`** double-wraps it. Final expression `return await $f`, not `return $f`.
- **`@_` is not preserved across `await` on Perl < 5.24.** Unpack args into lexicals at the top of the sub.
- **`await` inside `map`/`grep` does not work.** Convert to a `for` loop with an accumulator.
- **Caller drops the Future** → "Suspended async sub … lost its returning future". The async sub's continuation is destroyed mid-flight. Always store the Future or `await` it.
- The async sub itself does not retain its returning Future. **Storing the Future in the calling object is the cure**, not adding `->retain` inside the async sub.

---

## Pattern 7 — Timeouts

```perl
# delay_future returns a Future that resolves with no value after N seconds
my $timer = $self->loop->delay_future(after => 5);

# Race the work against the timer
my $result = await Future->wait_any(
  $work_future,
  $timer->then_fail('timeout'),
);
```

`then_fail($msg)` is shorthand for "when the upstream Future is done, fail with this message" — perfect for timeouts. There's also `->timeout($secs)` on Future ≥ 0.42.

---

## Pattern 8 — Cancellation Hygiene

```perl
# Before starting a new attempt, kill stale ones
if (my $f = delete $self->{_connect_future}) {
  $f->cancel unless $f->is_ready;
}
```

- `->cancel` is idempotent but only meaningful on a non-ready Future.
- `needs_all` cancels siblings on first failure; `wait_any` cancels losers on first success. You usually don't need to cancel manually inside a composition.
- A cancelled Future is *neither* done nor failed — `is_ready` is true but `result`/`failure` will throw. Check `is_cancelled` if you need to distinguish.

---

## Pattern 9 — Loops & Tests

```perl
use IO::Async::Loop;
my $loop = IO::Async::Loop->new;
$loop->add($client);

# Block-wait on a Future from sync code (test code, main script):
my @result = $client->connect->get;

# Run loop until a Future is ready (older style):
$loop->await($f);
```

**Test pattern:**
```perl
use Test::More;
use IO::Async::Test;
use IO::Async::Loop;

my $loop = IO::Async::Loop->new;
testing_loop($loop);                # IO::Async::Test hook

my $client = MyClient->new(...);
$loop->add($client);

my $f = $client->do_thing;
wait_for_future($f);                # spins the loop
ok($f->is_done, 'completed');
is_deeply([$f->result], [...]);
```

---

## Decision Guide

| Situation | Use |
|---|---|
| Subclassing IO::Async object | `parent 'IO::Async::Notifier'`, override `configure` |
| Storing async state on $self | `$self->{_foo_future}` — never bare lexicals |
| Sequential dependent ops | `->then` / `async`+`await` |
| Parallel, all required | `Future->needs_all` |
| First-to-finish race | `Future->wait_any` |
| Add timeout to op | `wait_any($op, $loop->delay_future(after=>N)->then_fail('timeout'))` |
| Side-effect observation | `->on_done` / `->on_fail` (don't chain) |
| Truly fire-and-forget | `->retain` (rare — usually you should hold it) |
| Closure capturing $self | `weaken(my $weak = $self)` + null-check inside |
| Cancelling stale attempt | `delete $self->{_f}; $f->cancel unless $f->is_ready` |
| Sync block-wait (tests/scripts) | `$f->get` |

---

## Common Pitfalls (the recurring ones)

- **Local-variable-only Future** → silent GC. Hold it on `$self`.
- **Async sub whose caller drops the Future** → "lost its returning future". Hold the result.
- **Strong `$self` capture in callback chain** → object never destroyed; reconnect loops leak. `weaken` it.
- **Forgetting to `delete` the held Future on completion** → stale guards block future operations.
- **Not chaining `SUPER::configure`** → defaults silently missing, or unknown keys silently accepted.
- **Returning a Future from `async sub` without `await`** → double-wrapped result.
- **Calling `$self->loop` before `$loop->add($self)`** → `loop` is undef.
- **Mixing `then` and `on_done` thinking they're the same** → `on_done` returns the original Future, your "chain" is actually two parallel observers.
- **Cancelling a Future inside its own callback** → undefined; cancel from outside.
- **Net::Async::HTTP (0.50): a module that dies loading at connect time leaks the host's connection slot** → the first request fails, every later one to that host hangs forever, no error. The modules are loaded by name only when a connection opens: `IO::Async::Internals::Connector`, and for https `IO::Async::SSL` (so also a missing `IO::Socket::SSL`/`Net::SSLeay`/libssl, or a PAR binary that didn't pack them). With the default `max_connections_per_host => 1` the slot never frees; raising it only postpones the hang. Before the first request, `require` those modules in an `eval` and fail with the module name; list `IO::Async::SSL` as a hard dependency whenever anything speaks https — upstream it is only a recommends. Redirects to https need the same check (`on_redirect` runs before the new connect).
- **Using `Future->new` instead of `$loop->new_future`** when you need loop-aware behavior (the loop variant integrates with timeouts and is the recommended form inside Notifier subclasses).

---

## The Mental Model

A Future is a *handle to a result that may not exist yet*. It is also a *Perl SV with a refcount*. Both facts matter equally:

- As a handle, you compose, await, observe.
- As an SV, if nothing holds it, it disappears — and async work it represents either completes into the void or never completes at all.

Every async bug in this codebase has been one of: (a) nobody held the Future, (b) somebody held it too tightly via $self capture, or (c) the Future was held but never deleted, blocking the next operation. The patterns above exist to make all three impossible by construction.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…