System Calls & the Kernel Interface Questions
The boundary between user space and the kernel: how programs request privileged services through system calls, the user/kernel mode transition, and the semantics of core POSIX calls such as fork, exec, wait, open, read, write, and stat. Covers syscall numbers, arguments, return values and errno, and how libc wrappers relate to the underlying trap. This is the foundational interface for all systems programming on Unix/Linux.
Describe how process credentials (real and effective UID/GID) interact with execve(2) and the setuid/setgid bits. Explain the security model for setuid binaries, how interpreters and shebang lines affect setuid behavior, and what operational recommendations you'd make for running third-party binaries that require elevated privileges.
Sample Answer
A process's identity for permission checks is a set of credentials, not one number, and execve(2) (the syscall that replaces a process's memory image with a new program) has specific, well-defined rules for updating them. This answer covers the credential model, the setuid/setgid security model, why interpreter scripts are a special case, and what to actually do operationally.
1. Process credentials and how execve() updates them
Every process carries (at least) three UIDs and three GIDs: the real UID (RUID, "who actually launched this"), the effective UID (EUID, "whose permissions are checked for most operations right now"), and the saved set-user-ID (saved UID, "the privilege this process is allowed to switch back to"). Linux adds a fourth, the filesystem UID (FSUID), used only for filesystem access checks and normally tracks EUID automatically.
On an ordinary execve() of a non-setuid file, RUID, EUID, and saved UID are all preserved unchanged from the calling process. The setuid/setgid bits change that: if the file being executed has the set-user-ID bit (mode bit S_ISUID) set, a successful execve() sets the new process's EUID to the file owner's UID, and also updates the saved UID to that same value, while RUID stays whatever it was before (typically the invoking user). The set-group-ID bit (S_ISGID) does the analogous thing to EGID using the file's group owner. This is exactly how /usr/bin/passwd works: it is owned by root and has the setuid bit set, so when an ordinary user runs it, RUID stays that user's UID (so the program can still tell who invoked it) but EUID becomes 0 (root), which is what lets the program write to /etc/shadow, a file the invoking user has no direct permission to touch.
2. The security model for setuid binaries
A setuid-root binary is a deliberate, narrow hole punched through the normal permission model: "this specific program, and only this program, may act as root on the invoking user's behalf." That model only holds if the program is written to close every path an attacker could use to make it act as root on their behalf instead of the intended narrow task. Concretely that means: sanitize or drop dangerous inherited environment variables (PATH, IFS, LD_PRELOAD, LD_LIBRARY_PATH) before doing anything, because a setuid program that shells out or dynamically links using an attacker-controlled PATH or LD_PRELOAD can be tricked into running attacker code with root's privileges; validate every argument and avoid unbounded buffers (a setuid binary with a stack-based buffer overflow is a direct local-root exploit, since the overflow runs with the effective, elevated privileges); drop privileges (via setuid()/setresuid(), not just seteuid(), because seteuid() alone leaves the saved UID unchanged, so a compromised process could call seteuid(0) again and reclaim the privilege it appeared to have dropped; setresuid() also updates the saved UID, closing that path) the moment the elevated action is done, rather than running the whole program's lifetime at EUID 0; and never trust the RUID caller's other ambient state (open file descriptors, working directory, umask) without re-checking it under the elevated identity. Every historical local-root CVE class (format-string bugs, TOCTOU races on files the program then opens as root, PATH-based library or helper hijacking) is dangerous specifically because the vulnerable code is running setuid.
3. Interpreters and shebang lines
Modern Linux does not honor the setuid bit on an interpreter script (a file starting with #!/path/to/interpreter), even if the script's file itself has the bit set and is owned by root. The reason is a classic time-of-check-to-time-of-use (TOCTOU) race: the kernel identifies the file as a script and hands the path to the interpreter to open and read; between the kernel's permission check on the script and the interpreter actually opening that path a moment later, an attacker with write access to the containing directory can swap the script out from under it (via a symlink race or a rename), so the interpreter ends up executing attacker-controlled content with the privilege the kernel granted to the original, legitimate script. Because that race is essentially unfixable at the shebang layer, Linux (and most modern Unixes) simply drops the setuid/setgid bit's effect when the target is an interpreter script; the interpreter itself starts at the caller's real privilege, not the elevated one. (Some older systems shipped a dedicated setuid-safe wrapper like suidperl for this exact gap; it is deprecated/removed on current distributions precisely because it kept reintroducing this class of bug.) The practical takeaway: if you need setuid behavior, it has to live in a compiled binary, not a shell/Python/Perl script with the bit set on the script file itself.
4. Operational recommendations for third-party binaries needing elevated privileges
- Prefer Linux file capabilities over setuid-root:
setcap cap_net_bind_service+ep /usr/local/bin/myproxygrants only the ability to bind ports below 1024, not full root, which shrinks the blast radius of any bug in that binary to "can bind low ports," not "can do anything." - If you must run something as root, scope it with
sudoto the exact command and arguments (asudoersentry with a fixed command line, notNOPASSWD: ALL), not blanket root shell access. - Run the binary in a container or with systemd service hardening:
User=/DynamicUser=yesto avoid literal root,NoNewPrivileges=yesto block it from gaining more privilege than it started with (this is the same effect asPR_SET_NO_NEW_PRIVS),CapabilityBoundingSet=to cap which capabilities it could even acquire, and a seccomp profile to restrict which syscalls it can issue at all. - Verify provenance before granting any of the above: checksum/signature-verify the binary, and if it's closed-source, treat it as untrusted input to your privilege boundary, not a trusted extension of it.
- If it insists on the setuid bit itself, audit whether that's actually required (many "needs root" third-party tools only need one specific capability, e.g. raw sockets or
CAP_NET_ADMIN) and replace the setuid bit with the matching file capability wherever possible, since capabilities are strictly narrower than "full effective UID 0." - One useful cross-check: setuid and setgid processes cannot be attached to with ptrace(2) (the debugging/tracing syscall) by an unprivileged tracer, by design, specifically so a normal user can't attach a debugger to a setuid-root process and manipulate its already-elevated memory. If you find yourself needing to strace a setuid binary to understand it, that itself is a sign to do the investigation as root or to test with a non-setuid copy in a sandboxed environment instead of fighting the protection.
What does futex(2) actually do under the hood on Linux: the userspace fast-path, when the kernel's futex wait/wake is actually invoked, wait-queue semantics, and how futexes show up in strace output? How would you diagnose a system where many threads are blocked in FUTEX_WAIT and latency spikes occur?
Sample Answer
A futex (fast userspace mutex) is the Linux primitive underneath essentially every userspace lock (pthread_mutex_t, semaphores, condition variables): the actual lock state is a plain integer word living in userspace, and the kernel's futex(2) syscall is only involved when there's contention.
1. The userspace fast path
In the uncontended case, acquiring and releasing a futex-backed lock never enters the kernel at all: the locking code does an atomic compare-and-swap on the futex word directly in userspace (e.g. "if the word is 0 (unlocked), set it to the caller's identity and proceed"), and unlocking is the reverse atomic operation. This is why uncontended locking is essentially free, on the order of a single atomic instruction, with no syscall overhead.
2. When the kernel's wait/wake is actually invoked
The kernel only gets involved on the slow path: when a thread's atomic compare-and-swap fails because the lock is already held, it calls futex(FUTEX_WAIT, addr, expected_value, ...). This call atomically re-checks that the word at addr still equals expected_value and, only if so, puts the calling thread to sleep on a kernel-maintained wait queue keyed off that memory location (internally, addresses are hashed into a global futex hash table). That atomic check-then-sleep is what avoids a lost-wakeup race: without it, the value could change (the lock could be released) in the gap between the userspace thread deciding to sleep and the kernel actually parking it, and the wakeup that was meant for it would have nothing to wake. The thread releasing the lock, if it noticed contention (typically via a "contended" bit or count it maintains in the same word), calls futex(FUTEX_WAKE, addr, n) to wake up to n waiters parked on that address.
3. Wait-queue semantics
Threads blocked via FUTEX_WAIT on the same address sit on that address's wait queue in the kernel; FUTEX_WAKE only wakes threads that are actually parked there right now; if no thread happens to be sleeping on that address at the moment of the wake (e.g. it hasn't reached its FUTEX_WAIT call yet), the wake is simply a no-op, which is fine precisely because the userspace protocol re-checks the actual lock word's value rather than trusting that a wake implies success. Per the futex(2) man page, a FUTEX_WAIT return of 0 doesn't even guarantee the wake was "for you": spurious wakeups can happen (e.g. from unrelated code that previously used the same memory address), so correct callers always re-check the userspace lock word after waking, not just trust the return value.
4. How futexes show up in strace
Running strace -f (following forked/cloned threads) against a multi-threaded program under lock contention shows lines like:
futex(0x7f2a1c0009d0, FUTEX_WAIT_PRIVATE, 2, NULL) = 0
futex(0x7f2a1c0009d0, FUTEX_WAKE_PRIVATE, 1) = 1
The _PRIVATE suffix (FUTEX_PRIVATE_FLAG) is a performance hint meaning the futex is only ever used within a single process's threads (not shared across processes via shared memory), which lets the kernel skip some address-space bookkeeping it would otherwise need for a possibly cross-process futex. A FUTEX_WAIT returning -1 ETIMEDOUT means a timed wait expired without a wake; -1 EAGAIN means the value at the address didn't match expected_value at check time (the caller lost the race and should retry in userspace rather than sleep).
5. Diagnosing many threads blocked in FUTEX_WAIT with latency spikes
Many threads parked in FUTEX_WAIT means heavy lock contention: threads are repeatedly hitting the slow path instead of acquiring uncontended. To diagnose:
- Confirm the shape.
strace -f -c -T(or attach to a running process) on the service to see how much time and how many calls are going intofutex()relative to everything else; a large fraction of wall time inFUTEX_WAITwith modest CPU usage says the bottleneck is serialization, not raw compute. - Check thread state.
ps -eLo pid,tid,state,wchan(wchan: the kernel function name the thread is blocked inside, useful for seeing what specifically it's waiting on) or/proc/<pid>/task/*/statusshows each thread's state; a futex wait normally shows as interruptible sleep (stateS), which is a useful contrast against D state (uninterruptible sleep, meaning the thread is blocked inside the kernel, usually on I/O, and cannot even be interrupted by a signal until the wait completes). Seeing threads inDstate alongside the futex waiters usually points at an I/O bottleneck feeding the contention, not the locking itself. - Get the actual call stack. The futex address alone doesn't say which mutex or which code path is contended; a stack-sampling profiler (
perf record/perf top, or a language-level profiler likepy-spyfor Python,async-profilerfor the JVM) attached during the contention window will show the specific lock acquisition site, which is usually the fastest way from "many futex waits" to "this specific global lock/connection pool/cache mutex." - Look for a lock convoy or an undersized shared resource. Common root causes: one hot global lock protecting a data structure that many threads touch on every request, a connection pool or semaphore sized well below the actual concurrency level, or a single-writer structure (like a language runtime's global interpreter lock analog) being hit far more often than intended.
- Fix by reducing contention, not by adding CPU. Shrinking the critical section, sharding the lock (e.g. per-shard mutexes instead of one global one), or increasing the size of the constrained resource (pool/semaphore) addresses the actual bottleneck; more CPU cores don't help serialized code waiting on a single lock.
Compare select(2), poll(2), and epoll(7) on Linux: their complexity characteristics, limits (e.g. fd_set size), kernel/user interactions, and pitfalls when implementing an event loop (such as edge-triggered epoll gotchas). Which would you choose for a service handling tens of thousands of concurrent connections, and why?
Sample Answer
select(2), poll(2), and epoll(7) all answer the same question ("which of these file descriptors are ready for I/O right now"), but they differ in exactly the dimension that matters at scale: how the cost of asking scales with the number of watched fds versus the number of ready fds.
1. Complexity and the fd_set limit
select() represents the watched set as a fixed-size bitmap (fd_set), sized by FD_SETSIZE, typically 1024 on Linux/glibc. That's a hard ceiling on the numeric value of any fd you can watch, not just a count limit: if your process happens to have an fd numbered 1025 open (easy to hit with enough concurrent connections over the process's lifetime), you literally cannot pass it to select(), regardless of how few fds are active at once. Every call is O(n) in the number of watched fds: the kernel scans the whole bitmap, and userspace has to rebuild the fd_set before every call since select() mutates it in place. poll() replaces the bitmap with an array of struct pollfd, removing the FD_SETSIZE ceiling, but is still O(n) per call: the kernel scans the whole array, and the whole array is copied user-to-kernel and kernel-to-user on every single call regardless of how many fds are actually ready. Neither scales to watching tens of thousands of mostly-idle connections, since both pay for the full watched set on every call. epoll() splits registration from waiting: epoll_ctl() registers a fd with a persistent, kernel-side interest list once, and epoll_wait() returns only the fds that are actually ready, an O(1)-amortized cost per ready event rather than O(n) over the whole watched set.
2. Kernel/user interactions
select/poll are stateless from the kernel's perspective: each call is a fresh, complete description of what to watch, so the kernel has to walk that whole description every time, and the copy-in/copy-out of the (possibly large) fd list happens on every call too. epoll keeps that state inside the kernel across calls, in the epoll instance created by epoll_create1(); epoll_ctl(EPOLL_CTL_ADD/MOD/DEL, ...) mutates that persistent interest list, and epoll_wait() just asks "what's ready," with no need to re-describe the whole set. This is the mechanical reason epoll's cost tracks the number of events, not the number of watched fds.
3. Pitfalls implementing an event loop, including edge-triggered gotchas
epoll has two readiness-reporting modes. Level-triggered (LT, the default) behaves like select/poll: as long as data remains, epoll keeps reporting the fd as ready on every epoll_wait() call. Edge-triggered (ET, EPOLLET) reports a fd exactly once, at the moment it transitions from not-ready to ready. The classic ET bug: if a handler reads one chunk from a socket, sees there's more data buffered, but moves on to the next fd "to be fair" instead of draining fully, the socket buffer still has bytes in it, but no new edge (transition) will occur until more data arrives from the peer, so that handler is never woken for the leftover bytes and the connection silently stalls. The only correct pattern under ET is: set the fd non-blocking, and loop read()/write() until the call returns -1/EAGAIN, then go back to epoll_wait(). LT mode avoids that specific class of bug by re-notifying every time, at the cost of the notification overhead LT and select/poll share.
4. Which to choose for tens of thousands of concurrent connections
epoll, and it isn't close. For a service holding, say, 50,000 concurrent connections where only a few hundred are active in any given instant, select/poll pay for scanning and copying all 50,000 watched descriptors on every call, while epoll_wait() only returns and processes the few hundred that are actually ready, roughly two orders of magnitude less work per call at that scale, on the same basis (cost per readiness-check call). The only reasons to still reach for poll() are portability (epoll is Linux-specific) or a genuinely small, low-fd-count workload where the difference doesn't matter.
5. The same pattern one layer up: Python's asyncio
Python's asyncio event loop is a good example of this exact reasoning showing up above the raw syscall layer. On Linux, the selectors module asyncio builds on prefers epoll (selectors.EpollSelector) over select/poll for precisely the C10K-style reasoning above (the classic C10K problem: how to serve ten thousand simultaneous connections without the per-connection cost exploding): a single-threaded event loop juggling thousands of open sockets needs cost proportional to ready connections, not watched ones. This is also why asyncio uses one (or a small, fixed number of) OS thread(s) running an epoll-based reactor rather than a thread-per-connection model: each OS thread carries real overhead (a default stack reservation, scheduler and context-switch cost), so thousands of threads for thousands of mostly-idle connections would reproduce the same scaling problem epoll was built to avoid, just moved from "syscall cost" to "thread/scheduling cost." As for the edge-triggered pitfall: asyncio's default selector-based loop runs epoll in level-triggered mode specifically so individual callback authors don't have to implement the drain-to-EAGAIN discipline themselves; the trade-off is the LT overhead described above. That pitfall doesn't disappear from the ecosystem, though, it moves to wherever a lower layer does use edge-triggered mode directly, e.g. a C extension or an alternative event-loop implementation (such as one built on libuv) that embeds its own edge-triggered epoll internals: any code integrated at that layer still has to fully drain a readable fd on each wakeup, or it will reproduce the exact same "stalled connection" bug described above, just one level of abstraction further from the raw syscall.
During an incident you observe a process issuing tens of thousands of open(2) calls per second causing IO and CPU overload. Provide a step-by-step incident response plan to: (1) collect evidence (syscall counts, flamegraphs, stack traces), (2) quickly mitigate impact (rate-limit, cgroups, restart), and (3) propose durable fixes (caching, lazy open, file descriptor pooling). Include exact commands/tools (bpftrace, perf, systemd-cgtop) you would use and trade-offs of each.
Sample Answer
A process issuing tens of thousands of open(2) calls per second is a distinct failure shape from a general fd leak or general syscall overhead: it's specifically an open()-storm, usually meaning the same file (or class of files) is being opened, used briefly, and closed, over and over, far more often than the workload actually requires. The response splits cleanly into evidence, mitigation, and durable fix, and each phase has its own tools.
1. Collect evidence
- Syscall counts, live and low-overhead.
bpftrace -e 'tracepoint:syscalls:sys_enter_open,tracepoint:syscalls:sys_enter_openat /pid == <pid>/ { @[str(args->filename)] = count(); }'(interrupt after a short window to print the aggregated map) gives a ranked list of exactly which paths are being opened and how often, in one pass, without the overhead of stopping the process at every call. A representative window of output looks like:
@[/var/lib/app/cache/entry-4471.tmp]: 41823
@[/var/lib/app/cache/entry-9002.tmp]: 39215
@[/etc/resolv.conf]: 118
@[/var/lib/app/config.yaml]: 4
Read it top-down: the highest counts are the paths to investigate first. Here, two per-request cache files being reopened tens of thousands of times each dwarf everything else, which is the signal to go straight to the caching code path rather than treating this as broad, diffuse open() activity. This single command usually answers "is it one hot file, one hot directory, or genuinely diverse paths" faster than anything else.
- Flamegraphs.
perf record -F 99 -p <pid> -g -- sleep 30followed by the standard flamegraph-generation scripts turns 30 seconds of CPU sampling into a visual breakdown of where CPU time is actually going; for an open()-storm this usually shows a wide, flat tower under whatever code path calls open(), making it obvious if it's one call site or several. - Stack traces at the moment of the call.
perf trace -e open,openat -p <pid>(or a bpftrace program that capturesustack()on the same tracepoints) attributes each open() call to a specific userspace call stack, which is what actually gets you from "there's an open() storm" to "this specific function is doing it."
2. Mitigate impact quickly
- Rate-limit at the edge if the open() storm is driven by incoming requests (e.g. a load balancer or API gateway throttle on the affected endpoint), buying time without touching the process's own code.
- cgroups. If the process is already in its own cgroup,
systemd-cgtopshows CPU/IO share in real time so you can confirm the impact is contained to that cgroup rather than starving unrelated services on the host; if it isn't yet isolated, moving it into a cgroup with acpu.max/io.maxcap bounds how much damage it can do to co-located workloads while the real fix is worked out, at the cost of slowing the affected service itself further. - Restart, as a last resort and only after evidence is captured. Restarting resets whatever accumulated state (e.g. an internal cache that's degraded into "never hit, always re-open") is driving the storm, but it also destroys the exact runtime state (in-flight request context, any leaked state) that would help find the root cause, so capture the flamegraph/stack-trace evidence above before restarting, not after.
3. Propose durable fixes
- Caching. If the same file is opened repeatedly with identical content each time, cache the parsed/read result in memory (with appropriate invalidation, e.g. an inotify watch on the file, or a bounded TTL) instead of re-opening and re-reading it on every use.
- Lazy open, or keep-open instead of open-per-use. If the code pattern is literally "open, do one small thing, close" on a hot path, keeping a long-lived fd open (and seeking as needed) instead of a fresh open()/close() pair per operation removes the per-operation open() cost entirely, at the cost of needing to handle the file being replaced or rotated out from under a long-held fd, the same file-identity problem that watching a file for changes (inotify) or mapping it into memory (mmap) also has to account for.
- File descriptor pooling. For a workload that genuinely needs many short-lived file handles to different files (not one hot file), a pool of pre-opened/reusable descriptors amortizes the open() cost across reuses rather than paying it fresh every time, similar in spirit to a database connection pool.
4. Trade-offs, tied to specific tools
bpftrace gives the lowest-overhead live visibility but requires eBPF tooling on the host and a bit more up-front query-writing than a canned command; perf (both trace and record) is broadly available on most distributions and gives excellent stack-level attribution via flamegraphs, at a higher (though still much lower than ptrace-based strace) sampling overhead; systemd-cgtop is the fastest way to confirm blast-radius containment but tells you nothing about why the storm is happening, only that it's happening and how much resource it's consuming. Sequencing matters: gather evidence with the low-overhead tools first (bpftrace counts, then a targeted perf flamegraph for the hot call site), apply the cheapest containment that doesn't destroy that evidence (rate-limit or cgroup cap before a restart), and only then build the durable fix, since a fix aimed at the wrong root cause (e.g. adding caching when the real problem is fd pooling being absent) can leave the underlying storm largely intact while looking resolved on the metric you happened to check first.
Design a small process supervisor for security tooling that starts workers, restarts failed children with backoff, and guarantees no zombies. What system calls and state transitions would you track?
Sample Answer
Design first: the state machine and the syscalls at each transition
A supervisor for a small worker pool is a state machine per worker, STARTING -> RUNNING -> (BACKOFF -> STARTING | STOPPED), driven entirely by process-lifecycle syscalls:
- STARTING -> RUNNING:
fork(2)(create the child), then in the child,execve(2)(replace its image with the worker binary; ifexecveitself fails, e.g.ENOENT, the child must_exit()with a distinguishable code rather than falling through into the supervisor's own code, since a failed exec leaves the fork'd copy still running your supervisor's logic, a classic footgun). - RUNNING -> (BACKOFF or STOPPED):
waitpid(2)(orwait4) to learn the worker's fate, decoded via theWIFEXITED/WIFSIGNALED/WEXITSTATUS/WTERMSIGmacro family, since a clean exit, a nonzero exit, and a signal-terminated exit each call for a different restart-versus-alert decision. This is also the single call that PREVENTS zombies: every exited worker must be waited on, or its entry lingers in the process table indefinitely. - BACKOFF -> STARTING: a timed delay before the next attempt,
nanosleep(2)(looping onEINTR, since a signal shouldn't silently truncate the backoff window), with the delay itself computed by simple exponential growth (double the wait each consecutive failure), capped, and reset to the base value after a sustained period of healthy running. - -> STOPPED: reached either on a clean exit (status 0) or once a restart-count budget is exhausted, at which point the supervisor stops attempting restarts and surfaces the failure rather than crash-looping forever.
Reference implementation (verified, not illustrative)
supervisor.c, built and run exactly as shown, no modifications needed to reproduce:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <string.h>
#include <errno.h>
#include <time.h>
typedef enum { ST_STARTING, ST_RUNNING, ST_BACKOFF, ST_STOPPED } state_t;
static const char *state_name(state_t s) {
switch (s) {
case ST_STARTING: return "STARTING";
case ST_RUNNING: return "RUNNING";
case ST_BACKOFF: return "BACKOFF";
case ST_STOPPED: return "STOPPED";
}
return "?";
}
static void sleep_ms(int ms) {
struct timespec ts = { ms / 1000, (long)(ms % 1000) * 1000000L };
/* Restart on EINTR: a signal delivered mid-sleep should not truncate
* our backoff window silently. */
while (nanosleep(&ts, &ts) == -1 && errno == EINTR) { }
}
int main(int argc, char **argv) {
if (argc < 2) {
fprintf(stderr, "usage: %s <worker-path> [args...]\n", argv[0]);
return 2;
}
const int max_restarts = 4;
int restarts = 0;
int backoff_ms = 200;
state_t state = ST_STARTING;
while (state != ST_STOPPED) {
printf("[supervisor] state=%s restarts=%d\n", state_name(state), restarts);
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* Child: exec the worker. execve() replaces this process's
* image in place -- no new PID, same fd table unless we
* change it, environment fully controlled by us here. */
execv(argv[1], &argv[1]);
/* Only reached if execve(2) itself failed (e.g. ENOENT). */
perror("execv");
_exit(127);
}
/* Parent: block until the child changes state. Plain waitpid()
* here because there's nothing else to do until the one worker
* we started exits -- a supervisor watching N workers would
* instead reap in a SIGCHLD handler or an event loop. */
state = ST_RUNNING;
int status = 0;
pid_t reaped = waitpid(pid, &status, 0);
if (reaped < 0) { perror("waitpid"); return 1; }
if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
printf("[supervisor] pid=%d exited cleanly (status 0), done\n", (int)reaped);
state = ST_STOPPED;
} else if (WIFEXITED(status)) {
printf("[supervisor] pid=%d exited with code %d\n", (int)reaped, WEXITSTATUS(status));
if (++restarts > max_restarts) {
printf("[supervisor] restart budget exhausted, giving up\n");
state = ST_STOPPED;
} else {
state = ST_BACKOFF;
printf("[supervisor] backing off %d ms before restart #%d\n", backoff_ms, restarts);
sleep_ms(backoff_ms);
backoff_ms *= 2; /* exponential backoff, no cap needed for this demo */
state = ST_STARTING;
}
} else if (WIFSIGNALED(status)) {
printf("[supervisor] pid=%d killed by signal %d%s\n", (int)reaped,
WTERMSIG(status), WCOREDUMP(status) ? " (core dumped)" : "");
if (++restarts > max_restarts) { state = ST_STOPPED; }
else { state = ST_BACKOFF; sleep_ms(backoff_ms); backoff_ms *= 2; state = ST_STARTING; }
}
}
printf("[supervisor] final state=STOPPED, total restarts=%d\n", restarts);
return 0;
}
Driver, flaky_worker.sh, which fails twice via a counter file, then succeeds, so the restart-with-backoff path is exercised deterministically:
#!/bin/sh
COUNTER=/tmp/flaky_worker_count
n=0
[ -f "$COUNTER" ] && n=$(cat "$COUNTER")
n=$((n + 1))
echo "$n" > "$COUNTER"
if [ "$n" -le 2 ]; then
echo "flaky_worker: attempt $n, failing on purpose"
exit 1
fi
echo "flaky_worker: attempt $n, succeeding"
rm -f "$COUNTER"
exit 0
Build and run:
$ gcc -Wall -o supervisor supervisor.c
$ chmod +x flaky_worker.sh
$ ./supervisor ./flaky_worker.sh
MEASURED output (Debian 12, real run):
flaky_worker: attempt 1, failing on purpose
flaky_worker: attempt 2, failing on purpose
flaky_worker: attempt 3, succeeding
[supervisor] state=STARTING restarts=0
[supervisor] pid=5020 exited with code 1
[supervisor] backing off 200 ms before restart #1
[supervisor] state=STARTING restarts=1
[supervisor] pid=5021 exited with code 1
[supervisor] backing off 400 ms before restart #2
[supervisor] state=STARTING restarts=2
[supervisor] pid=5023 exited cleanly (status 0), done
[supervisor] final state=STOPPED, total restarts=2
I also confirmed no zombies were left behind for THIS supervisor's own children afterward (ps -eo pid,ppid,stat,cmd | grep defunct showed none owned by the supervisor's PID), which is the point of the design: every exit is reaped through waitpid, so nothing lingers.
Extending to N concurrent workers
This single-worker version blocks on one waitpid(pid, ...) because it only has one child at a time. A supervisor managing several workers concurrently swaps that for either: a SIGCHLD handler that loops waitpid(-1, &status, WNOHANG) until nothing's left to reap (necessary because SIGCHLD delivery can coalesce, one signal for several near-simultaneous exits), reading which specific pid came back to look up which worker's state machine to advance; or, on modern Linux, pidfd_open(2) per child plus epoll, which sidesteps SIGCHLD/PID-reuse ambiguity entirely by giving each worker its own pollable file descriptor, the natural next step once you're past a handful of workers.
Unlock Full Question Bank
Get access to all 47 System Calls & the Kernel Interface interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.