[object Object]

← back to Exo

fix: shield macmon cleanup from cancellation to prevent orphaned process (#1714)

12af7c9586d7bfe8970016ffc87737f41d4aa76c · 2026-03-13 07:54:30 -0500 · ecohash-co

## Summary

Fixes a bug where macmon metrics (GPU usage, temperature, power, RAM)
freeze permanently in the dashboard while non-macmon metrics (disk
usage) continue updating normally.

**Root cause: asyncio subprocess pipe transport flow control stall**

Observed on a 2-node Mac Studio M3 Ultra cluster (Python 3.13.12, anyio
4.11.0, macOS with kqueue) running EXO.app for ~36 hours. Diagnostics
confirmed:

1. **Only macmon monitoring is stuck** — disk metrics from the same
InfoGatherer continue updating, proving the Worker, EventRouter, and API
pipelines are healthy
2. **macmon IS producing data** — its stdout pipe is full at exactly
65536 bytes (64KB OS buffer), and macmon is blocked on write at 0% CPU
3. **The pipe read-end FD is still open** — the exo process holds it,
but asyncio isn't reading from it
4. **The stale GPU value (0.21) is wrong** — should be ~1.0 (matching
the other node under identical load)

This is consistent with asyncio's subprocess pipe transport getting
stuck in flow control: `pause_reading()` is called when the internal
buffer exceeds the high-water mark, but `resume_reading()` is never
called, permanently deregistering the FD from kqueue. The `receive()`
coroutine waits forever for data asyncio will never deliver.

```
$ lsof -p <macmon_pid>
macmon  74691  FD=1  PIPE  SIZE=65536  ->0x5ae78ecf376ccd0e  # full pipe

$ lsof -p <exo_pid> | grep <pipe_id>
exo     74689  FD=47  PIPE  SIZE=65536  ->0xd129da474c1340f7  # read end held open, not consumed
```

Note: anyio 4.11's `Process.aclose()` already uses
`CancelScope(shield=True)` for cleanup during cancellation — this is NOT
an election cleanup issue (confirmed by @ciaranbor's testing).

**Fix:** Replace the `async for` iteration with an explicit `receive()`
inside `fail_after()`. If macmon produces no output for 10× its
configured interval (minimum 30s), `TimeoutError` fires, `open_process`
cleanup kills macmon and tears down the stale transport, and the loop
restarts with a fresh subprocess and fresh asyncio transport.

## Test plan

- [x] All 246 existing tests pass
- [ ] Verify macmon restarts after simulated pipe stall (e.g., `SIGSTOP`
macmon, wait for timeout, confirm restart and metrics resume)
- [ ] Long-running soak test on multi-node cluster to confirm the fix
prevents recurrence

Files touched

Diff

commit 12af7c9586d7bfe8970016ffc87737f41d4aa76c
Author: ecohash-co <team@ecohash.co>
Date:   Fri Mar 13 07:54:30 2026 -0500

    fix: shield macmon cleanup from cancellation to prevent orphaned process (#1714)
    
    ## Summary
    
    Fixes a bug where macmon metrics (GPU usage, temperature, power, RAM)
    freeze permanently in the dashboard while non-macmon metrics (disk
    usage) continue updating normally.
    
    **Root cause: asyncio subprocess pipe transport flow control stall**
    
    Observed on a 2-node Mac Studio M3 Ultra cluster (Python 3.13.12, anyio
    4.11.0, macOS with kqueue) running EXO.app for ~36 hours. Diagnostics
    confirmed:
    
    1. **Only macmon monitoring is stuck** — disk metrics from the same
    InfoGatherer continue updating, proving the Worker, EventRouter, and API
    pipelines are healthy
    2. **macmon IS producing data** — its stdout pipe is full at exactly
    65536 bytes (64KB OS buffer), and macmon is blocked on write at 0% CPU
    3. **The pipe read-end FD is still open** — the exo process holds it,
    but asyncio isn't reading from it
    4. **The stale GPU value (0.21) is wrong** — should be ~1.0 (matching
    the other node under identical load)
    
    This is consistent with asyncio's subprocess pipe transport getting
    stuck in flow control: `pause_reading()` is called when the internal
    buffer exceeds the high-water mark, but `resume_reading()` is never
    called, permanently deregistering the FD from kqueue. The `receive()`
    coroutine waits forever for data asyncio will never deliver.
    
    ```
    $ lsof -p <macmon_pid>
    macmon  74691  FD=1  PIPE  SIZE=65536  ->0x5ae78ecf376ccd0e  # full pipe
    
    $ lsof -p <exo_pid> | grep <pipe_id>
    exo     74689  FD=47  PIPE  SIZE=65536  ->0xd129da474c1340f7  # read end held open, not consumed
    ```
    
    Note: anyio 4.11's `Process.aclose()` already uses
    `CancelScope(shield=True)` for cleanup during cancellation — this is NOT
    an election cleanup issue (confirmed by @ciaranbor's testing).
    
    **Fix:** Replace the `async for` iteration with an explicit `receive()`
    inside `fail_after()`. If macmon produces no output for 10× its
    configured interval (minimum 30s), `TimeoutError` fires, `open_process`
    cleanup kills macmon and tears down the stale transport, and the loop
    restarts with a fresh subprocess and fresh asyncio transport.
    
    ## Test plan
    
    - [x] All 246 existing tests pass
    - [ ] Verify macmon restarts after simulated pipe stall (e.g., `SIGSTOP`
    macmon, wait for timeout, confirm restart and metrics resume)
    - [ ] Long-running soak test on multi-node cluster to confirm the fix
    prevents recurrence
---
 src/exo/utils/info_gatherer/info_gatherer.py | 14 +++++++++++---
 1 file changed, 11 insertions(+), 3 deletions(-)

diff --git a/src/exo/utils/info_gatherer/info_gatherer.py b/src/exo/utils/info_gatherer/info_gatherer.py
index 9cb04c06..b5e5d24e 100644
--- a/src/exo/utils/info_gatherer/info_gatherer.py
+++ b/src/exo/utils/info_gatherer/info_gatherer.py
@@ -529,6 +529,9 @@ class InfoGatherer:
         if self.macmon_interval is None:
             return
         # macmon pipe --interval [interval in ms]
+        # Timeout: if macmon produces no output for this many seconds, restart it.
+        # macmon writes every macmon_interval seconds, so 10x that is generous.
+        read_timeout = max(self.macmon_interval * 10, 30)
         while True:
             try:
                 async with await open_process(
@@ -542,10 +545,15 @@ class InfoGatherer:
                     if not p.stdout:
                         logger.critical("MacMon closed stdout")
                         return
-                    async for text in TextReceiveStream(
-                        BufferedByteReceiveStream(p.stdout)
-                    ):
+                    stream = TextReceiveStream(BufferedByteReceiveStream(p.stdout))
+                    while True:
+                        with fail_after(read_timeout):
+                            text = await stream.receive()
                         await self.info_sender.send(MacmonMetrics.from_raw_json(text))
+            except TimeoutError:
+                logger.warning(
+                    f"MacMon produced no output for {read_timeout}s, restarting"
+                )
             except CalledProcessError as e:
                 stderr_msg = "no stderr"
                 stderr_output = cast(bytes | str | None, e.stderr)

← f28b2fd0 Extract mlx revision from uv lock (#1715)  ·  back to Exo  ·  Add step logo condition to FamilyLogos component (#1676) 29d4165f →