Skip to content
LinuxDeep Dive Published Updated 10 min readViews unavailable

Linux prlimit: Set a Child's Resource Contract Before exec

Use Linux prlimit() to shape a disposable child before exec, understand soft and hard ceilings, and verify inheritance without changing the host or an arbitrary PID.

A process limit is part of a program’s launch contract. It determines what the process and its threads may consume, and Linux carries that policy through fork() and execve(). prlimit() can inspect or change one resource limit for the calling process or, when permissions allow, another process. Its usefulness comes with a sharp boundary: changing your own disposable child is different from reaching into a service that is already running.

For a launcher, the clearest sequence is to create a child, set the child’s own limit before it runs the worker, then replace the child image with execve(). That keeps the parent’s limits unchanged and avoids a window in which the worker starts before a parent-side adjustment. The example below follows that pattern for RLIMIT_NOFILE; it never accepts a PID and never changes the hard limit.

Read the soft value and hard ceiling as a pair

The struct rlimit fields have different jobs:

struct rlimit {
    rlim_t rlim_cur; /* soft limit: enforced value */
    rlim_t rlim_max; /* hard limit: ceiling for the soft value */
};

The soft limit is the operating value for the resource. An unprivileged process can set it from zero through the hard limit. It can also lower its hard limit, but ordinarily cannot raise that hard ceiling again. On Linux, a process with CAP_SYS_RESOURCE in the initial user namespace can change either value; this is not a reason to add a privileged retry to an application.

For RLIMIT_NOFILE, the value is one greater than the largest file-descriptor number the process may open. It is not a command to close existing descriptors. Lowering the soft limit leaves descriptors already open in place, including numbers now above the new ceiling. Later allocations that need a descriptor number at or above that ceiling can fail with EMFILE. The manual’s general rule is that lowering a soft limit below current consumption succeeds and prevents further increasing consumption for that resource. That is not a reclamation operation; the exact failure point depends on the resource, and the kernel does not close existing descriptors to make usage fit.

Use prlimit() at the child boundary

The Linux-specific interface is declared in <sys/resource.h> and requires _GNU_SOURCE with glibc:

int prlimit(pid_t pid, int resource,
            const struct rlimit *new_limit,
            struct rlimit *old_limit);

Pass pid as zero to operate on the calling process. A null new_limit queries without changing the value; a non-null new_limit supplies the new soft and hard pair. A non-null old_limit receives the previous pair on successful return. If the resource or the pair is invalid, or the caller lacks permission, the call fails and sets errno.

The self-before-exec pattern is useful for a small helper or a test launcher. This complete fixture forks one child, lowers only that child’s soft descriptor limit to at most 32, verifies the pair, and execs itself in a worker mode. The worker makes no more than eight attempts to open /dev/null, closes every descriptor it obtained, and exits. Save the C source as prlimit-child-test.c, then compile its executable into a fresh temporary directory on a disposable Linux test machine:

set -eu
test_dir=$(mktemp -d)
trap 'rm -rf -- "$test_dir"' EXIT
cc -Wall -Wextra -O2 -o "$test_dir/prlimit-child-test" prlimit-child-test.c
"$test_dir/prlimit-child-test"
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <sys/resource.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

extern char **environ;

static int
run_worker(const char *expected_soft_text, const char *expected_hard_text)
{
    struct rlimit limit;
    int opened[8];
    size_t count = 0;
    char *end;
    uintmax_t expected_soft, expected_hard;

    errno = 0;
    expected_soft = strtoumax(expected_soft_text, &end, 10);
    if (errno != 0 || *end != '\0')
        return 1;
    errno = 0;
    expected_hard = strtoumax(expected_hard_text, &end, 10);
    if (errno != 0 || *end != '\0')
        return 1;

    if (prlimit(0, RLIMIT_NOFILE, NULL, &limit) == -1) {
        perror("prlimit worker query");
        return 1;
    }
    if ((uintmax_t) limit.rlim_cur != expected_soft ||
        (uintmax_t) limit.rlim_max != expected_hard) {
        fprintf(stderr, "worker limits differ from the pre-exec pair\n");
        return 1;
    }
    printf("worker limits: soft=%ju hard=%ju\n",
           (uintmax_t) limit.rlim_cur, (uintmax_t) limit.rlim_max);

    for (size_t i = 0; i < sizeof(opened) / sizeof(opened[0]); i++) {
        int fd = open("/dev/null", O_RDONLY | O_CLOEXEC);

        if (fd == -1) {
            if (errno != EMFILE) {
                perror("open /dev/null");
                for (size_t j = 0; j < count; j++)
                    close(opened[j]);
                return 1;
            }
            break;
        }
        opened[count++] = fd;
    }

    for (size_t i = 0; i < count; i++)
        close(opened[i]);
    printf("bounded open attempts completed; opened=%zu\n", count);
    return 0;
}

int
main(int argc, char *argv[])
{
    pid_t child;
    int status;
    struct rlimit parent_before, parent_after;

    if (argc == 4 && strcmp(argv[1], "--worker") == 0)
        return run_worker(argv[2], argv[3]);
    if (argc != 1 || strchr(argv[0], '/') == NULL) {
        fprintf(stderr, "run this fixture as ./prlimit-child-test\n");
        return 2;
    }
    if (prlimit(0, RLIMIT_NOFILE, NULL, &parent_before) == -1) {
        perror("prlimit parent query");
        return 1;
    }

    child = fork();
    if (child == -1) {
        perror("fork");
        return 1;
    }
    if (child == 0) {
        struct rlimit before, requested, observed;

        if (prlimit(0, RLIMIT_NOFILE, NULL, &before) == -1)
            _exit(120);
        requested = before;
        if (requested.rlim_cur > (rlim_t) 32)
            requested.rlim_cur = (rlim_t) 32;

        /* Keep the inherited hard ceiling; change only the soft value. */
        if (prlimit(0, RLIMIT_NOFILE, &requested, &before) == -1)
            _exit(121);
        if (before.rlim_max != requested.rlim_max)
            _exit(122);
        if (prlimit(0, RLIMIT_NOFILE, NULL, &observed) == -1 ||
            observed.rlim_cur != requested.rlim_cur ||
            observed.rlim_max != requested.rlim_max)
            _exit(123);

        char expected_soft[32], expected_hard[32];
        if (snprintf(expected_soft, sizeof(expected_soft), "%ju",
                     (uintmax_t) requested.rlim_cur) < 0 ||
            snprintf(expected_hard, sizeof(expected_hard), "%ju",
                     (uintmax_t) requested.rlim_max) < 0)
            _exit(124);
        char *const worker_argv[] = {
            argv[0], "--worker", expected_soft, expected_hard, NULL
        };
        execve(argv[0], worker_argv, environ);
        _exit(127);
    }

    if (waitpid(child, &status, 0) == -1) {
        perror("waitpid");
        return 1;
    }
    if (prlimit(0, RLIMIT_NOFILE, NULL, &parent_after) == -1) {
        perror("prlimit parent verification");
        return 1;
    }
    if (parent_before.rlim_cur != parent_after.rlim_cur ||
        parent_before.rlim_max != parent_after.rlim_max) {
        fprintf(stderr, "parent resource limits changed unexpectedly\n");
        return 1;
    }
    printf("parent limits unchanged: soft=%ju hard=%ju\n",
           (uintmax_t) parent_after.rlim_cur,
           (uintmax_t) parent_after.rlim_max);
    if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
        fprintf(stderr, "disposable child failed, wait status=%d\n", status);
        return 1;
    }
    return 0;
}

The parent never calls prlimit() on the child. The child passes zero, so its own copied limit state is changed. requested starts as the inherited pair and only rlim_cur can be reduced; the hard value is then checked both as the prior value returned by the update and after the update. The worker’s short, fixed loop is a bounded fixture, not a way to exhaust a service’s descriptor table. Run it only as a disposable test, and remove the test binary when finished.

This fixture uses a query followed by an update because it chooses a soft value relative to the child’s inherited value. It is safe as a single-threaded, isolated test child with no other limit writer. It is not compare-and-swap. Another actor can change a target between separate calls, and the API has no expected-old argument that says “install this pair only if the current pair still equals X.” Even the one-call form that supplies both new_limit and old_limit does not make that conditional promise. Do not build a concurrent coordination protocol from a query-then-set sequence.

Know which process and privilege boundary you are crossing

An ordinary self-update and an update to another PID have different authorization checks. For another process, the manual requires either CAP_SYS_RESOURCE in the target’s user namespace, or a match between the target’s real, effective, and saved user IDs and the caller’s real UID, together with the equivalent group-ID match to the caller’s real GID. Linux security modules can impose additional checks. Separately, raising a hard limit requires CAP_SYS_RESOURCE in the initial user namespace; matching the target’s user and group IDs does not grant that capability. For RLIMIT_NOFILE, Linux also rejects a hard value above /proc/sys/fs/nr_open.

Use pid = 0 for a process changing its own launch policy. If a supervisor must configure a child from outside, make ownership and startup ordering explicit: hold the child behind a synchronization point, confirm the process identity, apply the allowed limit, check the result, and only then let it exec. A numeric PID alone is not a durable capability, and a post-fork parent update can race with the child running, replacing its image, or exiting. Do not copy a command that accepts an arbitrary PID into an operational script without a specific authorization design.

Treat fork and exec as inheritance boundaries

Linux copies the parent’s resource limits to a child created by fork(). Resource limits are process attributes shared by all threads in the process, rather than independent per-thread settings. A limit change therefore affects the process’s threads together; changing it from one thread is not a private budget for that thread.

execve() replaces the program image but preserves resource limits. A launcher can set the child’s limit before execve() and the new program will start with that policy. This differs from process attributes that execve() resets or discards, so do not assume the worker begins with system defaults. Inspect the actual execution context, for example through /proc/<pid>/limits, when diagnosing a service.

Setting the child before exec also makes the design easier to reason about than adjusting a long-running service. It avoids changing a parent’s own budget, narrows the time before the worker starts under the intended limit, and gives the new image a clear inherited contract. It is not a sandbox: resource limits complement, but do not replace, cgroups, namespaces, capability reduction, seccomp, or service-manager policy.

Do not assume an update can always be rolled back

The old_limit output is the previous pair on a successful call; it is not a general transaction log. In the current upstream implementation, cross-process permission and security checks happen before the update helper. That helper locks the target’s group leader, checks the hard-limit capability and the task-specific setrlimit security hook, then reads or installs the pair. The syscall copies a requested old-value result to user memory after that locked update path. If the copy fails, the caller can receive EFAULT even though the limit change has already happened. There is no documented promise that every failure after a requested update restores the prior pair.

Treat an error as a state that needs verification, not as proof that nothing changed. In an isolated helper, query the helper’s own current limits after an error if continuing is safe. For a launcher, the simpler failure policy is often to stop the child before it executes the workload. Do not try to “roll back” by raising a hard limit that an unprivileged process may no longer restore.

Test the contract, not just the setter’s return value

In a Linux-only test fixture, verify all of the following:

  1. The parent reports the same RLIMIT_NOFILE pair before and after the disposable child runs.
  2. The child observes a soft value no greater than the chosen bound and the same hard value it inherited.
  3. The --worker image reports the same pair after execve(), confirming the inherited contract.
  4. The worker performs only its fixed number of harmless open attempts and closes every descriptor it acquired.
  5. Invalid resource values, a soft value above the hard ceiling, a missing target PID in a separately designed test, and denied cross-process access produce checked failures rather than assumed success.

Run privilege-boundary cases only in a disposable VM or container fixture designed for them. Do not test by lowering the interactive shell’s hard limit, changing an arbitrary service PID, exhausting descriptors, or altering /proc/sys/fs/nr_open. Keep tests small enough that a mistake affects only the test child. For persistent deployment policy, configure the service manager or container runtime that owns process creation, then verify the launched process; an application-level prlimit() call is not a global setting.

The practical contract is simple: inspect the inherited pair, lower only what the child needs, retain the hard ceiling unless a privileged owner deliberately configures it, and establish the limit before the worker runs. Verify the new image’s actual values. That yields a testable per-process boundary without confusing it with a host-wide resource setting.

Related:

Sources:

Comments