Layer 0 Spec — process Family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::process module exposes the kernel primitives for managing processes, threads, and child processes. It is one of the foundational families of layer 0: everything concerning a process lifecycle (creation, waiting, termination), identity (PIDs, TIDs), capabilities, and rlimits.
Family scope.
Eighteen wrapped syscalls, organized into sub-categories:
- Identity:
getpid,gettid. - Process creation:
clone3,execve,execveat. - Waiting and termination:
waitid,exit_group,pidfd_open,pidfd_send_signal,pidfd_getfd. - Groups and sessions:
setpgid,getpgid,setsid,getsid. - Process control:
prctl(operations exposed individually). - Resource limits:
getrlimit,setrlimit,prlimit. - Capabilities:
capget,capset.
Cross-cutting characteristics of the family.
-
Preference for modern variants.
clone3rather thanclone,pidfd_openrather than using bare PIDs for post-fork operations. The legacy syscalls are listed inUNSUPPORTED.mdwith justification. -
Systematic newtypes for identifiers.
Pid,Tid,PidFdare typed newtypes. No barei32for a PID. -
Clear ownership semantics.
PidFdis RAII: it automatically closes the FD on destruction. FDs received by operations are always returned asOwnedFd. -
Systematic upstream validation. In accordance with Engineering Principle 4, every public function validates its parameters at the start and returns
ResultorOptionon violation.
Subsection 1: Identity
getpid
Signature.
#![allow(unused)]
fn main() {
pub fn getpid() -> Pid;
}
Underlying syscall. getpid (x86_64 #39, ARM64 #172). Man page getpid(2).
Behavior.
Returns the PID of the calling process. Cannot fail. POSIX guarantees that a returned PID is strictly positive.
Return type.
Pid is a newtype over NonZeroI32. The function is total (no Result).
Performance. Negligible (~30-50 ns via vDSO on recent kernels).
Tests.
- Nominal test:
getpid()returns a valid Pid. - Consistency test: two successive calls return the same value.
gettid
Signature.
#![allow(unused)]
fn main() {
pub fn gettid() -> Tid;
}
Underlying syscall. gettid (x86_64 #186, ARM64 #178). Man page gettid(2).
Behavior.
Returns the TID of the calling thread. On Linux, each thread has a distinct TID; the main thread of a process has a TID equal to the process PID.
Return type.
Tid is a newtype distinct from Pid to prevent confusion by type.
Performance. ~30-50 ns.
Tests.
- Main thread test:
gettid()equalsgetpid()for the main thread. - Multi-thread test: two threads of the same process have distinct TIDs.
Subsection 2: Process creation
clone3
Signature.
#![allow(unused)]
fn main() {
pub unsafe fn clone3(args: &CloneArgs) -> Result<CloneResult, Errno>;
pub enum CloneResult {
Parent { child_pid: Pid, child_pidfd: Option<PidFd> },
Child,
}
#[derive(Debug, Default)]
pub struct CloneArgs {
pub flags: CloneFlags,
pub pidfd: Option<PidFdReceiver>,
pub child_tid: Option<TidReceiver>,
pub parent_tid: Option<TidReceiver>,
pub exit_signal: Option<Signal>,
pub stack: Option<StackSpecification>,
pub tls: Option<u64>,
pub set_tid: Option<&[Pid]>,
pub cgroup: Option<RawFd>,
}
bitflags! {
pub struct CloneFlags: u64 {
const VM = 0x00000100;
const FS = 0x00000200;
const FILES = 0x00000400;
const SIGHAND = 0x00000800;
const PIDFD = 0x00001000;
const PTRACE = 0x00002000;
const VFORK = 0x00004000;
const PARENT = 0x00008000;
const THREAD = 0x00010000;
const NEWNS = 0x00020000;
const SYSVSEM = 0x00040000;
const SETTLS = 0x00080000;
const PARENT_SETTID = 0x00100000;
const CHILD_CLEARTID = 0x00200000;
const DETACHED = 0x00400000;
const UNTRACED = 0x00800000;
const CHILD_SETTID = 0x01000000;
const NEWCGROUP = 0x02000000;
const NEWUTS = 0x04000000;
const NEWIPC = 0x08000000;
const NEWUSER = 0x10000000;
const NEWPID = 0x20000000;
const NEWNET = 0x40000000;
const IO = 0x80000000;
const CLEAR_SIGHAND = 0x100000000;
const INTO_CGROUP = 0x200000000;
const NEWTIME = 0x400000000;
}
}
pub struct StackSpecification {
pub addr: *mut u8,
pub size: usize,
}
}
Underlying syscall. clone3 (x86_64 #435, ARM64 #435). Man page clone(2). Available since Linux 5.3 (September 2019).
Preconditions (Safety).
The API is unsafe because clone3 can create a thread sharing memory with the parent (CLONE_VM), which requires careful handling of memory aliasing that Rust cannot verify.
The # Safety documentation requires:
-
For “classic fork” uses (creation of an independent child process), no particular precondition. The Air wrapper may provide a safe
fork_processhelper that calls clone3 with the appropriate flags. -
For uses with
CLONE_VM, the caller must guarantee that the shared memory is manipulated in a thread-safe manner by both sides. -
For uses with
CLONE_NEWUSER,CLONE_NEWNS, etc. (namespaces), the caller must have the necessary capabilities.
Behavior.
clone3 is the modern unified operation for creating processes, threads, and any combination of the two. The precise behavior depends on the flags:
- Without special flags: equivalent to
fork(). Creates an independent child process. - With
CLONE_VM | CLONE_THREAD | CLONE_SIGHAND: creates a thread in the current process. - With
CLONE_NEW*: creates the process in new namespaces. - With
CLONE_PIDFD: also returns a pidfd for the new process, avoiding races on the recycled PID.
The Air wrapper returns a CloneResult that clearly distinguishes the parent side from the child side. For the parent, the child’s PID is returned, plus optionally a PidFd if CLONE_PIDFD was requested.
Recommended pattern.
#![allow(unused)]
fn main() {
let mut args = CloneArgs::default();
args.flags = CloneFlags::PIDFD;
args.exit_signal = Some(Signal::SIGCHLD);
// SAFETY: no shared memory, classic fork with pidfd
let result = unsafe { clone3(&args)? };
match result {
CloneResult::Parent { child_pid, child_pidfd } => {
let pidfd = child_pidfd.expect("requested PIDFD");
// Wait for the child via waitid on the pidfd
}
CloneResult::Child => {
// Child code
// Probably execve here
unreachable!("execve should not return");
}
}
}
Errors.
EAGAIN: system limits reached (maximum number of processes).EINVAL: invalid flag combination.ENOMEM: insufficient kernel memory.EPERM: insufficient capabilities for the requested flags.EUSERS: too many user namespaces.
Performance.
clone3 with classic fork: ~50-100 µs. The main cost is the duplication of page tables (copy-on-write).
clone3 for thread creation: ~20-50 µs.
clone3 with namespaces: variable, ~100-500 µs depending on the requested namespaces.
Portability.
Strictly Linux. No direct equivalent on other Unix systems (which have separate fork/vfork/pthread_create).
Tests.
- Classic fork test: create a child process that exits immediately, waitid, verify exit code.
- Pidfd test: create with CLONE_PIDFD, verify that the pidfd is valid.
- Exec-in-child test: fork then execve, verify that the external binary is executed.
- Namespaces test: create in a new PID namespace, verify isolation.
- Capabilities test: attempt without privileges, verify EPERM.
execve
Signature.
#![allow(unused)]
fn main() {
pub fn execve(
path: &CStr,
argv: &[&CStr],
envp: &[&CStr],
) -> Result<Infallible, Errno>;
}
Underlying syscall. execve (x86_64 #59, ARM64 #221). Man page execve(2).
Preconditions.
argv and envp are arrays of C-compatible strings. The wrapper converts to *const *const c_char internally.
Behavior.
Replaces the memory image of the current process with that of the binary at path. On success, never returns (the process now runs the new binary). On failure, returns with an Errno.
Return type.
Result<Infallible, Errno>: Infallible is the Rust idiom for “never happens on success”. The user can write let _: Infallible = execve(...)?; and then have an unreachable!().
Errors.
E2BIG: argv/envp too large.EACCES: insufficient permissions to executepath.ENOENT:pathdoes not exist.ENOEXEC:pathis not a valid executable.ENOMEM: insufficient memory.ETXTBSY: the binary is open for writing by another process.
Performance.
Variable depending on the binary size and the complexity of the loader. Typically ~1-5 ms for a simple binary, more for dynamically linked binaries with many libraries.
Tests.
- Nominal test: fork then execve of a simple binary (
/bin/true), waitid. - ENOENT test: execve on a nonexistent path.
- EACCES test: execve on a non-executable file.
execveat
Signature.
#![allow(unused)]
fn main() {
pub fn execveat(
dirfd: DirFd,
path: &CStr,
argv: &[&CStr],
envp: &[&CStr],
flags: ExecveatFlags,
) -> Result<Infallible, Errno>;
bitflags! {
pub struct ExecveatFlags: i32 {
const EMPTY_PATH = 0x1000;
const SYMLINK_NOFOLLOW = 0x100;
}
}
}
Underlying syscall. execveat (x86_64 #322, ARM64 #281). Available since Linux 3.19.
Behavior.
Variant of execve that takes a directory FD (for relative path resolution) or a direct FD to the binary (with EMPTY_PATH).
Main use case: execute a binary referenced by a previously opened FD, without a race on the filesystem path.
#![allow(unused)]
fn main() {
let bin_fd = openat(DirFd::Cwd, c"/usr/bin/myapp", OpenFlags::PATH | OpenFlags::CLOEXEC, Mode::empty())?;
// Later, after possible checks:
execveat(DirFd::Fd(bin_fd.as_fd()), c"", &argv, &envp, ExecveatFlags::EMPTY_PATH)?;
}
Tests.
- EMPTY_PATH test: openat on binary, execveat with FD.
- Relative path test: execveat with dirfd + path.
Subsection 3: Waiting and termination
waitid
Signature.
#![allow(unused)]
fn main() {
pub fn waitid(
target: WaitTarget,
options: WaitOptions,
) -> Result<Option<WaitStatus>, Errno>;
pub enum WaitTarget {
AnyChild,
Pid(Pid),
ProcessGroup(Pid),
AnyProcessGroup,
PidFd(BorrowedFd<'_>),
}
bitflags! {
pub struct WaitOptions: i32 {
const EXITED = 4;
const STOPPED = 2;
const CONTINUED = 8;
const NOHANG = 1;
const NOWAIT = 0x01000000;
}
}
pub struct WaitStatus {
pub pid: Pid,
pub uid: u32,
pub event: WaitEvent,
}
pub enum WaitEvent {
Exited { code: i32 },
Killed { signal: Signal, core_dumped: bool },
Stopped { signal: Signal },
Continued,
Trapped { signal: Signal },
}
}
Underlying syscall. waitid (x86_64 #247, ARM64 #95). Man page waitid(2).
Preconditions.
WaitOptions must contain at least one of EXITED, STOPPED, or CONTINUED (otherwise waitid does not know which events to report).
Behavior.
Waits for an event to occur on the targeted process or group. More powerful than wait or waitpid because it can wait on a pidfd directly (avoiding races) and discriminate event types.
The wrapper returns Result<Option<WaitStatus>, Errno>. Ok(None) indicates that NOHANG was requested and that no event was available.
Pattern with pidfd.
#![allow(unused)]
fn main() {
let mut args = CloneArgs::default();
args.flags = CloneFlags::PIDFD;
// SAFETY: classic fork with pidfd
let result = unsafe { clone3(&args)? };
if let CloneResult::Parent { child_pidfd: Some(pidfd), .. } = result {
let status = waitid(WaitTarget::PidFd(pidfd.as_fd()), WaitOptions::EXITED)?;
match status {
Some(WaitStatus { event: WaitEvent::Exited { code }, .. }) => {
println!("Child exited with code {}", code);
}
_ => { /* other cases */ }
}
}
}
Errors.
ECHILD: no child to wait for.EINTR: interrupted by a signal (in accordance with convention 2 of ADR-021, propagated to the caller).EINVAL: invalid options.
Performance.
Variable. With NOHANG, immediate return ~1 µs. Without NOHANG, blocking until an event.
exit_group
Signature.
#![allow(unused)]
fn main() {
pub fn exit_group(status: i32) -> !;
}
Underlying syscall. exit_group (x86_64 #231, ARM64 #94). Man page exit_group(2).
Behavior.
Terminates all threads of the calling process with the specified exit code. Never returns.
Return type.
! (never type): the function never returns.
Performance. Negligible on the caller side (the process disappears).
pidfd_open
Signature.
#![allow(unused)]
fn main() {
pub fn pidfd_open(pid: Pid, flags: PidFdOpenFlags) -> Result<PidFd, Errno>;
bitflags! {
pub struct PidFdOpenFlags: u32 {
const NONBLOCK = 0x800;
}
}
pub struct PidFd { /* owns an internal OwnedFd */ }
impl PidFd {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
}
}
Underlying syscall. pidfd_open (x86_64 #434, ARM64 #434). Available since Linux 5.3.
Behavior.
Opens an FD that references a process by PID. The FD remains valid even if the PID is recycled after the death of the original process (the FD does not “transfer” to a new process).
The pidfd can then be used for:
waitid(wait for the process to finish).pidfd_send_signal(send a signal without a race on the PID).pidfd_getfd(retrieve an FD from the target process).- Monitoring via poll/epoll (the FD becomes readable when the process terminates).
Errors.
EINVAL: invalid flags.ESRCH: nonexistent process.ENOMEM: insufficient kernel memory.
Performance. ~5-10 µs.
Tests.
- Nominal test: fork child, pidfd_open on its PID, waitid on the pidfd.
- ESRCH test: pidfd_open on a nonexistent PID.
- Post-mortem test: create pidfd, kill process, verify that the pidfd remains valid until waitid.
pidfd_send_signal
Signature.
#![allow(unused)]
fn main() {
pub fn pidfd_send_signal(
pidfd: BorrowedFd<'_>,
signal: Signal,
info: Option<&SignalInfo>,
) -> Result<(), Errno>;
}
Underlying syscall. pidfd_send_signal (x86_64 #424, ARM64 #424). Available since Linux 5.1.
Behavior.
Sends a signal to the process referenced by the pidfd. No race on the recycled PID: if the original process is dead, the signal is not delivered to a possible new process with the same PID.
To be preferred over kill(pid, sig) when a pidfd is available.
Errors.
EBADF: invalid pidfd.EINVAL: invalid signal.EPERM: insufficient permissions.ESRCH: the referenced process no longer exists.
pidfd_getfd
Signature.
#![allow(unused)]
fn main() {
pub fn pidfd_getfd(
pidfd: BorrowedFd<'_>,
target_fd: RawFd,
flags: u32,
) -> Result<OwnedFd, Errno>;
}
Underlying syscall. pidfd_getfd (x86_64 #438, ARM64 #438). Available since Linux 5.6.
Behavior.
Retrieves a duplicate of an FD open in another process. Requires CAP_SYS_PTRACE or being the same user as the target process.
Specialized use case: debuggers, monitoring tools, resource transfer between cooperating processes.
Subsection 4: Groups and sessions
setpgid, getpgid, setsid, getsid
Signatures.
#![allow(unused)]
fn main() {
pub fn setpgid(pid: Option<Pid>, pgid: Option<Pid>) -> Result<(), Errno>;
pub fn getpgid(pid: Option<Pid>) -> Result<Pid, Errno>;
pub fn setsid() -> Result<Pid, Errno>;
pub fn getsid(pid: Option<Pid>) -> Result<Pid, Errno>;
}
Underlying syscalls. setpgid (x86_64 #109, ARM64 #154), getpgid (#121, #155), setsid (#112, #157), getsid (#124, #156).
Preconditions.
Option<Pid> allows expressing “the current process” via None (instead of the kernel sentinel 0). Consistent with convention 1 of ADR-021.
Behavior.
Management of Unix process groups and sessions. Rarely used in modern applications but necessary for shells, daemons, and certain terminal applications.
Performance. Negligible (~1-2 µs).
Subsection 5: prctl (operations exposed individually)
prctl is a multiplexed syscall: 60+ distinct operations depending on the first argument. In accordance with convention 3 of ADR-021, Air does not wrap prctl generically but exposes each operation as a dedicated typed function.
Exposed operations
#![allow(unused)]
fn main() {
// PR_SET_PDEATHSIG / PR_GET_PDEATHSIG
pub fn set_parent_death_signal(signal: Option<Signal>) -> Result<(), Errno>;
pub fn get_parent_death_signal() -> Result<Option<Signal>, Errno>;
// PR_SET_NO_NEW_PRIVS / PR_GET_NO_NEW_PRIVS
pub fn set_no_new_privs() -> Result<(), Errno>;
pub fn get_no_new_privs() -> Result<bool, Errno>;
// PR_SET_NAME / PR_GET_NAME (thread name, max 16 bytes)
pub fn set_thread_name(name: &CStr) -> Result<(), Errno>;
pub fn get_thread_name() -> Result<CString, Errno>;
// PR_SET_DUMPABLE / PR_GET_DUMPABLE
pub fn set_dumpable(dumpable: DumpableMode) -> Result<(), Errno>;
pub fn get_dumpable() -> Result<DumpableMode, Errno>;
pub enum DumpableMode {
NotDumpable,
Dumpable,
SuidDumpable,
}
// PR_SET_KEEPCAPS / PR_GET_KEEPCAPS
pub fn set_keep_caps(keep: bool) -> Result<(), Errno>;
pub fn get_keep_caps() -> Result<bool, Errno>;
// PR_SET_TIMERSLACK
pub fn set_timer_slack(slack_ns: u64) -> Result<(), Errno>;
// PR_CAP_AMBIENT (with sub-operations)
pub fn cap_ambient_raise(cap: Capability) -> Result<(), Errno>;
pub fn cap_ambient_lower(cap: Capability) -> Result<(), Errno>;
pub fn cap_ambient_clear_all() -> Result<(), Errno>;
pub fn cap_ambient_is_set(cap: Capability) -> Result<bool, Errno>;
// PR_SET_SECCOMP_MODE_FILTER (alternative to the direct seccomp syscall)
// Not exposed here, prefer seccomp_load_filter in the security family.
}
This list covers the most-used prctl operations. Rare operations (PR_TASK_PERF_EVENTS, PR_GET_TID_ADDRESS, etc.) are listed in UNSUPPORTED.md and can be added on a case-by-case basis if Air needs them.
Subsection 6: Resource limits
getrlimit, setrlimit, prlimit
Signatures.
#![allow(unused)]
fn main() {
pub fn getrlimit(resource: Resource) -> Result<Rlimit, Errno>;
pub fn setrlimit(resource: Resource, limit: Rlimit) -> Result<(), Errno>;
pub fn prlimit(
pid: Option<Pid>,
resource: Resource,
new_limit: Option<Rlimit>,
) -> Result<Rlimit, Errno>;
pub enum Resource {
Cpu,
FileSize,
Data,
Stack,
Core,
Rss,
NProc,
NoFile,
MemLock,
As,
Locks,
SigPending,
MsgQueue,
Nice,
RtPrio,
RtTime,
}
pub struct Rlimit {
pub soft: RlimitValue,
pub hard: RlimitValue,
}
pub enum RlimitValue {
Finite(u64),
Infinity,
}
}
Underlying syscalls. getrlimit (x86_64 #97, ARM64 #163), setrlimit (#160, #164), prlimit64 (#302, #261).
Behavior.
Reads and modifies the resource limits of a process. prlimit is more powerful: it allows operating on another process (with appropriate privileges) and performs both operations (reading the old value + writing the new one) in a single atomic call.
Air recommendation. Prefer prlimit everywhere. getrlimit and setrlimit are exposed for compatibility but are essentially special cases of prlimit.
Errors.
EFAULT: should not happen via the safe API.EINVAL: invalid resource or invalid limit.EPERM: attempt to raise the hard limit without privileges.ESRCH: nonexistent process (for prlimit).
Subsection 7: Capabilities
capget, capset
Signatures.
#![allow(unused)]
fn main() {
pub fn capget(target: CapabilityTarget) -> Result<CapabilitySet, Errno>;
pub fn capset(target: CapabilityTarget, set: &CapabilitySet) -> Result<(), Errno>;
pub enum CapabilityTarget {
CurrentThread,
Thread(Tid),
Process(Pid),
}
pub struct CapabilitySet {
pub effective: CapabilityMask,
pub permitted: CapabilityMask,
pub inheritable: CapabilityMask,
}
#[derive(Debug, Clone, Copy)]
pub struct CapabilityMask(u64);
#[derive(Debug, Clone, Copy)]
pub enum Capability {
Chown,
DacOverride,
DacReadSearch,
Fowner,
Fsetid,
Kill,
Setgid,
Setuid,
SysAdmin,
SysBoot,
SysChroot,
SysModule,
SysNice,
SysPtrace,
SysRawio,
SysResource,
SysTime,
NetAdmin,
NetBindService,
NetRaw,
// ... other Linux capabilities
}
}
Underlying syscalls. capget (x86_64 #125, ARM64 #90), capset (#126, #91).
Behavior.
Management of Linux capabilities: fine-grained permissions replacing the binary root/non-root model.
Three sets per thread:
- Effective: capabilities currently usable.
- Permitted: capabilities the thread can enable.
- Inheritable: capabilities transmitted across
execve.
Performance. ~1-2 µs.
Tests.
- Current capget test: read the capabilities, verify consistency.
- capset drop test: remove a capability, verify that it disappears.
- EPERM test: attempt to raise a non-permitted capability, verify the error.
Process family summary
Exposed functions:
| Category | Main functions |
|---|---|
| Identity | getpid, gettid |
| Creation | clone3, execve, execveat |
| Waiting | waitid, exit_group |
| Pidfd | pidfd_open, pidfd_send_signal, pidfd_getfd |
| Groups | setpgid, getpgid, setsid, getsid |
| prctl | ~12 individual operations |
| Limits | getrlimit, setrlimit, prlimit |
| Capabilities | capget, capset |
Total: 18 wrapped syscalls, plus the individual prctl operations.
Non-wrapped syscalls (listed in UNSUPPORTED.md):
fork,vfork: replaced byclone3.clone(without the 3): replaced byclone3.wait,wait3,wait4,waitpid: replaced bywaitid.kill: exposed in thesignalfamily, not inprocess.tkill: deprecated, replaced bytgkill(in the signal family)._exit: replaced byexit_group.- Rare
prctloperations not listed above.
Types added to air-sys-types
Pid,TidPidFd,PidFdReceiver,TidReceiverWaitTarget,WaitOptions,WaitStatus,WaitEventCloneFlags,CloneArgs,CloneResultSignal,SignalMask(also used in the signal family)Capability,CapabilityMask,CapabilitySet,CapabilityTargetResource,Rlimit,RlimitValueDumpableModeExecveatFlags,PidFdOpenFlags
That is ~20 types for this family.
Underlying decisions that emerged in the process family
1. Pid and Tid as distinct newtypes.
Prevents, by typing, confusion between a process PID and a thread TID. Direct application of Principle 7.
2. Option<Pid> for “current process”.
Instead of the kernel sentinel 0 which means “the calling process”, Air uses None. Clearer, safer, application of convention 1 of ADR-021.
3. PidFd RAII with clear ownership.
The pidfd is encapsulated in a type that automatically closes the FD on destruction. No leak possible.
4. clone3 unsafe but with a safe helper to come.
The low-level API remains unsafe because CLONE_VM can violate Rust invariants. A safe fork_process helper covering the common case (classic fork without shared VM) will be exposed in layer 1.
5. prctl exposed operation by operation.
Refusal of the generic multiplexed form. Each prctl operation is a dedicated typed function. The cost in code volume is offset by the clarity.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::process module (layer 0).