Skip to content

Show child process cpu usage in dtop#1880

Open
aclauer wants to merge 9 commits intodevfrom
andrew/feat/dtop-subprocess-cpu-usage
Open

Show child process cpu usage in dtop#1880
aclauer wants to merge 9 commits intodevfrom
andrew/feat/dtop-subprocess-cpu-usage

Conversation

@aclauer
Copy link
Copy Markdown
Collaborator

@aclauer aclauer commented Apr 18, 2026

Problem

dtop only shows cpu usage for Python workers spawned by DimOS. Any native modules spawned by that worker don't show up in the cpu statistics. Also add --log flag to log dtop statistics and dtop-plot to generate plots of cpu usage.

Closes DIM-XXX

Solution

Read the pids of any processes spawned and include their cpu usage in a drop down of the main worker.

dtop

Breaking Changes

None

How to Test

dimos --dtop --replay --replay-db=go2_bigoffice run unitree-go2

and

dtop

When dimos spawns the viewer, it will show up as a subprocess of the rerun bridge worker.

Contributor License Agreement

  • I have read and approved the CLA.

@aclauer aclauer changed the title Initial subprocess display Show child process cpu usage in dtop Apr 18, 2026
Comment on lines +130 to +142
try:
proc = _get_process(pid)
for child in proc.children(recursive=False):
child_proc = _get_process(child.pid)
try:
name = child_proc.name()
cpu = child_proc.cpu_percent(interval=None)
result.append(ChildProcessStats(pid=child.pid, name=name, cpu_percent=cpu))
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return result
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Presumably _get_process raises (psutil.NoSuchProcess, psutil.AccessDenied). Then surround just that function call with try-except. Nesting the try-excepts is confusing.

Comment thread dimos/utils/cli/dtop.py Outdated
parser.add_argument(
"--log",
nargs="?",
const=f"dtop_{time.strftime('%Y%m%d_%H%M%S')}.jsonl",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it's automatically git-ignored.

Suggested change
const=f"dtop_{time.strftime('%Y%m%d_%H%M%S')}.jsonl",
const=f"dtop_{time.strftime('%Y%m%d_%H%M%S')}.ignore.jsonl",

@aclauer aclauer marked this pull request as ready for review April 24, 2026 21:13
@aclauer aclauer requested a review from paul-nechifor April 24, 2026 21:13
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 24, 2026

Greptile Summary

This PR extends dtop to aggregate CPU usage from direct child processes of each DimOS worker, surfacing them as collapsible rows in the TUI. It also adds a --log flag to write JSONL stats and a new dtop-plot CLI tool to visualise the logged data.

Confidence Score: 5/5

Safe to merge; all findings are P2 quality-of-life issues with no correctness impact on the happy path.

No P0 or P1 issues found. The stale _child_cpu_history entry and first-sample cpu_percent=0.0 are cosmetic glitches; the double proc.children() syscall is a minor efficiency concern. The more serious dtop_plot.py edge cases (KeyError on coordinator, NaN workers iteration) were flagged in a previous review cycle.

dimos/utils/cli/dtop_plot.py — KeyError and NaN-iteration risks from previous review are still open.

Important Files Changed

Filename Overview
dimos/core/resource_monitor/stats.py Adds ChildProcessStats dataclass and collect_children_stats; extends WorkerStats with children field. First-call cpu_percent=0.0 for new children and double proc.children() syscall are minor efficiency issues.
dimos/core/resource_monitor/monitor.py Wires collect_children_stats into the per-worker stats loop and aggregates child CPU into parent total before constructing WorkerStats. Logic is straightforward and correct.
dimos/utils/cli/dtop.py Adds --log flag, JSONL logging, child-process rows in TUI, _child_cpu_history, and refactors CPU metric rendering. _child_cpu_history entries for dead PIDs are never pruned, risking stale sparkline data on PID reuse.
dimos/utils/cli/dtop_plot.py New dtop-plot CLI tool for plotting JSONL log files. Has KeyError and NaN-iteration risks on the workers column (flagged in previous threads); otherwise straightforward pandas + matplotlib usage.
pyproject.toml Registers dtop-plot as a new console script entry point.

Sequence Diagram

sequenceDiagram
    participant M as StatsMonitor
    participant S as stats.py
    participant TUI as ResourceSpyApp
    participant Log as JSONL log

    loop every poll interval
        M->>S: collect_process_stats(worker_pid)
        S-->>M: ProcessStats
        M->>S: collect_children_stats(worker_pid)
        S-->>M: list[ChildProcessStats]
        M->>M: aggregate child cpu_percent into parent total
        M->>M: build WorkerStats(children=[...])
        M->>TUI: LCM message {coordinator, workers}
        TUI->>Log: json.dumps(msg) if --log
        TUI->>TUI: _render_panels() ↳ _make_lines() for worker ↳ _make_child_line() per child
    end
Loading

Reviews (4): Last reviewed commit: "Fix mypy" | Re-trigger Greptile

Comment thread dimos/utils/cli/dtop.py
self._latest = msg
self._last_msg_time = time.monotonic()
if self._log_file:
self._log_file.write(json.dumps({"ts": time.time(), **msg}) + "\n")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Log file not flushed between writes

Each _on_msg call writes a line to _log_file but never calls flush(). Because Python's file I/O is buffered by default, lines written near a crash or SIGKILL will silently stay in the OS/Python buffer and never reach disk. Adding a flush() after the write ensures each message is durable.

Suggested change
self._log_file.write(json.dumps({"ts": time.time(), **msg}) + "\n")
self._log_file.write(json.dumps({"ts": time.time(), **msg}) + "\n")
self._log_file.flush()

Comment thread dimos/utils/cli/dtop.py
) -> None:
super().__init__()
self._topic_name = topic_name
self._log_file = open(log_path, "a") if log_path else None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 File handle leak if __init__ raises after open()

_log_file is opened before autoconf, PickleLCM(), and subscribe(). If any of those subsequent calls throw, on_unmount is never called and the file handle is leaked. A try/except (or opening the file later, e.g. in on_mount) would prevent this.

Comment thread dimos/utils/cli/dtop.py
parts.append(Rule(title=title, style=border_style))
parts.extend(self._make_lines(d, stale, ranges, self._cpu_history[role]))
for child in d.get("children", []):
pid = child.get("pid", 0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 pid variable shadowed by inner loop

The outer for tuple unpacks pid as a string (worker pid for display), but this inner assignment overwrites it with an integer child pid. The outer pid is rebound at the start of each outer iteration so there's no runtime bug, but the shadowing is confusing and could easily introduce a bug if code is added between the inner loop and the next outer iteration.

Suggested change
pid = child.get("pid", 0)
child_pid = child.get("pid", 0)
if child_pid not in self._child_cpu_history:
self._child_cpu_history[child_pid] = deque(maxlen=_SPARK_WIDTH * 2)
if not stale:
self._child_cpu_history[child_pid].append(child.get("cpu_percent", 0.0))
parts.append(self._make_child_line(child, stale, self._child_cpu_history[child_pid]))

rows = []
for _, msg in raw.iterrows():
ts = msg["ts"]
rows.append({"ts": ts, "role": _COORDINATOR, **msg[_COORDINATOR]})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 KeyError on malformed log lines

msg[_COORDINATOR] raises KeyError if any line in the JSONL file is missing the "coordinator" key (e.g., a truncated line written during an unclean shutdown). Wrapping the row processing in a try/except KeyError and skipping bad rows would make the tool more robust.

Suggested change
rows.append({"ts": ts, "role": _COORDINATOR, **msg[_COORDINATOR]})
try:
ts = msg["ts"]
rows.append({"ts": ts, "role": _COORDINATOR, **msg[_COORDINATOR]})
except (KeyError, TypeError):
continue

for _, msg in raw.iterrows():
ts = msg["ts"]
rows.append({"ts": ts, "role": _COORDINATOR, **msg[_COORDINATOR]})
for w in msg.get("workers", []):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 msg.get("workers", []) returns NaN, not [], on pandas null rows

pd.read_json(path, lines=True) creates a "workers" column for the whole DataFrame. If any log line is missing the "workers" key (e.g. a coordinator-only message from an older build, or a partially-written line), pandas fills that row with NaN. A pandas Series get(key, default) only falls back to default when the key is absent from the index — not when the value is NaN. So msg.get("workers", []) returns NaN for those rows, and for w in NaN raises TypeError: 'float' object is not iterable.

Use msg.get("workers") or [] to handle the NaN case:

        for w in msg.get("workers") or []:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants