From 771011ee44433a6b8a766692c56e55fb801268a9 Mon Sep 17 00:00:00 2001 From: Aleksei Fedorov Date: Wed, 12 Aug 2026 12:21:14 +0000 Subject: [PATCH] ML stages --- kernel_ai/api/rest.py | 2 + kernel_ai/contracts/api_contracts.py | 27 +- kernel_ai/http/api.py | 4 + kernel_ai/http/api_handlers/processes.py | 8 + kernel_ai/http/pages.py | 24 -- kernel_ai/ml/sequence.py | 4 +- kernel_ai/services/core_observability.py | 271 ++++++++++++------ kernel_ai/services/kernel_maps.py | 53 +++- kernel_ai/services/process_inspect.py | 151 ++++++++++ kernel_ai/services/syscalls.py | 26 +- kernel_ai/services/telemetry_orchestration.py | 9 +- kernel_ai/views/pages.py | 6 - 12 files changed, 444 insertions(+), 141 deletions(-) diff --git a/kernel_ai/api/rest.py b/kernel_ai/api/rest.py index fb2ad56..30a870f 100644 --- a/kernel_ai/api/rest.py +++ b/kernel_ai/api/rest.py @@ -25,6 +25,8 @@ ("/process//threads", "get_process_threads", h.get_process_threads, None), ("/process//cpu", "get_process_cpu", h.get_process_cpu, None), ("/process//fds", "get_process_fds", h.get_process_fds, None), + ("/process//lineage", "get_process_lineage", h.get_process_lineage, None), + ("/process//activity", "get_process_activity", h.get_process_activity, None), ("/processes-detailed", "get_processes_detailed", h.get_processes_detailed, None), ("/ipc-links", "get_ipc_links", h.get_ipc_links, None), ("/proc-matrix", "get_proc_matrix", h.get_proc_matrix, None), diff --git a/kernel_ai/contracts/api_contracts.py b/kernel_ai/contracts/api_contracts.py index fc65d42..177cf45 100644 --- a/kernel_ai/contracts/api_contracts.py +++ b/kernel_ai/contracts/api_contracts.py @@ -41,10 +41,12 @@ class SecurityRealtimeResponse(TypedDict): class FilesystemBlocksResponse(TypedDict): timestamp: str - rows: int - cols: int - zones: list - blocks: list + mounts: list + devices: list + writepath: dict + writeback: dict + # None when no real block device exposes a scheduler (containers, ram-only). + io_scheduler: dict | None meta: dict @@ -151,11 +153,20 @@ def validate_security_realtime_response(payload: Any) -> None: def validate_filesystem_blocks_response(payload: Any) -> None: data = _expect_dict(payload, "filesystem_blocks") _expect_key(data, "timestamp", str, "filesystem_blocks") - _expect_key(data, "rows", int, "filesystem_blocks") - _expect_key(data, "cols", int, "filesystem_blocks") - _expect_key(data, "zones", list, "filesystem_blocks") - _expect_key(data, "blocks", list, "filesystem_blocks") + _expect_key(data, "mounts", list, "filesystem_blocks") + _expect_key(data, "devices", list, "filesystem_blocks") + _expect_key(data, "writepath", dict, "filesystem_blocks") + _expect_key(data, "writeback", dict, "filesystem_blocks") + # A machine without a real block device reports no scheduler at all. + _expect_key(data, "io_scheduler", (dict, type(None)), "filesystem_blocks") _expect_key(data, "meta", dict, "filesystem_blocks") + # The write-path strip and the headline are what the map actually draws. + _expect_key(data["writepath"], "stages", list, "filesystem_blocks.writepath") + _expect_key(data["writepath"], "hot", str, "filesystem_blocks.writepath") + _expect_key(data["writeback"], "dirty_mb", (int, float), "filesystem_blocks.writeback") + _expect_key(data["writeback"], "writeback_mb", (int, float), "filesystem_blocks.writeback") + _expect_key(data["meta"], "used_percent", (int, float), "filesystem_blocks.meta") + _expect_key(data["meta"], "mount_count", int, "filesystem_blocks.meta") def validate_processes_realtime_response(payload: Any) -> None: diff --git a/kernel_ai/http/api.py b/kernel_ai/http/api.py index d8e7e1a..f40ed76 100644 --- a/kernel_ai/http/api.py +++ b/kernel_ai/http/api.py @@ -34,7 +34,9 @@ get_proc_timeline_branches, get_process_cpu, get_process_fds, + get_process_activity, get_process_files, + get_process_lineage, get_process_threads, get_processes, get_processes_detailed, @@ -67,7 +69,9 @@ "get_process_cpu", "get_process_fds", "get_process_files", + "get_process_activity", "get_process_kernel_map", + "get_process_lineage", "get_process_threads", "get_processes", "get_processes_detailed", diff --git a/kernel_ai/http/api_handlers/processes.py b/kernel_ai/http/api_handlers/processes.py index 45481b2..7afaf5a 100644 --- a/kernel_ai/http/api_handlers/processes.py +++ b/kernel_ai/http/api_handlers/processes.py @@ -26,6 +26,14 @@ def get_process_fds(pid): return api_json(lambda: _process_inspect_service.get_process_fds_info(pid)) +def get_process_lineage(pid): + return api_json(lambda: _process_inspect_service.get_process_lineage_info(pid)) + + +def get_process_activity(pid): + return api_json(lambda: _process_inspect_service.get_process_activity_counters(pid)) + + def get_processes_detailed(): return api_json(lambda: {"processes": _processes_service.get_processes_detailed_data()}) diff --git a/kernel_ai/http/pages.py b/kernel_ai/http/pages.py index 99c7a70..43961c8 100644 --- a/kernel_ai/http/pages.py +++ b/kernel_ai/http/pages.py @@ -108,30 +108,6 @@ def linux_devices_subsystem_html(): return redirect("/linux-devices-subsystem", code=301) -def linux_ebpf_subsystem_page(): - return render_template("linux-ebpf-subsystem.html") - - -def ebpf_page_legacy(): - return redirect("/linux-ebpf-subsystem", code=301) - - -def linux_ebpf_subsystem_html(): - return redirect("/linux-ebpf-subsystem", code=301) - - -def linux_ebpf_cyberlab_page(): - return render_template("linux-ebpf-cyberlab.html") - - -def ebpf_lab_page_legacy(): - return redirect("/linux-ebpf-cyberlab", code=301) - - -def linux_ebpf_cyberlab_html(): - return redirect("/linux-ebpf-cyberlab", code=301) - - def health_check(): return jsonify( { diff --git a/kernel_ai/ml/sequence.py b/kernel_ai/ml/sequence.py index 767e88f..75a691d 100644 --- a/kernel_ai/ml/sequence.py +++ b/kernel_ai/ml/sequence.py @@ -26,7 +26,7 @@ from collections import deque from dataclasses import dataclass, field -from kernel_ai.services.kernel_maps import SYSCALL_NAMES +from kernel_ai.services.kernel_maps import get_syscall_names logger = logging.getLogger("kernel_ai.ml.sequence") @@ -63,7 +63,7 @@ def sample(self) -> dict[int, str]: continue if num < 0: continue - out[int(pid)] = SYSCALL_NAMES.get(num, f"sys_{num}") + out[int(pid)] = get_syscall_names().get(num, f"sys_{num}") return out diff --git a/kernel_ai/services/core_observability.py b/kernel_ai/services/core_observability.py index ee5552a..ecd3521 100644 --- a/kernel_ai/services/core_observability.py +++ b/kernel_ai/services/core_observability.py @@ -16,6 +16,17 @@ # Cached counters for per-second I/O pulse deltas (vmstat + disk_io + net + irq). _IO_PULSE_PREV = {"ts": None, "vmstat": {}, "disk": None, "net": None, "intr": None} +# CPU time, I/O wait and throughput are rates, so subsystem load needs its own +# baseline between polls. Kept separate from _IO_PULSE_PREV: the two are read on +# different endpoints at different rhythms, and a shared baseline would leave both +# dividing by the wrong interval. +_SUBSYSTEM_PREV = {"ts": None, "cpu": None, "disk": None, "net": None, "net_peak": 0.0} + +# A throughput meter needs a full-scale mark. The busiest second we have seen sets +# it, decaying slowly so one burst does not flatten the bar for the rest of uptime. +_NET_SCALE_FLOOR = 64 * 1024 +_NET_SCALE_DECAY = 0.97 + def get_system_info(): """Get system information.""" @@ -44,114 +55,184 @@ def get_mock_system_calls(): ] +def _subsystem(metric, usage, value, unit, detail=None, detail_unit=None, processes=0, warming=False): + """One subsystem row: a meter fill plus the number the meter stands for.""" + return { + "status": "active", + "usage": int(max(0, min(100, round(usage)))), + "processes": int(processes), + "metric": metric, + "value": value, + "unit": unit, + "detail": detail, + "detail_unit": detail_unit, + "warming": warming, + } + + def get_mock_kernel_subsystems(): - """Mock data for kernel subsystems.""" + """Mock data for kernel subsystems (non-Linux hosts have no /proc to read).""" return { - "memory_management": {"status": "active", "usage": 75, "processes": 25}, - "process_scheduler": {"status": "active", "usage": 85, "processes": 45}, - "file_system": {"status": "active", "usage": 60, "processes": 15}, - "network_stack": {"status": "active", "usage": 50, "processes": 12}, + "memory_management": _subsystem("memory_used", 75, 75.0, "percent", 12884901888, "bytes"), + "process_scheduler": _subsystem("cpu_busy", 85, 85.0, "percent", 45, "runnable", processes=45), + "file_system": _subsystem("io_wait", 6, 6.0, "percent", 1048576, "bytes_per_sec"), + "network_stack": _subsystem("net_throughput", 50, 524288.0, "bytes_per_sec", 12, "sockets", processes=12), } +def _read_cpu_times(): + """Aggregate jiffies from the /proc/stat cpu line.""" + try: + with open("/proc/stat", "r", encoding="utf-8", errors="ignore") as f: + for line in f: + if line.startswith("cpu "): + return [int(part) for part in line.split()[1:]] + except (OSError, ValueError): + pass + return None + + +def _read_memory_used(): + """Bytes in use and total, the way free(1) counts them.""" + try: + with open("/proc/meminfo", "r", encoding="utf-8", errors="ignore") as f: + info = {} + for line in f: + key, _, value = line.partition(":") + value = value.strip().replace(" kB", "") + if value.isdigit(): + info[key.strip()] = int(value) * 1024 + total = info.get("MemTotal", 0) + available = info.get("MemAvailable", 0) + if total > 0: + return total - available, total + except (OSError, ValueError): + pass + return 0, 0 + + +def _read_loadavg(): + """Runnable processes now, plus the 1/5/15 minute load averages.""" + try: + with open("/proc/loadavg", "r", encoding="utf-8", errors="ignore") as f: + parts = f.read().split() + runnable = int(parts[3].split("/")[0]) + return runnable, [float(parts[0]), float(parts[1]), float(parts[2])] + except (OSError, ValueError, IndexError): + return 0, [] + + +def _read_tcp_inuse(): + """TCP sockets currently in use, from /proc/net/sockstat.""" + try: + with open("/proc/net/sockstat", "r", encoding="utf-8", errors="ignore") as f: + for line in f: + if line.startswith("TCP:"): + parts = line.split() + return int(parts[parts.index("inuse") + 1]) + except (OSError, ValueError, IndexError): + pass + return 0 + + +def _read_mount_count(): + """Mounted filesystems, from /proc/mounts.""" + try: + with open("/proc/mounts", "r", encoding="utf-8", errors="ignore") as f: + return sum(1 for line in f if line.strip() and not line.startswith("#")) + except OSError: + return 0 + + def get_kernel_subsystem_status(): - """Get real kernel subsystem status from /proc filesystem.""" + """Real load per kernel subsystem, read from /proc. + + CPU time, I/O wait and network throughput are rates, so they are measured as + deltas between polls. The first call after start only lays down the baseline + and marks those three as warming rather than inventing a number for them. + """ try: if platform.system() != "Linux": return get_mock_kernel_subsystems() - subsystems = {} - - try: - with open("/proc/meminfo", "r", encoding="utf-8", errors="ignore") as f: - meminfo = {} - for line in f: - if ":" in line: - key, value = line.split(":", 1) - meminfo[key.strip()] = value.strip() - mem_total_kb = int(meminfo.get("MemTotal", "0").replace(" kB", "")) - mem_available_kb = int(meminfo.get("MemAvailable", "0").replace(" kB", "")) - active_kb = int(meminfo.get("Active", "0").replace(" kB", "")) - memory_usage = int(((mem_total_kb - mem_available_kb) / mem_total_kb) * 100) if mem_total_kb > 0 else 0 - processes_estimate = max(10, min(100, active_kb // 50000)) - subsystems["memory_management"] = {"status": "active", "usage": memory_usage, "processes": processes_estimate} - except (IOError, ValueError, KeyError): - subsystems["memory_management"] = {"status": "active", "usage": 75, "processes": 25} - + now = time.time() + cpu = _read_cpu_times() try: - with open("/proc/stat", "r", encoding="utf-8", errors="ignore") as f: - cpu_usage = 0 - for line in f: - if line.startswith("cpu "): - parts = line.split() - if len(parts) >= 5: - user_time = int(parts[1]) - system_time = int(parts[3]) - idle_time = int(parts[4]) - total_time = user_time + system_time + idle_time - cpu_usage = int(((user_time + system_time) / total_time) * 100) if total_time > 0 else 0 - break - scheduler_usage = min(100, max(50, cpu_usage)) - try: - with open("/proc/loadavg", "r", encoding="utf-8", errors="ignore") as f: - loadavg = f.read().strip().split() - running_processes = int(float(loadavg[3].split("/")[0])) - except (OSError, ValueError, IndexError, psutil.Error): - running_processes = len(psutil.pids()) if "psutil" in sys.modules else 50 - subsystems["process_scheduler"] = {"status": "active", "usage": scheduler_usage, "processes": running_processes} - except (IOError, ValueError, KeyError): - subsystems["process_scheduler"] = {"status": "active", "usage": 85, "processes": 45} - + disk = psutil.disk_io_counters() + except (psutil.Error, OSError): + disk = None try: - with open("/proc/mounts", "r", encoding="utf-8", errors="ignore") as f: - mount_count = len([line for line in f if line.strip() and not line.startswith("#")]) - try: - with open("/proc/stat", "r", encoding="utf-8", errors="ignore") as f: - fs_usage = 60 - for line in f: - if line.startswith("cpu "): - parts = line.split() - fs_usage = min(100, max(20, int(parts[5]) // 100)) if len(parts) >= 6 else 60 - break - except (OSError, ValueError, IndexError): - fs_usage = 60 - fs_processes = max(5, min(50, mount_count * 2)) - subsystems["file_system"] = {"status": "active", "usage": fs_usage, "processes": fs_processes} - except (IOError, ValueError): - subsystems["file_system"] = {"status": "active", "usage": 60, "processes": 15} + net = psutil.net_io_counters() + except (psutil.Error, OSError): + net = None - try: - network_usage = 30 - network_processes = 8 - try: - with open("/proc/net/sockstat", "r", encoding="utf-8", errors="ignore") as f: - for line in f: - if line.startswith("TCP:"): - parts = line.split() - try: - inuse_idx = parts.index("inuse") + 1 if "inuse" in parts else -1 - alloc_idx = parts.index("alloc") + 1 if "alloc" in parts else -1 - tcp_inuse = int(parts[inuse_idx]) if 0 < inuse_idx < len(parts) else 0 - tcp_alloc = int(parts[alloc_idx]) if 0 < alloc_idx < len(parts) else tcp_inuse + 10 - network_usage = min(100, max(20, int((tcp_inuse / tcp_alloc) * 100))) if tcp_alloc > 0 else 30 - network_processes = max(8, min(50, tcp_inuse // 2)) - except (ValueError, IndexError): - network_usage = 30 - network_processes = 12 - break - except FileNotFoundError: - try: - with open("/proc/net/tcp", "r", encoding="utf-8", errors="ignore") as f: - tcp_connections = len([line for line in f if line.strip() and not line.startswith("sl")]) - network_usage = min(100, max(20, tcp_connections // 10)) - network_processes = max(8, min(50, tcp_connections // 5)) - except (OSError, ValueError, IndexError): - pass - subsystems["network_stack"] = {"status": "active", "usage": network_usage, "processes": network_processes} - except (IOError, ValueError): - subsystems["network_stack"] = {"status": "active", "usage": 50, "processes": 12} - - return subsystems + prev_ts = _SUBSYSTEM_PREV["ts"] + prev_cpu = _SUBSYSTEM_PREV["cpu"] + prev_disk = _SUBSYSTEM_PREV["disk"] + prev_net = _SUBSYSTEM_PREV["net"] + net_peak = _SUBSYSTEM_PREV["net_peak"] + + dt = max(0.001, now - prev_ts) if prev_ts else 0.0 + + # Memory is a level, not a rate, so it is honest from the very first call. + used_bytes, total_bytes = _read_memory_used() + memory_pct = (used_bytes / total_bytes * 100) if total_bytes else 0.0 + + cpu_busy = None + io_wait = None + if cpu and prev_cpu and len(cpu) == len(prev_cpu): + delta = [now_v - prev_v for now_v, prev_v in zip(cpu, prev_cpu)] + total_jiffies = sum(delta) + if total_jiffies > 0: + idle_jiffies = delta[3] + iowait_jiffies = delta[4] if len(delta) > 4 else 0 + # Waiting on a disk is not the CPU being busy, so iowait is its own + # number and is excluded from the busy share. + cpu_busy = (total_jiffies - idle_jiffies - iowait_jiffies) / total_jiffies * 100 + io_wait = iowait_jiffies / total_jiffies * 100 + + disk_bytes_s = None + if disk is not None and prev_disk is not None and dt: + moved = (disk.read_bytes - prev_disk.read_bytes) + (disk.write_bytes - prev_disk.write_bytes) + disk_bytes_s = max(0.0, moved / dt) + + net_bytes_s = None + if net is not None and prev_net is not None and dt: + moved = (net.bytes_sent - prev_net.bytes_sent) + (net.bytes_recv - prev_net.bytes_recv) + net_bytes_s = max(0.0, moved / dt) + net_peak = max(net_bytes_s, net_peak * _NET_SCALE_DECAY, _NET_SCALE_FLOOR) + + _SUBSYSTEM_PREV.update({"ts": now, "cpu": cpu, "disk": disk, "net": net, "net_peak": net_peak}) + + runnable, load_avg = _read_loadavg() + tcp_inuse = _read_tcp_inuse() + + return { + "memory_management": _subsystem( + "memory_used", memory_pct, round(memory_pct, 1), "percent", + used_bytes, "bytes", + ), + "process_scheduler": dict( + _subsystem( + "cpu_busy", cpu_busy or 0, round(cpu_busy, 1) if cpu_busy is not None else None, "percent", + runnable, "runnable", + processes=runnable, warming=cpu_busy is None, + ), + load=load_avg, + ), + "file_system": _subsystem( + "io_wait", io_wait or 0, round(io_wait, 1) if io_wait is not None else None, "percent", + round(disk_bytes_s) if disk_bytes_s is not None else None, "bytes_per_sec", + processes=_read_mount_count(), warming=io_wait is None, + ), + "network_stack": _subsystem( + "net_throughput", + (net_bytes_s / net_peak * 100) if net_bytes_s is not None and net_peak else 0, + round(net_bytes_s) if net_bytes_s is not None else None, "bytes_per_sec", + tcp_inuse, "sockets", + processes=tcp_inuse, warming=net_bytes_s is None, + ), + } except (OSError, ValueError, KeyError, psutil.Error) as exc: log_event( logger, diff --git a/kernel_ai/services/kernel_maps.py b/kernel_ai/services/kernel_maps.py index bed92df..eed4280 100644 --- a/kernel_ai/services/kernel_maps.py +++ b/kernel_ai/services/kernel_maps.py @@ -2,9 +2,16 @@ from __future__ import annotations +import functools +import platform +import shutil +import subprocess -# System call number to name mapping (common Linux syscalls) -SYSCALL_NAMES = { + +# Syscall numbers are per architecture: 73 is flock on x86_64 and ppoll on +# arm64. This bundled table is the x86_64 numbering and is only used when the +# machine really is x86_64 — see get_syscall_names(). +SYSCALL_NAMES_X86_64 = { 0: "read", 1: "write", 2: "open", @@ -403,6 +410,48 @@ 395: "futex_wake_requeue_pi", } +_X86_MACHINES = {"x86_64", "amd64"} + + +def _names_from_ausyscall(): + """Number to name for the running architecture, straight from auditd. + + ``ausyscall --dump`` prints the table the kernel on this machine actually + uses, which is the only source that is right on every architecture. + """ + binary = shutil.which("ausyscall") + if not binary: + return {} + try: + result = subprocess.run( + [binary, "--dump"], capture_output=True, text=True, timeout=5, check=False + ) + except (OSError, subprocess.SubprocessError): + return {} + table = {} + for line in (result.stdout or "").splitlines(): + parts = line.split() + if len(parts) == 2 and parts[0].isdigit(): + table[int(parts[0])] = parts[1] + return table + + +@functools.lru_cache(maxsize=1) +def get_syscall_names(): + """The syscall table of the running kernel, resolved once per process. + + Falls back to the bundled table only on x86_64, the architecture it + describes. When neither source applies the mapping stays empty on purpose: + callers then show ``syscall_``, which can be looked up, instead of + a confident name borrowed from another architecture. + """ + table = _names_from_ausyscall() + if table: + return table + if platform.machine().lower() in _X86_MACHINES: + return dict(SYSCALL_NAMES_X86_64) + return {} + def map_syscall_to_subsystem(syscall_name): """Map syscall name to kernel subsystem.""" diff --git a/kernel_ai/services/process_inspect.py b/kernel_ai/services/process_inspect.py index 481bcaf..4285e1b 100644 --- a/kernel_ai/services/process_inspect.py +++ b/kernel_ai/services/process_inspect.py @@ -329,6 +329,157 @@ def get_process_threads_info(pid): return {"error": str(e)} +def _read_status_counters(pid): + """Context-switch and thread counters from world-readable /proc status.""" + out = {"ctx_voluntary": None, "ctx_nonvoluntary": None, "num_threads": None} + try: + with open(f"/proc/{pid}/status", "r", encoding="utf-8", errors="ignore") as f: + for line in f: + if ":" not in line: + continue + key, value = line.split(":", 1) + value = value.strip() + if not value.isdigit(): + continue + if key == "voluntary_ctxt_switches": + out["ctx_voluntary"] = int(value) + elif key == "nonvoluntary_ctxt_switches": + out["ctx_nonvoluntary"] = int(value) + elif key == "Threads": + out["num_threads"] = int(value) + except (OSError, PermissionError): + pass + return out + + +def _read_io_counters(pid): + """Byte counters from /proc//io, which only the owner may read.""" + try: + with open(f"/proc/{pid}/io", "r", encoding="utf-8", errors="ignore") as f: + data = {} + for line in f: + if ":" not in line: + continue + key, value = line.split(":", 1) + value = value.strip() + if value.isdigit(): + data[key.strip()] = int(value) + return {"read_bytes": data.get("read_bytes"), "write_bytes": data.get("write_bytes")} + except (OSError, PermissionError): + return {"read_bytes": None, "write_bytes": None} + + +def get_process_activity_counters(pid): + """Cumulative counters for client-side delta sampling. + + Deliberately cheap: no blocking ``cpu_percent`` call, just counter reads, so + the dossier can poll this every couple of seconds. Rates are derived on the + client from the difference between two samples, which is why the sample + timestamp travels with the payload. + """ + try: + proc = psutil.Process(pid) + except psutil.NoSuchProcess as e: + return {"error": str(e), "pid": pid} + + try: + times = proc.cpu_times() + cpu_user = round(float(times.user), 3) + cpu_system = round(float(times.system), 3) + except (psutil.AccessDenied, psutil.NoSuchProcess, OSError): + cpu_user = None + cpu_system = None + + payload = { + "pid": pid, + "ts": time.time(), + "cpu_user": cpu_user, + "cpu_system": cpu_system, + } + payload.update(_read_status_counters(pid)) + payload.update(_read_io_counters(pid)) + payload["io_readable"] = payload["read_bytes"] is not None + return payload + + +_LINEAGE_MAX_DEPTH = 8 + + +def _describe_ancestor(proc): + """Snapshot of one process in the ancestry chain, with its real start time.""" + info = proc.as_dict(["pid", "name", "create_time", "status", "username"]) + create_time = info.get("create_time") + try: + cmdline = " ".join(proc.cmdline()) + except (psutil.AccessDenied, psutil.NoSuchProcess, OSError): + cmdline = "" + return { + "pid": int(info.get("pid") or 0), + "name": str(info.get("name") or "?"), + "status": info.get("status"), + "username": info.get("username"), + "cmdline": cmdline[:160], + "create_time": float(create_time) if create_time else None, + "age_s": round(time.time() - float(create_time), 1) if create_time else None, + } + + +def get_process_lineage_info(pid): + """Ancestry chain from init down to ``pid``, ordered oldest first. + + Unlike fd tables, ``/proc//stat`` is world readable, so start times and + parent links resolve for foreign processes too. Timestamps here are real + kernel start times, not a synthetic window. + """ + try: + proc = psutil.Process(pid) + except psutil.NoSuchProcess as e: + return {"error": str(e), "pid": pid} + + try: + chain = [] + node = proc + seen = set() + while node is not None and len(chain) < _LINEAGE_MAX_DEPTH: + if node.pid in seen: + break + seen.add(node.pid) + chain.append(_describe_ancestor(node)) + try: + node = node.parent() + except (psutil.AccessDenied, psutil.NoSuchProcess): + break + chain.reverse() + + try: + children = proc.children() + child_rows = [ + {"pid": c.pid, "name": c.name()} + for c in children[:6] + ] + child_count = len(children) + except (psutil.AccessDenied, psutil.NoSuchProcess): + child_rows = [] + child_count = 0 + + self_row = chain[-1] if chain else None + return { + "pid": pid, + "chain": chain, + "depth": len(chain), + "truncated": len(chain) >= _LINEAGE_MAX_DEPTH, + "children": child_rows, + "child_count": child_count, + "boot_time": psutil.boot_time(), + "age_s": self_row.get("age_s") if self_row else None, + } + except psutil.AccessDenied as e: + return {"error": str(e), "pid": pid} + except Exception as e: + capture_exception(e, where="services.process_inspect.get_process_lineage_info") + return {"error": str(e), "pid": pid} + + def get_process_cpu_info(pid): try: proc = psutil.Process(pid) diff --git a/kernel_ai/services/syscalls.py b/kernel_ai/services/syscalls.py index 4c36c2c..cb9cb37 100644 --- a/kernel_ai/services/syscalls.py +++ b/kernel_ai/services/syscalls.py @@ -8,6 +8,19 @@ from kernel_ai.sentry_helpers import capture_exception +# A syscall row lists the processes parked in it. More than a dozen names is +# already unreadable, and the row keeps the honest total next to the list. +_MAX_WAITERS_PER_SYSCALL = 12 + + +def _read_comm(pid): + """Process name from /proc//comm, or None if it just exited.""" + try: + with open(f"/proc/{pid}/comm", "r", encoding="utf-8", errors="replace") as f: + return f.read().strip() or None + except (OSError, ValueError): + return None + def _kernel_dna_read_proc_vmstat(): """Parse /proc/vmstat into a dict of int counters.""" @@ -106,6 +119,7 @@ def get_real_system_calls(syscall_names, map_syscall_to_subsystem_fn, kernel_dna sampled = sorted(proc_dirs, key=int)[: min(kernel_dna_max_procs, len(proc_dirs))] syscall_counts = {} + syscall_waiters = {} for pid in sampled: try: syscall_path = f"/proc/{pid}/syscall" @@ -124,13 +138,23 @@ def get_real_system_calls(syscall_names, map_syscall_to_subsystem_fn, kernel_dna continue syscall_name = syscall_names.get(syscall_num, f"syscall_{syscall_num}") syscall_counts[syscall_name] = syscall_counts.get(syscall_name, 0) + 1 + # Keep who is parked here: the count is a set of processes, and the + # UI opens the dossier of any of them by pid. + waiters = syscall_waiters.setdefault(syscall_name, []) + if len(waiters) < _MAX_WAITERS_PER_SYSCALL: + waiters.append({"pid": int(pid), "comm": _read_comm(pid)}) except (PermissionError, FileNotFoundError, IOError, ValueError): continue if syscall_counts: syscalls = [] for name, count in sorted(syscall_counts.items(), key=lambda x: x[1], reverse=True)[:20]: - syscalls.append({"name": name, "count": count, "subsystem": map_syscall_to_subsystem_fn(name)}) + syscalls.append({ + "name": name, + "count": count, + "subsystem": map_syscall_to_subsystem_fn(name), + "waiters": syscall_waiters.get(name, []), + }) return syscalls merged = [] diff --git a/kernel_ai/services/telemetry_orchestration.py b/kernel_ai/services/telemetry_orchestration.py index 0261e76..578bbcc 100644 --- a/kernel_ai/services/telemetry_orchestration.py +++ b/kernel_ai/services/telemetry_orchestration.py @@ -17,7 +17,10 @@ OPENAI_AVAILABLE = False -SYSCALL_NAMES = _kernel_maps_service.SYSCALL_NAMES +def get_syscall_names(): + return _kernel_maps_service.get_syscall_names() + + KERNEL_DNA_MAX_PROCS = int(os.environ.get("KERNEL_DNA_MAX_PROCS", "1200")) @@ -35,7 +38,7 @@ def get_mock_system_calls(): def get_real_system_calls(): return _syscalls_service.get_real_system_calls( - syscall_names=SYSCALL_NAMES, + syscall_names=get_syscall_names(), map_syscall_to_subsystem_fn=map_syscall_to_subsystem, kernel_dna_max_procs=KERNEL_DNA_MAX_PROCS, fallback_mock_calls_fn=get_mock_system_calls, @@ -143,7 +146,7 @@ def get_kernel_dna_data(): def get_execution_context_data(exec_context_prev): return _execution_service.get_execution_context_data( - syscall_names=SYSCALL_NAMES, + syscall_names=get_syscall_names(), map_interrupt_to_subsystem_fn=map_interrupt_to_subsystem, exec_context_prev=exec_context_prev, ) diff --git a/kernel_ai/views/pages.py b/kernel_ai/views/pages.py index a42778d..aa53fe3 100644 --- a/kernel_ai/views/pages.py +++ b/kernel_ai/views/pages.py @@ -29,12 +29,6 @@ ("/linux-devices-subsystem", "linux_devices_subsystem_page", h.linux_devices_subsystem_page), ("/devices", "devices_page_legacy", h.devices_page_legacy), ("/linux-devices-subsystem.html", "linux_devices_subsystem_html", h.linux_devices_subsystem_html), - ("/linux-ebpf-subsystem", "linux_ebpf_subsystem_page", h.linux_ebpf_subsystem_page), - ("/ebpf", "ebpf_page_legacy", h.ebpf_page_legacy), - ("/linux-ebpf-subsystem.html", "linux_ebpf_subsystem_html", h.linux_ebpf_subsystem_html), - ("/linux-ebpf-cyberlab", "linux_ebpf_cyberlab_page", h.linux_ebpf_cyberlab_page), - ("/ebpf-lab", "ebpf_lab_page_legacy", h.ebpf_lab_page_legacy), - ("/linux-ebpf-cyberlab.html", "linux_ebpf_cyberlab_html", h.linux_ebpf_cyberlab_html), ("/health", "health_check", h.health_check), ]