Integrated repository, worktree, git stacker, and project manager
0

Configure Feed

Select the types of activity you want to include in your feed.

emacs: fix pm-sidebar render runaway (overlay leak + render debounce)

pm-sidebar--render rebuilt the buffer with erase-buffer but never deleted
the overlays the previous render created (visibility indicators, magit
highlight/selection). erase-buffer only collapses overlays to point 1, so
they accumulated every refresh; the leaked overlays anchor markers near
point 1, turning every insert into O(stale-markers) and growing RSS without
bound until the single-threaded daemon pegged a core and wedged (observed
23GB+ RSS, unrecoverable without a daemon restart).

- render: remove-overlays before erase-buffer (stops the leak)
- handle + window-size-change: coalesce renders behind a 0.2s timer so an
SSE burst (snapshot replay, status churn, resize storm) collapses into
one render instead of one full rebuild per event
- proc-filter: walk lines with a running index + one trailing substring
(was O(n^2) substring-per-line), use string-replace, and cap the
accumulator to guard a non-terminating line

Add ERT coverage: overlay cleanup, render coalescing, quit cancels the
render timer, chunked-SSE parsing, accumulator cap.

Jordan Isaacs (Jun 30, 2026, 8:35 PM UTC) aeb63253 a908ac66

+137 -8
+48 -8
integrations/emacs/pm-sidebar.el
··· 78 78 "Non-nil while the sidebar should stay connected; cleared by `pm-sidebar-quit'.") 79 79 (defvar pm-sidebar--reconnect-timer nil 80 80 "Pending reconnect timer, so a dropped stream (e.g. daemon restart) recovers.") 81 + (defvar pm-sidebar--render-timer nil 82 + "Pending coalesced-render timer, so an SSE burst collapses into one render.") 81 83 82 84 ;; --- text helpers ----------------------------------------------------------- 83 85 (defun pm-sidebar--ellipsize (str width) ··· 172 174 (ws (and (window-live-p win) (window-start win)))) 173 175 (setq pm-sidebar--last-width width) 174 176 (pm-table-reset-section-state) 177 + ;; `erase-buffer' does NOT delete overlays — it only collapses them to 178 + ;; point 1. Each render makes a fresh set (visibility indicators via 179 + ;; `pm-table-show-root-section', plus magit highlight/selection overlays), 180 + ;; so without this they pile up every refresh. The leaked overlays anchor 181 + ;; markers near point 1, which turns every `insert' below into 182 + ;; O(leaked-markers) and grows RSS without bound until the single-threaded 183 + ;; daemon wedges. Drop them before rebuilding. 184 + (remove-overlays (point-min) (point-max)) 175 185 (erase-buffer) 176 186 (magit-insert-section (pm-sidebar-root) 177 187 (insert (pm-table-banner "pm agents" (hash-table-count pm-sidebar--sessions))) ··· 202 212 (set-window-point win (point)) 203 213 (when ws (set-window-start win (min ws (point-max)) t))))))) 204 214 215 + (defun pm-sidebar--schedule-render () 216 + "Coalesce renders behind a single 0.2s timer instead of rendering inline. 217 + One event triggering a full `erase-buffer'+rebuild is cheap, but a burst 218 + (snapshot replay after a reconnect, rapid status updates, a resize storm) 219 + rendering synchronously per event starves the single-threaded daemon. The 220 + first event in a window arms the timer and later events fold into it, so we 221 + render at most once per 0.2s with the latest state." 222 + (unless (timerp pm-sidebar--render-timer) 223 + (setq pm-sidebar--render-timer 224 + (run-with-timer 0.2 nil 225 + (lambda () 226 + (setq pm-sidebar--render-timer nil) 227 + (pm-sidebar--render)))))) 228 + 205 229 ;; --- actions ---------------------------------------------------------------- 206 230 (defun pm-sidebar-visit () 207 231 "Act on the thing at point. ··· 254 278 ;; `agent'/`vendor_session_id' sit at the top level of the 255 279 ;; remove payload, exactly the keys `session-key' reads. 256 280 (remhash (pm-sidebar--session-key obj) pm-sidebar--sessions))) 257 - (pm-sidebar--render)) 281 + (pm-sidebar--schedule-render)) 258 282 (error nil)))) 259 283 284 + (defconst pm-sidebar--acc-limit (* 1 1024 1024) 285 + "Hard cap on the SSE accumulator; reset it if a single line never terminates.") 286 + 260 287 (defun pm-sidebar--proc-filter (_proc chunk) 261 288 ;; Normalize CRLF→LF and key off \n; skip HTTP headers once. 262 - (setq pm-sidebar--acc (concat pm-sidebar--acc (replace-regexp-in-string "\r" "" chunk))) 289 + (setq pm-sidebar--acc (concat pm-sidebar--acc (string-replace "\r" "" chunk))) 263 290 (unless pm-sidebar--headers-done 264 291 (let ((idx (string-search "\n\n" pm-sidebar--acc))) 265 292 (when idx 266 293 (setq pm-sidebar--acc (substring pm-sidebar--acc (+ idx 2)) 267 294 pm-sidebar--headers-done t)))) 268 295 (when pm-sidebar--headers-done 269 - (let (nl) 270 - (while (setq nl (string-search "\n" pm-sidebar--acc)) 271 - (let ((line (string-trim (substring pm-sidebar--acc 0 nl)))) 272 - (setq pm-sidebar--acc (substring pm-sidebar--acc (1+ nl))) 296 + ;; Walk complete lines with a running START index and substring the consumed 297 + ;; prefix exactly once at the end. Substringing the whole accumulator per line 298 + ;; (the old approach) is O(lines×length) and conses heavily; under a burst that 299 + ;; floods GC and helps peg the single-threaded daemon. 300 + (let ((start 0) nl) 301 + (while (setq nl (string-search "\n" pm-sidebar--acc start)) 302 + (let ((line (string-trim (substring pm-sidebar--acc start nl)))) 303 + (setq start (1+ nl)) 273 304 (when (string-prefix-p "data:" line) 274 - (pm-sidebar--handle (string-trim (substring line 5))))))))) 305 + (pm-sidebar--handle (string-trim (substring line 5)))))) 306 + (when (> start 0) 307 + (setq pm-sidebar--acc (substring pm-sidebar--acc start))))) 308 + ;; Guard against an unbounded line on a malformed/runaway stream. 309 + (when (> (length pm-sidebar--acc) pm-sidebar--acc-limit) 310 + (setq pm-sidebar--acc ""))) 275 311 276 312 (defun pm-sidebar--schedule-reconnect () 277 313 "Arm a one-shot reconnect, unless we deliberately disconnected. ··· 333 369 "Re-fit the sidebar when its window's usable width changes." 334 370 (let ((win (get-buffer-window pm-sidebar-buffer-name t))) 335 371 (when (and win (/= (max 24 (window-max-chars-per-line win)) (or pm-sidebar--last-width -1))) 336 - (pm-sidebar--render)))) 372 + (pm-sidebar--schedule-render)))) 337 373 338 374 ;; --- mode + entry points ---------------------------------------------------- 339 375 (defvar pm-sidebar-mode-map ··· 398 434 (when (timerp pm-sidebar--reconnect-timer) 399 435 (cancel-timer pm-sidebar--reconnect-timer) 400 436 (setq pm-sidebar--reconnect-timer nil)) 437 + ;; Drop any pending coalesced render so it can't resurrect the killed buffer. 438 + (when (timerp pm-sidebar--render-timer) 439 + (cancel-timer pm-sidebar--render-timer) 440 + (setq pm-sidebar--render-timer nil)) 401 441 (remove-hook 'window-size-change-functions #'pm-sidebar--on-window-size-change) 402 442 (when (process-live-p pm-sidebar--proc) 403 443 (set-process-sentinel pm-sidebar--proc #'ignore)
+89
integrations/emacs/pm-tests.el
··· 1153 1153 (let ((kill-buffer-query-functions nil)) 1154 1154 (kill-buffer pm-sidebar-buffer-name)))))) 1155 1155 1156 + (ert-deftest pm-test--sidebar-render-clears-stale-overlays () 1157 + "Each render must drop overlays left in the buffer instead of stacking new 1158 + ones on top. This is the leak that grew the daemon's RSS into the gigabytes: 1159 + `erase-buffer' only collapses overlays to point 1, so without an explicit 1160 + `remove-overlays' they accumulate every refresh and turn each `insert' into 1161 + O(stale-markers)." 1162 + (let ((pm-sidebar--sessions (make-hash-table :test 'equal))) 1163 + (puthash "claude\0s1" 1164 + '((agent . "claude") (vendor_session_id . "s1") (project . "p") 1165 + (title . "Alpha") (status . "working")) 1166 + pm-sidebar--sessions) 1167 + (unwind-protect 1168 + (progn 1169 + (pm-sidebar--render) 1170 + ;; Stand in for the overlays a previous render leaves behind 1171 + ;; (visibility indicators, magit highlight/selection overlays). 1172 + (with-current-buffer pm-sidebar-buffer-name 1173 + (dotimes (_ 50) (make-overlay (point-min) (point-max))) 1174 + (should (>= (length (overlays-in (point-min) (point-max))) 50))) 1175 + ;; Re-rendering must clear them; the count stays bounded no matter 1176 + ;; how many times we refresh. 1177 + (pm-sidebar--render) 1178 + (pm-sidebar--render) 1179 + (with-current-buffer pm-sidebar-buffer-name 1180 + (should (< (length (overlays-in (point-min) (point-max))) 50)))) 1181 + (when (get-buffer pm-sidebar-buffer-name) 1182 + (let ((kill-buffer-query-functions nil)) 1183 + (kill-buffer pm-sidebar-buffer-name)))))) 1184 + 1185 + (ert-deftest pm-test--sidebar-schedule-render-coalesces () 1186 + "A burst of schedule calls arms exactly one timer (not one per event), and 1187 + firing it renders a single time — so an SSE flood can't peg the daemon with 1188 + back-to-back full rebuilds." 1189 + (let ((pm-sidebar--render-timer nil) 1190 + (renders 0) 1191 + (timers-before (length timer-list))) 1192 + (cl-letf (((symbol-function 'pm-sidebar--render) 1193 + (lambda () (cl-incf renders)))) 1194 + (unwind-protect 1195 + (progn 1196 + (dotimes (_ 10) (pm-sidebar--schedule-render)) 1197 + (should (timerp pm-sidebar--render-timer)) 1198 + (should (= (1+ timers-before) (length timer-list))) ; one, not ten 1199 + (should (= 0 renders)) ; debounced 1200 + (timer-event-handler pm-sidebar--render-timer) ; fire it 1201 + (should (= 1 renders)) ; burst -> 1 1202 + (should-not pm-sidebar--render-timer)) ; self-cleared 1203 + (when (timerp pm-sidebar--render-timer) 1204 + (cancel-timer pm-sidebar--render-timer)))))) 1205 + 1206 + (ert-deftest pm-test--sidebar-quit-cancels-render-timer () 1207 + "`pm-sidebar-quit' cancels a pending coalesced render, so it can't fire 1208 + after the buffer is killed and resurrect a dead sidebar." 1209 + (let ((pm-sidebar--render-timer nil) 1210 + (pm-sidebar--reconnect-timer nil) 1211 + (pm-sidebar--want-connection t) 1212 + (pm-sidebar--proc nil)) 1213 + (cl-letf (((symbol-function 'pm-sidebar--render) #'ignore)) 1214 + (pm-sidebar--schedule-render) 1215 + (should (timerp pm-sidebar--render-timer)) 1216 + (let ((tm pm-sidebar--render-timer)) 1217 + (pm-sidebar-quit) 1218 + (should-not pm-sidebar--render-timer) 1219 + (should-not (memq tm timer-list)))))) 1220 + 1221 + (ert-deftest pm-test--sidebar-proc-filter-parses-chunked-sse () 1222 + "The filter skips HTTP headers once and dispatches each `data:' line, even 1223 + when a line is split across chunk boundaries, and fully drains the accumulator." 1224 + (let ((pm-sidebar--acc "") 1225 + (pm-sidebar--headers-done nil) 1226 + (seen '())) 1227 + (cl-letf (((symbol-function 'pm-sidebar--handle) 1228 + (lambda (json) (push json seen)))) 1229 + (pm-sidebar--proc-filter 1230 + nil (concat "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n" 1231 + "data: {\"a\":1}\n")) 1232 + (pm-sidebar--proc-filter nil "data: {\"b\"") ; split mid-line 1233 + (pm-sidebar--proc-filter nil ":2}\ndata: {\"c\":3}\n") 1234 + (should (equal (nreverse seen) '("{\"a\":1}" "{\"b\":2}" "{\"c\":3}"))) 1235 + (should (equal pm-sidebar--acc ""))))) 1236 + 1237 + (ert-deftest pm-test--sidebar-proc-filter-caps-runaway-acc () 1238 + "A never-terminating line can't grow the accumulator past its cap." 1239 + (let ((pm-sidebar--acc "") 1240 + (pm-sidebar--headers-done t)) 1241 + (cl-letf (((symbol-function 'pm-sidebar--handle) #'ignore)) 1242 + (pm-sidebar--proc-filter nil (make-string (1+ pm-sidebar--acc-limit) ?x)) 1243 + (should (equal pm-sidebar--acc ""))))) 1244 + 1156 1245 (ert-deftest pm-test--sidebar-visit-project-opens-dispatch () 1157 1246 "RET on a project heading opens `pm-project-dispatch' scoped to it." 1158 1247 (let ((pm-sidebar--sessions (make-hash-table :test 'equal))