Native architecture

Hot path

The current steady-state readiness path is intentionally short:

epoll_wait()
   |
   v
epoll_event.data.ptr
   |
   v
le_watcher_t *
   |
   v
XS dispatch
   |
   v
one Perl callback

There is no fd-to-Perl-hash lookup in the hot dispatch path. The fd-indexed native registry remains for registration, replacement, cancellation, and lifetime management.

Native loop state

le_loop_t owns:

The default event array capacity is 8192.

Normal raw registration uses watch(fh => $fh) or watch(fd => $fd). That Perl-facing method resolves a handle to its integer fd once at construction and then enters the existing native registration path. It adds nothing to steady-state readiness dispatch. watch_fd remains the low-level positional entry point underneath it.

Native watcher state

le_watcher_t contains the fd, epoll mask/flags, owning loop pointer, callback SV/CV references, optional accessor references, callback-mode flags, and benchmark/lifecycle state.

epoll_event.data.ptr points directly at this structure, which avoids a registry lookup after epoll_wait() returns.

Callback representation

When a callback is a plain coderef the XS layer stores the CV directly rather than retaining an extra RV wrapper. The fast path can therefore call the CV with minimal Perl-side construction.

Normal callbacks receive the watcher handle. A no-argument mode exists for hot closures that capture their own state.

Temporary scopes

The dispatcher shares an ENTER/SAVETMPS scope across a bounded group of callbacks while still running FREETMPS after each callback. The default scope limit is 128 callbacks, selected from benchmark sweeps as a stable balance.

Terminal-event semantics

For an event containing terminal flags and normal readiness, dispatch order is:

EPOLLERR / EPOLLHUP / EPOLLRDHUP
EPOLLIN
EPOLLOUT

The watcher is re-checked after each callback so cancellation takes effect within the same returned epoll batch.

Registering a new native record for an already registered fd uses one EPOLL_CTL_MOD, changes epoll_event.data.ptr to the new record, and marks the old handle inactive. Cancelling that old handle is harmless and cannot remove the replacement.

Native registration lifetime

The public opaque handle is a stable registration token that refers to a native watcher while it is active. Epoll continues to store the watcher pointer directly. cancel/unwatch_fd removes the epoll registration, marks the token inert, and releases its retained Perl state immediately outside dispatch or after the active callback returns. Replacing an fd or destroying the Loop also makes every obsolete token inert, so native watcher or fd reuse cannot redirect an old handle to a new resource.

Experimental watcher reclamation can defer watcher reuse until a returned epoll batch has finished, avoiding reuse while an event array may still contain the old data.ptr.

The performance default keeps aggressive reclaim disabled because the memory savings measured in earlier experiments came with a throughput cost.

Profiling

Cheap counters remain enabled. Nanosecond timing of epoll/callback/dispatch regions is opt-in because instrumentation itself changes the workload.

Benchmark-only native echo

The XS source still contains a private native echo diagnostic used to decompose callback entry from Perl sysread/buffer/syswrite work. It is deliberately prefixed _bench_ and is not an application API. Its result is what motivated the next Stream work: the larger remaining cost is above the reactor.

Stream class descriptors

The higher-level Stream extension has a separate native state model. The first construction of a concrete Linux::Event::Stream subclass creates one immutable XS descriptor containing resolved named callbacks, read size, output watermarks, optional hard pending-output limit, framed-buffer limit, and native parser configuration.

Every connection's XS state retains that descriptor and owns only mutable fd, input/parser, output-queue, lifecycle, and counter state. A framed connection therefore does not copy callbacks, delimiters, prefix policy, or Stream policy settings into every allocation.

The retained descriptor reference may be replaced explicitly by transition_to() while the mutable connection state stays in place. Parser loops snapshot their starting descriptor and stop after a callback changes it. The input driver then reinterprets the unread native suffix with the target descriptor. Raw targets receive the suffix as bytes; framed targets continue native boundary detection. This gives protocol upgrades a safe point without re-registering the fd or copying the output queue.

The connection state also holds a native transport operations table and provider context. The default plain identity is checked once per operation, then XS issues the original fd syscall directly. Non-plain providers can later map the same read, write, vectored-write, retry-direction, error, and writable shutdown operations to another byte transport. This keeps parsers, queues, and backpressure independent of encryption while avoiding a Perl callback or indirect function call on the ordinary syscall path.

The segmented write queue checks a nonzero max_pending_bytes before copying a new unsent remainder. Overflow enters Perl only for the semantic typed-error and close transition; no over-limit segment is allocated. The zero/default case is one predictable native branch and otherwise follows the unchanged write path.

Raw input drains into a reusable native read buffer and crosses into Perl for on_data. Framed input drains directly into native connection storage, runs the built-in parser there, and crosses into Perl only for complete on_message values or semantic errors. Both paths recheck pause and close state after callbacks.

The zero/default descriptor values retain those ordinary callback boundaries. A raw descriptor may instead set read_batch_bytes, in which case native storage combines successful reads and flushes at its byte bound or EAGAIN. A framed descriptor may set message_batch_size and cache on_messages; complete message SVs are then owned by one bounded AV until count, EAGAIN, EOF, error, or the aggregate byte guard flushes it. The AV is detached from native state before entering Perl, so callback exceptions cannot leave mortal values owned by the connection. Close clears any undispatched native aggregate.

The Stream extension does not include private reactor headers. Loop passes watcher data directly to Stream's private readiness entry points, preserving a generic readiness core and an independently testable buffered Stream layer.

Stream's native implementation is one XS extension built from focused private translation units. Stream.xs contains only the Perl/native binding surface; stream_read.c, stream_write.c, stream_transport.c, stream_input.c, stream_delivery.c, stream_transition.c, and stream_callbacks.c own their named mechanisms. Each built-in framing family has a matching framer_*.c parser. stream_internal.h is the sole shared native state and helper contract. These files link into the same extension and introduce no additional Perl or dynamic-loading boundary.

At the Perl layer, Linux::Event::Stream remains the public lifecycle object. The private _Descriptor module owns subclass declarations, option validation, callback resolution, and the immutable descriptor cache. The deliberately tiny deadline Timer type remains next to Stream's lifecycle methods rather than creating another implementation layer. Connection acquisition remains in the existing private _Connection engine. None of these source boundaries add public classes or change Stream identity.

Public ownership layer

Linux::Event::Loop->add() accepts distribution objects that implement Loop attachment: Stream, Listener, Datagram, Timer, Signal, Wakeup, and Process. There is no public Watcher or IO base class and no generic Perl dispatcher. Raw watch() returns an opaque native handle. Every high-level object retains its concrete cached callbacks and private registrations.

An application object is one logical activity rather than one fd. A connecting Stream can own attempt and deadline registrations until connection completes, then retains the same public identity while its established socket is registered with the native Stream engine.

The introspection layer enumerates the existing native Timer heap, watcher ownership, Signal service, Wakeup owner state, and resolver requests only when queried. It does not maintain a duplicate public-object registry. Queries validate ownership and lifecycle against each object's authoritative state. Native resource queries scan the existing fd registry; they do not mirror it in Perl. One private flag on a native registration distinguishes direct user watch() calls from the backing registrations owned by high-level objects, so liveness reports remain actionable instead of listing implementation fds twice. None of this work runs in readiness dispatch.

Timer scheduler layer

Every Loop lazily creates one nonblocking, close-on-exec timerfd when its first Timer is attached. All active Timers on that Loop share the descriptor. An indexed native minimum heap orders nanosecond monotonic deadlines and permits O(log n) insertion, cancellation, and arbitrary rescheduling.

Each Timer subclass contributes one immutable descriptor containing its resolved on_timer CV. Instances hold only mutable schedule, heap position, application data, lifecycle, and expiration count. The Loop retains active instances, so dropping an application reference does not cancel work.

The timerfd watcher enters a specialized native dispatch path. Equal deadlines use schedule sequence for FIFO order. Fixed-rate recurring timers advance from their previous deadline, coalescing missed intervals into one callback. Dispatch is capped at 1024 callbacks per batch, and immediate work created from inside a Timer callback is deferred to a later Loop turn.

Established Stream deadlines reuse this scheduler. A deadline-enabled Stream owns at most one private Timer containing a weak route back to the Stream. That heap entry always represents the earliest idle, read, write, or explicit operation deadline. Native Stream state records successful I/O timestamps only when inactivity tracking is enabled; progress never enters Perl merely to reschedule the heap. When an old deadline arrives, the private callback checks the latest snapshot and either expires the Stream or moves the same Timer. Ordinary Streams perform no activity clock reads, and pause, resume, EOF, and output drain skip deadline candidate rebuilding unless the corresponding read or write timeout is active.

Signal delivery layer

Each Loop with Signal subscriptions owns one private nonblocking signalfd and a native per-number fan-out registry. Signal subclass callbacks are cached once. The raw lean watcher crosses into the Signal extension once per readiness, where complete signalfd batches are drained and aggregated before semantic callbacks enter Perl.

One Signal may register several numbers and several Signals on that Loop may register the same number. Dispatch snapshots subscribers so callbacks may cancel themselves or later subscribers safely. Reference counts keep a number in the shared mask until its last subscriber is gone. Original thread-mask membership is recorded and only entries changed by Linux::Event are restored.

Stream acquisition layer

The private Linux::Event::Stream::_Connection engine owns outbound socket acquisition, while the public Stream owns the entire logical lifecycle. The engine validates address modes, stores candidate/attempt state, and checks SO_ERROR after writable readiness. Sockets are created with SOCK_NONBLOCK | SOCK_CLOEXEC.

A small adjacent XS extension owns only timerfd mechanics. It gives pending requests monotonic deadlines and ensures immediate success or operational failure is delivered from the loop rather than reentrantly inside the constructor. The attempt engine is intentionally a cold Perl path until profiling demonstrates a reason to move it.

This private connection deadline helper remains separate from the public Timer scheduler in this release. Migrating connection and TLS deadlines to Timer is a later lifecycle change, not part of the Timer API itself.

MyStream->connect() creates one Stream and the private engine reports directly to it. During success, Stream binds native state to the connected fd. The application-visible object is never replaced.

Hostname resolution lives in the shared private Linux::Event::_Resolver XS extension used by Stream and Datagram. Two lazy native workers per Loop run getaddrinfo without touching Perl state, copy results to a native completion queue, and signal one eventfd. An ordinary raw Loop watcher drains that queue on the reactor thread. The connection engine interleaves IPv6/IPv4 candidates, starts pending alternatives at 250 ms intervals, and transfers only the first successful socket while closing every loser. Numeric, Unix, and packed addresses do not start the resolver.

Listener acquisition layer

Linux::Event::Listener owns inbound stream-socket acquisition. It creates TCP or filesystem Unix listeners, or adopts a caller-provided listening handle, then registers one read watcher for that listening descriptor. It never creates a watcher for an accepted connection.

Readiness enters a private Listener XS engine that drains accept4() with atomic SOCK_NONBLOCK | SOCK_CLOEXEC. Descriptor and packed-sockaddr pairs return to Perl for the cached private _accept_client construction method. Address text remains lazy through Linux::Event::Address; applications that do not inspect a peer avoid formatting it.

Listener constructs its configured Stream subclass, attaches the Stream to the same Loop, invokes the optional public on_accept($listener, $stream), and then reports a plain Stream ready. TLS Stream readiness waits for its handshake. Listener data is passed automatically to every accepted Stream. A TLS declaration on the Stream class selects a fresh server-side provider and is validated before Listener creates its socket. Listener does not create a temporary accepted-socket registration. An on_accept exception closes only that Stream and becomes a nonfatal callback Error. Resource accept errors pause listener readiness before the typed error callback so a readable backlog cannot create an error spin.

Socket policy layer

Common socket validation and setsockopt conversion live in a private cold-path module shared by Stream and Datagram. Stream copies cached class policy into one acquisition instance, applies constructor overrides, and configures every outbound candidate before bind/connect. Accepted and adopted Streams apply the same policy before native transport attachment. Listener separately owns listening-socket creation policy.

The optional cached configure_socket callback follows built-in policy. It is outside steady-state I/O. Failures become typed socket_configuration values instead of silently changing candidate or transport behavior.

Wakeup layer

Wakeup owns one nonblocking eventfd and raw lean registration. An ithread clone duplicates only the descriptor identity needed for signal; cancellation and destruction cannot redirect a stale fd number to an unrelated resource. Loop, watcher, callback descriptor, and data live in an owner-only state object that is skipped during ithread cloning. All other native resource objects also skip cloning.

The private resolver eventfd is not implemented through public Wakeup because its native workers already own a typed C completion queue and cancellation table. Both paths share the same architectural rule: foreign threads publish data before writing eventfd, and Perl handles semantics only on the Loop thread.

Datagram layer

Datagram uses a separate XS packet engine. One readiness call allocates one receive buffer and drains recvmsg(MSG_DONTWAIT | MSG_TRUNC) into a flat batch of payload, packed peer, original length, and truncation fields. Perl creates lazy Address values and invokes the cached semantic callback once per packet.

Output uses send or sendto with MSG_NOSIGNAL. Queue segments are whole packets with separate byte and packet accounting. Connected hostname mode reuses the private resolver with SOCK_DGRAM hints but does not reuse Stream's byte buffers or TCP attempt race.

Process layer

Process constructs stdio pipes in Perl's owning interpreter, then one XS call uses posix_spawnp file actions and opens a pidfd. No Perl code executes in a post-fork child. The Loop may register pidfd, stdin, stdout, and stderr, all routed back to one Process object.

pidfd readiness uses waitid(P_PIDFD) for decoded exit identity. Pipe output is drained before on_exit; queued stdin uses a SIGPIPE-safe native write helper. Signals use pidfd_send_signal, avoiding numeric PID reuse. Setup failure after spawn kills and reaps the exact child before partial ownership is released.