A descriptor registered with EPOLLET can remain readable after an event has been delivered without appearing again in the next epoll_wait(). The kernel reports a readiness transition; it does not promise to repeat the same notification merely because unread data remains. That distinction turns edge-triggered epoll into a state-transition contract between the kernel and the event loop.

The consequence is structural. A handler cannot treat one event as permission for one read() and then return to the wait loop. With edge-triggered monitoring, the handler must account for all immediately available I/O state before relying on another transition.

The ready list is not the descriptor buffer

An epoll instance maintains an interest list and a ready list. The interest list records monitored descriptors and requested events. The ready list contains references for descriptors whose I/O state has made them ready for delivery.

Neither list is the socket receive queue, pipe buffer, or device buffer itself. Consuming an epoll event does not consume bytes from the underlying descriptor. Conversely, consuming only some bytes does not require the kernel to create another edge for state that never returned to not-ready.

Consider a pipe that changes from empty to containing 2048 bytes. That transition can place the read descriptor on the epoll ready list. If epoll_wait() reports it and the handler reads only 1024 bytes, the pipe can remain readable. A later wait has no general obligation to report the same readiness again under EPOLLET, because the relevant state did not need to cross from not-ready to ready a second time.

Nonblocking I/O defines a safe exhaustion boundary

Edge-triggered handlers are normally paired with O_NONBLOCK. The handler repeatedly performs the relevant operation until it reaches a result that states the current I/O opportunity is exhausted.

For reads, that boundary is commonly EAGAIN or EWOULDBLOCK:

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

    if (n > 0) {
        consume(buf, (size_t)n);
        continue;
    }
    if (n == 0) {
        close_peer(fd);
        break;
    }
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
        break;
    }
    if (errno == EINTR) {
        continue;
    }

    fail_peer(fd, errno);
    break;
}

The nonblocking flag matters because the final probe must be allowed to report that no operation can proceed immediately. A blocking descriptor could instead suspend the event-loop thread after available input has already been consumed, preventing that thread from servicing unrelated ready descriptors.

The loop is not a requirement to perform unlimited application work. It is a mechanism for reconciling kernel readiness state with user-space bookkeeping. An implementation can impose processing budgets, but then it must retain its own ready state so a partially serviced descriptor remains scheduled even if no fresh kernel edge arrives.

Readiness is broader than successful payload transfer

EPOLLIN indicates that an associated file is available for a read operation; it is not equivalent to a promise that the next call returns positive payload bytes. Stream shutdown, errors, and descriptor-specific semantics affect the actual result.

Event loops therefore need to process return values and terminal conditions rather than treating the event mask as a complete state machine. EPOLLRDHUP can make peer half-close detection explicit for stream sockets, while EPOLLERR and EPOLLHUP can be reported independently of the ordinary requested mask.

A readiness event narrows the set of plausible blocking outcomes. The I/O operation remains authoritative about bytes transferred, end-of-file, and errors.

Write readiness has the same transition issue

The same contract applies to output. A nonblocking stream socket can move from having no send-buffer capacity to accepting more data, producing an EPOLLOUT edge. If a handler writes only part of its queued application data and leaves the descriptor writable, it cannot assume another identical edge will arrive solely to remind it about the remaining queue.

A robust output path therefore couples kernel readiness with an application-side queue. While the descriptor accepts data, queued bytes can be written. Once write() reports EAGAIN, the event loop has reached the current kernel boundary and can wait for a later writable transition.

This also separates two forms of backpressure. The application queue records data not yet accepted by the kernel, while EAGAIN records the current inability of the descriptor to accept more without blocking. Conflating them can produce either busy loops or stalled queues.

EPOLLONESHOT adds an explicit rearm boundary

EPOLLONESHOT changes the lifecycle again. After an event is reported for a descriptor registered with that flag, epoll disables further event delivery for that registration until user space rearms it with EPOLL_CTL_MOD.

This is distinct from edge-triggering. EPOLLET changes when readiness notifications are generated. EPOLLONESHOT changes whether the registration remains enabled after delivery. They are often combined in multithreaded dispatchers because one-shot delivery gives user space an explicit point for transferring ownership of a descriptor to a worker and later returning it to the polling set.

The rearm operation must occur after the handler has established the state it wants the next wait to observe. Rearming too early can permit concurrent handling that the ownership protocol intended to exclude; failing to rearm leaves the descriptor silent even when its I/O state later changes.

Fairness requires user-space scheduling state

Draining a busy descriptor until EAGAIN can monopolize an event-loop iteration if producers continue supplying work. The epoll interface does not impose an application fairness policy.

A server can cap bytes, messages, or operations handled per turn. Under edge-triggered semantics, stopping before exhaustion means the server must remember that descriptor as runnable in its own queue. The descriptor cannot simply be forgotten until epoll_wait() repeats the event.

This produces a useful separation of responsibilities: epoll reports kernel-side readiness transitions, while the event loop owns scheduling among work items it already knows are runnable. A local ready queue can preserve fairness without asking the kernel to recreate notifications for unchanged state.

Edge-triggered epoll moves state tracking into the event loop

Level-triggered epoll can repeatedly report a descriptor while the requested condition remains true. Edge-triggered epoll removes that repeated reminder and makes transitions the primary notification boundary.

The reduced repetition comes with a stronger user-space invariant: after receiving an edge, the event loop must either drive the descriptor to a known non-ready boundary such as EAGAIN, reach a terminal condition, or retain explicit runnable state for later processing. The kernel tracks readiness transitions; the application tracks unfinished work. Correctness depends on keeping those two state machines aligned.