Repository navigation
Conversation
…ts.read fast path idle cpu burn traced to agtermctl events subscribers: the cli short-polls events.read at 4 hz, one connection per request, and every request paid a main-actor dispatch plus a refreshWindowCache rebuild. seven subscribers meant ~28 connections a second for nothing. events.stream answers that with one connection: the reply is the ordinary events.read anchor batch, then one ndjson frame per sequenced event. admission reserves the hub slot before the anchor read, so an event in the open window is buffered and flushed past the anchor cursor at commit; fanout encodes once and queues to every matching subscriber; a stalled peer is dropped, never waited on; eof is the unsubscribe; cap 32 with EventStreamHub.capacityRefusal, which the cli answers by degrading to polling with one stderr notice. the cli is stream-first, falls back to polling for servers without the command, and --poll forces the old transport. events.read itself also stops costing a main-actor hop per poll: a lock-guarded ring tail snapshot lets the accept thread answer the bootstrap anchor, the at-tail page and the three cursor errors byte-identically, and both events commands skip the window cache refresh. one ControlEventRequest.parse feeds the anchor dispatch, the fast path and stream admission so validation cannot drift. scripts/bench-events.sh benches both transports on isolated instances with listeners typed into panes: 50 listeners over 10 workspaces x 5 sessions, 120 s steady window, poll 7.7% vs stream 3.6% app cpu, 150-event burst delivered to 50/50 listeners on both.
|
the problem is real, but the baseline moved. #955 (in v0.42.0) already made an idle the I'm not convinced about for now I prefer to keep the existing command model. If the remaining polling cost still matters after the fast path, there is a backlog item about long-polling |
Not merge-ready. This PR is a demonstration opened for feedback on the direction; the real PR follows once the approach is agreed. The old transport stays fully in place and remains the documented fallback, so comparing the two is one
--pollflag away, and the bench script drives both.the daily-driver instance sat at a constant visible CPU burn while doing nothing.
samplepinned it onagtermctl eventssubscribers: the CLI short-pollsevents.readat 4 Hz, one connection per request, and every request paid a main-actor dispatch plus arefreshWindowCache()rebuild. Seven subscribers — opencode plugins, an oc-spawn coordinator, a couple of long-lived monitors — meant ~28 connections a second for nothing, and that was the whole idle load. The consumers were fine; the transport was the problem.Two changes, together:
events.stream— a push transportevents.readanchor batch, then every sequenced event arrives as one NDJSON line. No polling, no reconnects while the app lives.EventStreamHub.capacityRefusaland the CLI degrades to polling, loudly: one stderr notice, stdout stays event lines only.agtermctl eventsis stream-first. A server without the command — an old app, agterm-server, a remote window — falls back toevents.readpolling silently and permanently for the run; an error reply carrying an events payload is a cursor verdict exactly as polling surfaces it.--pollforces the old transport from the start.A cheap
events.readrefreshWindowCache(): nothing they read can change a window node, and they are polled continuously.ControlEventRequest.parse) feeds the anchor dispatch, the fast path and stream admission, so validation cannot drift between the three.Old CLIs, remote windows and agterm-server keep polling unchanged; the
events.readwire contract did not move.control-api.md, the bundled skill,reference.mdandsite/commands.htmlare updated in step.Why this is faster
Two-minute bench (macOS 26.6.2, Apple silicon, Debug build; full output in
/tmp/agterm-events-bench-*.md):agtermctl events --jsonlistener typed into every session — 50 listeners running inside their panes exactly as a user starts themmsg.sendper session) fired over the socketRead it as: sustained cost halved, and the burst, tree and request latency all follow at 2–3×. Delivery identical. Both phases run the same build — the poll side is already served by the new fast path, so the pre-PR app sits well above that 8.2%. Steady numbers were stable across runs; treat the burst column as relative, not absolute: on a warm machine with two minutes of poll traffic queued behind it, an earlier run measured the poll burst as high as 67.8% against 34.0% — the same ratio, worse absolute numbers.
Run the bench yourself
It launches its own isolated Debug instances (state under
/tmp/agterm-bench-events, stopped by pid), types the listeners into panes viasession.type, fires the burst, samples CPU and latency, and writes a markdown report to/tmp/agterm-events-bench-<ts>.md. Takes about five minutes.BENCH_WORKSPACES,BENCH_SESSIONS_PER_WS,BENCH_STEADY_SECONDSandBENCH_APPscale it. It never touches the live~/Applicationsapp or the default socket.Gates
swift test: 773 + 5013 + 78 + 264 tests, zero failures (one pre-existing known issue)agtermTests/ControlServerTests: 26/26, including fast-path byte parity, stream adoption end-to-end and the cap refusalagtermUITests/ControlEventsStreamUITests: the real app plus the bundled CLI receiving a pushed frameswiftlint --strict: zero findingsOpen questions for the real PR
session waitstill polls; moving it onto the stream was deliberately left out of this demonstration.