|
27 | 27 | {"cmd":"list_metrics","device":"<name>"} |
28 | 28 | {"cmd":"subscribe","device":"<name>","metrics":["m1","m2"]} |
29 | 29 | {"cmd":"unsubscribe","device":"<name>","metrics":["m1"]} |
| 30 | + {"cmd":"smaps","device":"<name>"} |
30 | 31 |
|
31 | 32 | Wire protocol (driver -> client) |
32 | 33 | -------------------------------- |
33 | 34 | {"type":"snapshot","devices":[{"name","pid","active","streamingState","deviceState"},...]} |
34 | 35 | {"type":"update","device":<idx>,"name":"<name>","metrics":{<name:value,...>}} |
35 | 36 | {"type":"metrics_list","device":"<name>","metrics":["m1","m2",...]} |
| 37 | + {"type":"smaps","device":"<name>","pid":<pid>,"fields":[...],"mappings":{"<path>":[<kB>,...]}} |
36 | 38 | """ |
37 | 39 |
|
38 | 40 | from __future__ import annotations |
@@ -63,6 +65,7 @@ def __init__(self, *, url: str, name: str, extra_headers: dict[str, str] | None |
63 | 65 | self.updates: list[dict] = [] |
64 | 66 | self.logs: list[dict] = [] |
65 | 67 | self.metrics_lists: dict[str, list[str]] = {} |
| 68 | + self.smaps: dict[str, dict] = {} |
66 | 69 |
|
67 | 70 | async def ensure_connected(self) -> None: |
68 | 71 | """Connect (or reconnect) to the driver's /status WebSocket.""" |
@@ -113,6 +116,8 @@ async def _reader(self) -> None: |
113 | 116 | elif t == "metrics_list": |
114 | 117 | device = msg.get("device", "") |
115 | 118 | self.metrics_lists[device] = msg.get("metrics", []) |
| 119 | + elif t == "smaps": |
| 120 | + self.smaps[msg.get("device", "")] = msg |
116 | 121 | except Exception: |
117 | 122 | pass |
118 | 123 | finally: |
@@ -315,6 +320,71 @@ async def list_metrics(workflow: str, device: str) -> str: |
315 | 320 | return f"No reply from driver for device '{device}' (timeout)." |
316 | 321 |
|
317 | 322 |
|
| 323 | +# Where a mapping's memory is attributed, first match wins (O2Physics before O2, |
| 324 | +# since both paths contain "/O2"). |
| 325 | +_SMAPS_GROUPS = ( |
| 326 | + ("O2Physics libs", lambda p: "/O2Physics/" in p), |
| 327 | + ("O2 libs", lambda p: "/O2/" in p), |
| 328 | + ("ROOT libs", lambda p: "/ROOT/" in p), |
| 329 | + ("arrow libs", lambda p: "/arrow/" in p), |
| 330 | + ("other libs", lambda p: ".so" in p), |
| 331 | + ("heap", lambda p: p == "[heap]"), |
| 332 | + ("anon", lambda p: p == "[anon]"), |
| 333 | + ("shm", lambda p: p.startswith("/dev/shm") or "SYSV" in p or "fmq_" in p), |
| 334 | + ("other", lambda p: True), |
| 335 | +) |
| 336 | + |
| 337 | + |
| 338 | +@mcp.tool() |
| 339 | +async def smaps(workflow: str, device: str, top: int = 25) -> str: |
| 340 | + """Memory of a DPL device per mapping, read from /proc/<pid>/smaps by the driver. |
| 341 | +
|
| 342 | + The driver runs as the same user as its devices, so this works where an |
| 343 | + outside reader cannot (e.g. Hyperloop tests running as another account). |
| 344 | + Reports PSS / private-dirty / RSS per group (O2Physics, O2, ROOT, arrow and |
| 345 | + other libs, heap, anon, shm) and the ``top`` paths by PSS. Private-dirty is |
| 346 | + the device's own cost, independent of how many processes share a library; |
| 347 | + PSS splits shared pages between them. Linux only. |
| 348 | +
|
| 349 | + Args: |
| 350 | + workflow: Workflow name as returned by connect. |
| 351 | + device: Device name exactly as shown by list_devices. |
| 352 | + top: Number of individual paths to list. |
| 353 | + """ |
| 354 | + conn = _get(workflow) |
| 355 | + conn.smaps.pop(device, None) |
| 356 | + await conn.send({"cmd": "smaps", "device": device}) |
| 357 | + for _ in range(100): # up to 5 s |
| 358 | + await asyncio.sleep(0.05) |
| 359 | + if device in conn.smaps: |
| 360 | + break |
| 361 | + else: |
| 362 | + return f"No reply from driver for device '{device}' (timeout, or a driver without the smaps command)." |
| 363 | + msg = conn.smaps[device] |
| 364 | + if "error" in msg: |
| 365 | + return f"{device} (pid {msg.get('pid')}): {msg['error']}" |
| 366 | + idx = {f: i for i, f in enumerate(msg["fields"])} |
| 367 | + rows = sorted(((p, v[idx["pss"]], v[idx["private_dirty"]], v[idx["rss"]]) for p, v in msg["mappings"].items()), |
| 368 | + key=lambda r: -r[1]) |
| 369 | + groups = {name: [0, 0, 0] for name, _ in _SMAPS_GROUPS} |
| 370 | + for path, pss, pd, rss in rows: |
| 371 | + g = groups[next(n for n, match in _SMAPS_GROUPS if match(path))] |
| 372 | + g[0] += pss |
| 373 | + g[1] += pd |
| 374 | + g[2] += rss |
| 375 | + mb = lambda kb: f"{kb / 1024:9.1f}" |
| 376 | + total = [sum(g[i] for g in groups.values()) for i in range(3)] |
| 377 | + lines = [f"{device} (pid {msg['pid']})", "", f"{'group':16} {'PSS MB':>9} {'privDirty':>9} {'RSS MB':>9}"] |
| 378 | + for name, (pss, pd, rss) in sorted(groups.items(), key=lambda kv: -kv[1][0]): |
| 379 | + if rss: |
| 380 | + lines.append(f"{name:16} {mb(pss)} {mb(pd)} {mb(rss)}") |
| 381 | + lines.append(f"{'TOTAL':16} {mb(total[0])} {mb(total[1])} {mb(total[2])}") |
| 382 | + lines += ["", f"top {top} paths by PSS:", f"{'PSS MB':>9} {'privDirty':>9} {'RSS MB':>9} path"] |
| 383 | + for path, pss, pd, rss in rows[:max(1, top)]: |
| 384 | + lines.append(f"{mb(pss)} {mb(pd)} {mb(rss)} {path}") |
| 385 | + return "\n".join(lines) |
| 386 | + |
| 387 | + |
318 | 388 | @mcp.tool() |
319 | 389 | async def subscribe(workflow: str, device: str, metrics: list[str]) -> str: |
320 | 390 | """Subscribe to one or more metrics for a DPL device. |
|
0 commit comments