nx_os_proc.nx source
↩ module page · 333 lines · 16271 B
1// nx_os_proc.nx -- OS PROCESS-INTROSPECTION abstraction (the PORTABILITY SEAM). THE ONE place OS-specific
2// process access lives, so every tool above it (nx_heal, ...) stays OS-AGNOSTIC + portable. Interop by
3// construction: ONE source compiles to BOTH backends via the @ifdef target guard --
4// LINUX backend = procfs (/proc/<pid>/{stat,cmdline}, getdents on /proc) [current NAS deploy]
5// NISHIOS backend = native process table (@ifdef TARGET_NISHI seam) -- superior: a direct kernel
6// query, NO text-parsing of /proc, NO USER_HZ guesswork. FAILS LOUD until wired,
7// so a NishiOS build never silently inherits Linux assumptions.
8// LAW (portability): NEVER scatter raw /proc, /sys, /dev, or hardcoded syscall numbers through the LOGIC
9// layer -- put OS-specifics behind an nx_os_* seam like this one. The sovereign core (seg_store, tool
10// logic) already only touches nx_syscalls (ABI-abstracted); this extends the same discipline to OS features.
11// license_tier: ORIGINAL
12import "nx_syscalls.nx"
13
14const OSP_HZ_LINUX: i64 = 100 // Linux USER_HZ: /proc/<pid>/stat starttime ticks/sec
15const OSP_PATH_CAP: i64 = 256
16const OSP_RD_CAP: i64 = 4096
17const OSP_STAT_CAP: i64 = 262144 // /proc/stat whole-file read cap (btime scan)
18const OSP_DENT_BUF: i64 = 65536 // getdents64 batch buffer (proven sizing)
19const OSP_F_PPID: i64 = 2 // /proc/<pid>/stat field after ')': state=1 ppid=2 ... utime=12 stime=13 ... starttime=20
20const OSP_F_START: i64 = 20
21const OSP_F_UTIME: i64 = 12 // user-mode CPU ticks (cumulative)
22const OSP_F_STIME: i64 = 13 // kernel-mode CPU ticks (cumulative)
23const OSP_ASCII_0: i64 = 48
24const OSP_ASCII_9: i64 = 57
25const OSP_SP: i64 = 32
26const OSP_NL: i64 = 10
27const OSP_RP: i64 = 41 // ')'
28const OSP_SLASH: i64 = 47
29const OSP_NUL: i64 = 0
30const OSP_SENTINEL: i64 = 0 - 1 // "not available on this OS backend"
31
32func osp_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (OSP_NUL as u8) { n = n + 1 } return n }
33// pid integer -> decimal string in out; return len
34func osp_itoa(v: i64, out: *u8) -> i64 {
35 if v == 0 { out[0] = OSP_ASCII_0 as u8; out[1] = 0 as u8; return 1 }
36 let t: *u8 = sys_mmap(24); var m: i64 = v; var k: i64 = 0
37 while m > 0 { t[k] = (OSP_ASCII_0 + (m % 10)) as u8; m = m / 10; k = k + 1 }
38 var i: i64 = 0
39 while i < k { out[i] = t[k-1-i]; i = i + 1 }
40 out[k] = 0 as u8
41 return k
42}
43// parse leading integer at buf[off..]; endp[0] = position of the FIRST non-digit (NOT n) so a caller
44// walking fields can resume there. BUG-FIX 2026-07-16: the old `i = n` break jumped to end-of-buffer,
45// so osp_stat_field skipped every field after the first non-numeric one (the state char) and never
46// reached starttime (field 20) -- diagnose then dropped every real process.
47func osp_num(buf: *u8, n: i64, off: i64, endp: *i64) -> i64 {
48 var v: i64 = 0; var i: i64 = off; var any: i64 = 0; var go: i64 = 1
49 while go == 1 {
50 go = 0
51 if i < n { let c: i64 = buf[i] as i64; if c >= OSP_ASCII_0 { if c <= OSP_ASCII_9 { v = v*(10 as i64)+(c-OSP_ASCII_0); any = 1; i = i + 1; go = 1 } } }
52 }
53 endp[0] = i
54 if any == 0 { return OSP_SENTINEL }
55 return v
56}
57// basename (after last '/') of NUL-terminated s
58func osp_basename(s: *u8) -> *u8 {
59 var i: i64 = 0; var last: i64 = 0
60 while s[i] != (OSP_NUL as u8) { if s[i] == (OSP_SLASH as u8) { last = i + 1 } i = i + 1 }
61 return (s as i64 + last) as *u8
62}
63
64// ============================ LINUX BACKEND (procfs) ============================
65// NOTE: the OS-target guard is currently a SOURCE-SWAP seam, not a compile-time @ifdef -- nx_cc's
66// preprocessor is arch-guard-only today (TARGET_X86_64); a custom TARGET_NISHI compiled BOTH branches
67// and the stub won (proven 2026-07-16). When nx_cc gains OS-target guards, wrap this in @ifdef
68// TARGET_LINUX and the NishiOS backend (spec at bottom) in @ifdef TARGET_NISHI. The portability WIN
69// stands regardless: every raw /proc lives HERE and nowhere else, so swapping the backend is a
70// single-file change with the whole LOGIC layer (nx_heal) untouched.
71// bounded read of a whole (small) proc/file into buf; returns len (0 on empty/absent)
72func osp_bread(path: *u8, buf: *u8, cap: i64) -> i64 {
73 let fd: i64 = sys_openat_rd(path)
74 if fd < 0 { return 0 }
75 var got: i64 = 0; var go: i64 = 1
76 while go == 1 { let r: i64 = sys_read(fd, (buf as i64 + got) as *u8, cap - got); if r > 0 { got = got + r; if got >= cap { go = 0 } } else { go = 0 } }
77 sys_close(fd)
78 return got
79}
80// build "/proc/<pid><leaf>" (leaf NUL-terminated, e.g. "/stat") into out
81func osp_ppath(out: *u8, pid: i64, leaf: *u8) -> i64 {
82 var o: i64 = 0
83 let pre: *u8 = "/proc/" as *u8
84 var i: i64 = 0
85 while pre[i] != (OSP_NUL as u8) { out[o] = pre[i]; o = o + 1; i = i + 1 }
86 o = o + osp_itoa(pid, (out as i64 + o) as *u8)
87 i = 0
88 while leaf[i] != (OSP_NUL as u8) { out[o] = leaf[i]; o = o + 1; i = i + 1 }
89 out[o] = 0 as u8
90 return o
91}
92// enumerate live pids into pids[0..cap); returns count
93func osp_list_pids(pids: *i64, cap: i64) -> i64 {
94 let fd: i64 = sys_openat_rd("/proc" as *u8)
95 if fd < 0 { return 0 }
96 let dbuf: *u8 = sys_mmap(OSP_DENT_BUF)
97 let ep: *i64 = sys_mmap(16) as *i64
98 var cnt: i64 = 0; var run: i64 = 1
99 while run == 1 {
100 let n: i64 = sys_getdents64(fd, dbuf, OSP_DENT_BUF)
101 if n <= 0 { run = 0 } else {
102 var off: i64 = 0
103 while off < n {
104 let rec: *u8 = ((dbuf as i64 + off) as *u8)
105 let reclen: i64 = dirent_reclen(rec)
106 if reclen <= 0 { off = n } else {
107 let name: *u8 = dirent_name(rec)
108 if name[0] >= (OSP_ASCII_0 as u8) { if name[0] <= (OSP_ASCII_9 as u8) {
109 if cnt < cap { let pid: i64 = osp_num(name, osp_slen(name), 0, ep); if pid > 0 { pids[cnt] = pid; cnt = cnt + 1 } }
110 } }
111 off = off + reclen
112 }
113 }
114 }
115 }
116 sys_close(fd)
117 return cnt
118}
119// count OPEN FILE DESCRIPTORS of pid = numeric entries in /proc/<pid>/fd. -1 if unreadable (gone/no perm).
120// The fd METER for anomaly detection: a socket/file-descriptor leak trends up HERE while VmSize can stay
121// flat (the fd table is not the address space) -- a leak shows in whatever resource disappears. Leak-free.
122func osp_fd_count_from_fd_result(fd: i64, cause: *i64) -> i64 {
123 if (cause as i64) != 0 { cause[0]=0 }
124 let dbuf: *u8=sys_mmap(OSP_DENT_BUF)
125 var count: i64=0
126 var running: i64=1
127 var failure: i64=0
128 while running == 1 {
129 let n: i64=sys_getdents64(fd,dbuf,OSP_DENT_BUF)
130 if n < 0 { failure=n; running=0 } else {
131 if n == 0 { running=0 } else {
132 var off: i64=0
133 while off < n {
134 let rec: *u8=((dbuf as i64)+off) as *u8
135 let reclen: i64=dirent_reclen(rec)
136 if reclen <= 0 { failure=OSP_SENTINEL; running=0; break }
137 let name: *u8=dirent_name(rec)
138 if name[0] >= OSP_ASCII_0 as u8 && name[0] <= OSP_ASCII_9 as u8 { count=count+1 }
139 off=off+reclen
140 }
141 }
142 }
143 }
144 sys_munmap(dbuf,OSP_DENT_BUF)
145 if failure < 0 { if (cause as i64) != 0 { cause[0]=failure }; return OSP_SENTINEL }
146 return count
147}
148func osp_fd_count_from_fd(fd: i64) -> i64 {
149 return osp_fd_count_from_fd_result(fd,0 as *i64)
150}
151func osp_fd_count(pid: i64) -> i64 {
152 let path: *u8=sys_mmap(OSP_PATH_CAP)
153 osp_ppath(path,pid,"/fd")
154 let fd: i64=sys_openat_rd(path)
155 sys_munmap(path,OSP_PATH_CAP)
156 if fd < 0 { return OSP_SENTINEL }
157 let count: i64=osp_fd_count_from_fd(fd)
158 sys_close(fd)
159 return count
160}
161
162// field after the last ')' in /proc/<pid>/stat: OSP_F_PPID or OSP_F_START; SENTINEL on fail
163func osp_stat_field(pid: i64, fidx: i64) -> i64 {
164 let p: *u8 = sys_mmap(OSP_PATH_CAP)
165 osp_ppath(p, pid, "/stat" as *u8)
166 let b: *u8 = sys_mmap(OSP_RD_CAP)
167 let n: i64 = osp_bread(p, b, OSP_RD_CAP - 1)
168 if n <= 0 { return OSP_SENTINEL }
169 var rp: i64 = 0 - 1; var i: i64 = 0
170 while i < n { if b[i] == (OSP_RP as u8) { rp = i } i = i + 1 }
171 if rp < 0 { return OSP_SENTINEL }
172 var f: i64 = 0; i = rp + 1
173 let ep: *i64 = sys_mmap(16) as *i64
174 while i < n {
175 if b[i] == (OSP_SP as u8) { i = i + 1 } else {
176 f = f + 1
177 let v: i64 = osp_num(b, n, i, ep)
178 if f == fidx { return v }
179 i = ep[0]
180 var go: i64 = 1
181 while go == 1 { go = 0; if i < n { if b[i] != (OSP_SP as u8) { i = i + 1; go = 1 } } }
182 }
183 }
184 return OSP_SENTINEL
185}
186func osp_ppid(pid: i64) -> i64 { return osp_stat_field(pid, OSP_F_PPID) }
187func osp_starttime_ticks(pid: i64) -> i64 { return osp_stat_field(pid, OSP_F_START) }
188// cumulative CPU ticks consumed by pid = utime + stime. The CPU METER for anomaly detection: sampled over
189// time, its RATE (Theil-Sen slope of the per-interval deltas) = the burn = "power disappearing" (a busy-loop
190// pegs a core). SENTINEL if unreadable. HZ ticks/sec via osp_hz().
191func osp_cpu_ticks(pid: i64) -> i64 {
192 let u: i64 = osp_stat_field(pid, OSP_F_UTIME)
193 let s: i64 = osp_stat_field(pid, OSP_F_STIME)
194 if u == OSP_SENTINEL { return OSP_SENTINEL }
195 if s == OSP_SENTINEL { return OSP_SENTINEL }
196 return u + s
197}
198// argv0 basename of /proc/<pid>/cmdline into out; return len (0 if none)
199func osp_cmd_argv0(pid: i64, out: *u8, cap: i64) -> i64 {
200 let p: *u8 = sys_mmap(OSP_PATH_CAP)
201 osp_ppath(p, pid, "/cmdline" as *u8)
202 let cl: *u8 = sys_mmap(cap + 1)
203 let n: i64 = osp_bread(p, cl, cap)
204 if n <= 0 { out[0] = 0 as u8; return 0 }
205 cl[n] = 0 as u8 // argv0 = bytes up to the first NUL (already there)
206 let bn: *u8 = osp_basename(cl)
207 var o: i64 = 0
208 while bn[o] != (OSP_NUL as u8) { out[o] = bn[o]; o = o + 1 }
209 out[o] = 0 as u8
210 return o
211}
212func osp_hz() -> i64 { return OSP_HZ_LINUX }
213// where the HOST SUPERVISOR writes its log -- a deployment/OS question, so it lives in the seam.
214// Linux/NAS deploy: /tmp/supervisor.log (nx_hostctl supervise). NishiOS: its native supervisor journal.
215func osp_supervisor_log() -> *u8 { return "/tmp/supervisor.log" as *u8 }
216func osp_uptime_s() -> i64 { let ts: *i64 = sys_mmap(16) as *i64; sys_clock_gettime_mono(ts); return ts[0] }
217func osp_selfpid() -> i64 {
218 let b: *u8 = sys_mmap(OSP_RD_CAP)
219 let n: i64 = osp_bread("/proc/self/stat" as *u8, b, OSP_RD_CAP - 1)
220 if n <= 0 { return OSP_SENTINEL }
221 let ep: *i64 = sys_mmap(16) as *i64
222 return osp_num(b, n, 0, ep)
223}
224// wallclock epoch = /proc/stat btime + monotonic-since-boot
225func osp_boot_epoch() -> i64 {
226 let b: *u8 = sys_mmap(OSP_STAT_CAP)
227 let n: i64 = osp_bread("/proc/stat" as *u8, b, OSP_STAT_CAP - 1)
228 if n <= 0 { return 0 }
229 let needle: *u8 = "btime " as *u8
230 var i: i64 = 0
231 let ep: *i64 = sys_mmap(16) as *i64
232 while i < n {
233 var m: i64 = 1; var k: i64 = 0
234 while needle[k] != (OSP_NUL as u8) { if i+k >= n { m = 0 } else { if b[i+k] != needle[k] { m = 0 } } k = k + 1 }
235 if m == 1 { return osp_num(b, n, i + k, ep) }
236 i = i + 1
237 }
238 return 0
239}
240
241// ======================= NISHIOS BACKEND SPEC (the swap-in seam) =======================
242// When NishiOS's native process API lands, REPLACE the Linux backend above (or @ifdef-branch it once
243// nx_cc has OS-target guards) with these ~8 functions over NishiOS's DIRECT kernel process table --
244// superior to procfs: no /proc text-parsing, no USER_HZ, a real syscall query. The interface the LOGIC
245// layer depends on (and ALL it depends on) is exactly:
246// osp_list_pids(pids,cap)->count nishi_proc_enumerate (live pids)
247// osp_ppid(pid)->ppid nishi_proc_parent
248// osp_starttime_ticks(pid)->ticks nishi_proc_starttime (osp_hz() ticks/sec)
249// osp_cmd_argv0(pid,out,cap)->len nishi_proc_argv0 (basename of argv0)
250// osp_hz()->ticks_per_sec native rate (not the Linux-100 assumption)
251// osp_uptime_s()->secs sys_clock_gettime_mono (already OS-neutral)
252// osp_selfpid()->pid nishi_getpid
253// osp_boot_epoch()->epoch nishi_boot_epoch
254// Interop: NishiOS ships the superior backend; Linux stays supported for the current NAS deploy; the
255// LOGIC (nx_heal) compiles unchanged on both. THAT is the point of this file.
256
257// Direct-child enumeration does not depend on CONFIG_CHECKPOINT_RESTORE's
258// optional /proc/<pid>/task/<pid>/children file. The caller owns the output
259// capacity; overflow is an error, never a plausible partial child set.
260const OSP_DIRENT_NAME_OFFSET: i64 = 19 // Linux linux_dirent64 ABI
261func osp_stat_parent(buf: *u8, n: i64) -> i64 {
262 var last: i64=0-1;var i: i64=0
263 while i < n { if buf[i] == OSP_RP as u8 { last=i };i=i+1 }
264 if last < 0 { return 0-5 }
265 i=last+1
266 while i < n && buf[i] == OSP_SP as u8 { i=i+1 }
267 while i < n && buf[i] != OSP_SP as u8 { i=i+1 }
268 while i < n && buf[i] == OSP_SP as u8 { i=i+1 }
269 let first: i64=i;var parent: i64=0
270 while i < n && buf[i] >= OSP_ASCII_0 as u8 && buf[i] <= OSP_ASCII_9 as u8 {
271 parent=parent*10+(buf[i] as i64)-OSP_ASCII_0;i=i+1
272 }
273 if i == first || i == n || buf[i] != OSP_SP as u8 { return 0-5 }
274 return parent
275}
276func osp_children(parent: i64, children: *i64, capacity: i64) -> i64 {
277 if parent <= 0 || capacity < 0 { return 0-22 }
278 let directory: i64=sys_openat_directory("/proc")
279 if directory < 0 { return directory }
280 let batch: *u8=sys_mmap(OSP_DENT_BUF)
281 let path: *u8=sys_mmap(OSP_PATH_CAP)
282 let stat: *u8=sys_mmap(OSP_RD_CAP)
283 var count: i64=0;var failure: i64=0;var running: i64=1
284 while running == 1 {
285 let n: i64=sys_getdents64(directory,batch,OSP_DENT_BUF)
286 if n == (0-4) { continue }
287 if n <= 0 { failure=n;break }
288 var off: i64=0
289 while off < n {
290 if n-off <= OSP_DIRENT_NAME_OFFSET { failure=0-5;running=0;break }
291 let rec: *u8=batch+off
292 let size: i64=dirent_reclen(rec)
293 if size <= OSP_DIRENT_NAME_OFFSET || size > n-off { failure=0-5;running=0;break }
294 var i: i64=OSP_DIRENT_NAME_OFFSET;var pid: i64=0
295 while i < size && rec[i] >= OSP_ASCII_0 as u8 && rec[i] <= OSP_ASCII_9 as u8 {
296 pid=pid*10+(rec[i] as i64)-OSP_ASCII_0;i=i+1
297 }
298 if pid > 0 && i < size && rec[i] == OSP_NUL as u8 {
299 osp_ppath(path,pid,"/stat")
300 let fd: i64=sys_openat_rd(path)
301 // A process may disappear during enumeration; other failures
302 // make completeness unproven and must propagate.
303 if fd < 0 && fd != (0-2) { failure=fd;running=0;break }
304 if fd >= 0 {
305 var used: i64=0;var readrc: i64=1
306 while readrc > 0 && used < OSP_RD_CAP {
307 readrc=sys_read(fd,stat+used,OSP_RD_CAP-used)
308 if readrc == (0-4) { readrc=1;continue }
309 if readrc > 0 { used=used+readrc }
310 }
311 let closed: i64=sys_close(fd)
312 if readrc < 0 && readrc != (0-3) { failure=readrc;running=0;break }
313 if closed < 0 { failure=closed;running=0;break }
314 if used == OSP_RD_CAP { failure=0-75;running=0;break }
315 if used > 0 {
316 let observed: i64=osp_stat_parent(stat,used)
317 if observed < 0 { failure=observed;running=0;break }
318 if observed == parent {
319 if count >= capacity { failure=0-28;running=0;break }
320 children[count]=pid;count=count+1
321 }
322 }
323 }
324 }
325 off=off+size
326 }
327 }
328 let closed: i64=sys_close(directory)
329 sys_munmap(batch,OSP_DENT_BUF);sys_munmap(path,OSP_PATH_CAP);sys_munmap(stat,OSP_RD_CAP)
330 if failure < 0 { return failure }
331 if closed < 0 { return closed }
332 return count
333}