A process needs file descriptors for more than ordinary files. Network sockets, pipes, event interfaces, and duplicated references all compete for entries in its descriptor table. RLIMIT_NOFILE limits the descriptor numbers available to new allocations: the soft limit is one greater than the maximum permitted number. A service can therefore fail to accept work even while the machine has plenty of disk space and memory.

When it breaks

EMFILE reports that the calling process has reached its applicable descriptor limit. ENFILE identifies exhaustion of the system’s open-file resources. They require different investigations. Raising a machine-wide file setting does not by itself change the failed process’s soft limit, and increasing one application’s limit does not repair a system-wide resource shortage.

Exhaustion also damages diagnostics. Opening a log file or enumerating a directory may require another descriptor. An application that tries to inspect /proc/self/fd only after reaching its limit can fail during the inspection itself. Existing descriptors can still be usable, which makes an already established control pipe valuable for reporting the error without allocating another resource.

A rising descriptor count needs context. It can reflect growing legitimate concurrency, a pool warming up, or references whose owners never close them. The curve alone does not prove a leak. Compare descriptor types, workload, and the count after work drains; then trace ownership for references that remain beyond their intended lifetime.

Check it quickly

Save the following as exhaustion.py and run python3 exhaustion.py in a disposable Linux environment. The recorded test used Linux 6.12.76 and Python 3.11.2. The controller starts a separate child, which lowers only its own soft limit to 32. Both inherited limit values must initially permit that setting:

import os,resource,subprocess,sys

before=resource.getrlimit(resource.RLIMIT_NOFILE)
child=r'''
import errno,os,resource,socket,sys
soft,hard=resource.getrlimit(resource.RLIMIT_NOFILE)
assert soft>=32 and hard>=32
resource.setrlimit(resource.RLIMIT_NOFILE,(32,hard))
opened=[]
def emfile(operation):
    try:operation()
    except OSError as error:assert error.errno==errno.EMFILE,error
    else:raise AssertionError('expected per-process descriptor exhaustion')
try:
    while True:
        try:opened.append(os.open('/dev/null',os.O_RDONLY))
        except OSError as error:
            assert error.errno==errno.EMFILE,error;break
    assert opened and max(opened)==31
    emfile(lambda:socket.socket(socket.AF_UNIX,socket.SOCK_STREAM))
    emfile(lambda:os.dup(0))
    emfile(lambda:os.listdir('/proc/self/fd'))
    print('exhausted',flush=True)
    assert sys.stdin.readline()=='continue\n'
    released=opened.pop();os.close(released)
    replacement=os.open('/dev/null',os.O_RDONLY);opened.append(replacement)
    assert replacement==released
    print('EMFILE affects open, socket, dup, and self-enumeration; releasing one slot permits one open')
finally:
    for fd in opened:os.close(fd)
assert os.listdir('/proc/self/fd')
print('after cleanup, descriptor inspection succeeds again')
'''
process=subprocess.Popen([sys.executable,'-c',child],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
try:
    assert process.stdout.readline().strip()=='exhausted'
    descriptor=os.open('/dev/null',os.O_RDONLY);os.close(descriptor)
    print('controller can still open files while child is exhausted')
    process.stdin.write('continue\n');process.stdin.flush()
    output,error=process.communicate(timeout=10)
    assert process.returncode==0,error;print(output,end='')
finally:
    if process.poll() is None:process.kill();process.wait()
assert resource.getrlimit(resource.RLIMIT_NOFILE)==before

The child repeatedly opens /dev/null until allocation returns EMFILE. Its highest allocated number is 31, consistent with a soft limit of 32. The number of new opens is smaller than 32 because standard streams and any other inherited descriptors already occupy entries. Count the occupied table, rather than assuming the limit describes additional opens still available.

While full, the child checks socket creation, duplication of standard input, and enumeration of its own descriptor directory. All fail with EMFILE. The directory has not disappeared; reading its entries requires an open directory handle that this process can no longer allocate. This separates a diagnostic failure caused by resource exhaustion from a missing-path problem.

The child announces its state through the output pipe opened before exhaustion. The controller then successfully opens and closes /dev/null while the child remains full. That concurrent observation establishes the per-process boundary. The fixture does not intentionally exhaust the host’s system-wide file table, which would require a different, more disruptive experiment.

After receiving a continue message over its existing input pipe, the child closes one owned descriptor. The next open succeeds and reuses that available number. This demonstrates a precise recovery condition: one released slot permits one new allocation. It does not restore headroom for an arbitrary burst of incoming connections or a multi-step operation requiring several handles.

Finally, the child closes all descriptors it deliberately allocated and can enumerate /proc/self/fd again. The controller verifies its own resource limits are unchanged. Cleanup is in a finally block, and the controller terminates a child that fails to finish; the test leaves neither a permanently reduced parent limit nor a background workload consuming descriptors.

Recover capacity and repair ownership

During an incident, inspect the failing process’s limits and descriptor types from an authorized external process with available resources. Correlate the PID with the intended service instance. A shell’s ulimit output describes that shell, so it is insufficient evidence for a separately launched daemon’s budget.

Reduce new admissions when concurrency exceeds the intended budget, and release references when their work completes. If a leak is confirmed, repair its ownership path and exercise cancellation and error exits. Restarting can temporarily recover slots, but repeated exhaustion after similar work indicates that the underlying lifetime or capacity problem remains.

Set a launch-time limit with room for listeners, active work, files, pipes, and operational inspection. Validate the actual running process afterward. Increasing the allowance should follow a resource budget and measurements; it can permit more useful concurrency, but it can also move the bottleneck into memory, downstream connections, or the application’s event-handling mechanism.

Sources

  • getrlimit(2), RLIMIT_NOFILE, allocation limits, and EMFILE, checked 2026-09-12.
  • open(2), per-process EMFILE versus system-wide ENFILE, checked 2026-09-12.
  • dup(2), descriptor allocation and exhaustion when duplicating a reference, checked 2026-09-12.