nx_os_proc.nx source
↩ module page · 243 lines · 12121 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(pid: i64) -> i64 {
123 let path: *u8 = sys_mmap(OSP_PATH_CAP)
124 osp_ppath(path, pid, "/fd" as *u8)
125 let fd: i64 = sys_openat_rd(path)
126 if fd < 0 { sys_munmap(path, OSP_PATH_CAP); return 0 - 1 }
127 let dbuf: *u8 = sys_mmap(OSP_DENT_BUF)
128 var cnt: i64 = 0
129 var run: i64 = 1
130 while run == 1 {
131 let n: i64 = sys_getdents64(fd, dbuf, OSP_DENT_BUF)
132 if n <= 0 { run = 0 } else {
133 var off: i64 = 0
134 while off < n {
135 let rec: *u8 = ((dbuf as i64 + off) as *u8)
136 let reclen: i64 = dirent_reclen(rec)
137 if reclen <= 0 { off = n } else {
138 let name: *u8 = dirent_name(rec)
139 if name[0] >= (OSP_ASCII_0 as u8) { if name[0] <= (OSP_ASCII_9 as u8) { cnt = cnt + 1 } }
140 off = off + reclen
141 }
142 }
143 }
144 }
145 sys_close(fd)
146 sys_munmap(path, OSP_PATH_CAP)
147 sys_munmap(dbuf, OSP_DENT_BUF)
148 return cnt
149}
150// field after the last ')' in /proc/<pid>/stat: OSP_F_PPID or OSP_F_START; SENTINEL on fail
151func osp_stat_field(pid: i64, fidx: i64) -> i64 {
152 let p: *u8 = sys_mmap(OSP_PATH_CAP)
153 osp_ppath(p, pid, "/stat" as *u8)
154 let b: *u8 = sys_mmap(OSP_RD_CAP)
155 let n: i64 = osp_bread(p, b, OSP_RD_CAP - 1)
156 if n <= 0 { return OSP_SENTINEL }
157 var rp: i64 = 0 - 1; var i: i64 = 0
158 while i < n { if b[i] == (OSP_RP as u8) { rp = i } i = i + 1 }
159 if rp < 0 { return OSP_SENTINEL }
160 var f: i64 = 0; i = rp + 1
161 let ep: *i64 = sys_mmap(16) as *i64
162 while i < n {
163 if b[i] == (OSP_SP as u8) { i = i + 1 } else {
164 f = f + 1
165 let v: i64 = osp_num(b, n, i, ep)
166 if f == fidx { return v }
167 i = ep[0]
168 var go: i64 = 1
169 while go == 1 { go = 0; if i < n { if b[i] != (OSP_SP as u8) { i = i + 1; go = 1 } } }
170 }
171 }
172 return OSP_SENTINEL
173}
174func osp_ppid(pid: i64) -> i64 { return osp_stat_field(pid, OSP_F_PPID) }
175func osp_starttime_ticks(pid: i64) -> i64 { return osp_stat_field(pid, OSP_F_START) }
176// cumulative CPU ticks consumed by pid = utime + stime. The CPU METER for anomaly detection: sampled over
177// time, its RATE (Theil-Sen slope of the per-interval deltas) = the burn = "power disappearing" (a busy-loop
178// pegs a core). SENTINEL if unreadable. HZ ticks/sec via osp_hz().
179func osp_cpu_ticks(pid: i64) -> i64 {
180 let u: i64 = osp_stat_field(pid, OSP_F_UTIME)
181 let s: i64 = osp_stat_field(pid, OSP_F_STIME)
182 if u == OSP_SENTINEL { return OSP_SENTINEL }
183 if s == OSP_SENTINEL { return OSP_SENTINEL }
184 return u + s
185}
186// argv0 basename of /proc/<pid>/cmdline into out; return len (0 if none)
187func osp_cmd_argv0(pid: i64, out: *u8, cap: i64) -> i64 {
188 let p: *u8 = sys_mmap(OSP_PATH_CAP)
189 osp_ppath(p, pid, "/cmdline" as *u8)
190 let cl: *u8 = sys_mmap(cap + 1)
191 let n: i64 = osp_bread(p, cl, cap)
192 if n <= 0 { out[0] = 0 as u8; return 0 }
193 cl[n] = 0 as u8 // argv0 = bytes up to the first NUL (already there)
194 let bn: *u8 = osp_basename(cl)
195 var o: i64 = 0
196 while bn[o] != (OSP_NUL as u8) { out[o] = bn[o]; o = o + 1 }
197 out[o] = 0 as u8
198 return o
199}
200func osp_hz() -> i64 { return OSP_HZ_LINUX }
201// where the HOST SUPERVISOR writes its log -- a deployment/OS question, so it lives in the seam.
202// Linux/NAS deploy: /tmp/supervisor.log (nx_hostctl supervise). NishiOS: its native supervisor journal.
203func osp_supervisor_log() -> *u8 { return "/tmp/supervisor.log" as *u8 }
204func osp_uptime_s() -> i64 { let ts: *i64 = sys_mmap(16) as *i64; sys_clock_gettime_mono(ts); return ts[0] }
205func osp_selfpid() -> i64 {
206 let b: *u8 = sys_mmap(OSP_RD_CAP)
207 let n: i64 = osp_bread("/proc/self/stat" as *u8, b, OSP_RD_CAP - 1)
208 if n <= 0 { return OSP_SENTINEL }
209 let ep: *i64 = sys_mmap(16) as *i64
210 return osp_num(b, n, 0, ep)
211}
212// wallclock epoch = /proc/stat btime + monotonic-since-boot
213func osp_boot_epoch() -> i64 {
214 let b: *u8 = sys_mmap(OSP_STAT_CAP)
215 let n: i64 = osp_bread("/proc/stat" as *u8, b, OSP_STAT_CAP - 1)
216 if n <= 0 { return 0 }
217 let needle: *u8 = "btime " as *u8
218 var i: i64 = 0
219 let ep: *i64 = sys_mmap(16) as *i64
220 while i < n {
221 var m: i64 = 1; var k: i64 = 0
222 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 }
223 if m == 1 { return osp_num(b, n, i + k, ep) }
224 i = i + 1
225 }
226 return 0
227}
228
229// ======================= NISHIOS BACKEND SPEC (the swap-in seam) =======================
230// When NishiOS's native process API lands, REPLACE the Linux backend above (or @ifdef-branch it once
231// nx_cc has OS-target guards) with these ~8 functions over NishiOS's DIRECT kernel process table --
232// superior to procfs: no /proc text-parsing, no USER_HZ, a real syscall query. The interface the LOGIC
233// layer depends on (and ALL it depends on) is exactly:
234// osp_list_pids(pids,cap)->count nishi_proc_enumerate (live pids)
235// osp_ppid(pid)->ppid nishi_proc_parent
236// osp_starttime_ticks(pid)->ticks nishi_proc_starttime (osp_hz() ticks/sec)
237// osp_cmd_argv0(pid,out,cap)->len nishi_proc_argv0 (basename of argv0)
238// osp_hz()->ticks_per_sec native rate (not the Linux-100 assumption)
239// osp_uptime_s()->secs sys_clock_gettime_mono (already OS-neutral)
240// osp_selfpid()->pid nishi_getpid
241// osp_boot_epoch()->epoch nishi_boot_epoch
242// Interop: NishiOS ships the superior backend; Linux stays supported for the current NAS deploy; the
243// LOGIC (nx_heal) compiles unchanged on both. THAT is the point of this file.