Files
socktop/socktop_agent
jason 4c59716610
Build Debian Packages / Build .deb for x86_64-unknown-linux-gnu (push) Has been cancelled
Build Debian Packages / Build .deb for aarch64-unknown-linux-gnu (push) Has been cancelled
Build Debian Packages / Build .deb for armv7-unknown-linux-gnueabihf (push) Has been cancelled
Build Debian Packages / Build .deb for riscv64gc-unknown-linux-gnu (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Build Debian Packages / Combine all .deb packages (push) Has been cancelled
Build Debian Packages / Publish to APT Repository (push) Has been cancelled
Build Debian Packages / Create GitHub Release (push) Has been cancelled
Kill a local process from the TUI, and stop the agent reporting dead ones (#40)
* fix(agent): drop processes that no longer exist

`refresh_processes_specifics` was called with remove_dead_processes = false
against a long-lived System, so the agent accumulated every process it had
ever seen and went on reporting them. Measured on a Pi 5 after a few hours of
build churn: 21,648 processes reported, 289 actually running, growing a few
every poll.

Three consequences, in ascending order of how confusing they are:

  * unbounded memory growth, and every poll iterating ~75x more entries than
    it should
  * process_count — the client's "Top Processes (N total)" — is meaningless
  * a process you kill keeps its row forever, because the agent keeps sending
    it. Killing it again reports "no longer exists", since the kernel is
    telling the truth and the agent is not.

The third is how this was found: no amount of client-side reconciliation could
fix a list whose producer never forgets anything.

Passing `true` is only correct because these two sites use
ProcessesToUpdate::All. With `Some(pids)` sysinfo treats every process outside
the list as dead and removes it, so the per-PID refresh in
collect_process_metrics must keep `false` — noted in a comment there.

Verified by driving the TUI against a rebuilt agent: reported count 292 vs 290
real, and a killed row is removed once and never reappears.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(socktop): kill a local process from the TUI

btop-style process termination, for local agents only. The signal is sent by
socktop itself through a direct sysinfo call — nothing is transmitted to the
agent, and the agent and connector have no kill capability at all.

Why local-only: the PIDs on screen are reported by the agent, and the signal is
sent with socktop's own OS privileges. A PID is therefore only meaningful, and
only safe to act on, when the agent lives on this machine; acting on a remote
agent's PIDs would signal whatever unrelated local process happened to share
that number. local::agent_is_local treats an address as local when it is
loopback or when an ephemeral bind succeeds (which only works for an address on
one of our own interfaces, so it also covers reaching our own agent by LAN IP),
requires every address a hostname resolves to to be local, and fails closed.

  * `t` on the selected process, and `t` inside Process Details. One key for
    both: `k` scrolls the thread table in the modal, so it could not be reused
    there.
  * The confirmation offers Terminate (focused first, so a reflexive Enter is
    the safe one), Force kill, and Cancel. Keeping SIGKILL behind a second
    button rather than a second keybinding means the destructive option has to
    be chosen deliberately.
  * The selection hint gained the key, but only for a local agent — advertising
    a key that deliberately does nothing is worse than no hint. Same for the
    details modal's help line.

The list is reconciled after a signal rather than left to the next poll. A
signalled PID goes on a watch list re-checked each metrics tick, because SIGTERM
is a request: the process is usually still alive at signal time, and its row
should go when it actually exits (or stay, if it ignores the signal). PIDs
confirmed gone are remembered briefly, since the agent serves Processes from a
1500ms cache and would otherwise hand back a pre-kill snapshot. A selection
whose process has left the list is dropped, and the details view closes for a
process that no longer exists — including when it dies on its own, which
previously flipped that modal to "Agent Update Required" because the wire cannot
distinguish "no such PID" from "endpoint unsupported".

Also fixes two pre-existing UI faults found on the way:

  * The selection hint was sized from its full label including the process name
    and skipped entirely when that exceeded the pane width — so it vanished
    exactly when a long-named process was selected. The name is now the elastic
    part, and widths are measured in columns rather than bytes.
  * Confirmation and Info dialogs laid their content out over the whole modal
    rect instead of the block's inner rect, putting the first line of text on
    the border row, and fell into the catch-all 70%x50% sizing arm, so a
    one-line question got half the screen. They now size to their content and
    use the theme's colors like the connection-error modal does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(kill): close stacked details view, guard PID reuse, scale settle with interval

Review fixes for the process-kill feature:

1. Killing from INSIDE the details view left it open forever, frozen on
   the dead process (reproduced live): the 'Signal sent' Info modal sits
   on top when the death is confirmed on the next tick, the old top-only
   close_process_details missed it, gone-PIDs are processed once, and the
   details-poll fallback was gated on the selection the kill had just
   cleared. The close now removes the dead PID's view wherever it sits in
   the stack — which also retires a dead parent's view from under a child
   in a navigation chain, so backing out lands on the process list rather
   than a frozen corpse view. Tests updated to the new semantics, plus a
   regression test for the Info-stacked case.

2. PID-reuse guard: the PID comes from an agent snapshot and the
   confirmation can sit open indefinitely, so by signal time the kernel
   may have recycled the number. kill_local_process now takes the name
   the user confirmed and refuses to signal a PID whose current owner
   does not match ('PID N now belongs to X, not Y').

3. A transient request error no longer closes the details view: with
   process_details_answered set, any Err was read as 'process gone',
   including socket blips. The view now closes only when the PID is also
   absent from the agent's own process list.

4. PROC_CACHE_SETTLE scales with the user's processes interval (floor at
   the old 1.6s default-TTL value), and the tombstone lifetime rides on
   top of it — users who raise the agent's Processes TTL raise the client
   interval to match, so the interval is the best client-side signal for
   how stale an agent snapshot can be.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(kill): resume polling for the details view that resurfaces from a chain

Reported: kill still orphaned a window via Enter (child details) -> P
(parent details) -> t (terminate parent). The parent's view closed
correctly, but the child's view underneath resurfaced with no selection
(forget_process_row had cleared it — it pointed at the parent) and wiped
data; the selection-gated details poll never refilled it.

close_details_for_gone_process now retargets the selection to the
uppermost remaining ProcessDetails view (looking through stacked
Info/Confirmation modals) and makes the poll due immediately — the same
retarget SwitchToParentProcess performs on the way down the chain.

Same class of hole in plain navigation, fixed alongside: Esc-ing back
from a parent view left the selection on the PARENT, so the resurfaced
child-titled view refilled with the parent's data. The dismiss arm now
retargets the selection to whatever details view it lands on.

Both verified live: child -> P -> kill parent -> child view resurfaces
populated and updating; child -> P -> Esc -> child view shows the child.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: changelog section for the process-kill feature and agent dead-process fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 08:11:17 -07:00
..
2025-09-07 18:55:23 -07:00

socktop_agent (server)

Lightweight ondemand metrics WebSocket server for the socktop TUI.

Highlights:

  • Collects system metrics only when requested (keeps idle CPU <1%)
  • Optional TLS (selfsigned cert autogenerated & pinned by client)
  • JSON for fast metrics / disks; protobuf (optionally gzipped) for processes
  • Accurate perprocess CPU% on Linux via /proc jiffies delta
  • Optional GPU & temperature metrics (disable via env vars)
  • Simple token auth (?token=...) support

Run (no TLS):

cargo install socktop_agent
socktop_agent --port 3000

Enable TLS:

SOCKTOP_ENABLE_SSL=1 socktop_agent --port 8443
# cert/key stored under $XDG_DATA_HOME/socktop_agent/tls

Environment toggles:

  • SOCKTOP_AGENT_GPU=0 (disable GPU collection)
  • SOCKTOP_AGENT_TEMP=0 (disable temperature)
  • SOCKTOP_TOKEN=secret (require token param from client)
  • SOCKTOP_AGENT_METRICS_TTL_MS=250 (cache fast metrics window)
  • SOCKTOP_AGENT_PROCESSES_TTL_MS=1000
  • SOCKTOP_AGENT_DISKS_TTL_MS=1000

NOTE ON ENV vars

Generally these have been added for debugging purposes. you do not need to configure them, default values are tuned and GPU will deisable itself after the first poll if not available.

Systemd unit example & full docs: https://github.com/jasonwitty/socktop

WebSocket API Integration Guide

The socktop_agent exposes a WebSocket API that can be directly integrated with your own applications. This allows you to build custom monitoring dashboards or analysis tools using the agent's metrics.

WebSocket Endpoint

ws://HOST:PORT/ws         # Without TLS
wss://HOST:PORT/ws        # With TLS

With authentication token (if configured):

ws://HOST:PORT/ws?token=YOUR_TOKEN
wss://HOST:PORT/ws?token=YOUR_TOKEN

Communication Protocol

All communication uses JSON format for requests and responses, except for the process list which uses Protocol Buffers (protobuf) format with optional gzip compression.

Request Types

Send a JSON message with a type field to request specific metrics:

{"type": "metrics"}       // Request fast-changing metrics (CPU, memory, network)
{"type": "disks"}         // Request disk information
{"type": "processes"}     // Request process list (returns protobuf)

Response Formats

  1. Fast Metrics (JSON):
{
  "cpu_total": 12.4,
  "cpu_per_core": [11.2, 15.7],
  "mem_total": 33554432,
  "mem_used": 18321408,
  "swap_total": 0,
  "swap_used": 0,
  "hostname": "myserver",
  "cpu_temp_c": 42.5,
  "networks": [{"name":"eth0","received":12345678,"transmitted":87654321}],
  "gpus": [{"name":"nvidia-0","usage":56.7,"memory_total":8589934592,"memory_used":1073741824,"temp_c":65.0}]
}
  1. Disks (JSON):
[
  {"name":"nvme0n1p2","total":512000000000,"available":320000000000},
  {"name":"sda1","total":1000000000000,"available":750000000000}
]
  1. Processes (Protocol Buffers):

Processes are returned in Protocol Buffers format, optionally gzip-compressed for large process lists. The protobuf schema is:

syntax = "proto3";

message Process {
  uint32 pid = 1;
  string name = 2;
  float cpu_usage = 3;
  uint64 mem_bytes = 4;
}

message ProcessList {
  uint32 process_count = 1;
  repeated Process processes = 2;
}

Example Integration (JavaScript/Node.js)

const WebSocket = require('ws');

// Connect to the agent
const ws = new WebSocket('ws://localhost:3000/ws');

ws.on('open', function open() {
  console.log('Connected to socktop_agent');
  
  // Request metrics immediately on connection
  ws.send(JSON.stringify({type: 'metrics'}));
  
  // Set up regular polling
  setInterval(() => {
    ws.send(JSON.stringify({type: 'metrics'}));
  }, 1000);
  
  // Request processes every 3 seconds
  setInterval(() => {
    ws.send(JSON.stringify({type: 'processes'}));
  }, 3000);
});

ws.on('message', function incoming(data) {
  // Check if the response is JSON or binary (protobuf)
  try {
    const jsonData = JSON.parse(data);
    console.log('Received JSON data:', jsonData);
  } catch (e) {
    console.log('Received binary data (protobuf), length:', data.length);
    // Process binary protobuf data with a library like protobufjs
  }
});

ws.on('close', function close() {
  console.log('Disconnected from socktop_agent');
});

Example Integration (Python)

import json
import asyncio
import websockets

async def monitor_system():
    uri = "ws://localhost:3000/ws"
    async with websockets.connect(uri) as websocket:
        print("Connected to socktop_agent")
        
        # Request initial metrics
        await websocket.send(json.dumps({"type": "metrics"}))
        
        # Set up regular polling
        while True:
            # Request metrics
            await websocket.send(json.dumps({"type": "metrics"}))
            
            # Receive and process response
            response = await websocket.recv()
            
            # Check if response is JSON or binary (protobuf)
            try:
                data = json.loads(response)
                print(f"CPU: {data['cpu_total']}%, Memory: {data['mem_used']/data['mem_total']*100:.1f}%")
            except json.JSONDecodeError:
                print(f"Received binary data, length: {len(response)}")
                # Process binary protobuf data with a library like protobuf
            
            # Wait before next poll
            await asyncio.sleep(1)

asyncio.run(monitor_system())

Notes for Integration

  1. Error Handling: The WebSocket connection may close unexpectedly; implement reconnection logic in your client.

  2. Rate Limiting: Avoid excessive polling that could impact the system being monitored. Recommended intervals:

    • Metrics: 500ms or slower
    • Processes: 2000ms or slower
    • Disks: 5000ms or slower
  3. Authentication: If the agent is configured with a token, always include it in the WebSocket URL.

  4. Protocol Buffers Handling: For processing the binary process list data, use a Protocol Buffers library for your language and the schema provided in the proto/processes.proto file.

  5. Compression: Process lists may be gzip-compressed. Check if the response starts with the gzip magic bytes (0x1f, 0x8b) and decompress if necessary.

LLM Integration Guide

If you're using an LLM to generate code for integrating with socktop_agent, this section provides structured information to help the model understand the API better.

API Schema

# WebSocket API Schema for socktop_agent
endpoint: ws://HOST:PORT/ws or wss://HOST:PORT/ws (with TLS)
authentication: 
  type: query parameter
  parameter: token
  example: ws://HOST:PORT/ws?token=YOUR_TOKEN

requests:
  - type: metrics
    format: JSON
    example: {"type": "metrics"}
    description: Fast-changing metrics (CPU, memory, network)
    
  - type: disks
    format: JSON
    example: {"type": "disks"}
    description: Disk information
    
  - type: processes
    format: JSON
    example: {"type": "processes"}
    description: Process list (returns protobuf)

responses:
  - request_type: metrics
    format: JSON
    schema:
      cpu_total: float # percentage of total CPU usage
      cpu_per_core: [float] # array of per-core CPU usage percentages
      mem_total: uint64 # total memory in bytes
      mem_used: uint64 # used memory in bytes
      swap_total: uint64 # total swap in bytes
      swap_used: uint64 # used swap in bytes
      hostname: string # system hostname
      cpu_temp_c: float? # CPU temperature in Celsius (optional)
      networks: [
        {
          name: string # network interface name
          received: uint64 # total bytes received
          transmitted: uint64 # total bytes transmitted
        }
      ]
      gpus: [
        {
          name: string # GPU device name
          usage: float # GPU usage percentage
          memory_total: uint64 # total GPU memory in bytes
          memory_used: uint64 # used GPU memory in bytes
          temp_c: float # GPU temperature in Celsius
        }
      ]?
  
  - request_type: disks
    format: JSON
    schema:
      [
        {
          name: string # disk name
          total: uint64 # total space in bytes
          available: uint64 # available space in bytes
        }
      ]
  
  - request_type: processes
    format: Protocol Buffers (optionally gzip-compressed)
    schema: See protobuf definition below

Protobuf Schema (processes.proto)

syntax = "proto3";

message Process {
  uint32 pid = 1;
  string name = 2;
  float cpu_usage = 3;
  uint64 mem_bytes = 4;
}

message ProcessList {
  uint32 process_count = 1;
  repeated Process processes = 2;
}

Step-by-Step Integration Pseudocode

1. Establish WebSocket connection to ws://HOST:PORT/ws
   - Add token if required: ws://HOST:PORT/ws?token=YOUR_TOKEN
   
2. For regular metrics updates:
   - Send: {"type": "metrics"}
   - Parse JSON response
   - Extract CPU, memory, network info
   
3. For disk information:
   - Send: {"type": "disks"}
   - Parse JSON response
   - Extract disk usage data
   
4. For process list:
   - Send: {"type": "processes"}
   - Check if response is binary
   - If starts with 0x1f, 0x8b bytes:
     - Decompress using gzip
   - Parse binary data using protobuf schema
   - Extract process information
   
5. Implement reconnection logic:
   - On connection close/error
   - Use exponential backoff
   
6. Respect rate limits:
   - metrics: ≥ 500ms interval
   - disks: ≥ 5000ms interval
   - processes: ≥ 2000ms interval

Common Implementation Patterns

Pattern 1: Periodic Polling

// Set up separate timers for different metric types
const metricsInterval = setInterval(() => ws.send(JSON.stringify({type: 'metrics'})), 500);
const disksInterval = setInterval(() => ws.send(JSON.stringify({type: 'disks'})), 5000);
const processesInterval = setInterval(() => ws.send(JSON.stringify({type: 'processes'})), 2000);

// Clean up on disconnect
ws.on('close', () => {
  clearInterval(metricsInterval);
  clearInterval(disksInterval);
  clearInterval(processesInterval);
});

Pattern 2: Processing Binary Protobuf Data

// Using protobufjs
const root = protobuf.loadSync('processes.proto');
const ProcessList = root.lookupType('ProcessList');

ws.on('message', function(data) {
  if (typeof data !== 'string') {
    // Check for gzip compression
    if (data[0] === 0x1f && data[1] === 0x8b) {
      data = gunzipSync(data); // Use appropriate decompression library
    }
    
    // Decode protobuf
    const processes = ProcessList.decode(new Uint8Array(data));
    console.log(`Total processes: ${processes.process_count}`);
    processes.processes.forEach(p => {
      console.log(`PID: ${p.pid}, Name: ${p.name}, CPU: ${p.cpu_usage}%`);
    });
  }
});

Pattern 3: Reconnection Logic

function connect() {
  const ws = new WebSocket('ws://localhost:3000/ws');
  
  ws.on('open', () => {
    console.log('Connected');
    // Start polling
  });
  
  ws.on('close', () => {
    console.log('Connection lost, reconnecting...');
    setTimeout(connect, 1000); // Reconnect after 1 second
  });
  
  // Handle other events...
}

connect();