With EPOLLET, an event loop can consume one notification, read only part of the available data, and then wait indefinitely even though unread bytes remain in the socket buffer. The descriptor is still ready, but no new readiness transition has occurred to generate another edge.

That behavior makes edge-triggered epoll a contract between notification semantics and nonblocking I/O. The event says that readiness changed; it is not a promise that the kernel will keep repeating the same notification until the application finishes the work.

An edge represents a state transition

Level-triggered operation reports a descriptor while the watched condition remains true. If a socket still has readable data, a later epoll_wait() can report it again. Edge-triggered operation changes that relationship. With EPOLLET, the application is notified when the monitored state changes in a relevant direction.

Consider a nonblocking stream socket receiving 8 KiB. A handler reads 1 KiB and returns to the event loop. The remaining 7 KiB can keep the socket readable. Since the descriptor did not become non-readable and then readable again, relying on another edge for the retained bytes is unsafe.

The handler therefore needs to consume the current readiness state until the operation reports that immediate progress is no longer possible.

EAGAIN marks the drain boundary

For a nonblocking socket, read() or recv() returns -1 with errno set to EAGAIN or EWOULDBLOCK when no data can be read without blocking. In an edge-triggered loop, that result is the normal boundary between draining current readiness and returning to epoll_wait().

A compact read loop has this shape:

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

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

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

    if (errno == EINTR)
        continue;

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

    fail_connection(fd, errno);
    break;
}

This loop does not require one recv() per event. It treats the event as permission to attempt progress and keeps consuming until the kernel reports that the descriptor currently has no immediately readable data.

EINTR has different semantics. It indicates interruption by a signal before the operation completed as requested, so retrying the operation is distinct from waiting for a new readiness edge.

Nonblocking mode is part of the contract

Draining to exhaustion is safe for an event-loop thread only when the file descriptor uses nonblocking I/O. A blocking descriptor can stall the entire loop if a subsequent read waits for data after the currently available bytes have been consumed.

For sockets, applications commonly set O_NONBLOCK with fcntl() or obtain nonblocking descriptors through interfaces that support creation flags. EPOLLET changes notification behavior; it does not itself change the blocking mode of the descriptor.

The two settings solve separate parts of the mechanism. EPOLLET limits repeated readiness reporting, while O_NONBLOCK lets the handler probe until progress would require waiting.

Partial writes create the same state-management problem

Writable readiness also needs explicit state. A nonblocking send() can accept fewer bytes than requested, and it can eventually return EAGAIN. The application must retain the unsent suffix and resume from that offset after writable readiness becomes available again.

A connection with a 32 KiB output queue cannot treat one EPOLLOUT event as a guarantee that all 32 KiB fit into the socket send buffer. The useful invariant is narrower: while writes make progress, advance the queue; when EAGAIN appears, retain the remaining bytes and wait for a later writable transition.

Keeping EPOLLOUT enabled permanently can also create unnecessary wakeups in designs where sockets are usually writable. Many event loops enable writable interest only while pending output exists, then remove that interest after the queue has been drained. The exact registration strategy is application-specific, but pending-byte ownership must remain explicit.

Readiness does not reserve data for one worker

A readiness event is not a reservation. Between notification and the subsequent system call, another thread or process sharing access to the same underlying object may consume data or otherwise change state. Nonblocking I/O keeps that race from turning into an unintended blocking wait.

This distinction also matters when several threads wait on the same epoll instance or when descriptors are duplicated. Event delivery and object state are related, but the event does not transfer exclusive ownership of the ready bytes.

Handlers must therefore treat the I/O operation itself as authoritative. A returned byte count, EOF, EAGAIN, or another error describes the state observed at the system-call boundary more precisely than an earlier readiness notification.

One-shot registration adds a separate rearm boundary

EPOLLONESHOT can be combined with edge-triggered operation. After an event is delivered for a descriptor registered with EPOLLONESHOT, that descriptor is disabled in the interest list until the application rearms it with epoll_ctl() using EPOLL_CTL_MOD.

That mechanism is useful when a connection is handed to a worker and concurrent handling of the same registration must be constrained. It also introduces another state transition: draining I/O is not sufficient by itself; the worker must rearm the descriptor when the connection is ready to receive future notifications.

Rearming too early can permit another worker to observe activity while the current worker still owns mutable connection state. Failing to rearm suppresses later notifications entirely. The ownership protocol around the connection therefore matters as much as the kernel registration flags.

Edge-triggered loops depend on explicit progress state

Edge-triggered epoll reduces repeated notification of conditions that remain ready, but it moves more state responsibility into the application. Read handlers need a drain boundary, write handlers need retained offsets or queues, and one-shot designs need a defined rearm point.

The kernel reports readiness transitions; the application records unfinished work. Keeping those roles separate prevents a common failure mode in which bytes remain available but no future edge is guaranteed to bring the handler back.