NAME

Acme::Parataxis::Driver - Base Class for the Event Loops that Drive Parataxis

SYNOPSIS

use v5.40;
use Acme::Parataxis qw[await_read await_sleep fiber];
use Mojo::IOLoop;

# Attach a loop. Parataxis builds the reference driver that matches it.
Acme::Parataxis->attach_loop( Mojo::IOLoop->new );

Acme::Parataxis::run( sub {
    my $reader = fiber { await_read( $fh, 2000 ) };    # a watch on the loop
    my $napper = fiber { await_sleep 50 };              # a timer on the loop
    ...
} );

Acme::Parataxis->detach_loop;    # unwinds every watch and timer

After the detach_loop the next run is back on the worker pool.

A loop Parataxis does not know gets its own driver - see "Writing a Driver".

DESCRIPTION

A driver is the adapter between Parataxis and an existing CPAN event loop. With a loop attached, Acme::Parataxis->attach_loop makes await_read, await_write and await_sleep stop submitting a job to the OS worker pool per filehandle or sleep and instead register watches and timers on that loop. The benefit is readiness driven by epoll, kqueue or select rather than a thread parked in select() per watched socket - which also lifts the FD_SETSIZE ceiling that a select()-based path runs into. See "The descriptor-number ceiling is the platform's, not Parataxis's".

The division of labour is deliberately narrow:

  • The driver tells the loop which filehandles to watch and which timers to arm, and hands control to the loop when every fiber is parked.

  • The scheduler keeps its own park/wake machinery. A loop callback only ever enqueues a fiber; it never runs one, so the scheduler cannot be re-entered from inside the loop.

  • The two never run each other's callbacks - they cooperate through the readiness callbacks alone.

What a caller sees does not change when a driver is attached: readiness reports the same values the worker-pool path reports, a driver deadline still resumes -1, and an enclosing with_timeout or nursery still throws. Only the backing mechanism differs.

Two reference drivers ship with the distribution, and "Acme::Parataxis::Driver::wrap( $loop )" picks between them by class: Acme::Parataxis::Driver::Mojo for Mojo::IOLoop and Acme::Parataxis::Driver::IOAsync for IO::Async::Loop.

METHODS

pending( )

How many watches and timers this driver still holds - watch_count plus timer_count.

my $driver = Acme::Parataxis->loop;    # undef unless a loop is attached
$driver->pending;                      # 2
$driver->watch_count;                  # 1
$driver->timer_count;                  # 1

The scheduler's idle branch only hands control to the loop while this is true. With nothing pending a run would block forever inside the loop instead of reaching the deadlock detector.

reset( )

Unwinds every watch and timer the driver registered, and returns true. run() calls this when a run ends and Acme::Parataxis->detach_loop calls it before dropping the driver, so a stale listener socket or deadline timer left behind by an exited run can never leave the next run blocked inside the loop.

$driver->reset;         # everything unwound, still attached
$driver->pending;       # 0

has_watch( $fh )

True when $fh already has a live watch slot.

$driver->has_watch( $fh );     # true

Both directions share one slot per filehandle, because every loop in support offers a single registration point per descriptor - asking to read and write the same $fh leaves watch_count at 1. A value that is not a filehandle is simply not watched, so this returns false rather than dying.

The slot is keyed by descriptor number but matched by handle, which are not the same thing: the OS recycles a descriptor number the moment a handle is closed, so a slot left behind by a handle that was watched and then closed must not answer for whichever unrelated handle the number is handed to next. It does not, so this returns false for a new handle that happens to be sitting on a retired number, and unwatch refuses it for the same reason.

watch_count( )

How many filehandles are currently watched.

timer_count( )

How many timers are currently armed.

Writing a Driver

A subclass supplies the loop-specific half. The base class keeps the bookkeeping - which watches and timers are still live - that pending and reset depend on, so call _track_watch, _untrack_watch, _track_timer and _untrack_timer whenever you register or deregister with the underlying loop. The four return the fileno or the timer id, and the two _untrack_* return false when there was nothing to remove.

package My::Loop::Driver {
    use feature 'class';
    no warnings 'experimental::class';

    class My::Loop::Driver : isa(Acme::Parataxis::Driver) {
        method watch_read ($fh, $cb) {
            my $fd = fileno($fh);
            defined $fd or croak 'cannot watch a closed filehandle';
            $loop->on_readable( $fh, sub { $cb->() } );
            $self->_track_watch($fh);
        }
        method watch_write($fh, $cb) { ... }    # same, with on_writable
        method unwatch    ($fh)      { ... }    # deregister, then _untrack_watch
        method timer      ($ms, $cb) { ... }    # arm, then _track_timer; returns an opaque id
        method cancel_timer($id)      { ... }    # cancel, then _untrack_timer
        method drive      ()          { ... }    # run the loop until an event fires
        method poll_ready ()          { ... }    # one readiness pass
    }
};

The seven methods a subclass has to provide, and what the scheduler expects of each:

  • watch_read( $fh, $cb ) - call $cb->() when $fh becomes readable. Croaks on a closed handle.

  • watch_write( $fh, $cb ) - call $cb->() when $fh becomes writable. Croaks on a closed handle.

  • unwatch( $fh ) - stop watching $fh in both directions; returns false when nothing was watched.

  • timer( $ms, $cb ) - call $cb->() after $ms milliseconds and return an opaque id. Croaks on a non-positive delay.

  • cancel_timer( $id ) - cancel a pending timer; returns false when the id is unknown or the timer already fired.

  • drive( ) - run the loop until at least one event fires. This may block, and the scheduler only calls it while pending is true.

  • poll_ready( ) - one readiness pass, expected to come straight back. See "poll_ready( ) is best effort, and the two reference drivers differ" before implementing it.

The base class does not stub these out: a subclass that leaves one out dies with Can't locate object method the first time the scheduler reaches it.

FUNCTIONS

Acme::Parataxis::Driver::wrap( $loop )

Wraps an event-loop object in the matching reference driver and returns a Acme::Parataxis::Driver. This is a plain function, not a method - call it with the full name. Acme::Parataxis->attach_loop routes everything through it, so callers can hand in a Mojo::IOLoop, a Mojo::Reactor or an IO::Async::Loop and get the right driver for free. An object that already is a driver is passed straight through, unchanged.

my $driver = Acme::Parataxis::Driver::wrap( $loop );

Dispatch is by class: anything in the Mojo:: namespace, or inheriting from Mojo::IOLoop or Mojo::Reactor, becomes Acme::Parataxis::Driver::Mojo; anything in the IO::Async:: namespace becomes Acme::Parataxis::Driver::IOAsync. It croaks without a blessed object, and croaks for a blessed object whose class it does not know how to drive.

Recipes

Attach for one run, then hand the pool back

A driver session is deliberately short. attach_loop returns the driver it replaced, so the first call returns undef; detach_loop returns the one it dropped, or undef if nothing was attached.

my $previous = Acme::Parataxis->attach_loop( $loop );    # undef the first time
Acme::Parataxis::run( $body );
my $driver   = Acme::Parataxis->detach_loop;             # unwinds it; gives it back

Acme::Parataxis::run( $other_body );                     # back on the worker pool

Neither call is allowed while a run is active - both croak rather than leave a run with a half-swapped driver. A run does not detach on your behalf: the driver is still attached when run returns, already reset, and stays that way until you detach_loop.

Drive a Mojo::Reactor you already own

Anything with the reactor API works, so a mini-loop inside a worker can be driven by Parataxis too.

Acme::Parataxis->attach_loop( $loop->reactor );    # Mojo::Reactor, not Mojo::IOLoop
Acme::Parataxis::run( sub { await_sleep 10 } );

The Mojo driver installs one io watcher per descriptor and flips the interest mask on each registration, because the reactor allows only one watcher per filehandle. That is also why both directions share a single watch slot.

Behavior Notes

The descriptor-number ceiling is the platform's, not Parataxis's

Readiness under a driver comes from the attached loop, and that loop reaches the kernel through epoll, kqueue or - where neither is available - select(). The select() case is bounded by FD_SETSIZE, which is a property of the platform's C library and bounds the descriptor number being watched, not how many are being watched. It is worth measuring rather than assuming: getconf FD_SETSIZE is not a valid symbol and answers 20, and Fcntl::FD_SETSIZE() dies at runtime, so neither is a measurement.

Measured on the platforms this runs on:

  • Linux, macOS, FreeBSD: 1024, a 128-byte fd_set

  • NetBSD: 256, a 32-byte fd_set

  • Win32: the winsock fd_set is a 64-entry bitmask

So a program that opens descriptors past that ceiling gets different answers on different platforms, and sizing a workload by descriptor count alone is not enough - twenty sockets opened early and three hundred opened late are the same count and not the same watch set. Size against the highest descriptor number, not the number of handles.

Parataxis refuses the read rather than registering a watch that can never fire, on both paths. The worker pool declines to hand an unrepresentable descriptor to select() and reports not ready; a driver declines to register the watch, because the fd_set in that case belongs to the loop library and Parataxis cannot widen it. Either way the answer is -1, which is also what a deadline produces, so a caller already knows how to read it. The refusal warns once per process, naming the descriptor and the ceiling, because the limit belongs to the platform and a workload sized on a different OS can cross it without anyone noticing.

poll_ready( ) is best effort, and the two reference drivers differ

The scheduler reaches poll_ready only on the pass where it still has pool jobs in flight but nothing runnable, and it expects to come straight back. Acme::Parataxis::Driver::IOAsync honours that: loop_once(0) returns at once and still fires an already-due timer. Acme::Parataxis::Driver::Mojo cannot. Mojo's one_tick discards its arguments entirely and always waits for the earliest registered timer, so a poll_ready with a 500 ms timer pending measures 500 ms, and one_tick(0) is not the non-blocking variant it looks like. With nothing registered at all one_tick returns immediately, having stopped the reactor.

drive is the mirror image, and the difference costs nothing in practice because the scheduler only calls it while pending is true: the Mojo driver's drive returns at once when nothing is registered, and the IO::Async driver's blocks in select() until an event arrives.

The consequence worth planning around, read off the scheduler rather than reproduced - nothing in this distribution's own API leaves a pool job in flight while a driver is attached - is that a program mixing pool work with an attached Mojo loop and arming a long timer waits out that timer on the polling pass before it notices a finished job. Short deadlines are invisible. A long one reads as a hang. Keep deadlines to a length you would be willing to wait for, or detach_loop around the blocking work.

SEE ALSO

Acme::Parataxis (attach_loop, detach_loop, loop), Acme::Parataxis::Driver::Mojo, Acme::Parataxis::Driver::IOAsync

AUTHOR

Sanko Robinson https://github.com/sanko

LICENSE

Copyright (C) Sanko Robinson.

This library is free software; you can redistribute it and/or modify it under the terms found in the Artistic License 2.