# Pulsar editor lag — investigation log (2026-07-18)

**Symptoms** (reported during rehearsal): multi-second typing/cursor latency in Pulsar ("typing
blind"), a GHC error renders a big red stack over several seconds, evals stay slow afterwards —
while the taskbar CPU indicator shows the machine "barely used" and window-manager shortcuts
stay instant. Fans maxed and 73–82°C despite recently cleaned vents.

## Verdict

The machine is healthy. The lag is **Pulsar's renderer main thread saturating one core**
(~65–75% sustained through the whole session), which reads as ~4% on a 16-thread CPU meter.
Every keystroke, cursor move and eval queues behind that single busy thread; KWin shortcuts
bypass it entirely, which is why the desktop stayed fast.

## Evidence

- Renderer process (PID 14530): **37:51 CPU over a 58:05 session** (65%), still ~74%/core over
  the following 4 minutes of active use; ~1–5% when truly idle. All on the **main thread**.
- System-wide: no memory pressure (22 Gi available, swap 0B), no IO/CPU PSI pressure.
- GPU process holds `/dev/dri` fds → **hardware acceleration active** (not SwiftShader).
- Pulsar 1.118.0 = **Electron 12.2.3 / Chrome 89 (April 2021)**, running as an X11 client via
  **Xwayland** on Plasma 6 (Xwayland itself at ~18% CPU relaying Pulsar's pixels).
- Thermals: package 82°C, both fans pegged at 3300 RPM, non-zero `thermal_throttle` counters,
  load avg 2.87 — real sustained load (Pulsar renderer + Ardour ~20% + Xwayland ~18%), not a
  cooling defect. Mixed power state: `perf.sh --cool` leftovers (max_perf_pct=85, powersave
  governor, EPP balance_performance, Pulsar main+GPU at nice 10 / renderer at 0) while
  power-profiles-daemon reports "performance".

## Root causes (in `~/Work/Tools/pulsar-tidalcycles`, branch `perf/event-highlighter`)

1. **`lib/console-view.js` — the "big red stack" stall.** Every log line appends a `<pre>` to a
   DOM that is never pruned, then forces a synchronous full reflow via
   `scrollTop = scrollHeight`. `showErrorNotifications` (default **on**) additionally renders
   the *entire* GHC stack through `atom.notifications.addError()`. Reflow cost grows with the
   session-long console DOM → evals after an error stay slow.
2. **`lib/editors.js` `decorateCodeBlocks()` — progressive typing lag.** Runs on every
   line-count change (every Enter / deleted line), destroys line-number decorations (including
   other packages') but **leaks the markers** (`invalidate: 'never'`, never destroyed). Atom
   updates its marker index on every keystroke → cost grows all session.
3. **`lib/event-highlighter.js` `#createPositionMarkers()` — secondary marker leak.** Re-evaling
   a line overwrites the `rowMarkers` map entry without destroying the previous marker.
   Live stats after a 63-min session: **2170 live markers** (and leaked-overwritten ones on top).
4. **`styles/tidal.less`** — `backdrop-filter: blur(10px)` on the sound-browser waveform and
   search panels: one of the most paint-expensive CSS properties on Chrome 89; costs whenever
   the panel is visible.
5. Config/state mismatch: `eventHighlighting: {}` on disk (= default `enable: false`) while the
   running instance has an active highlighter; BootTidal.hs streams `/editor/highlights` OSC to
   port 6013 regardless of the editor-side toggle.

## Highlighter profile (user-run `tidalcycles:highlight-stats`, 63-min window)

```
[Highlighter profile] 3799.5s window | RAF 4/s, work 3/s (idle 0%) | frame avg 0.502ms / max 89.195ms |
DOM writes 17/s | markers 2170 live, creates/destroys 1/s, toggles 34/s | active 0 | errors 0
[Highlighter profile] jank: 7 frames >16ms, 2 >50ms | worst frame 89.195ms did 16 adds / 1 removes
```

Read: the perf branch works — the highlighter is now cheap on average (0.5 ms/frame, idle-aware
RAF at 3–4/s). The remaining concerns are the 2170-marker accumulation and rare 50–90 ms spike
frames. The session-long ~65% burn comes from the console/marker/styles causes above, not from
the highlighter loop itself.

## Remediation plan

1. Config (immediate): `tidalcycles.showErrorNotifications: false`;
   `autocomplete-plus.autoActivationDelay` ≥ 300 ms (documented community fix for typing lag).
2. Patch fork: cap console DOM (max messages), truncate long messages, lighter error
   notifications (first line + short detail); fix `decorateCodeBlocks` marker leak + debounce;
   fix `#createPositionMarkers` overwrite leak; optionally drop `backdrop-filter`.
3. Verify with `tidalcycles:highlight-stats` + renderer CPU sampling before/after.
4. Re-audition `editor-background` only after the renderer is no longer saturated.
5. Editor endgame: don't fork Pulsar (Electron 12 ceiling, no prior art); if Pulsar still
   disappoints, thin-plugin path (tidal.nvim / VSCode extension) carries the identity cheaper.
   Upstream issue #229 (Aug 2025) plans a web-worker offload for highlighting.

## Update 2026-07-24 — live-playback profile (mid-set)

Captured ~14.5 min into an actual set (16 orbits active), machine in `cool` regime
(max_perf_pct=85, on AC, package 67–70°C, power-aware watcher holding — no turbo spike).
This is the **playing** counterpart to the July-18 idle capture above (which had `active 0`).

```
[Highlighter profile] 868.4s window | RAF 28/s, work 19/s (idle 0%) | frame avg 0.485ms / max 78.485ms |
DOM writes 150/s | markers 580 live, creates/destroys 2/s, toggles 359/s | active 16 | errors 0
[Highlighter profile] jank: 7 frames >16ms, 3 >50ms | worst frame 78.485ms did 18 adds / 0 removes
```

Read:
- **Marker accumulation rate holds.** 580 live in 868 s ≈ **40 markers/min**, vs 2170 in 3799 s
  ≈ 34/min on July 18 — consistent, and only **16 actually active**. The position-marker pool
  still never evicts (root cause #3, `#createPositionMarkers`, is still live in the running build).
  Confirms the leak isn't idle-only: it grows at the same rate while playing.
- **Toggle/DOM churn scales with active events, as designed.** 359 toggles/s + 150 DOM writes/s at
  `active 16` (vs 34/s + 17/s at `active 0` in July). The marker-reuse path works — churn tracks the
  16-event working set, not the 580-marker pool — so the pool size costs *paint/reflow*, not toggles.
- **Tail unchanged.** Worst frame 78 ms on an **18-add burst / 0 removes** (adds unbalanced by
  removes = accumulation signature); 3 frames >50 ms in 14.5 min. Average stays cheap (0.485 ms,
  idle 0% — RAF gating good). The open work is still (a) **evict/cap the pool to the working set**
  and (b) tame the add-burst spikes.
- **Impact: UI-smoothness only.** Audio is on scsynth's RT thread, untouched; jank is occasional
  cursor/scroll hitch that trends worse the longer a set runs (pool is monotonic). Mild at 14.5 min.

See companion: `2026-07-18-tidalcycles-2026-landscape-research.md`.
