Daeseon Yoo
Back to project
·Tech retro·3 min

System Pulse, a Pulse that froze the app, and a graph that never fit

Three threads in one session: shipped System Pulse (per-startup memory attribution the OS can't give you), fixed a synchronous Tauri command that froze the whole app on a 504 MB transcript read, and chased an empty connective-graph down to its real cause — React Flow never measured the nodes because the flow was controlled without onNodesChange. Then rebuilt the graph from a 1,615-commit hairball into one node per startup.

System Pulse — attribution the OS structurally can't do

Looking at Activity Monitor, you see 2.1.162 — 591 MB a dozen times and have no idea which startup each is. Talkak can know: every pane's tmux session is named dalkkak-pane-<id>, so tmux list-panes hands back each pane's PID, and we sum that PID's process subtree. New syspulse.rs reports machine pressure (kern.memorystatus_vm_pressure_level), system swap, load, and per-pane resident memory — all from the same OS sources Activity Monitor uses, so any number cross-checks. Verified live: 13 Claude sessions, 5.1 GB, the heaviest pinpointed to a startup. Honest limit baked into the code: macOS does not expose per-process swap, so swap is system-wide only — never faked per pane.

One bug caught before it shipped: pty_kill targets dalkkak-{id}, so stripping the full dalkkak-pane- prefix would have produced an id the kill couldn't match — a silent no-op. Stripping only dalkkak- keeps the kill working. Verified against the live session list before trusting it.

The Pulse that froze everything

Then: "pulse누르니까 걍 버그걸린듯 멈추고" — opening Usage Pulse hung the entire UI. The cause was textbook once measured: usage_pulse was a synchronous #[tauri::command], and Tauri runs those on the main thread. It reads every session transcript — 1566 files, 504 MB — synchronously. So the UI blocked until the parse finished.

Fix: async fn + tokio::task::spawn_blocking, snapshotting the in-memory state first and doing the disk I/O off-thread. Same treatment for system_pulse. The lesson is a rule now: a Tauri command that touches disk or processes more than trivially must be async — a sync command is a main-thread block by construction, and the cost is invisible until a power user has 500 MB of data.

The graph that never fit

The connective graph had been broken for a while — "Graph는 여전히 병신이다." It rendered a blank canvas with a single node stuck in the minimap; even a manual "Fit" button did nothing. I guessed twice (re-fitting on init, then on the next animation frame) and both failed, because I was fighting a timing symptom.

So I stopped guessing and put a readout on the canvas itself: nodes=1615 init=false zoom=1.000. That init=false was the whole story. React Flow v12 measures node sizes asynchronously and writes them back through onNodesChange — and the flow was controlled without that handler, so useNodesInitialized never flipped true and fitView had no bounds to fit. The fix was to make the flow controlled with onNodesChange (useNodesState/useEdgesState). One instrumentation round beat two blind guesses.

But fixing the fit only revealed the real problem: 1,615 commit nodes is a terrifying wall, not a graph — "존나 무섭다 … 왜이리 많아." A graph of leaf events is data, not insight. So the graph is now one node per startup (~22), sized by activity and colored by recency (active / drifting / neglected), the full commit count kept in each card. No edges yet — that's step 1. Step 2 is the part that earns the name "connective": real edges between startups that share dependencies or patterns. That's the unbuilt differentiator, and it's a product decision, not a rendering one.