An edge-triggered epoll registration can stop producing notifications while unread bytes still remain in a socket or pipe. The descriptor is still usable for I/O, but the event loop has already consumed the notification associated with the readiness transition. If the handler reads only part of the available data and returns to epoll_wait(), no fresh transition is required to occur, so the pending bytes can remain untouched indefinitely.
This behavior makes EPOLLET more than a notification preference. It changes the contract between the kernel’s ready list and application state. A level-triggered loop can repeatedly receive a descriptor while the requested I/O condition remains true. An edge-triggered loop must preserve enough local state to treat a delivered event as an obligation to exhaust the currently available nonblocking I/O, normally until an operation reports EAGAIN.
The boundary is readiness, not message completion, request completion, or application-level progress.
A notification is not a count of available operations
An epoll instance maintains an interest list and a ready list. The interest list records monitored targets and event masks. The ready list records references that currently have events available for delivery.
With level-triggered behavior, a descriptor that remains readable can continue to be reported across waits. This resembles the readiness semantics of poll(): the condition itself is sufficient to keep the descriptor eligible for reporting.
EPOLLET changes that relationship. Notification is associated with changes in readiness rather than continuous restatement of an already-ready condition. Consider a pipe that receives 2048 bytes. An event loop receives EPOLLIN, reads 1024 bytes, then immediately waits again. The remaining 1024 bytes do not constitute a new transition merely because the application left them behind.
The resulting state can be represented as:
empty
|
| writer adds 2048 bytes
v
readable ---- event delivered
|
| reader consumes 1024 bytes
v
still readable ---- no required fresh edgeThe event is therefore not a token authorizing one read. It is evidence that the descriptor entered or experienced a state relevant to the registered interest. Application code that maps one event to one bounded I/O call can lose forward progress even though neither the peer nor the kernel has failed.
Nonblocking I/O defines the safe exhaustion boundary
Draining an edge-triggered descriptor requires a stopping condition. Blocking I/O is unsuitable for that role because a handler can consume all immediately available input and then block waiting for data that has not arrived. In a single event-loop thread, that can prevent unrelated ready descriptors from being serviced.
Nonblocking mode supplies a precise local boundary. Reads can continue while data is immediately available. Once no more data can be read without waiting, read() fails with EAGAIN or EWOULDBLOCK. At that point, the handler has exhausted the current readable state according to the interface contract and can return control to the event loop.
A compact read path has this shape:
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) {
consume(buf, (size_t)n);
continue;
}
if (n == 0) {
close_connection(fd);
break;
}
if (errno == EAGAIN || errno == EWOULDBLOCK)
break;
if (errno == EINTR)
continue;
fail_connection(fd);
break;
}This loop does not imply that all application work must run without bounds. It only establishes the readiness boundary correctly. Parsing, request execution, or downstream processing can be scheduled separately once bytes have been transferred out of the kernel-facing I/O path.
Partial reads have different meanings for streams and records
The drain rule interacts with the kind of descriptor being monitored. Stream-oriented interfaces expose a byte stream without preserving application message boundaries. A short read can indicate that the currently available stream data has been exhausted when the requested count exceeds what is immediately available, subject to the documented behavior of the specific interface.
Record-oriented sources require more care. Datagram sockets preserve message boundaries, and other descriptor types can have their own read semantics. Treating a short transfer as a universal substitute for EAGAIN can therefore encode assumptions that do not hold for every monitored object.
The robust abstraction is not “read once” or “stop after a short read.” It is “establish that no further relevant nonblocking I/O can proceed now.” For general edge-triggered handlers, reaching EAGAIN makes that state explicit.
This distinction matters in reusable event-loop code. A handler designed around stream sockets can accidentally be applied to pipes, terminals, datagram sockets, or device descriptors with materially different transfer semantics. epoll reports readiness; it does not normalize the I/O contract of the target descriptor.
Write readiness creates the symmetric obligation
The same mechanism applies to output. A nonblocking socket can accept some bytes, reach capacity, and return EAGAIN. An event loop interested in EPOLLOUT can resume writes when the socket becomes writable again.
With edge triggering, a writable notification should not be treated as permission for exactly one write attempt. The handler normally advances the pending output until the socket can accept no more data without blocking or until the application has no output left.
This creates two distinct completion conditions:
application queue empty
kernel would blockThe first means the application has nothing left to send. The second means the kernel-facing output path is temporarily exhausted. Conflating them can produce unnecessary EPOLLOUT interest or stalled output.
A common event-loop design therefore tracks pending output explicitly. If the application queue becomes empty, write interest may no longer be useful. If write() reaches EAGAIN while bytes remain queued, write readiness remains relevant because a later capacity transition can resume progress.
The readiness API does not manage the application queue. It only exposes whether the underlying descriptor can currently make progress for the requested operation.
One-shot delivery adds an explicit rearm boundary
EPOLLONESHOT introduces another state transition. After an event is reported for a descriptor registered with this flag, that registration is disabled for further event delivery until user space rearms it with epoll_ctl() and EPOLL_CTL_MOD.
This is separate from edge triggering. EPOLLET changes notification behavior around readiness transitions. EPOLLONESHOT changes whether the registration remains enabled after an event is delivered.
Combining them can be useful when multiple worker threads share an event source. A worker can receive an event, own the descriptor’s processing interval, drain relevant nonblocking I/O, update connection state, then rearm the registration. The rearm operation becomes an explicit handoff boundary.
That boundary also creates a failure mode. If a worker returns through an error path without rearming a live descriptor, the descriptor can remain valid and even ready while producing no further events through that one-shot registration. The absence of events then reflects disabled interest, not an idle connection.
State machines using one-shot delivery need to make the enabled, processing, closing, and rearmed states explicit enough that every live path has a defined transition.
Descriptor identity extends beyond the integer value
An epoll interest entry is tied to the combination of a file descriptor number and its underlying open file description. This matters because descriptor integers can be duplicated and reused.
Closing one descriptor does not necessarily remove the underlying open file description from an epoll interest list if another descriptor still refers to that same open file description. Events can remain reportable until all references associated with that open file description are closed, unless the registration is explicitly removed first.
This property creates a sharp boundary for lifecycle code. An event payload that stores only an integer descriptor value can become ambiguous if the process closes that descriptor and the kernel later reuses the integer for another open file. A previously collected event and a newly opened resource can then carry the same integer while referring to different lifetimes.
Applications often attach a pointer, index, or generation-bearing token through epoll_event.data rather than treating the descriptor number as a complete identity. The exact strategy depends on object ownership and reclamation rules, but the invariant is broader: event delivery can outlive a simplistic integer-to-object association.
Batched events can contain stale application references
epoll_wait() can return multiple events in one call. Processing one event can close or otherwise invalidate application state associated with another event already present in that returned batch.
The kernel has already copied the event records into the caller’s array. Removing a registration does not retroactively erase another element from that user-space batch. Event-loop code therefore needs a way to detect that an object referenced by a later batch entry has already been retired during earlier processing.
This is an application-lifetime issue rather than a contradiction in readiness semantics. The event array is a snapshot of deliveries selected for that wait call; application callbacks can mutate the world while iterating through it.
Generation counters, stable connection objects with deferred reclamation, or explicit retired markers are common ways to keep batch iteration from dereferencing invalid state. The specific mechanism is less important than keeping resource lifetime distinct from event-array lifetime.
Draining can compete with fairness
A strict drain-until-EAGAIN loop can spend substantial time on one busy descriptor. If input keeps arriving fast enough, other ready descriptors can wait longer than the application intends.
That tension is not resolved by abandoning the edge-triggered readiness contract. Instead, the event loop can separate kernel readiness from scheduling policy. Once a descriptor has been observed ready, user space can record it in an application-level ready queue and apply bounded work budgets across connections.
A budgeted handler that stops before reaching EAGAIN must not simply forget the descriptor and rely on another kernel edge. It still has pending readiness knowledge. The application-level queue must preserve that fact and schedule the descriptor again even if epoll emits nothing new.
This produces a useful two-layer model:
kernel readiness transition
|
v
application ready state
|
v
bounded processing slices
|
v
EAGAIN clears local ready stateThe kernel supplies transitions. The scheduler supplies fairness. Mixing those roles causes subtle stalls because a scheduling decision to stop processing can accidentally be interpreted as evidence that readiness ended.
Edge triggering shifts state retention into user space
Level-triggered notification repeatedly exposes a condition that remains true. Edge-triggered notification reduces that repetition but requires the application to remember more.
After an edge is delivered, user space must retain the fact that work may remain until nonblocking I/O establishes exhaustion. If processing is split across callbacks, worker threads, or fairness slices, that retained state becomes part of the event loop’s correctness model.
This is the central trade. EPOLLET does not make readiness itself more precise, and it does not turn events into messages. It changes where persistent knowledge about readiness lives. The kernel reports transitions; the application carries the obligation forward until the relevant I/O path reaches a state that would block.