← board

crtl signal handlers: installed successfully, never fire

The measurement

                      gcc/glibc      pxx pinned v403
sigaction(SIGALRM)    rc=0 errno=0   rc=0 errno=0      <- identical, and one is a lie
signal(SIGUSR1)       ok             ok
raise(SIGALRM)        handler ran    handler did NOT run
sigaction+alarm+pause handler ran    "Alarm clock", process TERMINATED

lib/crtl/src/signal.c:127

int sigaction(int sig, const struct sigaction *act, struct sigaction *oact) {
  (void)sig; (void)act; (void)oact;
  return 0;
}

Why return 0 is the defect and not the stub

A stub that returned -1/ENOSYS would be a missing feature: every caller that checks if (sigaction(...) < 0) — which is what careful code does — would find out. Returning 0 converts it into a wrong answer, and the program proceeds on the strength of it. The process then runs with the kernel's default disposition, so the first signal it was written to handle terminates it, far from the call that reported success.

This is the same shape as an expected value colliding with a failure value: the success return and the true return are the same number, so no caller can tell them apart.

The stub's own premise has expired

lib/crtl/src/signal.c:4 says "there is no rt_sigaction PAL bridge yet, so handlers never fire". True when written. Since then lib/rtl/signals.pas carries rt_sigaction with a restorer, sigaltstack/SA_ONSTACK, and siginfo/ucontext — feature-signal-handlers and feature-signal-siginfo-ucontext are both in done/, with test/test_signal_altstack.pas and test/test_pal_signal.pas passing. The machinery is one lane over.

So this is a REACH, not a build. Two halves: a PAL entry that exposes what signals.pas already does (Track A), and crtl's sigaction/signal/raise lowering onto it (Track B, this ticket's lane). Filed in B because the stub is B's file; whoever takes it should expect to need one A-side entry and should say so rather than re-implementing rt_sigaction inside crtl — a second restorer, a second dispatch and a second altstack policy is exactly the "two mechanisms for one concept" that root-cause-over-microfix.md warns about.

What it bounds

Every signal-using C program. In busybox specifically: init (SIGHUP/SIGUSR1/ SIGTERM), hush (job control, SIGCHLD, SIGINT), syslogd, crond, top, watchdog. They compile, link and start.

The 853-case busybox corpus cannot see this, for the same structural reason it could not see the offsetof truncation: 516 of the 621 cases in the 258-applet run — and the equivalent majority at 374 — are applet --help, which prints a string literal and installs nothing. See [[feature-c-corpus-busybox-394-applets]] and frankD's real-argument case group (d0104ec8e), which is the instrument that could.

Repro

#include <signal.h>
#include <unistd.h>
static void h(int s){ (void)s; _exit(0); }
int main(void){ struct sigaction sa; memset(&sa,0,sizeof sa); sa.sa_handler=h;
                sigaction(SIGALRM,&sa,0); alarm(1); pause(); return 1; }

glibc exits 0. pxx prints Alarm clock and dies on SIGALRM.

Found by

Writing row 10 of test/c_crtl_busybox_394_gaps.c ([[feature-b-crtl-function-gaps-at-394-busybox-applets]]). The row now tests that pause() BLOCKS — fork, check the child is alive, kill it — because a handler-based test cannot pass until this is fixed. That is a workaround in a test, and it is recorded here so the test can be strengthened when this lands rather than staying at the weaker assertion because nobody remembers why it is weak.

FIXED — 2026-09-04, franks-ab

What it is now

gcc/glibc oracle   pxx x86-64 / i386 / arm32 / aarch64
9 rows             byte-identical, all four targets

test/c_crtl_signal_dispositions.c. The ticket's own repro — sigaction(SIGALRM), alarm(1), pause() — exits 0 under both, where pxx used to print Alarm clock and die.

The reach, and why nothing was re-implemented

crtl calls one bridge, __pxx_c_signal(sig, handler) in lib/rtl/pxxcio.pas. It parks the C function pointer in a 64-slot table and installs ONE parameterless trampoline via SetSignalHandler; the trampoline reads __pxxSigNum and calls the C handler with the number.

That is the same mechanism lib/rtl/signals.pas uses for the FPC Signal() surface. Two surfaces over one mechanism, not two mechanisms — no second restorer, no second altstack policy, no rt_sigaction inside crtl, which is what this ticket asked for and what root-cause-over-microfix.md is about.

What it refuses, and what it accepts without honouring

The test asserts EFFECTS, because rc cannot see this bug

Every row watches a counter the handler wrote, or the process surviving a signal whose default disposition is fatal. rc=0 is exactly what the broken version produced, so a row checking the return code is a row that passes against the bug. Row 5 watches a CHILD die, because the observable for "SIG_DFL reverted" is death and a parent cannot report a disposition that kills it; it reads WIFSIGNALED, not an exit code, since a shell's 128+n is a shell convention rather than the wait status.

The positive control needs no revert, which is the best kind: built --no-signals, the bridge refuses, the process is killed by its own raise at row 1, and all nine rows move.

Row 10 strengthened, as this ticket asked

c_crtl_busybox_394_gaps.c row 10 tested only that pause() BLOCKS, because a handler could not fire. It now has four columns: blocks, never returned, a SIGALRM handler wakes it, and it reports -1/EINTR. The blocking half stays — the handler half cannot see it, since a pause() that returned immediately would satisfy "handler ran and pause came back" just as well. Two properties, two shapes.

Two things that came out of the work

One process note worth keeping

The first version of the PXX_HAS_SIGNALS change added a forward for TargetHasSignalRuntime to compiler.pas while frontend_forwards.inc already had one. PXX prescans and accepted it; make compiler/pascal26 converged; gate.sh quick's FPC seed canary caught itFunction is already declared Public/Forward. Exactly the class CLAUDE.md says only the canary can see, and it only ran because compiler/** was still uncommitted. The fix was to MOVE the declaration earlier, not to add a second one.

POSTSCRIPT — this change surfaced a hole in the predicate it asks (frankA, 2466279ad)

TargetHasSignalRuntime had no wasm32 arm: ESP, then xtensa's ABI, then Result := True. A wasm module has no OS to deliver a signal, so that was wrong — and invisible, because EmitSignalRuntimeForTarget's wasm32 arm is deliberately empty. No runtime was emitted either way, so the predicate and the dispatcher agreed by ACCIDENT on every target that had ever asked.

PXX_HAS_SIGNALS became a third consumer asking the predicate DIRECTLY, so pxxcio.pas took the LIVE arm of __pxx_c_signal on wasm32, emitted IR_SET_SIGNAL, and the backend refused the body: 3 → 56 IR op 65 gaps in one commit, 55 of them __pxx_c_signal. The {$else} −38 arm here is right; it simply was not reached there.

Worth stating as the general shape: two mechanisms that agree because neither is exercised are not agreeing. Routing a capability question through one predicate is what converts that silence into a diagnosable failure — the hole was pre-existing and had no way to be seen until something asked.

Confirmed here at HEAD rather than taken on report: the four cross targets are still byte-identical to the gcc oracle after the predicate fix, and a Pascal program pulling pxxcio now builds for wasm32.