Linux::Event

CPAN version CPANTS Kwalitee CI License Perl

Linux::Event is a Linux-only asynchronous I/O foundation for Perl. It combines an XS-first epoll reactor with timers, synchronous signal handling, eventfd wakeups, inbound and outbound byte streams, packet-preserving datagrams, pidfd processes, and an OpenSSL TLS transport in one distribution.

The public model is deliberately small. Linux::Event::Loop owns readiness and scheduled work; Linux::Event::Stream and Linux::Event::Datagram own byte and packet sockets; Linux::Event::Listener owns listening sockets; Linux::Event::Timer, Linux::Event::Signal, and Linux::Event::Wakeup own scheduled, signal, and external-notification activities; and Linux::Event::Process owns pidfd lifecycle and optional stdio. There is no public Watcher, IO, Connect, Connector, Poster, or Process watcher class. Native epoll registrations remain opaque, so one logical object may use several kernel event sources without exposing them as application objects.

Public modules

Linux::Event::Stream::_Connection, Linux::Event::_Resolver, and the internal socket configuration and deadline types are private implementation details. Applications must not construct, subclass, or depend on them.

Current capabilities

Reactor

Object lifecycle

Timer

Signal

Wakeup

Datagram

Process

Stream connection

Listener

Socket configuration

Most clients should omit local_host and local_port; Linux then chooses the source address and ephemeral source port. These options select the local side of an outbound connection and do not replace its remote host and port.

Use Listener on_accept for immediate connection accounting or admission policy. Use Stream on_ready when the connection is application-ready; for TLS that means after the handshake:

package ServerListener;
use parent 'Linux::Event::Listener';

sub on_accept ($listener, $stream) {
    $listener->data->{connections}{ $stream->fd } = $stream;
}

package main;
my $server = ServerListener->new(
    loop         => $loop,          # optional: attach immediately
    stream_class => 'ServerStream', # required
    host         => '0.0.0.0',      # required for TCP
    port         => 9999,           # required for TCP
    reuseaddr    => 1,              # default
);

Stream

The raw reactor never performs application I/O automatically. Stream is the higher-level layer for applications that want owned byte-stream I/O.

Established Stream deadlines

Stream subclasses may cache connection-wide inactivity defaults with their other class policy:

sub stream_options ($class) {
    return (
        idle_timeout  => 60,
        read_timeout  => 30,
        write_timeout => 10,
    );
}

Each value is seconds and zero disables that policy. Constructor values override the subclass for one outbound or directly adopted Stream. Accepted Streams use the configured Stream subclass's cached policy:

my $stream = ClientStream->connect(
    host => $host, port => $port,
    idle_timeout => 120,
    deadline => { after => 15, operation => 'authentication' },
);

An application can replace or clear the one explicit overall-operation deadline later:

$stream->set_deadline(after => 5, operation => 'response');
$stream->clear_deadline;

Established deadlines begin only when the Stream is usable. Resolver, connection, TLS handshake, and TLS shutdown time retain their existing deadline owners. Expiration delivers a typed timeout error through on_error and closes through the ordinary Stream lifecycle.

Loop attachment

Stream, Listener, Datagram, Timer, Signal, Wakeup, and Process accept loop => $loop, and may instead be constructed detached and added later:

use Linux::Event::Listener;

my $client = ClientStream->connect(
    loop => $loop, host => '127.0.0.1', port => 9999,
);

my $server = $loop->add(Linux::Event::Listener->new(
    stream_class => 'ServerStream',
    host => '0.0.0.0', port => 9999,
));

my $heartbeat = $loop->add(Heartbeat->new(every => 30));

my $shutdown = $loop->add(ShutdownSignal->new(
    signals => [SIGINT, SIGTERM],
));

my $udp = $loop->add(MetricsDatagram->new(
    host => '0.0.0.0', # required
    port => 9000,      # required
));

my $wakeup = $loop->add(ResultWakeup->new(
    data => $result_queue, # optional
));

my $worker = $loop->add(WorkerProcess->spawn(
    command => ['/usr/bin/worker', '--once'], # required
    stdout  => 'pipe',                        # optional; default inherit
));

These are equivalent attachment styles. add() stores the Loop, starts the object, and returns that same object. An object can be attached only once and cannot move between Loops.

Timer example

Timers follow the same subclass and attachment style as Streams:

package SessionTimeout;
use parent 'Linux::Event::Timer';

sub on_timer ($timer) {
    $timer->data->close;
}

package main;
my $timeout = $loop->add(SessionTimeout->new(
    after => 30,
    data  => $stream,
));

Application context is directly available through data, so a timer callback can close or modify any Stream, Listener, or other state it retains. Use reschedule to replace an active schedule and cancel for terminal removal.

Signal example

Signals use the same subclass and attachment style without asynchronous Perl signal handlers:

package ShutdownSignal;
use parent 'Linux::Event::Signal';

sub on_signal ($signal, $number, $count) {
    $signal->data->{listener}->close;
    $signal->loop->stop;
}

package main;
use POSIX qw(SIGINT SIGTERM);
my $shutdown = $loop->add(ShutdownSignal->new(
    signals => [SIGINT, SIGTERM],
    data    => { listener => $server },
));

Wakeup example

Wakeup makes a Loop notice results stored in a separate safe channel. It does not attempt to move a Perl callback between interpreters:

use threads;
use Thread::Queue;

package ResultWakeup;
use parent 'Linux::Event::Wakeup';

sub on_wakeup ($wakeup, $count) {
    while (defined(my $result = $wakeup->data->dequeue_nb)) {
        say "result: $result";
    }
    $wakeup->loop->stop;
}

package main;
my $results = Thread::Queue->new;
my $wakeup = $loop->add(ResultWakeup->new(
    data => $results, # optional
));
my $thread = threads->create(sub {
    $results->enqueue('complete');
    $wakeup->signal;
    return 1;
});
$thread->join;
$loop->run;

Native extensions and forked children can signal the same way without a threaded Perl. See docs/WAKEUP-DESIGN.md for the ownership boundary and why there is no arbitrary $loop->post($coderef) API.

Datagram example

package EchoDatagram;
use parent 'Linux::Event::Datagram';

sub on_datagram ($socket, $payload, $peer) {
    $socket->send($payload, to => $peer);
}

package main;
my $server = $loop->add(EchoDatagram->new(
    host => '0.0.0.0', # required
    port => 9999,      # required
));
$loop->run;

Connected Datagram objects use send($payload) without to; hostname resolution occurs through the same native resolver workers as Stream.

Process example

package CaptureProcess;
use parent 'Linux::Event::Process';

sub on_stdout ($process, $bytes) { print $bytes }
sub on_exit ($process) {
    say "exit=" . ($process->exit_code // 'signal');
    $process->loop->stop;
}

package main;
my $process = $loop->add(CaptureProcess->spawn(
    command => ['/usr/bin/uname', '-a'], # required
    stdout  => 'pipe',                   # optional; default inherit
));
$loop->run;

Process construction is side-effect free until Loop attachment. Spawning uses native posix_spawnp, not Perl code in a post-fork child.

Build and test

perl Makefile.PL
make
make test

All ten native extensions are built into the same blib tree. The supported runtime is Linux 5.4 or newer. Building requires Linux pidfd syscall headers, a libc with posix_spawn_file_actions_addchdir_np, and OpenSSL 1.1.1 or newer development headers and libraries. Perl 5.36 or newer is required; Perl ithreads are not. To use the built copy without installing it:

export PERL5LIB="$PWD/blib/lib:$PWD/blib/arch"

Before a release, capture or compare the permanent regression suite:

perl -Mblib bench/run-performance-regression.pl \
  --baseline bench/results/performance-baseline.json \
  --fail-on-regression

See bench/README.md for baseline capture, thresholds, and measurement controls.

Outbound connection example

The same Stream object exists before, during, and after connection setup:

package GatewayStream;
use parent 'Linux::Event::Stream';
use Linux::Event::TLS;

sub on_ready ($stream) {
    $stream->write("GET / HTTP/1.1\r\nHost: gateway.discord.gg\r\n\r\n");
}

package main;
use Linux::Event::Loop;

my $loop = Linux::Event::Loop->new;
my $stream = $loop->add(GatewayStream->connect(
    host    => 'gateway.discord.gg', # required
    port    => 443,                  # required
    timeout => 10,                   # default
));
$loop->run;

on_ready means application-ready: TCP is connected and, when configured, the TLS handshake and verification are complete. send() or write() may be called before readiness; bounded output is retained on the Stream and flushed after connection establishment. Hostnames resolve on a private native worker pool; completion wakes the owning Loop through eventfd, and IPv6/IPv4 connection attempts are staggered without blocking the reactor.

TLS belongs to the Stream type rather than to one client constructor. The same declaration becomes a server handshake when Listener accepts that Stream subclass. An accepted TLS Stream must declare its certificate and key:

package SecureEchoStream;
use parent 'Linux::Event::Stream';
use Linux::Event::TLS
    cert_file => '/etc/myapp/server-cert.pem', # required for server role
    key_file  => '/etc/myapp/server-key.pem',  # required for server role
    alpn      => ['my-protocol/1'];             # optional

sub on_ready ($stream) {
    $stream->send("ready\n");
}

package main;
my $server_state = { connections => {} };
my $server = Linux::Event::Listener->new(
    loop         => $loop,               # optional: attach immediately
    stream_class => 'SecureEchoStream',  # required
    host         => '0.0.0.0',           # required for TCP
    port         => 9443,                # required for TCP
    data         => $server_state,       # optional; inherited by each Stream
);

Listener calls on_accept immediately after attaching the accepted Stream; the Stream's on_ready waits until the server handshake completes. Outbound TLS defaults SNI and hostname verification to the connect(host => 'service.example') value.

Line echo server

Listener owns socket setup and automatically constructs the framed Stream. There is no application-level socket or accepted-filehandle plumbing:

package EchoStream;
use parent 'Linux::Event::Stream';
use Linux::Event::Framer 'Delimiter', "\n";

sub on_message ($stream, $line) { $stream->send($line) }

package main;
use Linux::Event::Listener;
use Linux::Event::Loop;
my $loop = Linux::Event::Loop->new;
my $server = $loop->add(
    Linux::Event::Listener->new(
        stream_class => 'EchoStream',
        host => '0.0.0.0', port => 9999,
    )
);
$loop->run;

Runnable versions are examples/line-echo-server.pl and examples/line-echo-client.pl. Datagram, Wakeup, and Process examples are examples/udp-echo-server.pl, examples/udp-echo-client.pl, examples/wakeup-thread.pl, and examples/process-capture.pl.

Raw Stream example

A Stream type is an ordinary package. It may live in the same file as the rest of the program.

use v5.36;
use Linux::Event::Loop;

package EchoStream;
use parent 'Linux::Event::Stream';

sub on_data ($stream, $bytes) {
    $stream->write($bytes);
}

sub on_error ($stream, $error) {
    warn "$error\n";
}

package main;
my $loop = Linux::Event::Loop->new;
my $stream = $loop->add(EchoStream->new(
    fh   => $socket,
    data => { user_id => 42 },
));
$loop->run;

data is the optional per-connection application value. It is the natural place for a user record, permissions, room membership, parser state for a raw protocol, or other connection-specific state.

Framed Stream example

Framing turns a byte stream into complete messages. A framed type adds one declaration after use parent and implements on_message:

package LineEchoStream;
use parent 'Linux::Event::Stream';
use Linux::Event::Framer 'Delimiter', "\n";

sub on_message ($stream, $message) {
    $stream->send($message);
}

The declaration name is the exact final component below Linux::Event::Framer. There is no alias table or per-connection framer object. Examples:

use Linux::Event::Framer 'Fixed', size => 32;
use Linux::Event::Framer 'LengthPrefix',
    bytes => 4, endian => 'big', max_frame => 16 * 1024 * 1024;
use Linux::Event::Framer 'U32BE',
    max_frame => 16 * 1024 * 1024;
use Linux::Event::Framer 'Netstring', max_frame => 1_048_576;
use Linux::Event::Framer 'Varint', max_frame => 1_048_576;
use Linux::Event::Framer 'DecimalLength',
    separator => ' ', max_frame => 1_048_576;

Built-in boundary detection runs in XS. send() applies the declared outbound wire encoding and hands the result to the native write engine. Every instance has independent parser and queue state even though immutable configuration and callbacks are shared through its class descriptor.

Protocols without a suitable built-in should define a raw on_data Stream and parse there. Arbitrary Perl framer objects are intentionally not accepted. Generally useful framing families can be added as native built-ins without adding a duplicate keyword registry.

Protocol transitions

One connection does not have to use one protocol definition forever. A handshake, protocol negotiation, or HTTP upgrade can change the live Stream to another subclass:

sub on_data ($stream, $bytes) {
    my ($upgrade, $remaining) = parse_upgrade_request($bytes);
    return if !$upgrade;

    $stream->write(upgrade_response());
    $stream->transition_to('WebSocketStream', input => $remaining);
    return;
}

transition_to() reblesses the same object and swaps its shared native descriptor. It retains the filehandle, native registration, XS connection state, queued output, backpressure and half-close state, data, and unread native input. Bytes already buffered by an old framed parser are reinterpreted by the target parser. input supplies the unconsumed suffix held by a raw callback.

The old parser stops after the callback that requested the transition. Target dispatch then continues without waiting for another socket read. Existing queued output stays byte-for-byte ordered; subsequent send() calls use the new framer. A paused Stream remains paused across the transition.

This is a protocol transition, not encryption or descriptor replacement. TLS is declared independently on a Stream subclass and is implemented at the native transport boundary rather than pretending to be a framing rule.

Class Stream options

Buffering, backpressure, and deadline policy also belong to the Stream type and are cached once:

sub stream_options ($class) {
    return (
        read_size         => 32_768,
        high_watermark    => 2 * 1024 * 1024,
        low_watermark     => 512 * 1024,
        max_pending_bytes => 8 * 1024 * 1024,
        max_buffer        => 16 * 1024 * 1024,
    );
}

Watermarks are cooperative: a false write() return still means the bytes were accepted and the producer should wait for on_drain. The same return, pending_bytes, is_write_blocked, and eventual drain behavior applies to output queued before attachment or connection readiness. A nonzero max_pending_bytes is the separate hard safety boundary. If an unsent remainder would exceed it, Stream does not queue that remainder; it reports an output_limit error through on_error and closes. The default is zero, which keeps pending output unlimited.

The base Linux::Event::Stream class is not directly constructible. The old constructor callback, framer-object, and per-object transport options were removed by design.

Why subclass descriptors

The first construction of a Stream subclass resolves its callback methods, framer declaration, native parser configuration, and Stream policy into one immutable Perl/XS descriptor. Each connection refers to that descriptor and allocates only mutable I/O and lifecycle state. This removes repeated callback hashes, framer objects, option parsing, validation, and native config copies from connection construction. Hot dispatch calls cached named CVs rather than performing method lookup.

Use bench/run-stream-lifecycle-bench.pl to measure construction and retained memory against the versioned object-configured baseline.

Documentation

Project direction

Linux::Event intentionally targets Linux rather than carrying a portability layer. Mechanical event, byte, buffer, queue, and framing work belongs in native code; ordinary named Perl callbacks receive semantic events.

Stream's fd operations pass through an exact-version native transport contract while its ordinary plain provider retains a specialized direct-syscall path. Linux::Event::TLS ships in this distribution as a separate native extension and attaches at construction without making TLS a framer or adding OpenSSL policy to the core Loop or plain Stream path. See docs/TRANSPORT-BOUNDARY.md.

Version 0.101 completes the original essential runtime set: shared timers, eventfd wakeups, asynchronous DNS and Happy Eyeballs, signalfd signals, pidfd processes, packet-preserving datagrams, established Stream deadlines, and production socket configuration. Further work is optimization or expansion of general protocol facilities rather than a missing lifecycle primitive.

License

This project is distributed under the same terms as Perl itself.