v0.0.47 - Release: processes diagnostics window with per-tab CPU probe

This commit is contained in:
Laan Tungir
2026-07-19 10:06:01 -04:00
parent 3bacac4a42
commit b3955e4231
20 changed files with 2531 additions and 57 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ NOSTR_LIB = ./nostr_core_lib/libnostr_core_x64.a
NOSTR_DEPS = -lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm
BIN := sovereign_browser
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/nostr_url.c src/nostr_scheme.c src/history.c src/settings.c src/net_services.c src/tor_control.c src/tor_scheme.c src/fips_control.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/nostr_url.c src/nostr_scheme.c src/history.c src/settings.c src/net_services.c src/tor_control.c src/tor_scheme.c src/fips_control.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c src/process_info.c src/perf_probe.c
# Web files embedded into the binary as C byte arrays.
WEB_FILES := $(shell find www -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' \) 2>/dev/null)
+1 -1
View File
@@ -1 +1 @@
0.0.46
0.0.47
+295
View File
@@ -0,0 +1,295 @@
# Processes Window — Implementation Plan
## Goal
A `sovereign://processes` internal page that lets the user pinpoint which
open tab is consuming CPU and dig into *what that page is doing*. Primary
use case: diagnosing `~/lt/client`, which currently burns CPU across many
tabs.
## The core technical reality
The codebase deliberately **shares one WebProcess across tabs** — every
new tab/webview is created with `webkit_web_view_new_with_related_view()`
(see [`tab_manager.c`](../src/tab_manager.c:135) and the call sites at
lines 1106, 1228, 2657, 3602, 3624). This was done to avoid a
`std::optional<WindowFeatures>` assertion crash in WebKitGTK.
Consequence: process-level CPU% from `/proc` tells you which *WebProcess*
is hot, but **not which tab within it**. If 10 `~/lt/client` tabs share
one WebProcess at 90% CPU, `/proc` alone cannot attribute the load.
The plan therefore uses **two layers**:
1. **Layer 1 — Process view** (from `/proc`): main, WebKitWebProcess(es)
with their hosted tabs listed, WebKitNetworkProcess, Tor, FIPS. Catches
the single-tab-per-process case and shows overall footprint.
2. **Layer 2 — Per-tab page probe** (injected JS in every webview): this
is what actually pinpoints the offending tab *within* a shared
WebProcess and tells you what it is doing.
## Architecture
```mermaid
flowchart LR
subgraph BrowserProcess[sovereign_browser main PID]
ProcInfo[process_info.c reads /proc]
Bridge[nostr_bridge.c sovereign:// routes]
AgentServer[agent_server / MCP]
end
subgraph WebProcess[WebKitWebProcess pid N - shared by many tabs]
ProbeA[perf-probe.js in tab A]
ProbeB[perf-probe.js in tab B]
ProbeC[perf-probe.js in tab C]
end
ProbeA -->|sovereign://processes/probe-report POST| Bridge
ProbeB -->|sovereign://processes/probe-report POST| Bridge
ProbeC -->|sovereign://processes/probe-report POST| Bridge
ProcInfo -->|enumerates /proc and webkit_web_view_get_process_id| Bridge
Bridge -->|JSON| UI[www/processes.js renders tables]
Bridge -->|JSON| AgentServer
```
## Layer 1 — Process view (`src/process_info.{c,h}`)
### Process discovery
| Process | How found |
|---|---|
| Main (`sovereign_browser`) | `getpid()` |
| WebKitWebProcess (≥1) | For each tab, `webkit_web_view_get_process_id(tab->webview)` returns the WebProcess PID. Group tabs by PID. |
| WebKitNetworkProcess | Scan `/proc/*/comm` for `WebKitNetworkProcess` whose PPID is the main PID (read `/proc/<pid>/stat` field 4). |
| WebKitGPUProcess / StorageProcess | Same PPID scan, if present. |
| Tor (managed) | `net_service_get_status(NET_SERVICE_TOR)->pid` when `ownership == OWNERSHIP_MANAGED`. |
| FIPS (managed) | `net_service_get_status(NET_SERVICE_FIPS)->pid` when `ownership == OWNERSHIP_MANAGED`. |
### Per-process fields (all from `/proc`, no external deps)
| Field | Source |
|---|---|
| pid | — |
| name | `/proc/<pid>/comm` |
| cmdline | `/proc/<pid>/cmdline` (NUL-split, space-joined) |
| state | `/proc/<pid>/stat` field 3 (R/S/D/Z/T) |
| ppid | `/proc/<pid>/stat` field 4 |
| cpu_percent | delta of (`utime`+`stime`, fields 14+15) between two reads, divided by elapsed wall time × `sysconf(_SC_CLK_TCK)` |
| rss_kb | `/proc/<pid>/status``VmRSS` |
| pss_kb | `/proc/<pid>/smaps_rollup``Pss` (fallback to RSS if unavailable) |
| uss_kb | `/proc/<pid>/smaps_rollup``Private_Clean` + `Private_Dirty` |
| vmpeak_kb | `/proc/<pid>/status``VmPeak` |
| vmswap_kb | `/proc/<pid>/status``VmSwap` |
| threads | count of entries in `/proc/<pid>/task/` |
| uptime_sec | `time(NULL) - (btime + starttime/clk_tck)`; btime from `/proc/stat` |
| io_read_kb | `/proc/<pid>/io``read_bytes` / 1024 |
| io_write_kb | `/proc/<pid>/io``write_bytes` / 1024 |
| fd_count | count of entries in `/proc/<pid>/fd/` |
| ownership | `main` / `webkit-renderer` / `webkit-network` / `webkit-gpu` / `tor` / `fips` |
| service_state | for Tor/FIPS: from `net_service_t.state` (READY/BOOTSTRAPPING/…) |
| hosted_tabs | for renderers: array of `{index, title, url}` from `tab_manager` |
### CPU% computation
Keep a static `GHashTable<pid, prev_sample>` between calls. Each call:
1. Read current `utime+stime` and `time(NULL)`.
2. `cpu% = (cur - prev) / clk_tck / (now_wall - prev_wall) * 100`.
3. Store current as prev.
Caller polls `sovereign://processes/list` every 1s; first call returns
0% (no baseline), second call onward is accurate. Same approach as
`top`/`htop`.
### API
```c
/* src/process_info.h */
cJSON *process_info_get_processes_json(void); /* Layer 1 table */
cJSON *process_info_get_tabs_json(void); /* tab→WebProcess mapping + last probe */
cJSON *process_info_get_tab_probe_json(int tab_index); /* drill-down for one tab */
void process_info_record_probe(int tab_index, cJSON *probe); /* called from probe-report route */
```
## Layer 2 — Per-tab page probe (`www/js/perf-probe.js`)
Injected into every webview via `WebKitUserContentManager` (same pattern
as the existing `window.nostr` injection in
[`nostr_inject.c`](../src/nostr_inject.c:1)). Runs before page scripts
where possible (using `WebKitUserContentInjectedFramesAllFrames` and
`WebKitUserScriptInjectAtDocumentStart`).
### Signals collected
| Signal | Source | What it tells you |
|---|---|---|
| **long_tasks** | `PerformanceObserver({entryTypes:['longtask']})` | Every main-thread task >50ms with duration + attribution. *Direct answer to "which tab is hogging CPU".* |
| **cpu_busy_percent** | sum of long-task durations in last 1s window | True per-tab CPU-busy %. |
| **fps** | `requestAnimationFrame` cadence over 1s | Constant repainting / animation loops. |
| **timer_count** | patched `setTimeout`/`setInterval` (count active) | Runaway polling loops (common in dashboards like `~/lt/client`). |
| **timer_top** | top 5 intervals by frequency in last 1s | Which polling endpoints/scripts. |
| **net_requests** | `PerformanceObserver({entryTypes:['resource']})` | Fetch/XHR/WebSocket storms, polling endpoints. |
| **net_in_flight** | patched `fetch`/`XMLHttpRequest`/`WebSocket` | Current open requests. |
| **heap_used_mb** | `performance.memory.usedJSHeapSize` (if exposed) | Memory leaks, growing arrays. |
| **event_listener_count** | patched `addEventListener` | Leaking listeners. |
| **worker_count** | patched `Worker` constructor | Background CPU not visible as long tasks. |
| **dom_node_count** | `document.getElementsByTagName('*').length` | Page bloat. |
| **long_task_timeline** | ring buffer of last 30s of long tasks | Drill-down chart. |
| **long_task_top_sources** | aggregate by `entry.name` / script URL | Which script is responsible. |
### Reporting
The probe batches a report every 1s and POSTs it to
`sovereign://processes/probe-report?tab_index=N` with JSON body. The
route handler in `nostr_bridge.c` calls
`process_info_record_probe(N, body)`, which stores the latest probe per
tab index in a static array (sized to `tab_manager_count()`).
The probe must NOT run on `sovereign://` internal pages (settings, fips,
processes itself, agents) — skip injection when the URL scheme is
`sovereign://`. This avoids self-noise and recursion.
### Probe payload shape
```json
{
"tab_index": 3,
"cpu_busy_percent": 42.5,
"fps": 12,
"timer_count": 38,
"timer_top": [{"code":"pollStatus","interval_ms":250,"count":4}],
"net_in_flight": 3,
"net_requests_last_sec": 8,
"net_top_endpoints": [{"url":"wss://.../events","count":4}],
"heap_used_mb": 124.3,
"event_listener_count": 217,
"worker_count": 2,
"dom_node_count": 4821,
"long_tasks_last_sec": [{"duration_ms":180,"name":"setTimeout","attribution":"app.js:420"}],
"long_task_top_sources": [{"name":"app.js","total_ms":1240,"count":8}]
}
```
## Routes (added to `nostr_bridge.c`)
Following the [`sovereign://fips`](../src/nostr_bridge.c:4227) pattern:
| Route | Handler | Returns |
|---|---|---|
| `sovereign://processes` | `serve_embedded_file("processes.html")` | HTML |
| `sovereign://processes.css` | `serve_embedded_file("processes.css")` | CSS |
| `sovereign://processes.js` | `serve_embedded_file("processes.js")` | JS |
| `sovereign://processes/list` | `process_info_get_processes_json()` | JSON, polled 1s |
| `sovereign://processes/tabs` | `process_info_get_tabs_json()` | JSON, polled 1s |
| `sovereign://processes/tab_probe?index=N` | `process_info_get_tab_probe_json(N)` | JSON, on drill-down |
| `sovereign://processes/probe-report?tab_index=N` | `process_info_record_probe(N, body)` | 204 No Content |
## UI (`www/processes.{html,css,js}`)
Follows the [`www/fips.*`](../www/fips.html:1) tabbed pattern.
### Tab 1 — Processes
Sortable table, default sort by `cpu_percent` desc:
| PID | Name | CPU% | RSS | PSS | Threads | Uptime | State | Tabs |
|---|---|---|---|---|---|---|---|---|
| 12345 | sovereign_browser | 2.1 | 180M | 160M | 12 | 1h23m | R | — |
| 12367 | WebKitWebProcess | 88.4 | 820M | 740M | 8 | 1h22m | R | 3,7,9,12,15 |
| 12368 | WebKitNetworkProcess | 1.2 | 90M | 80M | 4 | 1h22m | S | — |
| 12400 | tor | 0.3 | 45M | 40M | 3 | 1h20m | S | — |
Click a WebKitWebProcess row → expands inline to list its hosted tabs
with their Layer-2 `cpu_busy_percent` and `fps` so you can see the
hot tab even within a shared process.
### Tab 2 — Tabs (the primary diagnostic view)
Sortable table, default sort by `cpu_busy_percent` desc:
| # | Title | URL | CPU-busy% | FPS | Timers | Net | Heap | DOM | WPID |
|---|---|---|---|---|---|---|---|---|---|
| 9 | Client — Dashboard | https://client/... | 41.2 | 11 | 38 | 3 | 124M | 4821 | 12367 |
| 7 | Client — Alerts | https://client/... | 0.8 | 60 | 2 | 0 | 22M | 410 | 12367 |
| 3 | sovereign://settings | — | — | — | — | — | — | — | 12367 |
Click a tab row → **drill-down panel** opens below with:
- Long-task timeline (last 30s, sparkline)
- Top long-task sources (script URLs + total ms)
- Active timers list (code label, interval, count)
- Recent network requests (URL, type, count)
- Worker count, event listener count, heap trend
- Action buttons: **Reload tab**, **Suspend tab** (replace with
`about:blank` keeping URL), **Close tab**
### Styling
Reuse `sovereign-base.css` and the card/table styles from
[`www/fips.css`](../www/fips.css:1). Add a heat-bar column background
(green→yellow→red by CPU%) for instant visual scan.
## Menu integration
- Hamburger menu item **"Processes…"** in [`main.c`](../src/main.c:325)
next to "FIPS Mesh…", navigates active tab to `sovereign://processes`
(or opens a new tab if none active) — same pattern as
`open_fips_cb` at line 332.
- Keyboard shortcut `Ctrl+Shift+Esc` (matches Windows Task Manager
convention) registered in [`shortcuts.c`](../src/shortcuts.c:78) as
`open_processes`.
## MCP tools (so an agent can self-diagnose)
Add to [`agent_tools.c`](../src/agent_tools.c:1) /
[`agent_mcp.c`](../src/agent_mcp.c:1):
| Tool | Args | Returns |
|---|---|---|
| `processes.list` | none | Layer 1 JSON (all PIDs + fields) |
| `processes.tabs` | none | Layer 2 JSON (per-tab probes) |
| `processes.tab_probe` | `{tab_index}` | drill-down JSON for one tab |
This lets you ask the agent "find the tab burning CPU in `~/lt/client`
and tell me what it's doing" — the agent calls `processes.tabs`, finds
the high `cpu_busy_percent` tab, calls `processes.tab_probe` for the
drill-down, and reports the offending script/timer/endpoint.
## Build / packaging
- Add `src/process_info.c` to `Makefile` sources.
- Add `www/processes.{html,css,js}` and `www/js/perf-probe.js` to
[`embed_web_files.sh`](../embed_web_files.sh:1) so they are embedded
into the binary via `serve_embedded_file()`.
- Register the perf-probe user script in the shared
`WebKitWebContext`'s `WebKitUserContentManager` at startup (in
`main.c` near where `nostr_inject` is wired), with a URL filter that
excludes `sovereign://*`.
## Verification
1. `make`
2. `./browser.sh restart --login-method generate`
3. Open `sovereign://processes` — verify main + WebKit processes listed
with sane CPU%/RSS.
4. Open several tabs of a CPU-heavy page (e.g.
[`tests/local-site/media.html`](../tests/local-site/media.html:1) or
a synthetic `<script>while(true){}</script>` page) — verify the Tabs
view ranks them by `cpu_busy_percent` and the drill-down shows
long-task sources.
5. Open `~/lt/client` across multiple tabs — verify the hot tab is
identifiable and the drill-down shows which script/timer/endpoint is
responsible.
6. Via MCP: call `processes.tabs` and `processes.tab_probe` and verify
JSON shape matches the contract above.
## Out of scope (future work)
- Forcing process-per-tab via `WebKitWebsitePolicies` /
`WEBKIT_PROCESS_MODEL_MULTIPLE_SECONDARY_PROCESSES` — would give true
per-tab `/proc` attribution but breaks the existing
`related_view` sharing workaround. Tracked separately.
- Historical recording (save probe samples to SQLite for trend charts).
- Per-tab network throttling / CPU limiting (would need WebKit API
support that may not exist).
- Killing/suspending a tab's WebProcess specifically (currently only
per-tab reload/close, which is safe).
+13
View File
@@ -574,6 +574,19 @@ const mcp_tool_def_t tool_defs[] = {
{"shell_exec",
"Run a shell command via /bin/sh -c and return stdout, stderr, and the exit code. The browser runs in a dedicated qube, so full shell access is intended. A timeout (default 30000ms) kills the command if it runs too long.",
"{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout_ms\":{\"type\":\"integer\",\"default\":30000,\"description\":\"Timeout in milliseconds\"}},\"required\":[\"command\"]}"},
/* Process / per-tab diagnostics (sovereign://processes) */
{"processes.list",
"List all sovereign_browser processes (main, WebKit renderers with their hosted tabs, WebKit network/gpu/storage, managed Tor/FIPS) with CPU%, RSS/PSS/USS, threads, uptime, I/O, FD count, and service state. CPU% is from /proc utime+stime deltas — the first call returns 0 for all processes (no baseline); poll ~1s for accurate values. Use this to find which WebProcess is hot.",
"{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
{"processes.tabs",
"List all open tabs with their per-tab performance probe (cpu_busy_percent from long-task sum, fps, active timer count, net in-flight, heap used, event listener count, worker count, DOM node count) and WebProcess PID. Sort by cpu_busy_percent to find the tab hogging CPU within a shared WebProcess. Internal sovereign:// tabs have null probes.",
"{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
{"processes.tab_probe",
"Get the full drill-down probe for a single tab: long-task timeline (last 30s), top long-task sources (script URLs + total ms), top active timers (code + interval + count), top network endpoints, heap, workers, DOM nodes. Use after processes.tabs identifies a hot tab to find out WHAT it is doing.",
"{\"type\":\"object\",\"properties\":{\"tab_index\":{\"type\":\"integer\",\"description\":\"Tab index from processes.tabs\"}},\"required\":[\"tab_index\"]}"},
};
const int tool_defs_count = (int)(sizeof(tool_defs) / sizeof(tool_defs[0]));
+25
View File
@@ -16,6 +16,7 @@
#include "settings.h"
#include "search.h"
#include "nostr_url.h"
#include "process_info.h"
#include <string.h>
#include <stdlib.h>
@@ -4275,6 +4276,30 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
return agent_fs_tools_dispatch(tool_name, params);
}
/* ── Process / per-tab diagnostics (work before login) ────────── *
* These read /proc and the in-memory probe store; they don't touch
* Nostr or page content, so they're safe before login. */
if (strcmp(tool_name, "processes.list") == 0) {
cJSON *arr = process_info_get_processes_json();
return make_success(arr);
}
if (strcmp(tool_name, "processes.tabs") == 0) {
cJSON *arr = process_info_get_tabs_json();
return make_success(arr);
}
if (strcmp(tool_name, "processes.tab_probe") == 0) {
int idx = get_int_param(params, "tab_index", -1);
if (idx < 0) {
return make_error("MISSING_PARAM",
"Provide 'tab_index' (from processes.tabs)");
}
cJSON *obj = process_info_get_tab_probe_json(idx);
if (obj == NULL) {
return make_error("BAD_INDEX", "Tab index out of range");
}
return make_success(obj);
}
/* Check login requirement for known tools. */
gboolean requires_login = TRUE;
gboolean is_login_tool = (strcmp(tool_name, "login_status") == 0 ||
+23 -34
View File
@@ -281,60 +281,45 @@ void app_menu_about_proxy(GtkMenuItem *item, gpointer data) {
* sovereign:// URI scheme bridge in nostr_bridge.c.
*/
/* Hamburger-menu items for sovereign:// internal pages always open in
* a new tab so the user's current page is preserved. */
void on_menu_settings(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://settings");
} else {
/* No active tab — open a new one pointed at the settings page. */
tab_manager_new_tab("sovereign://settings");
}
tab_manager_new_tab("sovereign://settings");
}
void on_menu_profile(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://profile");
} else {
tab_manager_new_tab("sovereign://profile");
}
tab_manager_new_tab("sovereign://profile");
}
/* The hamburger-menu "Agent Setup…" item navigates the active tab to
* the sovereign://agents internal page, which renders the agent provider
/* The hamburger-menu "Agent Setup…" item opens the sovereign://agents
* internal page in a new tab. It renders the agent provider
* configuration UI and a link to open the agent chat. */
void on_menu_agent(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://agents");
} else {
/* No active tab — open a new one pointed at the agent page. */
tab_manager_new_tab("sovereign://agents");
}
tab_manager_new_tab("sovereign://agents");
}
/* The hamburger-menu "FIPS Mesh…" item navigates the active tab to
* the sovereign://fips internal page, which renders the FIPS mesh
* network status + management UI. */
/* The hamburger-menu "FIPS Mesh…" item opens the sovereign://fips
* internal page in a new tab. It renders the FIPS mesh network
* status + management UI. */
void on_menu_fips(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://fips");
}
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://fips");
} else {
tab_manager_new_tab("sovereign://fips");
}
/* The hamburger-menu "Processes…" item opens the sovereign://processes
* internal page in a new tab. It renders the process list + per-tab
* performance diagnostics UI. */
void on_menu_processes(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://processes");
}
/* ---- Keyboard shortcuts --------------------------------------------- *
@@ -462,6 +447,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
on_menu_settings(NULL, NULL);
return TRUE;
case SHORTCUT_OPEN_PROCESSES:
on_menu_processes(NULL, NULL);
return TRUE;
case SHORTCUT_NEW_IDENTITY:
app_menu_switch_identity_proxy(NULL, g_window);
return TRUE;
+174
View File
@@ -34,6 +34,7 @@
#include "net_services.h"
#include "fips_control.h"
#include "relay_fetch.h"
#include "process_info.h"
/* ── Global bridge state ────────────────────────────────────────── *
* The URI scheme callback needs access to the current signer. We keep
@@ -1091,6 +1092,7 @@ static void handle_settings_set(WebKitURISchemeRequest *request,
new_state = bs->theme_dark;
settings_save();
settings_sync_publish();
tab_manager_apply_settings(); /* re-apply GTK app theme */
reload = 0; /* Don't reload — the JS handles the visual toggle */
} else if (strcmp(feature, "agent_server_enabled") == 0) {
bs->agent_server_enabled = !bs->agent_server_enabled;
@@ -2976,6 +2978,138 @@ static void handle_settings_config_json(WebKitURISchemeRequest *request) {
free(json);
}
/* ── Processes / performance page (sovereign://processes) ─────────── */
/* Handle sovereign://processes/list — return the Layer 1 process list
* as a JSON array. Polled by www/processes.js every 1s. */
static void handle_processes_list_json(WebKitURISchemeRequest *request) {
cJSON *arr = process_info_get_processes_json();
char *json = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
respond_json(request, json);
free(json);
}
/* Handle sovereign://processes/tabs — return the Layer 2 tab list
* (tab identity + latest probe) as a JSON array. */
static void handle_processes_tabs_json(WebKitURISchemeRequest *request) {
cJSON *arr = process_info_get_tabs_json();
char *json = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
respond_json(request, json);
free(json);
}
/* Handle sovereign://processes/tab_probe?index=N — return the drill-down
* JSON for a single tab (full probe with timeline + top sources). */
static void handle_processes_tab_probe_json(WebKitURISchemeRequest *request,
const char *query) {
int idx = -1;
if (query) {
const char *p = strstr(query, "index=");
if (p) idx = atoi(p + 6);
}
if (idx < 0) {
respond_error_json(request, -1, "Missing or invalid index");
return;
}
cJSON *obj = process_info_get_tab_probe_json(idx);
if (obj == NULL) {
respond_error_json(request, -1, "Tab index out of range");
return;
}
char *json = cJSON_PrintUnformatted(obj);
cJSON_Delete(obj);
respond_json(request, json);
free(json);
}
/* Handle sovereign://processes/probe-report?tab_index=N&body=<json>
* Records the probe report from a page's perf-probe.js. The body is
* URL-encoded JSON passed as a query parameter (same convention as the
* window.nostr shim — WebKitGTK's custom scheme handler doesn't expose
* POST bodies cleanly). Returns 204 No Content (empty JSON). */
static void handle_processes_probe_report(WebKitURISchemeRequest *request,
const char *query) {
int tab_index = -1;
const char *body_encoded = NULL;
if (query) {
/* Parse "tab_index=N&body=<encoded>" or "body=<encoded>&tab_index=N". */
const char *p = strstr(query, "tab_index=");
if (p) tab_index = atoi(p + 10);
p = strstr(query, "body=");
if (p) body_encoded = p + 5;
}
if (tab_index < 0 || body_encoded == NULL) {
respond_json(request, "{\"status\":\"ignored\"}");
return;
}
/* URL-decode the body. */
char *body = g_uri_unescape_string(body_encoded, NULL);
if (body == NULL) {
respond_json(request, "{\"status\":\"decode_failed\"}");
return;
}
cJSON *probe = cJSON_Parse(body);
g_free(body);
if (probe == NULL) {
respond_json(request, "{\"status\":\"bad_json\"}");
return;
}
process_info_record_probe(tab_index, probe); /* takes ownership */
respond_json(request, "{\"status\":\"ok\"}");
}
/* Handle sovereign://processes/tab_action?index=N&action=reload|suspend|close
* Performs a tab lifecycle action from the drill-down panel. */
static void handle_processes_tab_action(WebKitURISchemeRequest *request,
const char *query) {
int idx = -1;
char action[16] = "";
if (query) {
const char *p = strstr(query, "index=");
if (p) idx = atoi(p + 6);
p = strstr(query, "action=");
if (p) {
const char *v = p + 7;
size_t i = 0;
while (v[i] && v[i] != '&' && i < sizeof(action) - 1) {
action[i] = v[i]; i++;
}
action[i] = '\0';
}
}
if (idx < 0 || idx >= tab_manager_count()) {
respond_error_json(request, -1, "Invalid tab index");
return;
}
if (strcmp(action, "reload") == 0) {
tab_info_t *tab = tab_manager_get(idx);
if (tab && tab->webview) webkit_web_view_reload(tab->webview);
respond_json(request, "{\"status\":\"reloaded\"}");
} else if (strcmp(action, "suspend") == 0) {
/* Suspend = navigate to about:blank, preserving the URL in
* current_url so the user can see what was suspended. The
* tab_manager load handler will overwrite current_url, so we
* restore it after the load. */
tab_info_t *tab = tab_manager_get(idx);
if (tab && tab->webview) {
char saved[TAB_URL_MAX];
strncpy(saved, tab->current_url, sizeof(saved) - 1);
saved[sizeof(saved) - 1] = '\0';
webkit_web_view_load_uri(tab->webview, "about:blank");
strncpy(tab->current_url, saved, sizeof(tab->current_url) - 1);
tab->current_url[sizeof(tab->current_url) - 1] = '\0';
}
respond_json(request, "{\"status\":\"suspended\"}");
} else if (strcmp(action, "close") == 0) {
tab_manager_close_tab(idx);
respond_json(request, "{\"status\":\"closed\"}");
} else {
respond_error_json(request, -1, "Unknown action");
}
}
/* ── FIPS mesh management page (sovereign://fips) ─────────────────── */
/* Map a service_state_t to a lowercase string for the JSON status. */
@@ -4222,6 +4356,46 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request,
return;
}
/* Route sovereign://processes — process + per-tab performance page.
* Served from embedded www/processes.{html,css,js}. */
if (strcmp(uri, "sovereign://processes") == 0 ||
strncmp(uri, "sovereign://processes?", 22) == 0) {
serve_embedded_file(request, "processes.html");
return;
}
if (strcmp(uri, "sovereign://processes.css") == 0 ||
strncmp(uri, "sovereign://processes.css?", 26) == 0) {
serve_embedded_file(request, "processes.css");
return;
}
if (strcmp(uri, "sovereign://processes.js") == 0 ||
strncmp(uri, "sovereign://processes.js?", 25) == 0) {
serve_embedded_file(request, "processes.js");
return;
}
if (strcmp(uri, "sovereign://processes/list") == 0 ||
strncmp(uri, "sovereign://processes/list?", 26) == 0) {
handle_processes_list_json(request);
return;
}
if (strcmp(uri, "sovereign://processes/tabs") == 0 ||
strncmp(uri, "sovereign://processes/tabs?", 26) == 0) {
handle_processes_tabs_json(request);
return;
}
if (strncmp(uri, "sovereign://processes/tab_probe?", 32) == 0) {
handle_processes_tab_probe_json(request, uri + 32);
return;
}
if (strncmp(uri, "sovereign://processes/probe-report?", 35) == 0) {
handle_processes_probe_report(request, uri + 35);
return;
}
if (strncmp(uri, "sovereign://processes/tab_action?", 33) == 0) {
handle_processes_tab_action(request, uri + 33);
return;
}
/* Route sovereign://fips — FIPS mesh management page. */
if (strcmp(uri, "sovereign://fips") == 0 ||
strncmp(uri, "sovereign://fips?", 17) == 0) {
+102
View File
@@ -0,0 +1,102 @@
/*
* perf_probe.c per-tab performance probe injection
*
* See perf_probe.h. The probe script itself lives at www/js/perf-probe.js
* and is embedded into the binary by embed_web_files.sh. This module
* wraps it with a per-tab preamble that injects a
* <meta name="sb-tab-index" content="N"> tag into the document before
* the probe runs, so the probe can identify which tab it is reporting
* for.
*
* The preamble also short-circuits on sovereign:// pages (the probe
* would be self-referential and noisy on internal pages). We can't
* filter by URL at injection time because WebKitUserScript is added
* once per webview and applies to all future loads, so the URL check
* happens at runtime inside the preamble.
*/
#include "perf_probe.h"
#include "embedded_web_content.h"
#include <string.h>
#include <stdio.h>
/* The preamble runs at document_start, before perf-probe.js. It:
* 1. Checks the current URL if it's a sovereign:// internal page,
* sets a flag so the probe body no-ops.
* 2. Injects a <meta name="sb-tab-index" content="N"> tag so the
* probe (which runs at document_start as well, but after this
* preamble in script-order) can read its tab index.
*
* The tab index is baked in at injection time as a literal. */
static char *build_preamble(int tab_index) {
/* The preamble is small and fixed-shape; 512 bytes is plenty. */
char *p = g_malloc(512);
if (p == NULL) return NULL;
snprintf(p, 512,
"(function(){\n"
" 'use strict';\n"
" try {\n"
" var u = location.href || '';\n"
" if (u.indexOf('sovereign://') === 0 || u.indexOf('about:') === 0) {\n"
" window.__sbPerfProbeSkip = true;\n"
" } else {\n"
" window.__sbPerfProbeSkip = false;\n"
" window.__sbPerfTabIndex = %d;\n"
" }\n"
" } catch(e) {}\n"
"})();\n",
tab_index);
return p;
}
void perf_probe_setup(WebKitWebView *webview, int tab_index) {
WebKitUserContentManager *manager =
webkit_web_view_get_user_content_manager(webview);
if (manager == NULL) {
g_printerr("[perf-probe] No user content manager — probe not injected\n");
return;
}
/* Preamble: sets the tab index meta tag (or skips on internal pages). */
char *preamble = build_preamble(tab_index);
if (preamble == NULL) return;
WebKitUserScript *pre_script = webkit_user_script_new(
preamble,
WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
NULL, /* allow list (NULL = all) */
NULL /* block list */
);
webkit_user_content_manager_add_script(manager, pre_script);
webkit_user_script_unref(pre_script);
g_free(preamble);
/* The probe body itself, from the embedded www/js/perf-probe.js. */
const embedded_file_t *f = get_embedded_file("js/perf-probe.js");
if (f == NULL) {
g_printerr("[perf-probe] Embedded js/perf-probe.js not found\n");
return;
}
/* webkit_user_script_new needs a NUL-terminated string. The embedded
* data is a byte array of known size; copy it into a NUL-terminated
* buffer. */
char *probe_js = g_malloc(f->size + 1);
if (probe_js == NULL) return;
memcpy(probe_js, f->data, f->size);
probe_js[f->size] = '\0';
WebKitUserScript *probe_script = webkit_user_script_new(
probe_js,
WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
NULL,
NULL
);
webkit_user_content_manager_add_script(manager, probe_script);
webkit_user_script_unref(probe_script);
g_free(probe_js);
g_print("[perf-probe] perf probe installed for tab %d\n", tab_index);
}
+36
View File
@@ -0,0 +1,36 @@
/*
* perf_probe.h per-tab performance probe injection
*
* Injects www/js/perf-probe.js into a webview via
* WebKitUserContentManager, prefixed with a tiny preamble that sets
* the tab index (so the probe knows which tab it is reporting for).
*
* The probe is skipped on sovereign:// internal pages to avoid
* self-noise and recursion. Call once per webview at tab creation,
* after nostr_inject_setup().
*/
#ifndef PERF_PROBE_H
#define PERF_PROBE_H
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Inject the perf probe into the given webview, tagged with the given
* tab index. The index is baked into a preamble that runs before the
* probe script and sets window.__sbPerfTabIndex.
*
* Safe to call multiple times on the same webview (the probe guards
* against double-injection).
*/
void perf_probe_setup(WebKitWebView *webview, int tab_index);
#ifdef __cplusplus
}
#endif
#endif /* PERF_PROBE_H */
+560
View File
@@ -0,0 +1,560 @@
/*
* process_info.c process and per-tab performance diagnostics
*
* See process_info.h for the two-layer design. This file implements:
*
* Layer 1 /proc enumeration for the main PID, WebKit child PIDs
* (discovered via webkit_web_view_get_process_id() per tab
* plus a PPID scan for network/gpu/storage processes), and
* managed Tor/FIPS PIDs from net_services. CPU% is computed
* from utime+stime deltas between successive calls.
*
* Layer 2 storage of the most recent perf-probe.js report per tab
* index, exposed to the UI and MCP tools.
*
* All /proc parsing is Linux-specific and uses only libc + glib.
*/
#include "process_info.h"
#include "tab_manager.h"
#include "net_services.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include <dirent.h>
#include <ctype.h>
#include <sys/types.h>
#include <glib.h>
/* ── CPU% baseline tracking ──────────────────────────────────────────── */
typedef struct {
guint64 prev_jiffies; /* utime + stime (in clock ticks) */
gint64 prev_wall_us; /* g_get_monotonic_time() at last sample (microseconds) */
} cpu_sample_t;
static GHashTable *g_cpu_samples = NULL; /* pid (guint) -> cpu_sample_t* */
static cpu_sample_t *cpu_sample_get(guint pid) {
if (g_cpu_samples == NULL) {
g_cpu_samples = g_hash_table_new_full(g_direct_hash, g_direct_equal,
NULL, g_free);
}
cpu_sample_t *s = g_hash_table_lookup(g_cpu_samples, GUINT_TO_POINTER(pid));
if (s == NULL) {
s = g_new0(cpu_sample_t, 1);
g_hash_table_insert(g_cpu_samples, GUINT_TO_POINTER(pid), s);
}
return s;
}
static long g_clk_tck = 0;
static long clk_tck(void) {
if (g_clk_tck == 0) g_clk_tck = sysconf(_SC_CLK_TCK);
return g_clk_tck > 0 ? g_clk_tck : 100;
}
/* ── /proc readers ──────────────────────────────────────────────────── */
/* Read the first line of a /proc file into buf (NUL-terminated). */
static int read_proc_file(const char *path, char *buf, size_t buflen) {
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
size_t n = fread(buf, 1, buflen - 1, f);
buf[n] = '\0';
fclose(f);
return (int)n;
}
/* Parse /proc/<pid>/stat. Fields (1-indexed, per man proc):
* (2) comm (3) state (4) ppid (14) utime (15) stime
* (22) starttime (in clock ticks since boot)
* comm is wrapped in parens and may contain spaces, so we parse by
* finding the last ')' and tokenizing after it. */
static int parse_stat(guint pid, char *state_out, size_t state_sz,
guint *ppid_out, guint64 *utime_out, guint64 *stime_out,
guint64 *starttime_out, char *comm_out, size_t comm_sz) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/stat", pid);
char buf[4096];
if (read_proc_file(path, buf, sizeof(buf)) < 0) return -1;
/* comm: between first '(' and last ')'. */
char *lp = strchr(buf, '(');
char *rp = strrchr(buf, ')');
if (lp == NULL || rp == NULL || rp <= lp) return -1;
size_t comm_len = (size_t)(rp - lp - 1);
if (comm_len >= comm_sz) comm_len = comm_sz - 1;
memcpy(comm_out, lp + 1, comm_len);
comm_out[comm_len] = '\0';
/* The rest after ") " is space-separated fields starting at field 3. */
char *rest = rp + 2;
/* rest = "state ppid ... " — tokenize. */
char *save = NULL;
char *tok = strtok_r(rest, " ", &save); /* field 3: state */
if (tok == NULL) return -1;
if (state_out) {
size_t sl = strlen(tok);
if (sl >= state_sz) sl = state_sz - 1;
memcpy(state_out, tok, sl);
state_out[sl] = '\0';
}
tok = strtok_r(NULL, " ", &save); /* field 4: ppid */
if (tok == NULL) return -1;
if (ppid_out) *ppid_out = (guint)strtoul(tok, NULL, 10);
/* Fields 5..13 are pgrp, session, tty, tpgid, flags, minflt, cminflt,
* majflt, cmajflt. Skip them. */
for (int i = 0; i < 9; i++) strtok_r(NULL, " ", &save);
tok = strtok_r(NULL, " ", &save); /* field 14: utime */
if (tok == NULL) return -1;
if (utime_out) *utime_out = strtoull(tok, NULL, 10);
tok = strtok_r(NULL, " ", &save); /* field 15: stime */
if (tok == NULL) return -1;
if (stime_out) *stime_out = strtoull(tok, NULL, 10);
/* Fields 16..21: cutime, cstime, priority, nice, numthreads, itrealvalue. */
for (int i = 0; i < 6; i++) strtok_r(NULL, " ", &save);
tok = strtok_r(NULL, " ", &save); /* field 22: starttime */
if (tok == NULL) return -1;
if (starttime_out) *starttime_out = strtoull(tok, NULL, 10);
return 0;
}
/* Read a named field from /proc/<pid>/status (e.g. "VmRSS:"). Returns
* the integer value in KB, or -1 if not found. */
static long status_field_kb(guint pid, const char *field) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/status", pid);
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
char line[256];
long val = -1;
size_t flen = strlen(field);
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, field, flen) == 0) {
/* e.g. "VmRSS: 12345 kB" */
char *p = line + flen;
while (*p == ' ' || *p == '\t') p++;
val = strtol(p, NULL, 10);
break;
}
}
fclose(f);
return val;
}
/* Parse /proc/<pid>/smaps_rollup for Pss / Private_Clean+Private_Dirty.
* Returns 0 on success. */
static int parse_smaps_rollup(guint pid, long *pss_kb, long *uss_kb) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/smaps_rollup", pid);
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
char line[256];
long pss = -1, priv_clean = 0, priv_dirty = 0;
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "Pss:", 4) == 0) {
pss = strtol(line + 4, NULL, 10);
} else if (strncmp(line, "Private_Clean:", 14) == 0) {
priv_clean = strtol(line + 14, NULL, 10);
} else if (strncmp(line, "Private_Dirty:", 14) == 0) {
priv_dirty = strtol(line + 14, NULL, 10);
}
}
fclose(f);
if (pss_kb) *pss_kb = pss;
if (uss_kb) *uss_kb = priv_clean + priv_dirty;
return (pss < 0) ? -1 : 0;
}
/* Read /proc/<pid>/io fields. Returns 0 on success. */
static int parse_io(guint pid, long *read_bytes, long *write_bytes) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/io", pid);
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
char line[256];
long rb = 0, wb = 0;
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "read_bytes:", 11) == 0) {
rb = strtoll(line + 11, NULL, 10);
} else if (strncmp(line, "write_bytes:", 12) == 0) {
wb = strtoll(line + 12, NULL, 10);
}
}
fclose(f);
if (read_bytes) *read_bytes = rb;
if (write_bytes) *write_bytes = wb;
return 0;
}
/* Read /proc/<pid>/cmdline (NUL-separated args) into a space-joined buf. */
static void read_cmdline(guint pid, char *buf, size_t buflen) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/cmdline", pid);
FILE *f = fopen(path, "r");
if (f == NULL) { buf[0] = '\0'; return; }
size_t n = fread(buf, 1, buflen - 1, f);
fclose(f);
if (n == 0) { buf[0] = '\0'; return; }
buf[n] = '\0';
/* Replace NUL separators with spaces. */
for (size_t i = 0; i < n; i++) {
if (buf[i] == '\0') buf[i] = ' ';
}
/* Trim trailing space. */
while (n > 0 && buf[n - 1] == ' ') buf[--n] = '\0';
}
/* Count entries in a /proc directory (used for threads and fds). */
static int count_dir_entries(const char *path) {
DIR *d = opendir(path);
if (d == NULL) return -1;
int count = 0;
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (e->d_name[0] == '.') continue;
count++;
}
closedir(d);
return count;
}
/* Read /proc/stat's btime (boot time in seconds since epoch). */
static time_t g_btime = 0;
static time_t boot_time(void) {
if (g_btime != 0) return g_btime;
FILE *f = fopen("/proc/stat", "r");
if (f == NULL) return 0;
char line[256];
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "btime", 5) == 0) {
g_btime = (time_t)strtoll(line + 5, NULL, 10);
break;
}
}
fclose(f);
return g_btime;
}
/* ── Probe storage (Layer 2) ────────────────────────────────────────── */
typedef struct {
cJSON *probe; /* most recent probe report (owned) */
time_t ts; /* when recorded */
} probe_slot_t;
/* Sparse array indexed by tab index. Grows as needed. */
static probe_slot_t *g_probes = NULL;
static int g_probe_cap = 0;
static void probe_ensure(int idx) {
if (idx < 0) return;
if (idx >= g_probe_cap) {
int new_cap = g_probe_cap == 0 ? 16 : g_probe_cap;
while (new_cap <= idx) new_cap *= 2;
probe_slot_t *arr = g_realloc(g_probes, new_cap * sizeof(probe_slot_t));
if (arr == NULL) return;
for (int i = g_probe_cap; i < new_cap; i++) {
arr[i].probe = NULL;
arr[i].ts = 0;
}
g_probes = arr;
g_probe_cap = new_cap;
}
}
void process_info_record_probe(int tab_index, cJSON *probe) {
if (tab_index < 0 || probe == NULL) {
if (probe) cJSON_Delete(probe);
return;
}
probe_ensure(tab_index);
if (tab_index >= g_probe_cap) {
cJSON_Delete(probe);
return;
}
if (g_probes[tab_index].probe) {
cJSON_Delete(g_probes[tab_index].probe);
}
g_probes[tab_index].probe = probe; /* take ownership */
g_probes[tab_index].ts = time(NULL);
}
/* Return a *reference* (not a copy) to the stored probe for a tab, or
* NULL. Caller must NOT free. */
static cJSON *probe_get(int tab_index) {
if (tab_index < 0 || tab_index >= g_probe_cap) return NULL;
return g_probes[tab_index].probe;
}
/* Deep-copy a probe (so the caller can free independently). */
static cJSON *probe_copy(int tab_index) {
cJSON *p = probe_get(tab_index);
if (p == NULL) return NULL;
char *s = cJSON_PrintUnformatted(p);
cJSON *copy = cJSON_Parse(s);
free(s);
return copy;
}
/* ── Process enumeration ────────────────────────────────────────────── */
/* Ownership tag for a discovered PID. */
typedef enum {
OWN_MAIN,
OWN_WEBKIT_RENDERER,
OWN_WEBKIT_NETWORK,
OWN_WEBKIT_GPU,
OWN_WEBKIT_STORAGE,
OWN_TOR,
OWN_FIPS,
OWN_OTHER
} proc_own_t;
/* Build a cJSON object for one process. `own` and `service_state`
* (NULL for non-services) are caller-supplied. `hosted_tabs` is an
* array (may be NULL) for renderers. */
static cJSON *build_proc_obj(guint pid, proc_own_t own,
const char *service_state,
cJSON *hosted_tabs) {
char comm[256] = "";
char state[8] = "?";
guint ppid = 0;
guint64 utime = 0, stime = 0, starttime = 0;
if (parse_stat(pid, state, sizeof(state), &ppid, &utime, &stime,
&starttime, comm, sizeof(comm)) != 0) {
return NULL;
}
/* CPU% via delta. Use g_get_monotonic_time() (microsecond
* resolution) instead of time(NULL) (1-second resolution) so the
* value updates on every poll even when the poll interval is ~1s. */
cpu_sample_t *s = cpu_sample_get(pid);
guint64 cur_jiffies = utime + stime;
gint64 now_us = g_get_monotonic_time();
double cpu_pct = 0.0;
if (s->prev_wall_us > 0) {
double dt = (double)(now_us - s->prev_wall_us) / 1000000.0;
if (dt > 0) {
double djiffies = (double)(cur_jiffies - s->prev_jiffies);
cpu_pct = (djiffies / (double)clk_tck()) / dt * 100.0;
if (cpu_pct < 0) cpu_pct = 0;
/* Cap at 100 * cpu_count to avoid absurd values; we report
* per-process so >100 is valid on multicore. */
}
}
s->prev_jiffies = cur_jiffies;
s->prev_wall_us = now_us;
long rss_kb = status_field_kb(pid, "VmRSS:");
long vmpeak_kb = status_field_kb(pid, "VmPeak:");
long vmswap_kb = status_field_kb(pid, "VmSwap:");
long pss_kb = -1, uss_kb = -1;
parse_smaps_rollup(pid, &pss_kb, &uss_kb);
long io_read = 0, io_write = 0;
parse_io(pid, &io_read, &io_write);
char task_path[64];
snprintf(task_path, sizeof(task_path), "/proc/%u/task", pid);
int threads = count_dir_entries(task_path);
if (threads < 0) threads = 0;
char fd_path[64];
snprintf(fd_path, sizeof(fd_path), "/proc/%u/fd", pid);
int fds = count_dir_entries(fd_path);
if (fds < 0) fds = 0;
char cmdline[1024];
read_cmdline(pid, cmdline, sizeof(cmdline));
/* Uptime: starttime is in ticks since boot; btime is boot seconds
* since epoch. process_start = btime + starttime/clk. */
time_t start_sec = boot_time() + (time_t)(starttime / (guint64)clk_tck());
time_t uptime = time(NULL) - start_sec;
if (uptime < 0) uptime = 0;
const char *own_str = "other";
switch (own) {
case OWN_MAIN: own_str = "main"; break;
case OWN_WEBKIT_RENDERER: own_str = "webkit-renderer"; break;
case OWN_WEBKIT_NETWORK: own_str = "webkit-network"; break;
case OWN_WEBKIT_GPU: own_str = "webkit-gpu"; break;
case OWN_WEBKIT_STORAGE: own_str = "webkit-storage"; break;
case OWN_TOR: own_str = "tor"; break;
case OWN_FIPS: own_str = "fips"; break;
default: own_str = "other"; break;
}
cJSON *o = cJSON_CreateObject();
cJSON_AddNumberToObject(o, "pid", (double)pid);
cJSON_AddStringToObject(o, "name", comm);
cJSON_AddStringToObject(o, "cmdline", cmdline);
cJSON_AddStringToObject(o, "state", state);
cJSON_AddNumberToObject(o, "ppid", (double)ppid);
cJSON_AddNumberToObject(o, "cpu_percent", cpu_pct);
cJSON_AddNumberToObject(o, "rss_kb", rss_kb < 0 ? 0 : rss_kb);
cJSON_AddNumberToObject(o, "pss_kb", pss_kb < 0 ? rss_kb : pss_kb);
cJSON_AddNumberToObject(o, "uss_kb", uss_kb < 0 ? 0 : uss_kb);
cJSON_AddNumberToObject(o, "vmpeak_kb", vmpeak_kb < 0 ? 0 : vmpeak_kb);
cJSON_AddNumberToObject(o, "vmswap_kb", vmswap_kb < 0 ? 0 : vmswap_kb);
cJSON_AddNumberToObject(o, "threads", threads);
cJSON_AddNumberToObject(o, "uptime_sec", (double)uptime);
cJSON_AddNumberToObject(o, "io_read_kb", (double)(io_read / 1024));
cJSON_AddNumberToObject(o, "io_write_kb", (double)(io_write / 1024));
cJSON_AddNumberToObject(o, "fd_count", fds);
cJSON_AddStringToObject(o, "ownership", own_str);
if (service_state) {
cJSON_AddStringToObject(o, "service_state", service_state);
}
if (hosted_tabs) {
cJSON_AddItemToObject(o, "hosted_tabs", hosted_tabs);
} else {
cJSON_AddItemToObject(o, "hosted_tabs", cJSON_CreateArray());
}
return o;
}
/* Map a net_service_type_t to a service_state string via net_services. */
static const char *service_state_for(net_service_type_t t) {
const net_service_t *s = net_service_get_status(t);
if (s == NULL) return NULL;
switch (s->state) {
case SERVICE_DISABLED: return "disabled";
case SERVICE_DISCOVERING: return "discovering";
case SERVICE_ATTACHING: return "attaching";
case SERVICE_STARTING: return "starting";
case SERVICE_BOOTSTRAPPING: return "bootstrapping";
case SERVICE_READY: return "ready";
case SERVICE_STOPPING: return "stopping";
case SERVICE_EXITED: return "exited";
case SERVICE_FAILED: return "failed";
default: return "unknown";
}
}
/* Scan /proc for child processes of main_pid whose comm starts with
* "WebKit". Classifies each as a renderer (WebKitWebProcess), network
* (WebKitNetworkProcess), GPU (WebKitGPUProcess), or storage
* (WebKitStorageProcess) and adds it to the `out` array with the
* appropriate ownership tag.
*
* Note: WebKitGTK 4.1 does not expose a per-webview OS PID, so we
* cannot map individual tabs to specific renderer PIDs here. Per-tab
* CPU attribution is handled by the Layer 2 probe instead. Renderer
* rows therefore show an empty hosted_tabs array. */
static void scan_webkit_procs(guint main_pid, cJSON *out) {
DIR *d = opendir("/proc");
if (d == NULL) return;
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (!isdigit((unsigned char)e->d_name[0])) continue;
guint pid = (guint)strtoul(e->d_name, NULL, 10);
if (pid == 0 || pid == main_pid) continue;
char comm[256] = "";
char state[8] = "?";
guint ppid = 0;
guint64 utime = 0, stime = 0, starttime = 0;
if (parse_stat(pid, state, sizeof(state), &ppid, &utime, &stime,
&starttime, comm, sizeof(comm)) != 0) continue;
if (ppid != main_pid) continue;
if (strncmp(comm, "WebKit", 6) != 0) continue;
proc_own_t own = OWN_OTHER;
if (strstr(comm, "Network")) own = OWN_WEBKIT_NETWORK;
else if (strstr(comm, "GPU")) own = OWN_WEBKIT_GPU;
else if (strstr(comm, "Storage")) own = OWN_WEBKIT_STORAGE;
else if (strstr(comm, "Web")) own = OWN_WEBKIT_RENDERER;
else continue; /* unknown WebKit aux — skip */
cJSON *obj = build_proc_obj(pid, own, NULL, NULL);
if (obj) cJSON_AddItemToArray(out, obj);
}
closedir(d);
}
cJSON *process_info_get_processes_json(void) {
cJSON *arr = cJSON_CreateArray();
guint main_pid = (guint)getpid();
/* Main process. */
cJSON *main_obj = build_proc_obj(main_pid, OWN_MAIN, NULL, NULL);
if (main_obj) cJSON_AddItemToArray(arr, main_obj);
/* WebKit child processes (renderers, network, gpu, storage).
* Discovered via /proc PPID scan WebKitGTK 4.1 doesn't expose
* a per-webview OS PID, so renderer rows have empty hosted_tabs. */
scan_webkit_procs(main_pid, arr);
/* Tor + FIPS managed subprocesses. */
const net_service_t *tor = net_service_get_status(NET_SERVICE_TOR);
if (tor && tor->ownership == OWNERSHIP_MANAGED && tor->pid > 0) {
cJSON *obj = build_proc_obj((guint)tor->pid, OWN_TOR,
service_state_for(NET_SERVICE_TOR), NULL);
if (obj) cJSON_AddItemToArray(arr, obj);
}
const net_service_t *fips = net_service_get_status(NET_SERVICE_FIPS);
if (fips && fips->ownership == OWNERSHIP_MANAGED && fips->pid > 0) {
cJSON *obj = build_proc_obj((guint)fips->pid, OWN_FIPS,
service_state_for(NET_SERVICE_FIPS), NULL);
if (obj) cJSON_AddItemToArray(arr, obj);
}
return arr;
}
/* ── Tab list (Layer 2) ─────────────────────────────────────────────── */
cJSON *process_info_get_tabs_json(void) {
cJSON *arr = cJSON_CreateArray();
int n = tab_manager_count();
for (int i = 0; i < n; i++) {
tab_info_t *tab = tab_manager_get(i);
if (tab == NULL) continue;
cJSON *o = cJSON_CreateObject();
cJSON_AddNumberToObject(o, "index", i);
cJSON_AddStringToObject(o, "title", tab->title[0] ? tab->title : "");
cJSON_AddStringToObject(o, "url",
tab->current_url[0] ? tab->current_url : "");
gboolean is_internal = (strncmp(tab->current_url, "sovereign://", 12) == 0);
cJSON_AddBoolToObject(o, "is_internal", is_internal);
/* WebKitGTK 4.1 does not expose a per-webview OS PID, so we
* report 0 here. The Processes tab lists renderer PIDs from
* /proc; per-tab CPU attribution is via the Layer 2 probe. */
cJSON_AddNumberToObject(o, "webprocess_pid", 0);
cJSON *probe = probe_copy(i);
if (probe) {
cJSON_AddItemToObject(o, "probe", probe);
} else {
cJSON_AddNullToObject(o, "probe");
}
cJSON_AddItemToArray(arr, o);
}
return arr;
}
cJSON *process_info_get_tab_probe_json(int tab_index) {
tab_info_t *tab = tab_manager_get(tab_index);
if (tab == NULL) return NULL;
cJSON *root = cJSON_CreateObject();
cJSON_AddNumberToObject(root, "index", tab_index);
cJSON_AddStringToObject(root, "title", tab->title[0] ? tab->title : "");
cJSON_AddStringToObject(root, "url",
tab->current_url[0] ? tab->current_url : "");
gboolean is_internal = (strncmp(tab->current_url, "sovereign://", 12) == 0);
cJSON_AddBoolToObject(root, "is_internal", is_internal);
/* WebKitGTK 4.1 does not expose a per-webview OS PID. */
cJSON_AddNumberToObject(root, "webprocess_pid", 0);
cJSON *probe = probe_copy(tab_index);
if (probe) {
cJSON_AddItemToObject(root, "probe", probe);
} else {
cJSON_AddNullToObject(root, "probe");
}
return root;
}
+82
View File
@@ -0,0 +1,82 @@
/*
* process_info.h process and per-tab performance diagnostics
*
* Provides the data backing for the sovereign://processes internal page.
* Two layers:
*
* Layer 1 process view: enumerates the main browser PID, the WebKit
* child processes (renderer / network / gpu / storage) and
* the managed Tor/FIPS subprocesses, reading /proc for CPU%,
* RSS/PSS/USS, threads, uptime, I/O, FD count, cmdline, and
* service state. CPU% is computed from utime+stime deltas
* between successive calls (top-style).
*
* Layer 2 per-tab probe: a JS user script (www/js/perf-probe.js)
* injected into every non-sovereign:// page reports a
* 1-second batch of long-task, timer, network, heap, and
* FPS samples via sovereign://processes/probe-report. The
* latest report per tab index is stored here and exposed to
* the UI and to MCP tools.
*
* All /proc parsing is Linux-specific and dependency-free.
*/
#ifndef PROCESS_INFO_H
#define PROCESS_INFO_H
#include "../nostr_core_lib/cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Build the Layer 1 process list as a cJSON array (caller frees).
* Shape:
* [{ "pid":123, "name":"...", "cmdline":"...", "state":"R",
* "ppid":1, "cpu_percent":2.1, "rss_kb":..., "pss_kb":...,
* "uss_kb":..., "vmpeak_kb":..., "vmswap_kb":...,
* "threads":12, "uptime_sec":..., "io_read_kb":...,
* "io_write_kb":..., "fd_count":..., "ownership":"main",
* "service_state":"ready" (Tor/FIPS only),
* "hosted_tabs":[ {"index":3,"title":"...","url":"..."}, ... ]
* (renderers only) }]
*
* The first call returns cpu_percent 0 for every process (no baseline);
* subsequent calls return accurate deltas. Caller should poll ~1s.
*/
cJSON *process_info_get_processes_json(void);
/*
* Build the Layer 2 tab list as a cJSON array (caller frees).
* Combines tab_manager state (index, title, url, WebProcess pid) with
* the most recent probe report for each tab (if any).
* Shape:
* [{ "index":3, "title":"...", "url":"...", "webprocess_pid":12367,
* "is_internal":true,
* "probe": { ...latest probe report, or null... } }]
*/
cJSON *process_info_get_tabs_json(void);
/*
* Build a drill-down JSON object for a single tab (caller frees).
* Returns the full latest probe report (including the long-task
* timeline and top sources) plus the tab's identity fields.
* Returns NULL if the tab index is out of range.
*/
cJSON *process_info_get_tab_probe_json(int tab_index);
/*
* Record a probe report received from a page's perf-probe.js.
* Takes ownership of `probe` (frees it). `tab_index` is the
* sovereign://processes/probe-report?tab_index=N query value.
* Stores a copy of the report fields; the timeline and top_sources
* arrays are preserved for the drill-down view.
*/
void process_info_record_probe(int tab_index, cJSON *probe);
#ifdef __cplusplus
}
#endif
#endif /* PROCESS_INFO_H */
+5
View File
@@ -78,6 +78,11 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
"open_settings", "Open settings",
"Open the sovereign://settings page", "<Control>comma"
},
[SHORTCUT_OPEN_PROCESSES] = {
"open_processes", "Open processes",
"Open the sovereign://processes diagnostics page",
"<Control><Shift>Escape"
},
[SHORTCUT_NEW_IDENTITY] = {
"new_identity", "Switch identity",
"Open the Nostr login dialog to switch identity",
+1
View File
@@ -41,6 +41,7 @@ typedef enum {
SHORTCUT_GO_FORWARD,
SHORTCUT_FIND,
SHORTCUT_OPEN_SETTINGS,
SHORTCUT_OPEN_PROCESSES,
SHORTCUT_NEW_IDENTITY,
SHORTCUT_TOGGLE_FULLSCREEN,
SHORTCUT_TOGGLE_INSPECTOR,
+100 -19
View File
@@ -10,6 +10,7 @@
#include "tab_manager.h"
#include "settings.h"
#include "nostr_inject.h"
#include "perf_probe.h"
#include "nostr_url.h"
#include "history.h"
#include "bookmarks.h"
@@ -68,6 +69,7 @@ extern void on_menu_settings(GtkMenuItem *item, gpointer data);
extern void on_menu_profile(GtkMenuItem *item, gpointer data);
extern void on_menu_agent(GtkMenuItem *item, gpointer data);
extern void on_menu_fips(GtkMenuItem *item, gpointer data);
extern void on_menu_processes(GtkMenuItem *item, gpointer data);
/* Key press handler defined in main.c — connected to each webview so
* keyboard shortcuts are caught before WebKit consumes the event. */
@@ -1256,6 +1258,10 @@ static GtkWidget *tab_manager_new_window(const char *url,
return NULL;
}
/* Inject the per-tab performance probe (sovereign://processes).
* Skips internal sovereign:// pages at runtime via the preamble. */
perf_probe_setup(tab->webview, index);
const browser_settings_t *s = settings_get();
int page_num = gtk_notebook_append_page(GTK_NOTEBOOK(notebook),
tab->page, tab->tab_label);
@@ -2381,6 +2387,11 @@ static GtkWidget *build_hamburger_menu(tab_info_t *tab) {
G_CALLBACK(on_menu_fips), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_fips_page);
GtkWidget *item_processes = gtk_menu_item_new_with_label("Processes…");
g_signal_connect(item_processes, "activate",
G_CALLBACK(on_menu_processes), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_processes);
GtkWidget *item_settings = gtk_menu_item_new_with_label("Settings…");
g_signal_connect(item_settings, "activate",
G_CALLBACK(on_menu_settings), g_window);
@@ -2831,6 +2842,7 @@ static tab_info_t *tab_create(const char *url) {
* GtkMenuButton popovers for subfolders. Refreshed whenever bookmarks
* change (see bookmark_bar_refresh_all via bookmarks_subscribe_changed). */
tab->bookmark_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_name(tab->bookmark_bar, "bookmark-bar");
gtk_widget_set_margin_top(tab->bookmark_bar, 2);
gtk_widget_set_margin_bottom(tab->bookmark_bar, 2);
gtk_widget_set_margin_start(tab->bookmark_bar, 4);
@@ -3149,6 +3161,71 @@ static void on_new_tab_clicked(GtkButton *btn, gpointer data) {
* image is created and tracked in g_avatar_images so it
* still gets updated when the picture arrives.
*/
/* ── App theme (GTK CSS) ───────────────────────────────────────────── *
* Applies the sovereign_browser visual theme to the GTK native UI:
* - Red accent (#ff0000) for URL entry focus, tab active/highlight
* - Button scheme matching the client project: black border, white bg,
* red hover border, red active bg (swapped for dark mode)
* Called at init and when the theme_dark setting changes. */
static GtkCssProvider *g_app_theme_provider = NULL;
static void apply_app_theme(void) {
const browser_settings_t *s = settings_get();
int dark = s ? s->theme_dark : 0;
/* Colors: light mode = black-on-white, dark mode = white-on-black.
* Only the tab underline and URL focus ring are overridden buttons
* and everything else stay with the OS GTK theme to avoid breaking
* the theme's rendering logic. */
const char *fg = dark ? "#ffffff" : "#000000";
char *css = g_strdup_printf(
/* Avatar / hamburger button sizing */
"#avatar-btn, #hamburger-btn {"
" padding: 0px;"
" min-width: 28px; min-height: 28px;"
" border-radius: 4px;"
"}"
"#avatar-btn image, #hamburger-btn image {"
" padding: 0px; margin: 0px;"
"}"
/* ── Red accent for the URL entry focus ring ──────────────── */
"entry:focus {"
" border-color: #ff0000 !important;"
" box-shadow: 0 0 0 1px #ff0000 !important;"
"}"
/* ── Tab active underline: black bar (replaces blue) ────────
* Adwaita renders the active-tab highlight as a box-shadow inset
* on tab:checked. We kill that and draw a solid border-bottom
* in the fg color. !important is needed to beat the theme. */
"notebook tab:checked {"
" box-shadow: none !important;"
" outline: none !important;"
" border-bottom: 3px solid %s !important;"
"}"
/* Tab text: normal (inherit from theme), no red. */
/* ── Bookmark bar buttons — compact padding ───────────────── */
"#bookmark-bar button {"
" padding: 2px 8px;"
"}",
fg /* tab:checked border-bottom color */
);
if (g_app_theme_provider == NULL) {
g_app_theme_provider = gtk_css_provider_new();
/* USER priority (highest) so the tab/entry overrides beat the
* GTK theme. We only override specific properties (tab underline,
* entry focus), so the rest of the theme is untouched. */
gtk_style_context_add_provider_for_screen(
gdk_screen_get_default(),
GTK_STYLE_PROVIDER(g_app_theme_provider),
GTK_STYLE_PROVIDER_PRIORITY_USER);
}
gtk_css_provider_load_from_data(g_app_theme_provider, css, -1, NULL);
g_free(css);
}
static void setup_notebook_action_widgets(GtkWidget *notebook, gboolean is_main) {
g_return_if_fail(notebook != NULL && GTK_IS_NOTEBOOK(notebook));
@@ -3228,23 +3305,14 @@ void tab_manager_init(GtkContainer *parent,
* fill edge-to-edge. Force both to a fixed square size (28x28) and
* restore rounded corners so the avatar image is clipped to the
* button's rounded shape. Sizes are also set via
* gtk_widget_set_size_request() in C. */
GtkCssProvider *css = gtk_css_provider_new();
gtk_css_provider_load_from_data(css,
"#avatar-btn, #hamburger-btn {"
" padding: 0px;"
" min-width: 28px; min-height: 28px;"
" border-radius: 4px;"
"}"
"#avatar-btn image, #hamburger-btn image {"
" padding: 0px; margin: 0px;"
"}",
-1, NULL);
gtk_style_context_add_provider_for_screen(
gdk_screen_get_default(),
GTK_STYLE_PROVIDER(css),
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
g_object_unref(css);
* gtk_widget_set_size_request() in C.
*
* Also apply the sovereign_browser app theme: red accent (#ff0000)
* for the URL entry focus ring, tab active/highlight indicators, and
* button hover/active states matching the client project's button
* scheme (black border, white bg, red hover border, red active bg).
* Adapts to the theme_dark setting (swaps fg/bg). */
apply_app_theme();
/* Build a window-level GtkPaned: left = sidebar container, right =
* notebook. The sidebar is per-window (not per-tab), so it persists
@@ -3271,8 +3339,14 @@ void tab_manager_init(GtkContainer *parent,
g_main_window.sidebar_visible = FALSE;
/* The main window/notebook are the default active window/notebook
* for MCP get_active_webview(). Updated when auxiliary windows
* gain focus (see on_window_focus_in). */
* for MCP get_active_webview() and keyboard shortcuts (zoom, etc.).
* Updated when any window gains focus (see on_window_focus_in).
* The main window also needs this handler so that when the user
* clicks back to it from an auxiliary window, g_active_notebook
* reverts to the main notebook otherwise zoom/shortcuts would
* keep targeting the auxiliary window's tab. */
g_signal_connect(window, "focus-in-event",
G_CALLBACK(on_window_focus_in), g_notebook);
g_active_window = window;
g_active_notebook = g_notebook;
g_active_ws = &g_main_window;
@@ -3284,6 +3358,9 @@ void tab_manager_apply_settings(void) {
if (g_notebook == NULL) return;
const browser_settings_t *s = settings_get();
/* Re-apply the GTK app theme (colors adapt to theme_dark). */
apply_app_theme();
gtk_notebook_set_tab_pos(GTK_NOTEBOOK(g_notebook), s->tab_bar_position);
/* Apply drag reorder to all existing tabs. */
@@ -3312,6 +3389,10 @@ int tab_manager_new_tab(const char *url) {
return -1;
}
/* Inject the per-tab performance probe (sovereign://processes).
* Skips internal sovereign:// pages at runtime via the preamble. */
perf_probe_setup(tab->webview, index);
/* Add to the effective notebook (active window's notebook, or the
* main notebook as fallback). This makes Ctrl+T / "New Tab" open in
* the focused window instead of always the main window. */
+2 -2
View File
@@ -11,9 +11,9 @@
#ifndef SOVEREIGN_BROWSER_VERSION_H
#define SOVEREIGN_BROWSER_VERSION_H
#define SB_VERSION "v0.0.46"
#define SB_VERSION "v0.0.47"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 46
#define SB_VERSION_PATCH 47
#endif /* SOVEREIGN_BROWSER_VERSION_H */
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head><title>CPU Hog Test</title></head>
<body>
<h1>CPU Hog Test</h1>
<p>This page runs a busy loop in setInterval to generate long tasks.</p>
<pre id="out"></pre>
<script>
// Busy loop every 250ms — should show up as long tasks + high cpu_busy_percent.
setInterval(function busyLoop() {
var start = performance.now();
while (performance.now() - start < 120) { /* burn 120ms */ }
document.getElementById('out').textContent =
'tick at ' + new Date().toISOString();
}, 250);
// Also spin a few extra timers to populate the timer_top list.
setInterval(function pollStatus() {
fetch('/test.json').catch(function(){});
}, 1000);
setInterval(function pollStatus2() {
fetch('/test.json').catch(function(){});
}, 2000);
</script>
</body>
</html>
+369
View File
@@ -0,0 +1,369 @@
/*
* perf-probe.js per-tab performance probe for sovereign_browser
*
* Injected by WebKitUserContentManager into every non-sovereign:// page
* (see process_info.c / nostr_inject.c wiring in main.c). Collects:
* - long tasks (PerformanceObserver 'longtask') -> cpu_busy_percent
* - resource entries (PerformanceObserver 'resource') -> net counts
* - requestAnimationFrame cadence -> fps
* - patched setTimeout/setInterval -> active timer count + top intervals
* - patched addEventListener -> event listener count
* - patched Worker -> worker count
* - performance.memory -> heap used
* - document.getElementsByTagName('*').length -> DOM node count
*
* Batches a report every 1s and POSTs it to:
* sovereign://processes/probe-report?tab_index=N&body=<urlencoded JSON>
*
* The tab_index is supplied by the browser via a <meta name="sb-tab-index">
* tag injected before this script runs (see inject_perf_probe() in
* nostr_bridge.c / main.c). If absent, the report is dropped.
*
* The probe is a no-op on sovereign:// pages (the injector skips them)
* to avoid self-noise and recursion.
*/
(function () {
'use strict';
// Honor the skip flag set by the preamble on sovereign:// / about:
// pages. The skip flag is re-evaluated on every navigation (the
// preamble runs at document_start for each new document), so we also
// reset our installed flag here to allow re-injection after a
// navigation from an internal page to a normal one.
if (window.__sbPerfProbeSkip) return;
if (window.__sbPerfProbe) return; // already running in this document
window.__sbPerfProbe = true;
// Tab index is injected by the browser via window.__sbPerfTabIndex
// (set by the preamble that runs before this script). Read it once.
var TAB_INDEX = window.__sbPerfTabIndex;
if (TAB_INDEX == null || TAB_INDEX < 0 || isNaN(TAB_INDEX)) return;
// Capture the native timers early — the heartbeat (below) needs
// origSetTimeout before the timer-patching block runs.
var origSetTimeout = window.setTimeout;
var origSetInterval = window.setInterval;
var origClearTimeout = window.clearTimeout;
var origClearInterval = window.clearInterval;
// ── Long tasks ──────────────────────────────────────────────────
var longTasks = []; // last 30s of {t, duration, name}
var longTasksLastSec = []; // tasks in the current 1s window
var longTaskSources = {}; // name -> {total_ms, count}
var longTaskSupported = false;
if (typeof PerformanceObserver !== 'undefined') {
try {
var ltObs = new PerformanceObserver(function (list) {
list.getEntries().forEach(function (e) {
var rec = {
t: performance.now(),
duration: e.duration,
name: e.name || 'unknown',
attribution: (e.attribution && e.attribution[0])
? (e.attribution[0].name + ':' + (e.attribution[0].containerName || ''))
: ''
};
longTasks.push(rec);
longTasksLastSec.push(rec);
var key = rec.name || 'unknown';
if (!longTaskSources[key]) {
longTaskSources[key] = { total_ms: 0, count: 0 };
}
longTaskSources[key].total_ms += rec.duration;
longTaskSources[key].count++;
});
});
ltObs.observe({ entryTypes: ['longtask'] });
longTaskSupported = true;
} catch (e) { /* longtask not supported */ }
}
// ── Main-thread blockage fallback ───────────────────────────────
// WebKitGTK does not fire 'longtask' entries, so we measure main-
// thread blockage directly: schedule a 0ms heartbeat and measure
// how late it fires. If the main thread is blocked by a busy loop,
// the heartbeat is delayed. The total delay in a 1s window is a
// proxy for cpu_busy_percent.
var heartbeatDelayMs = 0; // accumulated delay in the current window
var heartbeatLast = performance.now();
function heartbeat() {
var now = performance.now();
var expected = heartbeatLast + 50; // we schedule every 50ms
var delay = now - expected;
if (delay > 5) { // ignore small scheduler jitter
heartbeatDelayMs += delay;
// Record as a synthetic long task if > 50ms.
if (delay > 50) {
var rec = { t: now, duration: delay, name: 'main-thread-block',
attribution: '' };
longTasks.push(rec);
longTasksLastSec.push(rec);
var key = 'main-thread-block';
if (!longTaskSources[key]) {
longTaskSources[key] = { total_ms: 0, count: 0 };
}
longTaskSources[key].total_ms += delay;
longTaskSources[key].count++;
}
}
heartbeatLast = now;
origSetTimeout.call(window, heartbeat, 50);
}
// Always start the heartbeat. On engines where longtask fires
// reliably, the heartbeat delay will be near-zero (long tasks are
// captured by the observer). On WebKitGTK, where longtask is
// accepted but never fires, the heartbeat is the sole source of
// cpu_busy measurement. The report() function takes the max of
// both, so there's no double-counting.
origSetTimeout.call(window, heartbeat, 50);
// ── Resource entries (network) ────────────────────────────────
var netRequestsLastSec = [];
var netEndpoints = {}; // url-prefix -> count
if (typeof PerformanceObserver !== 'undefined') {
try {
var resObs = new PerformanceObserver(function (list) {
list.getEntries().forEach(function (e) {
netRequestsLastSec.push({ name: e.name, type: e.initiatorType });
var key = e.name.split('?')[0];
netEndpoints[key] = (netEndpoints[key] || 0) + 1;
});
});
resObs.observe({ entryTypes: ['resource'] });
} catch (e) { /* resource not supported */ }
}
// ── FPS via requestAnimationFrame ───────────────────────────────
var rafFrames = 0;
var fps = 0;
function rafLoop(ts) {
rafFrames++;
requestAnimationFrame(rafLoop);
}
requestAnimationFrame(rafLoop);
// ── Timer tracking (patched setTimeout/setInterval) ─────────────
var activeTimers = {}; // id -> {code, interval_ms, count}
var timerTop = {}; // "code|interval" -> count
var nextTimerId = 1;
function labelFor(fn) {
try {
var s = (fn && fn.name) ? fn.name : 'anonymous';
// Truncate long inline-function bodies.
if (s.length > 40) s = s.slice(0, 40);
return s;
} catch (e) { return 'anonymous'; }
}
// origSetTimeout / origSetInterval / origClearTimeout /
// origClearInterval were captured at the top of the IIFE (before the
// heartbeat was installed), so they reference the native timers.
window.setTimeout = function (fn, delay) {
var id = nextTimerId++;
var label = labelFor(fn);
activeTimers[id] = { code: label, interval_ms: 0, count: 0 };
var key = label + '|0';
timerTop[key] = (timerTop[key] || 0) + 1;
var args = Array.prototype.slice.call(arguments, 2);
var wrapped = function () {
delete activeTimers[id];
return fn.apply(this, args);
};
return origSetTimeout.call(this, wrapped, delay);
};
window.setInterval = function (fn, delay) {
var id = nextTimerId++;
var label = labelFor(fn);
var interval = delay | 0;
activeTimers[id] = { code: label, interval_ms: interval, count: 0 };
var key = label + '|' + interval;
timerTop[key] = (timerTop[key] || 0) + 1;
var args = Array.prototype.slice.call(arguments, 2);
var wrapped = function () {
if (activeTimers[id]) activeTimers[id].count++;
return fn.apply(this, args);
};
return origSetInterval.call(this, wrapped, delay);
};
window.clearTimeout = function (id) {
delete activeTimers[id];
return origClearTimeout.call(this, id);
};
window.clearInterval = function (id) {
delete activeTimers[id];
return origClearInterval.call(this, id);
};
// ── Event listener count (patched addEventListener) ─────────────
var eventListenerCount = 0;
var origAEL = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, fn, opts) {
// Skip our own internal listeners (tagged with __sbInternal).
if (!fn || !fn.__sbInternal) eventListenerCount++;
return origAEL.call(this, type, fn, opts);
};
// ── Worker count (patched Worker constructor) ───────────────────
var workerCount = 0;
var OrigWorker = window.Worker;
if (OrigWorker) {
window.Worker = function (url, opts) {
workerCount++;
return new OrigWorker(url, opts);
};
window.Worker.prototype = OrigWorker.prototype;
}
// ── In-flight network (patched fetch + XHR.open) ────────────────
var netInFlight = 0;
var origFetch = window.fetch;
if (origFetch) {
window.fetch = function () {
netInFlight++;
var p = origFetch.apply(this, arguments);
var dec = function () { if (netInFlight > 0) netInFlight--; };
p.then(dec, dec);
return p;
};
}
var origXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
netInFlight++;
var onEnd = function () {
if (netInFlight > 0) netInFlight--;
};
onEnd.__sbInternal = true;
this.addEventListener('loadend', onEnd);
return origXhrOpen.apply(this, arguments);
};
// ── Reporting ───────────────────────────────────────────────────
function report() {
// Trim long-task timeline to last 30s.
var now = performance.now();
while (longTasks.length && (now - longTasks[0].t) > 30000) {
longTasks.shift();
}
// cpu_busy_percent: sum of long-task durations in the last 1s
// window. If the longtask PerformanceObserver isn't supported
// (WebKitGTK), fall back to the heartbeat-delay measurement.
var busyMs = 0;
for (var i = 0; i < longTasksLastSec.length; i++) {
busyMs += longTasksLastSec[i].duration;
}
// If the heartbeat measured more blockage than the longtask
// observer (e.g. on WebKitGTK where longtask never fires), use
// the heartbeat measurement.
if (heartbeatDelayMs > busyMs) {
busyMs = heartbeatDelayMs;
}
var cpuBusy = busyMs / 10.0; // ms -> percent of 1000ms
if (cpuBusy > 100) cpuBusy = 100;
heartbeatDelayMs = 0; // reset for next window
// Top timer sources.
var timerTopArr = [];
Object.keys(timerTop).forEach(function (k) {
var parts = k.split('|');
timerTopArr.push({
code: parts[0],
interval_ms: parseInt(parts[1], 10) || 0,
count: timerTop[k]
});
});
timerTopArr.sort(function (a, b) { return b.count - a.count; });
timerTopArr = timerTopArr.slice(0, 5);
// Top network endpoints (last window).
var netTopArr = [];
Object.keys(netEndpoints).forEach(function (u) {
netTopArr.push({ url: u, count: netEndpoints[u] });
});
netTopArr.sort(function (a, b) { return b.count - a.count; });
netTopArr = netTopArr.slice(0, 5);
// Top long-task sources.
var ltSourcesArr = [];
Object.keys(longTaskSources).forEach(function (k) {
ltSourcesArr.push({
name: k,
total_ms: Math.round(longTaskSources[k].total_ms),
count: longTaskSources[k].count
});
});
ltSourcesArr.sort(function (a, b) { return b.total_ms - a.total_ms; });
ltSourcesArr = ltSourcesArr.slice(0, 8);
// Heap.
var heapMb = -1;
if (performance.memory && performance.memory.usedJSHeapSize) {
heapMb = performance.memory.usedJSHeapSize / (1024 * 1024);
}
// DOM node count.
var domNodes = -1;
try {
domNodes = document.getElementsByTagName('*').length;
} catch (e) { /* document not ready */ }
var payload = {
tab_index: TAB_INDEX,
cpu_busy_percent: Math.round(cpuBusy * 10) / 10,
fps: fps,
timer_count: Object.keys(activeTimers).length,
timer_top: timerTopArr,
net_in_flight: netInFlight,
net_requests_last_sec: netRequestsLastSec.length,
net_top_endpoints: netTopArr,
heap_used_mb: heapMb >= 0 ? Math.round(heapMb * 10) / 10 : -1,
event_listener_count: eventListenerCount,
worker_count: workerCount,
dom_node_count: domNodes,
long_tasks_last_sec: longTasksLastSec.map(function (r) {
return {
duration: Math.round(r.duration),
name: r.name,
attribution: r.attribution
};
}),
long_task_top_sources: ltSourcesArr,
long_task_timeline: longTasks.map(function (r) {
return { t: Math.round(r.t), duration: Math.round(r.duration), name: r.name };
})
};
// Reset per-window accumulators.
longTasksLastSec = [];
netRequestsLastSec = [];
netEndpoints = {};
timerTop = {};
// Send. Use sync XHR to sovereign:// (same convention as the
// window.nostr shim — WebKit's custom scheme handler doesn't
// reliably support fetch() or async XHR for sovereign:// URLs).
try {
var body = encodeURIComponent(JSON.stringify(payload));
var url = 'sovereign://processes/probe-report?tab_index=' + TAB_INDEX +
'&body=' + body;
var x = new XMLHttpRequest();
x.open('GET', url, false); // synchronous
x.send();
} catch (e) { /* swallow — probe must never break the page */ }
}
// FPS counter: count frames in each 1s window.
setInterval(function () {
fps = rafFrames;
rafFrames = 0;
}, 1000);
// Report every 1s. Use the original setInterval so we don't track
// ourselves in activeTimers.
origSetInterval.call(window, report, 1000);
})();
+152
View File
@@ -0,0 +1,152 @@
/* Processes page sovereign://processes
* Imports the shared sovereign:// base theme, then adds processes-specific
* layout for the process/tab tables, heat bars, and drill-down panel. */
@import url("sovereign://sovereign-base.css");
.note { font-size: 12px; font-weight: normal; }
code { font-family: var(--font); background: var(--border); padding: 1px 4px; border-radius: 3px; }
.url-sub { font-size: 11px; }
.dim { color: var(--muted); }
/* Tab navigation (shared with fips.css pattern). */
.tabs {
display: flex; gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.tab-btn {
background: var(--bg); color: var(--muted);
border: 1px solid transparent; border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
padding: 6px 16px; cursor: pointer;
font-family: var(--font); font-size: 13px; font-weight: bold;
margin-left: 0; min-width: auto;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn:hover { color: var(--primary); }
.tab-btn.active {
color: var(--primary);
border-color: var(--border);
border-bottom: 1px solid var(--bg);
margin-bottom: -1px;
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
#refresh-btn { margin-left: 12px; }
/* Process + tab tables. */
#proc-table, #tabs-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
#proc-table th, #proc-table td,
#tabs-table th, #tabs-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
white-space: nowrap;
}
#proc-table th, #tabs-table th {
color: var(--muted);
font-weight: bold;
border-bottom: 2px solid var(--primary);
cursor: pointer;
user-select: none;
}
#proc-table th:hover, #tabs-table th:hover { color: var(--primary); }
#proc-table th.sorted-asc::after { content: " ▲"; color: var(--primary); }
#proc-table th.sorted-desc::after { content: " ▼"; color: var(--primary); }
#tabs-table th.sorted-asc::after { content: " ▲"; color: var(--primary); }
#tabs-table th.sorted-desc::after { content: " ▼"; color: var(--primary); }
#proc-table td.title-cell,
#tabs-table td.title-cell {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
}
#proc-table td.url-cell,
#tabs-table td.url-cell {
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Heat bar for CPU columns. A colored background behind the numeric
* value: green < 10%, yellow < 50%, red >= 50%. */
.heat {
display: inline-block;
min-width: 48px;
padding: 1px 6px;
border-radius: 3px;
text-align: right;
color: var(--bg);
font-weight: bold;
}
.heat.cool { background: #4caf50; color: #fff; }
.heat.warm { background: #ff9800; color: #fff; }
.heat.hot { background: #f44336; color: #fff; }
.heat.cold { background: var(--border); color: var(--muted); }
/* Renderer row: clickable to expand hosted tabs. */
.renderer-row { cursor: pointer; }
.renderer-row:hover { background: var(--border); }
.hosted-tabs-row td { padding-left: 24px; color: var(--muted); }
.hosted-tabs-row .ht-entry { display: block; padding: 2px 0; }
.hosted-tabs-row .ht-idx { color: var(--primary); font-weight: bold; margin-right: 6px; }
/* Drill-down panel. */
#drilldown {
margin-top: 24px;
border-top: 2px solid var(--primary);
padding-top: 12px;
}
#drilldown h2 { margin-bottom: 8px; }
#drilldown h3 { margin: 16px 0 6px; color: var(--muted); font-size: 14px; }
.sub-table {
width: 100%;
border-collapse: collapse;
margin: 6px 0 12px;
}
.sub-table th, .sub-table td {
text-align: left;
padding: 4px 10px;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.sub-table th { color: var(--muted); font-weight: bold; }
.sub-table td.mono { font-family: var(--font); word-break: break-all; }
/* Long-task timeline: a horizontal bar of vertical lines, one per
* long task, height scaled by duration. */
.timeline {
height: 80px;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
overflow: hidden;
position: relative;
background: var(--bg);
}
.timeline .tl-bar {
display: inline-block;
width: 2px;
margin-right: 1px;
background: var(--accent);
vertical-align: bottom;
}
.timeline .tl-empty {
color: var(--muted);
font-size: 12px;
padding: 4px 8px;
}
.actions { margin-top: 16px; }
.actions .btn { margin-right: 8px; }
.empty { color: var(--muted); font-style: italic; }
.mono { font-family: var(--font); word-break: break-all; }
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Processes — Sovereign Browser</title>
<link rel="stylesheet" href="sovereign://processes.css">
</head>
<body>
<h1>Processes <button class="btn" id="refresh-btn">Refresh</button></h1>
<div class="tabs">
<button class="tab-btn active" data-tab="processes">Processes</button>
<button class="tab-btn" data-tab="tabs">Tabs</button>
</div>
<div id="status-msg" class="status"></div>
<!-- Processes tab -->
<div class="tab-panel active" id="tab-processes">
<p class="note">
All sovereign_browser processes: the main browser, WebKit renderer
/ network / GPU processes, and managed Tor/FIPS subprocesses.
CPU% is computed from <code>/proc</code> utime+stime deltas
(first sample is always 0). Renderer rows expand to show their
hosted tabs.
</p>
<table id="proc-table">
<thead>
<tr>
<th data-sort="pid">PID</th>
<th data-sort="name">Name</th>
<th data-sort="cpu_percent">CPU%</th>
<th data-sort="rss_kb">RSS</th>
<th data-sort="pss_kb">PSS</th>
<th data-sort="threads">Threads</th>
<th data-sort="uptime_sec">Uptime</th>
<th data-sort="state">State</th>
<th data-sort="ownership">Type</th>
<th>Tabs</th>
</tr>
</thead>
<tbody id="proc-body"></tbody>
</table>
</div>
<!-- Tabs tab -->
<div class="tab-panel" id="tab-tabs">
<p class="note">
Per-tab performance from the in-page probe. <b>CPU-busy%</b> is
the fraction of the last second spent in long (>50ms)
main-thread tasks — the direct measure of which tab is hogging
CPU. Click a row for a drill-down (long-task sources, timers,
network endpoints, heap, workers).
</p>
<table id="tabs-table">
<thead>
<tr>
<th data-sort="index">#</th>
<th data-sort="title">Title</th>
<th data-sort="cpu_busy_percent">CPU-busy%</th>
<th data-sort="fps">FPS</th>
<th data-sort="timer_count">Timers</th>
<th data-sort="net_in_flight">Net</th>
<th data-sort="heap_used_mb">Heap</th>
<th data-sort="dom_node_count">DOM</th>
<th data-sort="webprocess_pid">WPID</th>
</tr>
</thead>
<tbody id="tabs-body"></tbody>
</table>
<!-- Drill-down panel -->
<div id="drilldown" style="display:none">
<h2 id="dd-title">Tab detail</h2>
<div class="card" id="dd-summary"></div>
<h3>Long-task sources <span class="note">(top scripts by total ms)</span></h3>
<table class="sub-table" id="dd-lt-sources">
<thead><tr><th>name</th><th>total ms</th><th>count</th></tr></thead>
<tbody id="dd-lt-sources-body"></tbody>
</table>
<h3>Active timers <span class="note">(top by frequency)</span></h3>
<table class="sub-table" id="dd-timers">
<thead><tr><th>code</th><th>interval ms</th><th>count</th></tr></thead>
<tbody id="dd-timers-body"></tbody>
</table>
<h3>Network endpoints <span class="note">(top by request count)</span></h3>
<table class="sub-table" id="dd-net">
<thead><tr><th>url</th><th>count</th></tr></thead>
<tbody id="dd-net-body"></tbody>
</table>
<h3>Long-task timeline <span class="note">(last 30s)</span></h3>
<div id="dd-timeline" class="timeline"></div>
<div class="actions">
<button class="btn" id="dd-reload">Reload tab</button>
<button class="btn" id="dd-suspend">Suspend tab</button>
<button class="btn" id="dd-close">Close tab</button>
<button class="btn" id="dd-close-panel">Close panel</button>
</div>
</div>
</div>
<script src="sovereign://processes.js"></script>
</body>
</html>
+453
View File
@@ -0,0 +1,453 @@
/* Processes page — sovereign://processes
*
* Tabs: Processes | Tabs
*
* - Processes: /proc-level view of all sovereign_browser PIDs (main,
* WebKit renderers with their hosted tabs, WebKit network/gpu, Tor,
* FIPS). Polled from sovereign://processes/list every 1s. Sortable
* columns. Renderer rows expand to show hosted tabs.
* - Tabs: per-tab probe view (cpu_busy_percent, fps, timers, net, heap,
* DOM, WPID). Polled from sovereign://processes/tabs every 1s.
* Click a row to load the drill-down (sovereign://processes/tab_probe).
*
* Uses XMLHttpRequest via sovereignGet() because WebKit's custom scheme
* handler does not reliably support fetch() for sovereign:// URLs.
*/
function sovereignGet(url) {
return new Promise(function (resolve, reject) {
var x = new XMLHttpRequest();
x.open('GET', url, true);
x.onreadystatechange = function () {
if (x.readyState !== 4) return;
try { resolve(JSON.parse(x.responseText)); }
catch (e) { reject(e); }
};
x.onerror = function () { reject(new Error('XHR error')); };
x.send();
});
}
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function showMsg(text, isErr) {
var el = document.getElementById('status-msg');
el.textContent = text || '';
el.className = 'status show ' + (isErr ? 'err' : 'ok');
if (!text) el.className = 'status';
}
/* ── Formatting helpers ─────────────────────────────────────────── */
function fmtKb(kb) {
if (kb == null || kb < 0) return '—';
if (kb >= 1024 * 1024) return (kb / (1024 * 1024)).toFixed(1) + 'G';
if (kb >= 1024) return (kb / 1024).toFixed(0) + 'M';
return kb + 'K';
}
function fmtUptime(sec) {
if (sec == null || sec < 0) return '—';
var d = Math.floor(sec / 86400);
var h = Math.floor((sec % 86400) / 3600);
var m = Math.floor((sec % 3600) / 60);
var s = Math.floor(sec % 60);
if (d > 0) return d + 'd' + h + 'h';
if (h > 0) return h + 'h' + m + 'm';
if (m > 0) return m + 'm' + s + 's';
return s + 's';
}
function heatClass(pct) {
if (pct == null || pct <= 0) return 'cold';
if (pct < 10) return 'cool';
if (pct < 50) return 'warm';
return 'hot';
}
function heatHtml(pct) {
if (pct == null) return '<span class="heat cold">—</span>';
return '<span class="heat ' + heatClass(pct) + '">' +
pct.toFixed(1) + '</span>';
}
/* ── Tab switching ──────────────────────────────────────────────── */
function switchTab(tabName) {
document.querySelectorAll('.tab-btn').forEach(function (btn) {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
document.querySelectorAll('.tab-panel').forEach(function (panel) {
panel.classList.toggle('active', panel.id === 'tab-' + tabName);
});
}
document.querySelectorAll('.tab-btn').forEach(function (btn) {
btn.addEventListener('click', function () { switchTab(btn.dataset.tab); });
});
/* ── Processes tab ──────────────────────────────────────────────── */
var gProcSortKey = 'cpu_percent';
var gProcSortDir = 'desc';
var gProcData = [];
var gExpandedRenderers = {}; // pid -> bool
function setProcSort(key) {
if (gProcSortKey === key) {
gProcSortDir = gProcSortDir === 'asc' ? 'desc' : 'asc';
} else {
gProcSortKey = key;
gProcSortDir = (key === 'name' || key === 'state' || key === 'ownership')
? 'asc' : 'desc';
}
renderProcTable();
}
document.querySelectorAll('#proc-table th[data-sort]').forEach(function (th) {
th.addEventListener('click', function () { setProcSort(th.dataset.sort); });
});
function procSorter(a, b) {
var ka = a[gProcSortKey];
var kb = b[gProcSortKey];
var cmp;
if (typeof ka === 'number' || typeof kb === 'number') {
cmp = (ka || 0) - (kb || 0);
} else {
cmp = String(ka || '').localeCompare(String(kb || ''));
}
return gProcSortDir === 'asc' ? cmp : -cmp;
}
function renderProcTable() {
var body = document.getElementById('proc-body');
body.innerHTML = '';
var sorted = gProcData.slice().sort(procSorter);
// Update sort indicators.
document.querySelectorAll('#proc-table th[data-sort]').forEach(function (th) {
th.classList.remove('sorted-asc', 'sorted-desc');
if (th.dataset.sort === gProcSortKey) {
th.classList.add(gProcSortDir === 'asc' ? 'sorted-asc' : 'sorted-desc');
}
});
sorted.forEach(function (p) {
var tr = document.createElement('tr');
var isRenderer = p.ownership === 'webkit-renderer';
tr.className = isRenderer ? 'renderer-row' : '';
var tabsCount = (p.hosted_tabs || []).length;
var tabsCell;
if (isRenderer) {
tabsCell = '<span class="heat ' + (tabsCount ? 'cool' : 'cold') +
'">' + tabsCount + '</span>';
} else {
tabsCell = '—';
}
tr.innerHTML =
'<td>' + p.pid + '</td>' +
'<td class="title-cell">' + esc(p.name) + '</td>' +
'<td>' + heatHtml(p.cpu_percent) + '</td>' +
'<td>' + fmtKb(p.rss_kb) + '</td>' +
'<td>' + fmtKb(p.pss_kb) + '</td>' +
'<td>' + (p.threads || 0) + '</td>' +
'<td>' + fmtUptime(p.uptime_sec) + '</td>' +
'<td>' + esc(p.state) + '</td>' +
'<td>' + esc(p.ownership) +
(p.service_state ? ' (' + esc(p.service_state) + ')' : '') + '</td>' +
'<td>' + tabsCell + '</td>';
if (isRenderer) {
(function (pid, hosted) {
tr.addEventListener('click', function () {
gExpandedRenderers[pid] = !gExpandedRenderers[pid];
renderProcTable();
});
})(p.pid, p.hosted_tabs);
}
body.appendChild(tr);
// Expanded hosted-tabs row.
if (isRenderer && gExpandedRenderers[p.pid] && tabsCount > 0) {
var sub = document.createElement('tr');
sub.className = 'hosted-tabs-row';
var html = '<td colspan="10">';
p.hosted_tabs.forEach(function (t) {
html += '<span class="ht-entry"><span class="ht-idx">#' +
t.index + '</span>' + esc(t.title || '(untitled)') +
' <span class="url-sub">' + esc(t.url) + '</span></span>';
});
html += '</td>';
sub.innerHTML = html;
body.appendChild(sub);
}
});
if (sorted.length === 0) {
body.innerHTML = '<tr><td colspan="10" class="empty">No processes.</td></tr>';
}
}
async function fetchProcesses() {
try {
var data = await sovereignGet('sovereign://processes/list');
gProcData = Array.isArray(data) ? data : [];
renderProcTable();
} catch (e) {
showMsg('Failed to load processes: ' + e.message, true);
}
}
/* ── Tabs tab ───────────────────────────────────────────────────── */
var gTabsSortKey = 'cpu_busy_percent';
var gTabsSortDir = 'desc';
var gTabsData = [];
var gSelectedTab = -1;
function setTabsSort(key) {
if (gTabsSortKey === key) {
gTabsSortDir = gTabsSortDir === 'asc' ? 'desc' : 'asc';
} else {
gTabsSortKey = key;
gTabsSortDir = (key === 'title') ? 'asc' : 'desc';
}
renderTabsTable();
}
document.querySelectorAll('#tabs-table th[data-sort]').forEach(function (th) {
th.addEventListener('click', function () { setTabsSort(th.dataset.sort); });
});
function tabSorter(a, b) {
var ka = a[gTabsSortKey];
var kb = b[gTabsSortKey];
// For probe fields, fall back to -1 if probe is null.
if (gTabsSortKey !== 'index' && gTabsSortKey !== 'title' &&
gTabsSortKey !== 'webprocess_pid') {
ka = (a.probe && a.probe[gTabsSortKey] != null) ? a.probe[gTabsSortKey] : -1;
kb = (b.probe && b.probe[gTabsSortKey] != null) ? b.probe[gTabsSortKey] : -1;
}
var cmp;
if (typeof ka === 'number' || typeof kb === 'number') {
cmp = (ka || 0) - (kb || 0);
} else {
cmp = String(ka || '').localeCompare(String(kb || ''));
}
return gTabsSortDir === 'asc' ? cmp : -cmp;
}
function probeField(tab, key, fmt) {
if (!tab.probe) return '—';
var v = tab.probe[key];
if (v == null || v < 0) return '—';
return fmt ? fmt(v) : v;
}
function renderTabsTable() {
var body = document.getElementById('tabs-body');
body.innerHTML = '';
var sorted = gTabsData.slice().sort(tabSorter);
document.querySelectorAll('#tabs-table th[data-sort]').forEach(function (th) {
th.classList.remove('sorted-asc', 'sorted-desc');
if (th.dataset.sort === gTabsSortKey) {
th.classList.add(gTabsSortDir === 'asc' ? 'sorted-asc' : 'sorted-desc');
}
});
sorted.forEach(function (t) {
var tr = document.createElement('tr');
tr.className = 'tab-row' + (t.index === gSelectedTab ? ' selected' : '');
var busy = (t.probe && t.probe.cpu_busy_percent != null)
? t.probe.cpu_busy_percent : null;
var titleClass = (t.index === gSelectedTab) ? '' : 'dim';
tr.innerHTML =
'<td>' + t.index + '</td>' +
'<td class="title-cell"><span class="' + titleClass + '">' +
esc(t.title || '(untitled)') + '</span>' +
(t.is_internal ? ' (internal)' : '') +
'<br><span class="url-sub">' + esc(t.url) + '</span></td>' +
'<td>' + (t.is_internal ? '<span class="heat cold">—</span>'
: heatHtml(busy)) + '</td>' +
'<td>' + probeField(t, 'fps') + '</td>' +
'<td>' + probeField(t, 'timer_count') + '</td>' +
'<td>' + probeField(t, 'net_in_flight') + '</td>' +
'<td>' + probeField(t, 'heap_used_mb', function (v) { return v + 'M'; }) + '</td>' +
'<td>' + probeField(t, 'dom_node_count') + '</td>' +
'<td>' + (t.webprocess_pid || 0) + '</td>';
(function (idx) {
tr.addEventListener('click', function () { openDrilldown(idx); });
})(t.index);
body.appendChild(tr);
});
if (sorted.length === 0) {
body.innerHTML = '<tr><td colspan="9" class="empty">No tabs.</td></tr>';
}
}
async function fetchTabs() {
try {
var data = await sovereignGet('sovereign://processes/tabs');
gTabsData = Array.isArray(data) ? data : [];
renderTabsTable();
// If a tab is selected, refresh its drill-down too.
if (gSelectedTab >= 0) refreshDrilldown();
} catch (e) {
showMsg('Failed to load tabs: ' + e.message, true);
}
}
/* ── Drill-down ─────────────────────────────────────────────────── */
async function openDrilldown(idx) {
gSelectedTab = idx;
renderTabsTable();
document.getElementById('drilldown').style.display = 'block';
await refreshDrilldown();
}
async function refreshDrilldown() {
if (gSelectedTab < 0) return;
try {
var d = await sovereignGet('sovereign://processes/tab_probe?index=' +
gSelectedTab);
renderDrilldown(d);
} catch (e) {
document.getElementById('dd-title').textContent = 'Tab #' + gSelectedTab;
document.getElementById('dd-summary').innerHTML =
'<p class="err-text">Failed to load drill-down: ' + esc(e.message) + '</p>';
}
}
function renderDrilldown(d) {
document.getElementById('dd-title').textContent =
'Tab #' + d.index + ' — ' + (d.title || '(untitled)');
var p = d.probe || {};
var rows = [
['URL', esc(d.url)],
['WebProcess PID', d.webprocess_pid || 0],
['CPU-busy %', p.cpu_busy_percent != null ? heatHtml(p.cpu_busy_percent) : '—'],
['FPS', p.fps != null ? p.fps : '—'],
['Active timers', p.timer_count != null ? p.timer_count : '—'],
['Net in-flight', p.net_in_flight != null ? p.net_in_flight : '—'],
['Net reqs (last sec)', p.net_requests_last_sec != null ? p.net_requests_last_sec : '—'],
['Heap used', p.heap_used_mb >= 0 ? p.heap_used_mb + 'M' : '—'],
['Event listeners', p.event_listener_count != null ? p.event_listener_count : '—'],
['Workers', p.worker_count != null ? p.worker_count : '—'],
['DOM nodes', p.dom_node_count >= 0 ? p.dom_node_count : '—']
];
var html = '';
rows.forEach(function (r) {
html += '<div class="info-row"><span>' + r[0] + '</span><span>' +
r[1] + '</span></div>';
});
document.getElementById('dd-summary').innerHTML = html;
// Long-task sources.
var ltBody = document.getElementById('dd-lt-sources-body');
ltBody.innerHTML = '';
(p.long_task_top_sources || []).forEach(function (s) {
ltBody.innerHTML += '<tr><td class="mono">' + esc(s.name) + '</td><td>' +
s.total_ms + '</td><td>' + s.count + '</td></tr>';
});
if (!p.long_task_top_sources || p.long_task_top_sources.length === 0) {
ltBody.innerHTML = '<tr><td colspan="3" class="empty">No long tasks.</td></tr>';
}
// Timers.
var tmBody = document.getElementById('dd-timers-body');
tmBody.innerHTML = '';
(p.timer_top || []).forEach(function (s) {
tmBody.innerHTML += '<tr><td class="mono">' + esc(s.code) + '</td><td>' +
s.interval_ms + '</td><td>' + s.count + '</td></tr>';
});
if (!p.timer_top || p.timer_top.length === 0) {
tmBody.innerHTML = '<tr><td colspan="3" class="empty">No active timers.</td></tr>';
}
// Network endpoints.
var netBody = document.getElementById('dd-net-body');
netBody.innerHTML = '';
(p.net_top_endpoints || []).forEach(function (s) {
netBody.innerHTML += '<tr><td class="mono">' + esc(s.url) + '</td><td>' +
s.count + '</td></tr>';
});
if (!p.net_top_endpoints || p.net_top_endpoints.length === 0) {
netBody.innerHTML = '<tr><td colspan="2" class="empty">No network requests.</td></tr>';
}
// Timeline.
var tl = document.getElementById('dd-timeline');
tl.innerHTML = '';
var timeline = p.long_task_timeline || [];
if (timeline.length === 0) {
tl.innerHTML = '<div class="tl-empty">No long tasks in the last 30s.</div>';
} else {
var maxDur = 1;
timeline.forEach(function (r) { if (r.duration > maxDur) maxDur = r.duration; });
timeline.forEach(function (r) {
var h = Math.max(2, Math.round((r.duration / maxDur) * 72));
var bar = document.createElement('span');
bar.className = 'tl-bar';
bar.style.height = h + 'px';
bar.title = r.duration + 'ms — ' + (r.name || 'unknown');
tl.appendChild(bar);
});
}
}
/* ── Drill-down actions ─────────────────────────────────────────── */
document.getElementById('dd-reload').addEventListener('click', function () {
if (gSelectedTab < 0) return;
// Trigger reload via the bridge — there's no dedicated endpoint, so
// we use the agent eval path indirectly. Simplest: navigate the tab
// by opening its URL again via sovereign://processes/tab_action.
sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab +
'&action=reload').then(function () { refreshDrilldown(); });
});
document.getElementById('dd-suspend').addEventListener('click', function () {
if (gSelectedTab < 0) return;
sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab +
'&action=suspend').then(function () { refreshDrilldown(); });
});
document.getElementById('dd-close').addEventListener('click', function () {
if (gSelectedTab < 0) return;
if (!confirm('Close tab #' + gSelectedTab + '?')) return;
sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab +
'&action=close').then(function () {
document.getElementById('drilldown').style.display = 'none';
gSelectedTab = -1;
fetchTabs();
});
});
document.getElementById('dd-close-panel').addEventListener('click', function () {
document.getElementById('drilldown').style.display = 'none';
gSelectedTab = -1;
renderTabsTable();
});
/* ── Refresh button + polling ───────────────────────────────────── */
document.getElementById('refresh-btn').addEventListener('click', function () {
fetchProcesses();
fetchTabs();
});
function poll() {
fetchProcesses();
fetchTabs();
}
poll();
setInterval(poll, 1000);