An edge-triggered epoll consumer can block while unread data is still buffered. The failure appears when an event is consumed, only part of the available input is read, and the event loop returns to epoll_wait() expecting another notification for the bytes that remain.
This behavior follows directly from EPOLLET. Edge-triggered notification reports changes in readiness rather than continuously reporting a ready condition. Once a readiness transition has produced an event, leaving the file descriptor ready does not itself create a new transition.
A partial read can consume the notification but not the data
Consider a nonblocking pipe registered with EPOLLIN | EPOLLET. A writer places 2048 bytes into the pipe. epoll_wait() reports the read end as ready, then the consumer reads only 1024 bytes.
At that point, two facts coexist:
- 1024 bytes remain readable.
- the readiness edge associated with the write has already been reported.
A later epoll_wait() can therefore sleep even though the pipe still contains data. No contradiction exists: the descriptor is ready, but edge-triggered mode is not a standing assertion of readiness.
The same boundary matters for stream sockets. A receive buffer may hold more bytes than one application-level operation consumes. Treating one EPOLLIN event as permission for exactly one read() call can strand buffered input.
EAGAIN marks the useful edge boundary
With a nonblocking descriptor, repeated reads eventually reach one of several states. A positive return value means bytes were consumed. A zero return on a stream indicates end of file. A negative return with EAGAIN or EWOULDBLOCK means no more input can be consumed at that moment.
For an edge-triggered event loop, that last state is significant. Reaching EAGAIN establishes that the current readable state has been drained. A later arrival can then move the descriptor from not-ready to ready and create another edge.
A compact receive path has this shape:
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) {
process_bytes(buf, (size_t)n);
continue;
}
if (n == 0) {
close_connection(fd);
break;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
}
if (errno == EINTR) {
continue;
}
handle_read_error(fd, errno);
break;
}The loop is not an optimization detail. It preserves the event model: once the event loop stops servicing a readable descriptor, the descriptor should have reached a state in which further immediate reads cannot proceed.
Nonblocking mode prevents one descriptor from capturing the loop
Draining does not imply issuing blocking reads until more network traffic arrives. EPOLLET is normally paired with nonblocking file descriptors so the drain operation terminates at EAGAIN rather than suspending the thread inside read().
Without nonblocking mode, a handler that keeps reading can consume the currently available data and then block waiting for future data. That stalls processing for every other descriptor assigned to the same event-loop thread.
The two properties work together:
edge-triggered notification -> process current readiness fully
nonblocking I/O -> stop when current readiness is exhaustedThis pairing separates readiness processing from waiting. Waiting stays in epoll_wait(); individual I/O calls consume only work that is available immediately.
Write readiness has the same state boundary
The same mechanism applies to EPOLLOUT. A nonblocking socket can accept some output and then return EAGAIN when its send buffer has no usable space. With edge-triggered notification, the application should retain unsent bytes and treat the descriptor as writable until writes reach that boundary.
A partial write alone does not imply that the socket has become not-writable. Returning to epoll_wait() immediately after a short application-chosen write can leave additional capacity unused without a fresh readiness transition.
Output handling therefore needs explicit pending state. When the pending buffer becomes empty, interest in EPOLLOUT may no longer be useful. When a write reaches EAGAIN, a later transition back to writable can produce the next event.
EPOLLONESHOT adds a separate rearm requirement
EPOLLONESHOT changes another part of the lifecycle. After an event is delivered for a descriptor registered with this flag, that registration is disabled until epoll_ctl() with EPOLL_CTL_MOD rearms it.
Draining and rearming solve different state problems. Draining consumes the readiness represented by the edge. Rearming enables delivery after one-shot suppression. A handler using both EPOLLET and EPOLLONESHOT generally needs to complete its current nonblocking I/O work, update application state, and then rearm the descriptor when future events should be accepted.
This distinction becomes important in multithreaded dispatchers. One-shot registration can prevent multiple workers from concurrently processing the same descriptor, while the drain boundary still governs whether buffered I/O has been fully serviced.
Fairness can limit work without discarding readiness state
A descriptor with sustained traffic can produce enough work to monopolize an event-loop iteration. Draining without a scheduling policy can therefore create starvation pressure even when the readiness logic is correct.
A bounded event loop can keep an application-level ready queue. If a handler reaches its byte, message, or time budget before reaching EAGAIN, it records that the descriptor still has pending work and schedules it again locally rather than relying on a new kernel edge.
That preserves the key invariant: a descriptor known to remain ready is not forgotten merely because the handler yielded for fairness.
Edge-triggered epoll is therefore a transition protocol between kernel readiness state and application scheduling state. EAGAIN is the normal point at which the application can stop carrying local readable or writable state and rely on a future readiness transition to signal more work.