Processes and Signals
basaltc’s process control surface lives in process.rs (fork, exec, waitpid, kill, system, popen) and signal.rs (signal, sigaction, sigprocmask, raise).
The signal model is unusual compared to glibc and FreeBSD: SaltyOS delivers signals cooperatively through trona notification objects rather than interrupting system calls.
This page explains the process control wrappers, the cooperative signal model, why SA_RESTART has no effect, and the consequences for ports that depend on traditional signal semantics.
Process Control (process.rs)
The process module wraps the fork/exec/wait family. Each function is the usual thin shim:
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fork() -> i32 {
let result = unsafe { trona_posix::posix_fork() };
if result < 0 {
errno::set_errno(-result as i32);
return -1;
}
result as i32
}
The full surface:
| Function | trona_posix call |
|---|---|
Notes |
|
|
Returns child PID to parent, 0 to child, -1 on failure. Underlying procmgr clones the address space (COW), CSpace, and fd table. |
|
|
Identical to |
|
|
Replaces the current process image. On success does not return; on failure sets |
|
|
Convenience wrappers. |
|
|
Returns the PID of the changed child or 0 (with |
|
|
One-line wrapper. |
|
|
Send a signal to a process or process group. -1 with |
|
|
Send a signal to the current process. Implemented in |
|
|
Runs a shell command. Returns the child’s exit status. |
|
|
Opens a |
|
|
Process and session identifiers. |
fork is the operation with the most kernel interaction.
The procmgr server clones the parent’s address space using COW (copy-on-write at the page table level), duplicates the parent’s fd capability slots, and creates a new TCB.
The child’s posix_fork returns 0 in the child’s address space; the parent’s call returns the child’s PID.
execve discards the current address space, loads the new ELF, and re-runs the dynamic linker.
The fd table survives if the fd has FD_CLOEXEC clear; otherwise it is closed.
basaltc has no work to do here — every detail lives in procmgr and the loader.
Cooperative Signal Delivery
The signal model is the most distinctive part of basaltc’s process API.
Traditional Unix delivers signals by interrupting whatever the target thread is doing — a system call in progress is stopped (with EINTR), the user-mode stack is rewound to a signal trampoline, and the handler runs.
After the handler returns, the syscall is either restarted (SA_RESTART) or returns EINTR to the caller.
SaltyOS does not interrupt system calls.
Instead, signals are stored as pending bits in a per-process trona notification object.
When the process makes any IPC call (which is what every system call ultimately is), the trona substrate checks the notification before returning to user code, and posix_sigcheck runs the handler if a pending bit matches an unblocked signal.
The check happens at every IPC return. If user code is in a tight CPU loop with no syscalls, signals queue up and only fire on the next syscall. This is the same behavior as a Unix process running in a non-interruptible kernel sleep.
sigaction and _sig_sa*
sigaction(sig, act, oact) stores the handler in basaltc’s HANDLERS[NSIG] array and the per-signal mask and flags in trona globals:
(*(&raw mut trona_posix::__sig_sa_mask))[sig as usize] = (*act).sa_mask.bits;
(*(&raw mut trona_posix::__sig_sa_flags))[sig as usize] = (*act).sa_flags;
__sig_sa_mask[sig] is the set of signals to block while the handler for sig is running.
posix_sigcheck reads this when invoking the handler:
-
Save the current blocked mask.
-
Apply
__sig_sa_mask[sig](OR with the current mask). -
Call the handler.
-
Restore the saved blocked mask.
This matches the POSIX semantics for sa_mask.
The __sig_sa_flags[sig] field stores SA_NOCLDSTOP, SA_NOCLDWAIT, SA_SIGINFO, SA_RESETHAND, etc.
Most are honored. SA_RESTART is the exception:
// SA_RESTART: In SaltyOS, POSIX signals are delivered cooperatively via
// notification polling (posix_sigcheck). System calls (IPC to VFS/procmgr)
// complete atomically from userland's perspective and are never interrupted
// by signals. Therefore SA_RESTART has no behavioral effect — it is stored
// in __sig_sa_flags for sigaction() compatibility but intentionally unused.
pub const SA_RESTART: i32 = 0x10000000;
Code that depends on SA_RESTART to restart interrupted system calls is technically broken on SaltyOS because system calls are never interrupted in the first place — but the breakage manifests as "the program works" rather than "the program crashes", because the absence of interruption is strictly safer than the presence of interruption.
A program that sets SA_RESTART and never sees EINTR from a syscall just gets the behavior it asked for.
A program that does not set SA_RESTART and expects EINTR from a syscall during a signal will not see EINTR.
This is the harder case: ports that use select/poll with a manual sigprocmask to wake up on a signal need to use pselect/ppoll instead, where the signal mask is atomically applied for the duration of the wait.
signal()
The classic signal(sig, handler) function is implemented as a thin wrapper around posix_signal:
pub unsafe extern "C" fn signal(sig: i32, handler: SighandlerT) -> SighandlerT {
SIGNAL_LOCK.lock();
let old = HANDLERS[sig as usize];
HANDLERS[sig as usize] = handler;
let reg_result = trona_posix::signals::posix_signal(sig, handler);
if reg_result == usize::MAX {
HANDLERS[sig as usize] = old;
errno::set_errno(errno::EINVAL);
SIG_ERR
} else {
old
}
}
signal does not set sa_mask or sa_flags; the previous values remain in the trona globals.
For predictable cross-port behavior, prefer sigaction over signal, which is the same recommendation as on glibc and FreeBSD.
SIG_DFL and SIG_IGN are forwarded to posix_signal, which interprets them as "use the default action" (terminate, ignore, dump core, etc., depending on the signal) and "ignore this signal" respectively.
sigprocmask and the Blocked Mask
pub unsafe extern "C" fn sigprocmask(how: i32, set: *const Sigset, oldset: *mut Sigset) -> i32 {
let current = *(&raw const trona_posix::__sig_blocked_mask);
if !oldset.is_null() {
(*oldset).bits = current;
}
if !set.is_null() {
let updated = match how {
SIG_BLOCK => current | (*set).bits,
SIG_UNBLOCK => current & !(*set).bits,
SIG_SETMASK => (*set).bits,
_ => { errno::set_errno(errno::EINVAL); return -1; }
};
*(&raw mut trona_posix::__sig_blocked_mask) = updated;
}
0
}
__sig_blocked_mask is a single global because basaltc’s signal implementation is single-threaded.
pthread_sigmask is a one-line forwarder to sigprocmask for the same reason — there is no per-thread signal state.
Multi-threaded ports that send per-thread signals will not get the strict glibc-style isolation, but typical use cases (block SIGCHLD in a worker thread, deliver to the main thread) work because the next posix_sigcheck from the main thread sees the pending bit.
sigsuspend
sigsuspend(mask) is the one place basaltc actually waits for a signal:
pub unsafe extern "C" fn sigsuspend(mask: *const Sigset) -> i32 {
// Apply temporary mask
let saved = *(&raw const trona_posix::__sig_blocked_mask);
*(&raw mut trona_posix::__sig_blocked_mask) = (*mask).bits;
// Block on the signal notification
let cap_signal_ntfn: u64 = 6;
let pending_bits = trona::trona_wait(cap_signal_ntfn);
if pending_bits != 0 {
// Re-post for posix_sigcheck to consume
trona::trona_signal(cap_signal_ntfn, pending_bits);
}
trona_posix::signals::posix_sigcheck();
// Restore mask
*(&raw mut trona_posix::__sig_blocked_mask) = saved;
errno::set_errno(errno::EINTR);
-1 // POSIX: always returns -1 with EINTR
}
trona_wait consumes notification bits atomically — if a signal arrives between the temporary mask install and the wait, the wait returns immediately.
Because consuming the bits would prevent posix_sigcheck from seeing them, basaltc re-posts the consumed bits before invoking sigcheck.
sigsuspend always returns -1 with errno = EINTR, which is the POSIX-mandated behavior.
sigpending
sigpending(set) polls the notification non-destructively to discover which blocked signals are pending:
pub unsafe extern "C" fn sigpending(set: *mut Sigset) -> i32 {
let cap_signal_ntfn: u64 = 6;
let mut bits: u64 = 0;
let err = trona::trona_poll(cap_signal_ntfn, &raw mut bits);
if err == 0 && bits != 0 {
trona::trona_signal(cap_signal_ntfn, bits); // re-post (poll is destructive)
let blocked = *(&raw const trona_posix::__sig_blocked_mask);
(*set).bits = (bits as u32) & blocked;
} else {
(*set).bits = 0;
}
0
}
The "poll then re-post" pattern is needed because trona_poll consumes the notification bits.
sigpending re-posts whatever it found so that posix_sigcheck can still find them on the next IPC return.
Sigset Helpers
The sigemptyset, sigfillset, sigaddset, sigdelset, sigismember functions are pure bit-manipulation on the 32-bit Sigset (basaltc supports NSIG = 32 signals).
None of them touches kernel state.
sys_signame
basaltc exports sys_signame[NSIG], an array of NUL-terminated signal name strings indexed by signal number:
extern const char * const sys_signame[NSIG];
// sys_signame[1] == "HUP"
// sys_signame[2] == "INT"
// sys_signame[15] == "TERM"
This is used by psignal(3) and by ports that print signal names directly.
Slots 23-27 and 30-31 are NULL because the corresponding Linux signals (SIGURG, SIGXCPU, SIGXFSZ, SIGVTALRM, SIGPROF, SIGPWR, SIGSYS) are not yet wired into trona.
Most port-relevant signals (SIGINT, SIGTERM, SIGSEGV, SIGCHLD, SIGPIPE, SIGALRM, SIGUSR1/2) are present.
Constraints on Signal Handlers
Signal handlers run in the context of whichever thread happens to be executing the next syscall return — usually the main thread. This means:
-
Handlers run synchronously with
posix_sigcheck, never asynchronously inside a syscall. They cannot interrupt code that is running in user mode without making any IPC calls. -
Handlers may call any basaltc function, because there is no risk of self-deadlock from interrupting a previously held lock — the handler runs at a syscall return point, not in the middle of an arbitrary function.
-
Handlers should still avoid
printfbecause it acquiresSIGNAL_LOCKfor stdout, which the next handler invocation will also try to take. The non-recursive design ofSIGNAL_LOCKpredates the recursiveTypedMutexused in stdio. -
Handlers cannot pre-empt CPU-bound code. A program in a tight loop never sees signals until it makes a syscall. This is acceptable for the SaltyOS port set but is a behavioral departure from preemptive Unix that ports authors should know about.
Related Pages
-
Threads and Synchronization —
pthread_kill,pthread_sigmask -
CRT Startup — when the signal notification capability is wired in
-
Files and Directories —
pselect/ppollfor atomic mask + wait -
trona Boundary —
trona_posix::signals::*and_sig*globals