nx_crash.nx source
↩ module page · 193 lines · 11065 B
1// nx_crash.nx -- Captures and prints instruction and fault addresses on signal crashes, then re-raises the signal.
2const K_MAGIC_65536: i64 = 65536
3const K_MAGIC_32768: i64 = 32768
4// nx_crash.nx -- RUNTIME CRASH DIAGNOSTICS (lang climb v11 rung 1a + v12 rung 1b, 2026-08-13).
5//
6// WHY THIS EXISTS. The 2026-08-13 full-population transcript mine (1,090 sessions / 2.88 GiB)
7// ranked silent SEGV debugging as the single biggest language-attributable token burn: 5,151
8// hits across 376 sessions, and every one of them was a program that printed NOTHING when it
9// died. One call at main entry -- nx_crash_guard() -- turns SIGSEGV / SIGBUS / SIGILL / SIGFPE
10// into a 5W+H diagnostic naming the INSTRUCTION ADDRESS and the FAULT ADDRESS, then lets the
11// ORIGINAL signal end the process, so wait-status semantics are UNCHANGED: a watcher that
12// expects death-by-signal-11 still sees death-by-signal-11.
13//
14// SELF-CONTAINED BY DESIGN (v12 rung 1b): zero imports, so the default-on driver injection can
15// append this ONE file to any translation unit without dragging the full nx_syscalls wrapper
16// set into minimal binaries. The two tiny shims below use RAW x86-64 numbers (write=1, mmap=9)
17// -- the SAME constants nx_syscalls.nx selects under @ifdef TARGET_X86_64 -- because this lane
18// is x86-only: the RV64 backend does not inject and must not import this file.
19//
20// DECLARED LIMITS (named, not hidden):
21// * addresses print raw -- resolve to file:line offline with the .debug_line decoder on a -g
22// build (the encoder/decoder pair shipped 2026-08-07, nx_dwline_roundtrip_gate 13/13);
23// * handler is async-signal-safe by construction: write(2) only, stack buffers, no
24// allocation in the handler, no locks. (Stack overflow IS covered: see sigaltstack below.)
25//
26// MECHANISM. rt_sigaction (syscall 13) with the kernel's 4-word struct sigaction:
27// [0]=sa_sigaction [1]=sa_flags [2]=sa_restorer [3]=sa_mask (sigsetsize=8)
28// sa_flags = SA_SIGINFO | SA_ONSTACK | SA_RESTORER | SA_RESETHAND = 0x8C000004. SA_RESETHAND
29// restores the DEFAULT disposition before the handler runs, so the re-raise below takes the
30// kernel's real kill path. sa_restorer must be non-null for frame setup but is NEVER EXECUTED
31// -- this handler does not return (it unblocks the signal and re-raises it instead), which is
32// exactly what makes a NishiLang function legal here: an ordinary prologue would break
33// rt_sigreturn's frame contract, so the design forbids returning rather than fighting the ABI.
34// Kernel delivery ABI (x86-64): handler(sig in rdi, siginfo* in rsi, ucontext* in rdx) --
35// matches the NishiLang 3-param convention; PROVEN by the witness printing sig=11 and the
36// EXACT planted fault address (nx_probe_crash_live.nx derefs address 64 -> "0x...40").
37// Frame offsets (x86-64 signal frame, verified by that witness, not assumed):
38// siginfo: si_addr at byte 16 -> si[2]
39// ucontext: uc_mcontext.rip at byte 168 -> uc[21] (40-byte ucontext head + gregs[16])
40// ucontext: uc_mcontext.rsp at byte 160 -> uc[20] (drives the stack-overflow classifier)
41
42// stderr write + anonymous mmap, raw x86-64 (see header).
43func nxcr_write(s: *u8, n: i64) -> i64 {
44 return __syscall(1, 2, s as i64, n, 0, 0, 0)
45}
46func nxcr_mmap(size: i64) -> i64 {
47 return __syscall(9, 0, size, 3, 0x22, 0 - 1, 0)
48}
49
50// stderr puts, async-signal-safe (runtime strlen; no hand-counted lengths -- the sys_write
51// count class is banked as a trap).
52func nxcr_puts(s: *u8) -> i64 {
53 var n: i64 = 0
54 while s[n] != (0 as u8) { n = n + 1 }
55 nxcr_write(s, n)
56 return 0
57}
58
59// 0x + 16 hex nibbles to stderr from a stack buffer.
60func nxcr_puthex(v: i64) -> i64 {
61 var b: [20]u8
62 b[0] = (48 as u8)
63 b[1] = (120 as u8)
64 var i: i64 = 0
65 while i < 16 {
66 var nib: i64 = (v >> ((15 - i) * 4)) & 15
67 var c: i64 = 48 + nib
68 if nib > 9 { c = 87 + nib }
69 b[2 + i] = (c as u8)
70 i = i + 1
71 }
72 nxcr_write(b as *u8, 18)
73 return 0
74}
75
76func nxcr_putdec(v: i64) -> i64 {
77 var b: [24]u8
78 var t: [24]u8
79 var m: i64 = v
80 var k: i64 = 0
81 if m == 0 { t[0] = (48 as u8); k = 1 }
82 while m > 0 { t[k] = ((48 + (m % 10)) as u8); m = m / 10; k = k + 1 }
83 var i: i64 = 0
84 while i < k { b[i] = t[k - 1 - i]; i = i + 1 }
85 nxcr_write(b as *u8, k)
86 return 0
87}
88
89// The handler. NEVER RETURNS (see header). Prints the 5W+H diagnostic, unblocks the signal
90// (delivery auto-blocked it), re-raises -- SA_RESETHAND already restored the default
91// disposition, so the process now dies by the ORIGINAL signal. exit_group is the fail-safe
92// if the re-raise is somehow refused.
93func nx_crash_on_signal(sig: i64, si: *i64, uc: *i64) -> i64 {
94 let ip: i64 = uc[21]
95 let fa: i64 = si[2]
96 nxcr_puts("\nthe program crashed: signal " as *u8)
97 nxcr_putdec(sig)
98 if sig == 11 { nxcr_puts(" (SIGSEGV) -- it touched memory it does not own.\n" as *u8) }
99 if sig == 7 { nxcr_puts(" (SIGBUS) -- it touched memory the hardware refuses (bad alignment or a truncated mapping).\n" as *u8) }
100 if sig == 4 { nxcr_puts(" (SIGILL) -- the processor hit an instruction it cannot execute (usually a wild jump).\n" as *u8) }
101 if sig == 8 { nxcr_puts(" (SIGFPE) -- an arithmetic trap, usually division by zero.\n" as *u8) }
102 nxcr_puts(" where: the instruction at " as *u8)
103 nxcr_puthex(ip)
104 nxcr_puts(", touching address " as *u8)
105 nxcr_puthex(fa)
106 nxcr_puts("\n why you see this: the crash guard printed it and then let the original signal end the program, so the exit status is unchanged.\n" as *u8)
107 // Classify the fault for the fix line: a touched address within 64 KiB of the crashed
108 // thread's stack pointer (rsp = gregs[15] -> uc[20]) is the stack guard page -- almost
109 // always unbounded recursion -- and the null-pointer hint would send the reader to the
110 // wrong class entirely. A wrong hint is worse than no hint; the two cases are separable
111 // mechanically, so separate them.
112 // WARN SELF-CAUGHT 2026-08-13: these two branches fired for EVERY signal, so a SIGFPE printed
113 // "a touched address at or near zero is a null pointer" -- the null hint on an ARITHMETIC
114 // trap, which is the misdirection this guard's own header forbids ("a wrong hint is worse
115 // than no hint"). The memory branches now fire only for the memory signals; FPE and ILL
116 // carry their own. Found by probing constant divide-by-zero, not by review.
117 var mem_sig: i64 = 0
118 if sig == 11 { mem_sig = 1 }
119 if sig == 7 { mem_sig = 1 }
120 if sig == 8 {
121 nxcr_puts(" fix: an arithmetic trap on x86 is almost always DIVISION (or remainder) BY ZERO -- check the divisor on the line the address above resolves to. It is also produced by dividing the most-negative integer by -1, whose true result does not fit.\n" as *u8)
122 }
123 if sig == 4 {
124 nxcr_puts(" fix: the processor reached bytes that are not a valid instruction -- usually a call through a function pointer that was never assigned, or a jump through a corrupted value. Check the last function pointer this code stored or loaded.\n" as *u8)
125 }
126 let sp_now: i64 = uc[20]
127 var sp_delta: i64 = fa - sp_now
128 if sp_delta < 0 { sp_delta = 0 - sp_delta }
129 if mem_sig == 1 {
130 if sp_delta < K_MAGIC_65536 {
131 nxcr_puts(" fix: the touched address sits at the stack boundary -- this is almost always UNBOUNDED RECURSION (stack overflow). Find the call that recurses without a base case; the instruction address above names the frame that overflowed.\n" as *u8)
132 }
133 if sp_delta >= K_MAGIC_65536 {
134 nxcr_puts(" fix: a touched address at or near zero is a null or sentinel-zero pointer -- check the last pointer this code computed. Rebuild with -g and resolve the instruction address to file:line with the .debug_line decoder.\n" as *u8)
135 nxcr_puts(" note: raw pointers (*T) are the unchecked C-class lane. CHECKED access already exists -- fixed arrays [N]T and slices []T refuse an out-of-bounds touch with a message instead of crashing. capability=memory-safety-enforcement, roadmap: nishifamily.com/compare/lang\n" as *u8)
136 }
137 }
138 // Name the program and the EXACT resolver command, so the reader's next step is copy-paste:
139 // nx_addr2line <elf> <ip> prints file:line AND the function name on a -g build, and REFUSES
140 // (rc=4) on a build without .debug_line -- then the fix is one rebuild with -g. In-process
141 // resolution is deliberately NOT embedded: it would drag the DWARF decoder into every ~5 KB
142 // guarded binary; the one-command offline loop is the design. readlink(/proc/self/exe) = x86 89.
143 var pbuf: [256]u8
144 let pth: *u8 = "/proc/self/exe" as *u8
145 let pn: i64 = __syscall(89, pth as i64, (pbuf as *u8) as i64, 255, 0, 0, 0)
146 if pn > 0 {
147 nxcr_puts(" resolve: nx_addr2line " as *u8)
148 nxcr_write(pbuf as *u8, pn)
149 nxcr_puts(" " as *u8)
150 nxcr_puthex(ip)
151 nxcr_puts(" (file:line + function on a -g build; refuses without .debug_line, then rebuild with -g)\n" as *u8)
152 }
153 var mword: i64 = 1 << (sig - 1)
154 __syscall(14, 1, (&mword) as i64, 0, 8, 0, 0)
155 let pid: i64 = __syscall(172, 0, 0, 0, 0, 0, 0) // rv64 getpid=172; raw x86 39 is an RV64 KEY translated to ioctl -> -ENOTTY (debt idx 2277)
156 __syscall(129, pid, sig, 0, 0, 0, 0) // rv64 kill=129; raw x86 62 is an RV64 KEY translated to lseek, so this re-raise NEVER FIRED and every guarded crash left via exit_group(128+sig) instead of dying by its signal (debt idx 2277)
157 __syscall(231, 128 + sig, 0, 0, 0, 0, 0)
158 return 0
159}
160
161func nxcr_install_one(sig: i64, h: i64) -> i64 {
162 let act: *i64 = nxcr_mmap(64) as *i64
163 act[0] = h
164 // SA_SIGINFO | SA_ONSTACK | SA_RESTORER | SA_RESETHAND -- ONSTACK routes delivery onto the
165 // alternate stack installed below, which is the ONLY way a STACK-OVERFLOW SIGSEGV can run a
166 // handler at all (the faulting thread has no room left on its own stack).
167 act[1] = 0x8C000004
168 act[2] = h
169 act[3] = 0
170 return __syscall(13, sig, act as i64, 0, 8, 0, 0)
171}
172
173// One call at main entry arms the guard for the four fatal fault signals.
174// Installs a 32 KiB ALTERNATE SIGNAL STACK first (sigaltstack, syscall 131; stack_t =
175// {ss_sp, ss_flags, ss_size}) so the handler runs even when the crash IS stack exhaustion --
176// without this, a stack-overflow SEGV cannot push the handler frame and the process dies
177// silently. Idempotent: re-running just reinstalls the same dispositions, so an explicit
178// caller plus the default-on injection is harmless.
179func nx_crash_guard() -> i64 {
180 let sp: i64 = nxcr_mmap(K_MAGIC_32768)
181 let ss: *i64 = nxcr_mmap(32) as *i64
182 ss[0] = sp
183 ss[1] = 0
184 ss[2] = K_MAGIC_32768
185 __syscall(131, ss as i64, 0, 0, 0, 0, 0)
186 let h: func(i64, *i64, *i64) -> i64 = nx_crash_on_signal
187 let ha: i64 = h as i64
188 nxcr_install_one(11, ha)
189 nxcr_install_one(7, ha)
190 nxcr_install_one(4, ha)
191 nxcr_install_one(8, ha)
192 return 0
193}