With EPOLLET, a file descriptor can still contain unread data after its readiness event has already been consumed. A later epoll_wait() is not required to report that descriptor again merely because the old readable state persists.

That behavior is the central boundary of edge-triggered epoll: notification tracks changes in readiness, while the underlying I/O object retains its own state independently.

Readiness state and event delivery are separate

An epoll instance maintains an interest list and a ready list. Registration through epoll_ctl() defines which open file descriptions matter and which event classes are relevant. epoll_wait() returns entries that have reached the ready list.

In the default level-triggered mode, a descriptor that remains readable can continue to be reported. The readiness condition itself is sufficient for another wait to observe it.

EPOLLET changes that notification model. An event is associated with a change in the monitored state rather than with every observation of a state that remains true. If input makes a stream readable, epoll can report that transition. Consuming only part of the input does not necessarily create another transition.

The distinction becomes visible when application work and kernel buffer state diverge.

A partial read can leave no new edge

Consider a nonblocking pipe registered for EPOLLIN | EPOLLET. A writer places 2048 bytes into the pipe. The pipe changes from having no readable data to having readable data, and epoll_wait() reports the read end.

If the consumer reads only 1024 bytes, another 1024 bytes remain buffered. The descriptor is still readable; it did not return to the non-readable state.

A subsequent wait can therefore block even though data remains available. No fresh readiness transition is required to have occurred between the two waits.

The same boundary matters for stream sockets. One readiness notification can correspond to an arbitrary amount of queued stream data. Event count is not byte count, message count, or application-record count.

EAGAIN marks the useful exhaustion boundary

Linux documentation recommends nonblocking descriptors with edge-triggered epoll and treating a reported descriptor as ready until an I/O operation returns EAGAIN.

For reads, that pattern drains currently available input without risking a blocking call after the available data has been consumed:

for (;;) {
    ssize_t n = read(fd, buf, sizeof buf);

    if (n > 0) {
        consume(buf, (size_t)n);
        continue;
    }

    if (n == 0) {
        peer_closed(fd);
        break;
    }

    if (errno == EAGAIN || errno == EWOULDBLOCK)
        break;

    handle_read_error(fd, errno);
    break;
}

EAGAIN has a precise role here. It establishes that the nonblocking operation cannot currently make further progress. At that point, a later change that makes more input available can create a new edge.

This is stronger than assuming one read() per event is sufficient. The latter couples application dispatch count to a kernel notification model that does not provide such a mapping.

Nonblocking mode protects the event-loop boundary

Draining until no progress is possible would be unsafe with a blocking descriptor. After the consumer removes all available input, the next read() could sleep waiting for future data and stall the thread responsible for other ready descriptors.

O_NONBLOCK changes that final operation into an EAGAIN result instead. The event loop regains control at the exact point where immediate I/O progress ends.

The nonblocking requirement is therefore tied to scheduling as well as correctness. Edge-triggered dispatch often performs multiple I/O operations after one notification, so each operation must have a bounded path back to the dispatcher when the object has no more immediately available work.

Drainage can conflict with fairness

Draining a busy descriptor until EAGAIN can consume substantial CPU time if input keeps arriving fast enough to preserve immediate progress. Correct edge handling does not imply that one descriptor should monopolize an event-loop iteration.

A reactor can separate readiness bookkeeping from per-turn work limits. It may record that a descriptor still has pending work, process a bounded amount, rotate to other ready descriptors, and revisit the retained ready state without depending on another kernel edge.

This creates two distinct responsibilities: epoll detects readiness transitions, while the application scheduler decides how much work one ready source receives before yielding to others.

Dropping a locally retained ready state after stopping early can recreate the same stall as a partial read followed immediately by epoll_wait().

EPOLLONESHOT adds an explicit rearm boundary

EPOLLONESHOT is independent of EPOLLET. When one-shot mode is set, epoll disables the registered descriptor after delivering an event. Further events are not reported until the application rearms the entry with EPOLL_CTL_MOD.

Combining the flags introduces two boundaries. Edge-triggering controls which readiness changes produce notifications; one-shot mode controls whether the registration remains enabled after delivery.

This combination is useful in designs where ownership of a connection moves temporarily to one worker. The worker can process the descriptor, establish its next interest mask, and rearm it when the state is ready for another dispatch.

Rearming is not a substitute for consuming or tracking existing readiness. The application still needs a coherent state transition between I/O progress, local bookkeeping, and the next epoll notification.

Hangup does not erase buffered stream data

EPOLLHUP and peer shutdown signals also need to be interpreted alongside stream state. A peer closing its end does not imply that previously queued bytes have vanished.

For a stream or pipe, outstanding data can remain readable before read() finally returns zero for end-of-file. Treating a hangup notification as permission to discard the descriptor immediately can therefore lose buffered input.

Edge-triggered code has to preserve both dimensions: notification state from epoll and consumable state from the underlying file description.

EPOLLET reduces repeated notification for a readiness state that stays true. It does not convert readiness into a single consumable event, and it does not make one callback equivalent to one unit of I/O. The stable boundary is I/O progress itself: process or retain ready work until the nonblocking operation establishes that immediate progress has ended.