First-class ordered-byte callbacks

Linux::Event ordered-byte objects support both subclass methods and constructor-supplied coderefs. Constructor callbacks are ordinary Perl closures, so they retain lexical application state without target objects, method injection, caller-pad access, or package globals.

use v5.36;
use Socket qw(AF_UNIX SOCK_STREAM PF_UNSPEC);
use Linux::Event::Loop;
use Linux::Event::IO::Sock::Stream;

socketpair(my $stream_fh, my $peer_fh,
    AF_UNIX, SOCK_STREAM, PF_UNSPEC) or die "socketpair: $!";

my $loop = Linux::Event::Loop->new;
my $prefix = 'received';
my $stream = Linux::Event::IO::Sock::Stream->new(
    loop    => $loop,
    fh      => $stream_fh,
    on_data => sub ($stream, $bytes) {
        say "$prefix: $bytes";
        $stream->close;
        $loop->stop;
    },
);

syswrite($peer_fh, 'hello') == 5 or die "syswrite: $!";
$loop->run;
close $peer_fh;

Surface and precedence

The callback options and signatures are:

| Callback | Signature | Availability | |---|---|---| | on_data | ($stream, $bytes) | Raw Pipe, TTY, or connected Stream input | | on_message | ($stream, $message) | Framed Pipe, TTY, or connected Stream input | | on_messages | ($stream, $messages) | Framed input with message_batch_size | | on_drain | ($stream) | Output falls to the low watermark after backpressure | | on_eof | ($stream) | Read direction reaches EOF | | on_error | ($stream, $error) | Ordered-byte I/O, framing, timeout, or transport error | | on_close | ($stream) | Complete object reaches terminal close | | on_ready | ($stream) | Connected Stream becomes application-ready | | on_transport_ready | ($stream) | Connected Stream transport becomes usable, immediately before on_ready |

IO::Sock::Stream->new and IO::Sock::Stream->connect accept every callback above. IO::Pipe->new and IO::TTY->new accept the ordered-byte callbacks through on_close; they do not have a connection or transport readiness phase. A constructor callback overrides the corresponding class method for that instance. If no constructor callback is supplied, existing subclass behavior is unchanged.

The public IO::Sock::Stream leaf may be used directly for raw I/O when its required on_data callback is supplied to the constructor. A subclass remains the reusable policy mechanism for a framer, native consumer, stream_tuning(), socket_options(), named callbacks, and optional TLS defaults. Accepted TLS is selected independently by the Listener recipe and is not part of Stream class identity.

Framer selection and socket defaults remain class-level policy. Listener recipe tuning can override class stream_tuning() for generated connections and a live Stream can subsequently change mutable ordered-byte policy with tune().

Raw, framed, batched, and native-consumer modes remain explicit. on_data cannot be used on a framed class and message callbacks cannot be used on a raw class. A framed class may provide both on_message and on_messages so a live tune(message_batch_size => ...) can switch delivery policy without changing protocol class; the callback required by the initial effective policy must exist. Perl message callbacks cannot replace a native consumer. Invalid combinations fail during construction rather than during dispatch.

Dispatch model

Class methods are resolved once into the class descriptor. Constructor input callbacks are validated once and retained directly in native per-object state. The result is one effective CV for each active input boundary:

class method CV or constructor closure CV
                  |
                  v
       native per-object callback slot
                  |
                  v
          direct Perl invocation

There is no per-message method lookup, object-hash callback lookup, closure creation, or method-versus-coderef branch. Native reads, buffering, framing, and batching remain native until application code must run. Lifecycle callbacks are also resolved once, but remain in Perl because lifecycle and exception orchestration are cold-path policy.

Instance input callbacks and lifecycle overrides survive compatible transition_to() operations. Class-derived callbacks follow the target class descriptor. close() and failed construction release retained callbacks; detach() releases them without invoking on_close.

Listener reuse

An IO::Sock::Listener accepts the complete connected-Stream callback set in its generated-Stream recipe:

my $listener = Linux::Event::IO::Sock::Listener->new(
    loop => $loop,
    host => '127.0.0.1',
    port => 9999,
    stream => {
        on_data => sub ($stream, $bytes) {
            $stream->write($bytes);
        },
    },
);

The Listener resolves the recipe once, retains one CV, and supplies that same CV to every accepted Stream. Per-connection identity and initial mutable state belong in stream => { data => ... }. Creating a fresh closure per connection is unnecessary unless the application truly needs distinct lexical state, and carries measurable construction cost.

Top-level on_accept and on_error belong to the Listener itself. Stream callbacks such as on_data and Stream on_error belong inside stream => {}.

Performance evidence

The cached-closure experiment measured raw and framed native dispatch with 4,194,304 callbacks per small-message row. Capturing one or four lexicals did not produce a scaling penalty; closure rows remained approximately within 1-1.5% of the cached subclass method path in the decisive framed runs, with raw delivery showing the same practical equivalence.

Accepted-Stream construction showed that post-construction setter/wrapper work was the material cost, not retaining a shared closure. Direct native seeding reduced the shared-closure CPU difference to about 1.35% in the official paired result, with the distribution crossing zero. Fresh per-connection closure allocation remained measurably more expensive.

Permanent regression harnesses are: